feat: 增加 Client Windows 打包基础 (#92)

This commit is contained in:
chengma
2026-08-10 11:59:13 +08:00
parent 3bfaf44799
commit 99252aecce
14 changed files with 470 additions and 22 deletions
+20
View File
@@ -0,0 +1,20 @@
@echo off
setlocal
cd /d "%~dp0"
where powershell.exe >nul 2>nul
if errorlevel 1 (
echo [ERROR] powershell.exe not found.
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0packaging\build.ps1"
if errorlevel 1 (
echo.
echo [ERROR] Client build failed. See the error above.
exit /b 1
)
echo.
echo [OK] Client release files are in client\release.
exit /b 0
+1
View File
@@ -0,0 +1 @@
"""Client Windows 打包辅助代码。"""
+53
View File
@@ -0,0 +1,53 @@
"""发布包的轻量启动器。
启动器不联网、不更新文件,只负责从发布根目录启动 ``app/CMAutoBuy.exe``。
"""
from __future__ import annotations
import ctypes
import subprocess
import sys
from pathlib import Path
def install_root(executable: str | None = None) -> Path:
"""返回 Launcher.exe 所在的发布根目录。"""
return Path(executable or sys.executable).resolve().parent
def app_executable(root: Path) -> Path:
"""返回主程序路径。"""
return root / "app" / "CMAutoBuy.exe"
def show_error(message: str) -> None:
"""使用 Windows 原生对话框显示启动错误。"""
ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x10)
def main() -> int:
"""启动主程序;找不到程序时返回非零退出码。"""
root = install_root()
target = app_executable(root)
if not target.is_file():
show_error(
"主程序不存在,请确认 app 文件夹完整。\n\n"
f"缺少文件:{target}"
)
return 1
try:
subprocess.Popen([str(target)], cwd=str(target.parent))
except OSError as exc:
show_error(f"主程序启动失败。\n\n{exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+79
View File
@@ -0,0 +1,79 @@
"""生成可校验的 Client 发布清单。"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from typing import Any
MANIFEST_FILE_NAME = "autobuy——manifest.json"
def file_description(path: Path) -> dict[str, Any]:
"""返回发布文件的名称、字节大小和 SHA256。"""
digest = hashlib.sha256()
with path.open("rb") as file:
for block in iter(lambda: file.read(1024 * 1024), b""):
digest.update(block)
return {
"file": path.name,
"size": path.stat().st_size,
"sha256": digest.hexdigest(),
}
def build_manifest(version: str, update_zip: Path, portable_zip: Path) -> dict[str, Any]:
"""组装发布清单数据。"""
if not version.strip():
raise ValueError("版本号不能为空")
for path in (update_zip, portable_zip):
if not path.is_file():
raise FileNotFoundError(path)
return {
"schema_version": 1,
"product": "CMAutoBuy",
"version": version,
"update": file_description(update_zip),
"portable": file_description(portable_zip),
}
def write_manifest(
version: str,
update_zip: Path,
portable_zip: Path,
output: Path,
) -> None:
"""把发布清单写成不带 BOM 的 UTF-8 JSON。"""
manifest = build_manifest(version, update_zip, portable_zip)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="生成 Client 发布清单")
parser.add_argument("--version", required=True)
parser.add_argument("--update", required=True, type=Path)
parser.add_argument("--portable", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
return parser.parse_args()
def main() -> int:
args = parse_args()
write_manifest(args.version, args.update, args.portable, args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+185
View File
@@ -0,0 +1,185 @@
param(
[string]$PythonExe = "C:\Python310\python.exe",
[switch]$SkipInstall
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$env:PYTHONUTF8 = "1"
$clientRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$venvRoot = Join-Path $clientRoot ".build-venv"
$buildRoot = Join-Path $clientRoot "build\client-package"
$releaseRoot = Join-Path $clientRoot "release"
$buildPython = Join-Path $venvRoot "Scripts\python.exe"
function Assert-ChildPath {
param([string]$Path, [string]$Parent)
$fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\')
$fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
if (-not $fullPath.StartsWith($fullParent, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to operate outside the Client directory: $fullPath"
}
}
function Remove-BuildDirectory {
param([string]$Path)
Assert-ChildPath -Path $Path -Parent $clientRoot
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Recurse -Force
}
}
function Invoke-Checked {
param([scriptblock]$Command, [string]$FailureMessage)
& $Command
if ($LASTEXITCODE -ne 0) {
throw "$FailureMessage (exit code: $LASTEXITCODE)"
}
}
if (-not (Test-Path -LiteralPath $PythonExe -PathType Leaf)) {
throw "Python 3.10 was not found: $PythonExe"
}
if (-not (Test-Path -LiteralPath $buildPython -PathType Leaf)) {
Write-Host "[1/7] Creating the isolated build environment..."
Invoke-Checked -FailureMessage "Failed to create the build environment" -Command {
& $PythonExe -m venv $venvRoot
}
}
if (-not $SkipInstall) {
Write-Host "[2/7] Installing pinned runtime and build dependencies..."
Invoke-Checked -FailureMessage "Failed to install build dependencies" -Command {
& $buildPython -m pip install --disable-pip-version-check `
-r (Join-Path $clientRoot "requirements.txt") `
-r (Join-Path $PSScriptRoot "requirements-build.txt")
}
} else {
Write-Host "[2/7] Skipping dependency installation."
}
Push-Location $clientRoot
try {
$version = (& $buildPython -c "from src.version import __version__; print(__version__)").Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) {
throw "Failed to read the software version"
}
Write-Host "[3/7] Cleaning old build output..."
Remove-BuildDirectory -Path $buildRoot
Remove-BuildDirectory -Path $releaseRoot
New-Item -ItemType Directory -Force -Path $buildRoot, $releaseRoot | Out-Null
$versionParts = $version.Split('.')
if ($versionParts.Count -lt 3 -or $versionParts.Count -gt 4) {
throw "The version must contain three or four numeric parts: $version"
}
foreach ($part in $versionParts) {
$number = 0
if (-not [int]::TryParse($part, [ref]$number) -or $number -lt 0) {
throw "The version must contain three or four numeric parts: $version"
}
}
while ($versionParts.Count -lt 4) { $versionParts += "0" }
$fileVersion = $versionParts -join ','
$versionFile = Join-Path $buildRoot "windows-version.txt"
$versionInfo = @"
VSVersionInfo(
ffi=FixedFileInfo(filevers=($fileVersion), prodvers=($fileVersion),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0)),
kids=[StringFileInfo([StringTable('080404b0', [
StringStruct('CompanyName', 'CMAutoBuy'),
StringStruct('FileDescription', 'CMAutoBuy Client'),
StringStruct('FileVersion', '$version'),
StringStruct('InternalName', 'CMAutoBuy'),
StringStruct('OriginalFilename', 'CMAutoBuy.exe'),
StringStruct('ProductName', 'CMAutoBuy Client'),
StringStruct('ProductVersion', '$version')
])]), VarFileInfo([VarStruct('Translation', [2052, 1200])])]
)
"@
[IO.File]::WriteAllText($versionFile, $versionInfo, [Text.UTF8Encoding]::new($false))
$distPath = Join-Path $buildRoot "dist"
$workPath = Join-Path $buildRoot "work"
$specPath = Join-Path $buildRoot "spec"
Write-Host "[4/7] Building the main application..."
Invoke-Checked -FailureMessage "Failed to build the main application" -Command {
& $buildPython -m PyInstaller --noconfirm --clean --onedir --windowed `
--name CMAutoBuy --contents-directory dependencies `
--distpath $distPath --workpath $workPath --specpath $specPath `
--version-file $versionFile `
--collect-all qfluentwidgets `
--collect-all uiautomator2 `
--collect-all adbutils `
(Join-Path $clientRoot "buyer_main.py")
}
Write-Host "[5/7] Building the lightweight launcher..."
Invoke-Checked -FailureMessage "Failed to build the launcher" -Command {
& $buildPython -m PyInstaller --noconfirm --clean --onefile --windowed `
--name Launcher --distpath $distPath --workpath $workPath `
--specpath $specPath --version-file $versionFile `
(Join-Path $clientRoot "build_tools\launcher.py")
}
$mainDist = Join-Path $distPath "CMAutoBuy"
$launcherExe = Join-Path $distPath "Launcher.exe"
$adbExe = Join-Path $mainDist "dependencies\adbutils\binaries\adb.exe"
foreach ($requiredPath in @(
(Join-Path $mainDist "CMAutoBuy.exe"),
(Join-Path $mainDist "dependencies\PyQt5\Qt5\plugins\platforms\qwindows.dll"),
$adbExe,
$launcherExe
)) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
throw "A required packaged file is missing: $requiredPath"
}
}
Write-Host "[6/7] Assembling portable and update archives..."
$packageName = "CMAutoBuy-$version"
$packageRoot = Join-Path $releaseRoot $packageName
$appRoot = Join-Path $packageRoot "app"
New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null
Copy-Item -LiteralPath $mainDist -Destination $appRoot -Recurse
Copy-Item -LiteralPath $launcherExe -Destination (Join-Path $packageRoot "Launcher.exe")
New-Item -ItemType Directory -Force -Path `
(Join-Path $packageRoot "data"), `
(Join-Path $packageRoot "data\logs"), `
(Join-Path $packageRoot "data\artifacts") | Out-Null
[IO.File]::WriteAllText(
(Join-Path $appRoot "version.txt"),
"$version`n",
[Text.UTF8Encoding]::new($false)
)
$portableZip = Join-Path $releaseRoot "$packageName-portable.zip"
Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $portableZip -CompressionLevel Optimal
$updateStage = Join-Path $buildRoot "update-stage"
New-Item -ItemType Directory -Force -Path $updateStage | Out-Null
Copy-Item -LiteralPath $appRoot -Destination (Join-Path $updateStage "app") -Recurse
$updateZip = Join-Path $releaseRoot "$packageName-update.zip"
Compress-Archive -Path (Join-Path $updateStage "*") -DestinationPath $updateZip -CompressionLevel Optimal
Write-Host "[7/7] Generating the release manifest..."
$emDash = [char]0x2014
$manifestPath = Join-Path $releaseRoot ("autobuy{0}{0}manifest.json" -f $emDash)
Invoke-Checked -FailureMessage "Failed to generate the release manifest" -Command {
& $buildPython (Join-Path $clientRoot "build_tools\release_manifest.py") `
--version $version --update $updateZip --portable $portableZip `
--output $manifestPath
}
Write-Host "Build complete: $releaseRoot"
Write-Host "Release manifest: $manifestPath"
} finally {
Pop-Location
}
+3
View File
@@ -0,0 +1,3 @@
# 只在独立打包环境中安装,不属于 Client 运行时依赖。
# PyInstaller 6.21.0 支持 Python 3.8-3.15;本项目固定使用 Python 3.10。
PyInstaller==6.21.0
+9 -1
View File
@@ -19,7 +19,15 @@ def data_dir() -> Path:
"""返回可写数据目录;目录不存在时自动创建。"""
if getattr(sys, "frozen", False):
directory = Path(sys.executable).resolve().parent / "data"
executable_directory = Path(sys.executable).resolve().parent
# 发布包结构是 Launcher.exe + app/CMAutoBuy.exe。主程序位于 app
# 目录时,data 必须放在上一层,升级替换 app 才不会覆盖本地数据库。
install_root = (
executable_directory.parent
if executable_directory.name.casefold() == "app"
else executable_directory
)
directory = install_root / "data"
else:
directory = Path(__file__).resolve().parents[1] / "data"
directory.mkdir(parents=True, exist_ok=True)
+6
View File
@@ -0,0 +1,6 @@
"""Client 的软件版本。
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
"""
__version__ = "0.1.0"
+65
View File
@@ -0,0 +1,65 @@
"""Client 打包目录和发布清单测试。"""
import hashlib
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from build_tools.launcher import app_executable, install_root
from build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest
from src.db import data_dir
class LauncherPathTest(unittest.TestCase):
def test_main_program_is_inside_app_directory(self):
root = Path("D:/CMAutoBuy")
self.assertEqual(
app_executable(root),
root / "app" / "CMAutoBuy.exe",
)
def test_install_root_is_launcher_parent(self):
self.assertEqual(
install_root("D:/CMAutoBuy/Launcher.exe"),
Path("D:/CMAutoBuy"),
)
class PackagedDataPathTest(unittest.TestCase):
def test_packaged_main_program_uses_root_data_directory(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
executable = root / "app" / "CMAutoBuy.exe"
with patch("src.db.sys.frozen", True, create=True), patch(
"src.db.sys.executable", str(executable)
):
self.assertEqual(data_dir(), root / "data")
class ReleaseManifestTest(unittest.TestCase):
def test_manifest_name_and_hash_match_release_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
update_zip = root / "CMAutoBuy-0.1.0-update.zip"
portable_zip = root / "CMAutoBuy-0.1.0-portable.zip"
update_zip.write_bytes(b"update")
portable_zip.write_bytes(b"portable")
output = root / MANIFEST_FILE_NAME
write_manifest("0.1.0", update_zip, portable_zip, output)
self.assertEqual(output.name, "autobuy——manifest.json")
manifest = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "0.1.0")
self.assertEqual(manifest["update"]["size"], len(b"update"))
self.assertEqual(
manifest["update"]["sha256"],
hashlib.sha256(b"update").hexdigest(),
)
self.assertEqual(manifest["portable"]["file"], portable_zip.name)
if __name__ == "__main__":
unittest.main()