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-<ver>.zip (self-update payload = app\ contents, what manifest.url points to) and CMBot-<ver>-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 <noreply@anthropic.com>
This commit is contained in:
+61
-29
@@ -61,14 +61,19 @@ function Join-Url {
|
|||||||
return (New-Object -TypeName System.Uri -ArgumentList ([System.Uri]$BaseUrl), $Name).AbsoluteUri
|
return (New-Object -TypeName System.Uri -ArgumentList ([System.Uri]$BaseUrl), $Name).AbsoluteUri
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$LauncherName = "Launcher"
|
||||||
$AppName = Read-VersionValue -Path $VersionFile -Name "APP_NAME"
|
$AppName = Read-VersionValue -Path $VersionFile -Name "APP_NAME"
|
||||||
$AppVersion = Read-VersionValue -Path $VersionFile -Name "APP_VERSION"
|
$AppVersion = Read-VersionValue -Path $VersionFile -Name "APP_VERSION"
|
||||||
$ReleaseName = "$AppExeName-$AppVersion"
|
$ReleaseName = "$AppExeName-$AppVersion"
|
||||||
$ReleaseDir = Join-Path $ReleaseRoot $ReleaseName
|
$ReleaseDir = Join-Path $ReleaseRoot $ReleaseName # portable layout root
|
||||||
$ReleaseZip = Join-Path $ReleaseRoot "$ReleaseName.zip"
|
$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"
|
$ManifestPath = Join-Path $ReleaseRoot "manifest.json"
|
||||||
$PyInstallerOutputDir = Join-Path $DistDir $AppExeName
|
$AppPyiOut = Join-Path $DistDir $AppExeName # dist\CMBot (onedir)
|
||||||
$ExePath = Join-Path $ReleaseDir "$AppExeName.exe"
|
$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"
|
Write-Host "Building $AppName $AppVersion"
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ if (-not (Test-Path -LiteralPath $DefaultConfigDir)) {
|
|||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $ReleaseRoot | Out-Null
|
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
|
Assert-InProject -Path $path
|
||||||
if (Test-Path -LiteralPath $path) {
|
if (Test-Path -LiteralPath $path) {
|
||||||
Remove-Item -LiteralPath $path -Recurse -Force
|
Remove-Item -LiteralPath $path -Recurse -Force
|
||||||
@@ -99,6 +104,7 @@ foreach ($path in @($BuildDir, $PyInstallerOutputDir, $ReleaseDir, $ReleaseZip,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $SkipBuild) {
|
if (-not $SkipBuild) {
|
||||||
|
# Main app (onedir, windowed GUI)
|
||||||
& python -m PyInstaller `
|
& python -m PyInstaller `
|
||||||
--noconfirm `
|
--noconfirm `
|
||||||
--clean `
|
--clean `
|
||||||
@@ -109,49 +115,73 @@ if (-not $SkipBuild) {
|
|||||||
--add-data "$SrcDir\resources;resources" `
|
--add-data "$SrcDir\resources;resources" `
|
||||||
"$SrcDir\main.py"
|
"$SrcDir\main.py"
|
||||||
if ($LASTEXITCODE -ne 0) {
|
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)) {
|
if (-not (Test-Path -LiteralPath $AppPyiOut)) {
|
||||||
throw "PyInstaller output not found: $PyInstallerOutputDir"
|
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
|
# Assemble the portable layout: <ReleaseDir>\Launcher.exe + <ReleaseDir>\app\
|
||||||
Copy-Item -LiteralPath $DefaultConfigDir -Destination (Join-Path $ReleaseDir "config") -Recurse
|
New-Item -ItemType Directory -Force -Path $ReleaseDir | Out-Null
|
||||||
New-Item -ItemType Directory -Force -Path (Join-Path $ReleaseDir "logs") | Out-Null
|
Copy-Item -LiteralPath $AppPyiOut -Destination $AppDir -Recurse
|
||||||
New-Item -ItemType Directory -Force -Path (Join-Path $ReleaseDir "output") | Out-Null
|
Copy-Item -LiteralPath $DefaultConfigDir -Destination (Join-Path $AppDir "config") -Recurse
|
||||||
Set-Content -LiteralPath (Join-Path $ReleaseDir "version.txt") -Value $AppVersion -Encoding ascii -NoNewline
|
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"
|
$ReadmePath = Join-Path $ReleaseDir "README.txt"
|
||||||
$Readme = @(
|
$Readme = @(
|
||||||
"$AppName $AppVersion",
|
"$AppName $AppVersion",
|
||||||
"",
|
"",
|
||||||
"Start:",
|
"Start:",
|
||||||
"Double-click $AppExeName.exe.",
|
"Double-click Launcher.exe.",
|
||||||
"",
|
"",
|
||||||
"Folders:",
|
"Notes:",
|
||||||
"- config: default config and custom templates.",
|
"- Extract this folder to any writable location (e.g. D:\CMBot or the Desktop).",
|
||||||
"- logs: runtime logs.",
|
"- Do NOT put it under C:\Program Files or the C: drive root (not writable).",
|
||||||
"- output: default export folder.",
|
"- Your settings, templates and exports live in %USERPROFILE%\.cmbot.",
|
||||||
"",
|
"- The app itself is in app\; Launcher.exe checks for updates on start."
|
||||||
"Do not delete dependency files in this folder."
|
|
||||||
)
|
)
|
||||||
Set-Content -LiteralPath $ReadmePath -Value $Readme -Encoding UTF8
|
Set-Content -LiteralPath $ReadmePath -Value $Readme -Encoding UTF8
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $ExePath)) {
|
if (-not (Test-Path -LiteralPath $AppExePath)) {
|
||||||
throw "Executable not found: $ExePath"
|
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
|
# Self-update payload: contents of app\ (CMBot.exe at the zip root). manifest.url
|
||||||
$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash
|
# points here; the launcher downloads, verifies and swaps it into app\.
|
||||||
$ReleaseSize = (Get-Item -LiteralPath $ReleaseZip).Length
|
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) {
|
if (-not $MinSupported) {
|
||||||
$MinSupported = $AppVersion
|
$MinSupported = $AppVersion
|
||||||
}
|
}
|
||||||
$Manifest = [ordered]@{
|
$Manifest = [ordered]@{
|
||||||
version = $AppVersion
|
version = $AppVersion
|
||||||
url = Join-Url -BaseUrl $ManifestBaseUrl -Name (Split-Path -Leaf $ReleaseZip)
|
url = Join-Url -BaseUrl $ManifestBaseUrl -Name (Split-Path -Leaf $UpdateZip)
|
||||||
sha256 = $ReleaseHash
|
sha256 = $ReleaseHash
|
||||||
size = $ReleaseSize
|
size = $ReleaseSize
|
||||||
mandatory = [bool]$Mandatory
|
mandatory = [bool]$Mandatory
|
||||||
@@ -167,12 +197,14 @@ $Manifest = [ordered]@{
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ($PublishDir) {
|
if ($PublishDir) {
|
||||||
|
# Update server needs only the payload zip + manifest.
|
||||||
New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null
|
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
|
Copy-Item -LiteralPath $ManifestPath -Destination $PublishDir -Force
|
||||||
Write-Host "Published update files to: $PublishDir"
|
Write-Host "Published update files to: $PublishDir"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Release created: $ReleaseDir"
|
Write-Host "Portable layout: $ReleaseDir (Launcher.exe + app\)"
|
||||||
Write-Host "Release zip: $ReleaseZip"
|
Write-Host "Initial install zip: $PortableZip"
|
||||||
|
Write-Host "Update payload zip: $UpdateZip"
|
||||||
Write-Host "Manifest: $ManifestPath"
|
Write-Host "Manifest: $ManifestPath"
|
||||||
|
|||||||
+19
-8
@@ -214,16 +214,27 @@ def main(argv=None):
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
root = Path(args.install_root) if args.install_root else _default_install_root()
|
root = Path(args.install_root) if args.install_root else _default_install_root()
|
||||||
logging.basicConfig(
|
_setup_logging(root)
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.StreamHandler(),
|
|
||||||
logging.FileHandler(str(root / "launcher.log"), encoding="utf-8"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return run(root, no_launch=args.no_launch)
|
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__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -934,7 +934,7 @@
|
|||||||
- [x] 文档:`docs/10` 改为便携 + `Launcher.exe` + `~/.cmbot` 模型(§3/§4/§5/§8/§9/§11/§16)
|
- [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] `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 真实端到端验证
|
- [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-<ver>.zip`(app\ 内容,manifest.url 指向它)与便携安装包 `CMBot-<ver>-portable.zip`;`launcher.py` 日志初始化健壮化(--windowed/只读根不崩)。语法校验通过;实际 PyInstaller 构建需在 Windows 跑
|
||||||
- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`
|
- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`
|
||||||
- [ ] 端到端实测(解压到 D 盘运行、自更新、回滚)
|
- [ ] 端到端实测(解压到 D 盘运行、自更新、回滚)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user