换位置
This commit is contained in:
+15
-14
@@ -7,6 +7,7 @@ title Server Manager - build
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "SRC=%ROOT%\src"
|
||||
set "RESOURCE_CONFIG=%ROOT%\resources\config"
|
||||
set "APP_NAME=ServerManager"
|
||||
pushd "%SRC%" || (
|
||||
echo [ERROR] src directory not found: %SRC%
|
||||
exit /b 1
|
||||
@@ -55,32 +56,32 @@ echo [3/6] clean dist\build...
|
||||
if exist "dist" rd /s /q dist
|
||||
if exist "build" rd /s /q build
|
||||
if exist "*.spec" del /q *.spec 2>nul
|
||||
REM Pre-create work dir for PyInstaller warn-main.txt on some Windows setups
|
||||
if not exist "build\main" mkdir "build\main"
|
||||
REM Pre-create work dir for PyInstaller warn file on some Windows setups
|
||||
if not exist "build\%APP_NAME%" mkdir "build\%APP_NAME%"
|
||||
|
||||
echo [4/6] PyInstaller main (onedir)...
|
||||
echo [4/6] PyInstaller %APP_NAME% (onedir)...
|
||||
REM Single line: no ^ line-continuation (avoids trailing-space / encoding breakage)
|
||||
pyinstaller --noconfirm --onedir --console --name main !ICON_PARAM! --add-data "%ROOT%\resources\version.json;." --add-data "%ROOT%\resources\config.json;." --add-data "%ROOT%\resources\icon;icon" --hidden-import PyQt6.QtWidgets --hidden-import PyQt6.QtCore --hidden-import PyQt6.QtGui --hidden-import pymysql --hidden-import entrypoints.cli.main --hidden-import entrypoints.cli.extended --hidden-import entrypoints.gui.bootstrap --hidden-import mcp.server --hidden-import mcp.tools --hidden-import models.app_config --hidden-import services.commands --hidden-import services.server_creator --hidden-import services.hot_update --hidden-import services.rg_search --hidden-import bsdiff4 --collect-all PyQt6 --collect-all bsdiff4 --hidden-import requests entrypoints\main.py
|
||||
pyinstaller --noconfirm --onedir --console --name %APP_NAME% !ICON_PARAM! --add-data "%ROOT%\resources\version.json;." --add-data "%ROOT%\resources\config.json;." --add-data "%ROOT%\resources\icon;icon" --hidden-import PyQt6.QtWidgets --hidden-import PyQt6.QtCore --hidden-import PyQt6.QtGui --hidden-import pymysql --hidden-import entrypoints.cli.main --hidden-import entrypoints.cli.extended --hidden-import entrypoints.gui.bootstrap --hidden-import mcp.server --hidden-import mcp.tools --hidden-import models.app_config --hidden-import services.commands --hidden-import services.server_creator --hidden-import services.hot_update --hidden-import services.rg_search --hidden-import bsdiff4 --collect-all PyQt6 --collect-all bsdiff4 --hidden-import requests entrypoints\main.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] main PyInstaller failed
|
||||
echo [ERROR] %APP_NAME% PyInstaller failed
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [4b/6] copy rg.exe...
|
||||
if exist "%ROOT%\resources\bin\rg.exe" (
|
||||
copy /y "%ROOT%\resources\bin\rg.exe" "dist\main\rg.exe" >nul
|
||||
echo copied resources\bin\rg.exe to dist\main\rg.exe
|
||||
copy /y "%ROOT%\resources\bin\rg.exe" "dist\%APP_NAME%\rg.exe" >nul
|
||||
echo copied resources\bin\rg.exe to dist\%APP_NAME%\rg.exe
|
||||
) else (
|
||||
echo [WARN] resources\bin\rg.exe missing, search will not use ripgrep
|
||||
)
|
||||
|
||||
echo [4c/6] copy resources\config templates to dist\main\config ^(same as installer {app}\config^)...
|
||||
echo [4c/6] copy resources\config templates to dist\%APP_NAME%\config ^(same as installer {app}\config^)...
|
||||
if exist "%RESOURCE_CONFIG%" (
|
||||
if not exist "dist\main\config" mkdir "dist\main\config"
|
||||
xcopy "%RESOURCE_CONFIG%\*" "dist\main\config\" /E /I /Y /Q >nul
|
||||
echo copied ..\resources\config to dist\main\config
|
||||
if not exist "dist\%APP_NAME%\config" mkdir "dist\%APP_NAME%\config"
|
||||
xcopy "%RESOURCE_CONFIG%\*" "dist\%APP_NAME%\config\" /E /I /Y /Q >nul
|
||||
echo copied ..\resources\config to dist\%APP_NAME%\config
|
||||
) else (
|
||||
echo [WARN] ..\resources\config missing, Inno install will fail if resources\config is absent at compile time
|
||||
)
|
||||
@@ -96,8 +97,8 @@ if errorlevel 1 (
|
||||
|
||||
echo [6/6] done.
|
||||
|
||||
if not exist "dist\main\config" mkdir "dist\main\config"
|
||||
if exist "config\tool.config" copy /y "config\tool.config" "dist\main\config\tool.config" >nul
|
||||
if not exist "dist\%APP_NAME%\config" mkdir "dist\%APP_NAME%\config"
|
||||
if exist "config\user.config" copy /y "config\user.config" "dist\%APP_NAME%\config\user.config" >nul
|
||||
|
||||
rd /s /q build 2>nul
|
||||
rd /s /q build_mini 2>nul
|
||||
@@ -106,7 +107,7 @@ del /q build_mini\*.spec 2>nul
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo OK: dist\main\ dist\mini_updater.exe
|
||||
echo OK: dist\%APP_NAME%\ dist\mini_updater.exe
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
#define MyOutputBase "ServerManager_Setup"
|
||||
#endif
|
||||
|
||||
#define MyAppVersion "1.1.3"
|
||||
#define MyAppExeName "main.exe"
|
||||
#define MyAppVersion "1.1.8"
|
||||
#define MyAppExeName "ServerManager.exe"
|
||||
#define MyAppPublisher "ServerManager"
|
||||
#define MyAppURL "http://your-server.com/"
|
||||
|
||||
@@ -62,13 +62,13 @@ Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription:
|
||||
Name: "quicklaunchicon"; Description: "创建快速启动栏快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
; 主程序目录模式(src\dist\main\ 含 main.exe 与 _internal 等依赖,无 _MEI 解压,避免 DLL 加载失败)
|
||||
Source: "..\..\src\dist\main\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
; 独立进程外更新器:与 main.exe 同目录
|
||||
; 主程序目录模式(src\dist\ServerManager\ 含 ServerManager.exe 与 _internal 等依赖,无 _MEI 解压,避免 DLL 加载失败)
|
||||
Source: "..\..\src\dist\ServerManager\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
; 独立进程外更新器:与 ServerManager.exe 同目录
|
||||
Source: "..\..\src\dist\mini_updater.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; 版本与更新配置
|
||||
Source: "..\..\resources\version.json"; DestDir: "{app}"; Flags: ignoreversion
|
||||
; 默认配置模板(default.kv、sys_*.config.example 等,与 resources\config 同源;build.bat 会同步拷入 dist\main\config)
|
||||
; 默认配置模板(default.config、sys_*.config.example 等,与 resources\config 同源;build.bat 会同步拷入 dist\ServerManager\config)
|
||||
Source: "..\..\resources\config\*"; DestDir: "{app}\config"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Dirs]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Server Manager - Linux 下用 PyInstaller 打包 main(与 build.bat 的 onedir 主程序对应)
|
||||
# Server Manager - Linux 下用 PyInstaller 打包 ServerManager(与 build.bat 的 onedir 主程序对应)
|
||||
# 用法: chmod +x build_linux.sh && ./build_linux.sh
|
||||
# 产物: src/dist/main/main (可将该目录加入 PATH,或设置 SERVER_MANAGER_MAIN 供 scripts/run/run.sh 自动调用)
|
||||
# 产物: src/dist/ServerManager/ServerManager (可将该目录加入 PATH,或设置 SERVER_MANAGER_MAIN 供 scripts/run/run.sh 自动调用)
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -34,9 +34,9 @@ fi
|
||||
echo "[3/5] clean dist/build..."
|
||||
rm -rf dist build ./*.spec 2>/dev/null || true
|
||||
|
||||
echo "[4/5] PyInstaller main (onedir)..."
|
||||
echo "[4/5] PyInstaller ServerManager (onedir)..."
|
||||
# add-data 在 Linux 下用冒号分隔
|
||||
python3 -m PyInstaller --noconfirm --onedir --name main \
|
||||
python3 -m PyInstaller --noconfirm --onedir --name ServerManager \
|
||||
"${ICON_ARG[@]}" \
|
||||
--add-data "$ROOT/resources/version.json:." \
|
||||
--add-data "$ROOT/resources/config.json:." \
|
||||
@@ -61,13 +61,13 @@ python3 -m PyInstaller --noconfirm --onedir --name main \
|
||||
entrypoints/main.py
|
||||
|
||||
echo "[5/5] done."
|
||||
mkdir -p dist/main/config
|
||||
if [[ -f config/tool.config ]]; then
|
||||
cp -f config/tool.config dist/main/config/tool.config || true
|
||||
mkdir -p dist/ServerManager/config
|
||||
if [[ -f config/user.config ]]; then
|
||||
cp -f config/user.config dist/ServerManager/config/user.config || true
|
||||
fi
|
||||
if [[ -d "$RESOURCE_CONFIG" ]]; then
|
||||
cp -a "$RESOURCE_CONFIG/." dist/main/config/
|
||||
echo " copied resources/config -> dist/main/config"
|
||||
cp -a "$RESOURCE_CONFIG/." dist/ServerManager/config/
|
||||
echo " copied resources/config -> dist/ServerManager/config"
|
||||
else
|
||||
echo " [WARN] resources/config missing"
|
||||
fi
|
||||
@@ -77,9 +77,9 @@ rm -f ./*.spec 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo "OK: dist/main/main"
|
||||
echo "CLI: dist/main/main list"
|
||||
echo "GUI: dist/main/main"
|
||||
echo "OK: dist/ServerManager/ServerManager"
|
||||
echo "CLI: dist/ServerManager/ServerManager list"
|
||||
echo "GUI: dist/ServerManager/ServerManager"
|
||||
echo "或设置 SERVER_MANAGER_MAIN 指向该文件,配合 scripts/run/run.sh 使用"
|
||||
echo "============================================================"
|
||||
echo
|
||||
|
||||
@@ -3,6 +3,7 @@ chcp 65001 >nul
|
||||
title Server Manager - 打包便携版
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "SRC=%ROOT%\src"
|
||||
set "APP_NAME=ServerManager"
|
||||
pushd "%SRC%" || (
|
||||
echo [错误] 未找到 src 目录: %SRC%
|
||||
exit /b 1
|
||||
@@ -42,11 +43,11 @@ if not exist "%ROOT%\resources\icon\icon.ico" (
|
||||
echo [3/5] 清理旧的构建文件...
|
||||
if exist "dist" rd /s /q dist
|
||||
if exist "build" rd /s /q build
|
||||
if exist "main.spec" del /q "main.spec"
|
||||
if exist "%APP_NAME%.spec" del /q "%APP_NAME%.spec"
|
||||
|
||||
echo [4/5] 开始打包(单文件版本)...
|
||||
pyinstaller --noconfirm --onefile --windowed ^
|
||||
--name "main" ^
|
||||
--name "%APP_NAME%" ^
|
||||
%ICON_PARAM% ^
|
||||
--add-data "%ROOT%\resources\icon;icon" ^
|
||||
--hidden-import "PyQt6.QtWidgets" ^
|
||||
@@ -72,7 +73,7 @@ if errorlevel 1 (
|
||||
echo [错误] 打包失败,尝试使用目录模式...
|
||||
|
||||
pyinstaller --noconfirm --onedir --windowed ^
|
||||
--name "main" ^
|
||||
--name "%APP_NAME%" ^
|
||||
%ICON_PARAM% ^
|
||||
--add-data "%ROOT%\resources\icon;icon" ^
|
||||
--hidden-import "PyQt6.QtWidgets" ^
|
||||
@@ -104,10 +105,10 @@ echo [5/5] 打包完成!
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
if exist "dist\main.exe" (
|
||||
echo 打包成功!单文件版本: dist\main.exe
|
||||
) else if exist "dist\main\main.exe" (
|
||||
echo 打包成功!目录版本: dist\main\main.exe
|
||||
if exist "dist\%APP_NAME%.exe" (
|
||||
echo 打包成功!单文件版本: dist\%APP_NAME%.exe
|
||||
) else if exist "dist\%APP_NAME%\%APP_NAME%.exe" (
|
||||
echo 打包成功!目录版本: dist\%APP_NAME%\%APP_NAME%.exe
|
||||
)
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
@@ -16,8 +16,8 @@ echo( EXE + Inno Setup 安装包
|
||||
echo(============================================================
|
||||
echo(
|
||||
|
||||
:: Step 1: PyInstaller 打包 main.exe(在 src 目录执行)
|
||||
echo([Step 1/2] PyInstaller 打包 main.exe ...
|
||||
:: Step 1: PyInstaller 打包 ServerManager.exe(在 src 目录执行)
|
||||
echo([Step 1/2] PyInstaller 打包 ServerManager.exe ...
|
||||
echo(
|
||||
call "%ROOT%\scripts\package\build.bat" nopause
|
||||
if errorlevel 1 (
|
||||
|
||||
@@ -28,7 +28,7 @@ if "%NEW_VER%"=="" (
|
||||
)
|
||||
|
||||
echo(
|
||||
echo([阶段 1/4] PyInstaller 打包 main.exe ...
|
||||
echo([阶段 1/4] PyInstaller 打包 ServerManager.exe ...
|
||||
call "%ROOT%\scripts\package\build.bat" nopause
|
||||
if errorlevel 1 (
|
||||
echo([失败] PyInstaller 打包未成功
|
||||
|
||||
+227
-59
@@ -4,7 +4,7 @@
|
||||
发版与版本号:推荐顺序见 scripts/package/release.bat — 先 PyInstaller 打包,再更新版本号并同步到 dist,再 Inno Setup,最后 --post-build。
|
||||
单独用法:
|
||||
仅改版本号(通常在 build.bat 成功之后): python scripts/package/release.py 1.0.4 [--notes "说明"]
|
||||
打包完成后: python scripts/package/release.py --post-build [--old-exe 上一版main.exe]
|
||||
打包完成后: python scripts/package/release.py --post-build [--old-exe 上一版 ServerManager.exe]
|
||||
本地测试包: scripts/package/pack_local_test.bat 或 python scripts/package/pack_local_test.py(见 RELEASE.md)
|
||||
"""
|
||||
|
||||
@@ -15,14 +15,20 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VERSION_JSON = ROOT / "resources" / "version.json"
|
||||
ROOT_VERSION_JSON = ROOT / "version.json"
|
||||
OUTPUT_DIR = ROOT / "build" / "output"
|
||||
PUBLIC_MANIFEST_PATH = "server_manager/build/output/version.json"
|
||||
PUBLIC_INSTALLER_PATH = "server_manager/build/output/ServerManager_Setup.exe"
|
||||
PUBLIC_MANIFEST_URL = "https://www.sumi.work/api/software/server-manager/version.json"
|
||||
PUBLIC_INSTALLER_URL = "https://www.sumi.work/api/software/server-manager/update-download"
|
||||
PUBLIC_DELTA_URL = "https://www.sumi.work/api/software/server-manager/delta-download?from={from_version}"
|
||||
APP_DIST_NAME = "ServerManager"
|
||||
APP_EXE_NAME = "ServerManager.exe"
|
||||
FILE_DELTA_FORMAT = "server-manager-file-delta-v1"
|
||||
|
||||
|
||||
def read_text(p: Path) -> str:
|
||||
@@ -48,34 +54,119 @@ def get_current_version() -> str:
|
||||
return read_version_from_json(OUTPUT_DIR / "version.json")
|
||||
|
||||
|
||||
def _set_url_path(url: str, api_kind: str, public_path: str) -> str:
|
||||
fallback = f"http://172.18.180.94:3000/api/{api_kind}?path={public_path}"
|
||||
if not url:
|
||||
return fallback
|
||||
url = url.strip()
|
||||
url = url.replace("/api/file?", f"/api/{api_kind}?")
|
||||
url = url.replace("/api/download?", f"/api/{api_kind}?")
|
||||
if "path=" in url:
|
||||
return re.sub(r"path=[^&]+", lambda _m: f"path={public_path}", url, count=1)
|
||||
return fallback
|
||||
def format_size(bytes_count: int) -> str:
|
||||
return f"{bytes_count / 1024 / 1024:.1f} MB"
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest().lower()
|
||||
|
||||
|
||||
def rel_path(path: Path, root: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def collect_dist_files(root: Path) -> dict:
|
||||
files = {}
|
||||
if not root.is_dir():
|
||||
return files
|
||||
for p in sorted(root.rglob("*")):
|
||||
if not p.is_file():
|
||||
continue
|
||||
rel = rel_path(p, root)
|
||||
if rel.startswith(".update_backup/") or rel.startswith("logs/") or rel.endswith(".old"):
|
||||
continue
|
||||
stat = p.stat()
|
||||
files[rel] = {
|
||||
"path": p,
|
||||
"sha256": sha256_file(p),
|
||||
"size": stat.st_size,
|
||||
}
|
||||
return files
|
||||
|
||||
|
||||
def generate_file_delta(old_dist_path: Path, old_version: str, new_version: str) -> tuple[Path, dict]:
|
||||
old_root = Path(old_dist_path).resolve()
|
||||
new_root = ROOT / "src" / "dist" / APP_DIST_NAME
|
||||
if not old_root.is_dir():
|
||||
raise FileNotFoundError(f"old dist not found: {old_root}")
|
||||
if not new_root.is_dir():
|
||||
raise FileNotFoundError(f"new dist not found: {new_root}")
|
||||
|
||||
old_files = collect_dist_files(old_root)
|
||||
new_files = collect_dist_files(new_root)
|
||||
changed = []
|
||||
deleted = []
|
||||
|
||||
for rel, meta in sorted(new_files.items()):
|
||||
old_meta = old_files.get(rel)
|
||||
if not old_meta or old_meta["sha256"] != meta["sha256"]:
|
||||
changed.append({
|
||||
"path": rel,
|
||||
"zip_path": f"files/{rel}",
|
||||
"sha256": meta["sha256"],
|
||||
"size": meta["size"],
|
||||
"old_sha256": old_meta["sha256"] if old_meta else "",
|
||||
"old_size": old_meta["size"] if old_meta else 0,
|
||||
})
|
||||
|
||||
for rel, meta in sorted(old_files.items()):
|
||||
if rel not in new_files:
|
||||
deleted.append({
|
||||
"path": rel,
|
||||
"old_sha256": meta["sha256"],
|
||||
"old_size": meta["size"],
|
||||
})
|
||||
|
||||
patch_dir = OUTPUT_DIR / "patches"
|
||||
patch_dir.mkdir(parents=True, exist_ok=True)
|
||||
delta_file = patch_dir / f"v{old_version}_to_v{new_version}.smdelta.zip"
|
||||
manifest = {
|
||||
"format": FILE_DELTA_FORMAT,
|
||||
"base_version": old_version,
|
||||
"target_version": new_version,
|
||||
"app": APP_DIST_NAME,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
"files": changed,
|
||||
"delete": deleted,
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(delta_file, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||
zf.writestr("delta_manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
for entry in changed:
|
||||
zf.write(new_files[entry["path"]]["path"], entry["zip_path"])
|
||||
|
||||
size = delta_file.stat().st_size
|
||||
return delta_file, {
|
||||
"sha256": sha256_file(delta_file),
|
||||
"sizeBytes": size,
|
||||
"size": format_size(size),
|
||||
"fileCount": len(changed),
|
||||
"deleteCount": len(deleted),
|
||||
}
|
||||
|
||||
|
||||
def save_dist_snapshot(version: str) -> None:
|
||||
src = ROOT / "src" / "dist" / APP_DIST_NAME
|
||||
if not src.is_dir():
|
||||
return
|
||||
dst = OUTPUT_DIR / "snapshots" / f"{APP_DIST_NAME}-{version}"
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
print(f" [OK] 已保存 dist 快照 -> {dst.relative_to(ROOT)}")
|
||||
|
||||
|
||||
def ensure_compat_manifest(data: dict) -> dict:
|
||||
"""补齐客户端更新字段,并固定到 build/output 发布路径。"""
|
||||
seed = (
|
||||
data.get("update_url")
|
||||
or data.get("full_installer_url")
|
||||
or data.get("download_url")
|
||||
or ""
|
||||
)
|
||||
data["update_url"] = _set_url_path(seed, "file", PUBLIC_MANIFEST_PATH)
|
||||
full_url = _set_url_path(
|
||||
data.get("full_installer_url") or data.get("download_url") or seed,
|
||||
"download",
|
||||
PUBLIC_INSTALLER_PATH,
|
||||
)
|
||||
data["full_installer_url"] = full_url
|
||||
data["download_url"] = full_url
|
||||
data["update_url"] = PUBLIC_MANIFEST_URL
|
||||
data["full_installer_url"] = PUBLIC_INSTALLER_URL
|
||||
data["download_url"] = PUBLIC_INSTALLER_URL
|
||||
return data
|
||||
|
||||
|
||||
@@ -86,7 +177,7 @@ def write_version_json(data: dict) -> None:
|
||||
write_text(ROOT_VERSION_JSON, text)
|
||||
|
||||
|
||||
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False) -> str:
|
||||
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False, delta_kind: str = "file-zip") -> str:
|
||||
"""更新版本号并同步 manifest。默认全量更新;显式 --with-delta 才生成 delta 项。"""
|
||||
vj = VERSION_JSON
|
||||
if not vj.exists():
|
||||
@@ -126,28 +217,38 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
|
||||
data["release_notes"] = data.get("release_notes", "") or f"Server Manager {new_version} 更新。"
|
||||
|
||||
data = ensure_compat_manifest(data)
|
||||
full_url = data.get("full_installer_url", "")
|
||||
|
||||
if with_delta:
|
||||
# delta_updates: 只保留 上一版本 -> 当前版本 一项。
|
||||
# 目录模式下 delta 只替换 main.exe;只有确认依赖目录无需变化时才启用。
|
||||
patch_path = f"patches/v{old_version}_to_v{new_version}.patch"
|
||||
if full_url and "build/output/" in full_url:
|
||||
patch_url = re.sub(r"build/output/[^?]+", "build/output/" + patch_path, full_url)
|
||||
elif full_url and "output/" in full_url:
|
||||
patch_url = re.sub(r"output/[^?]+", "build/output/" + patch_path, full_url)
|
||||
else:
|
||||
patch_url = full_url.replace("ServerManager_Setup.exe", patch_path) if full_url else ""
|
||||
|
||||
data["delta_updates"] = {
|
||||
old_version: {
|
||||
# delta_updates: 只保留 上一版本 -> 当前版本 一项。默认使用文件级增量 zip。
|
||||
if delta_kind == "exe-patch":
|
||||
patch_url = PUBLIC_DELTA_URL.format(from_version=old_version)
|
||||
delta_entry = {
|
||||
"type": "bsdiff-exe",
|
||||
"patch_url": patch_url,
|
||||
"new_exe_sha256": "",
|
||||
}
|
||||
}
|
||||
else:
|
||||
delta_url = PUBLIC_DELTA_URL.format(from_version=old_version)
|
||||
delta_entry = {
|
||||
"type": "file-zip",
|
||||
"format": FILE_DELTA_FORMAT,
|
||||
"fromVersion": old_version,
|
||||
"from_version": old_version,
|
||||
"targetVersion": new_version,
|
||||
"target_version": new_version,
|
||||
"package_url": delta_url,
|
||||
"packageUrl": delta_url,
|
||||
"patch_url": delta_url,
|
||||
"patchUrl": delta_url,
|
||||
"sha256": "",
|
||||
"sizeBytes": 0,
|
||||
"size": "",
|
||||
}
|
||||
data["delta_updates"] = {old_version: delta_entry}
|
||||
data["deltaUpdates"] = data["delta_updates"]
|
||||
print(f" [OK] version.json -> version {new_version}, delta_updates[{old_version}] 已添加")
|
||||
else:
|
||||
data["delta_updates"] = {}
|
||||
data["deltaUpdates"] = {}
|
||||
data.pop("delta", None)
|
||||
print(f" [OK] version.json -> version {new_version},本次使用全量更新")
|
||||
|
||||
@@ -155,7 +256,10 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
|
||||
print(" [OK] 已同步 version.json -> resources/version.json 与根目录 version.json")
|
||||
|
||||
# 若已打过 PyInstaller,把新版本同步进 dist,避免安装包内仍是旧 version.json
|
||||
for rel in ("src/dist/main/_internal/version.json", "src/dist/main/version.json"):
|
||||
for rel in (
|
||||
f"src/dist/{APP_DIST_NAME}/_internal/version.json",
|
||||
f"src/dist/{APP_DIST_NAME}/version.json",
|
||||
):
|
||||
dest = ROOT / rel
|
||||
if dest.parent.is_dir():
|
||||
shutil.copy2(vj, dest)
|
||||
@@ -163,8 +267,8 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
|
||||
return old_version
|
||||
|
||||
|
||||
def set_version_post(old_exe_path: str = None) -> None:
|
||||
"""打包完成后:若提供 --old-exe(上一版 main.exe 路径)则生成 patch 并填 new_exe_sha256;否则仅复制 version.json。本地不保存 exe。"""
|
||||
def set_version_post(old_exe_path: str = None, old_dist_path: str = None) -> None:
|
||||
"""打包完成后:生成文件级增量 zip 或兼容旧 exe patch,并复制 version.json。"""
|
||||
vj = VERSION_JSON
|
||||
if not vj.exists():
|
||||
return
|
||||
@@ -174,25 +278,83 @@ def set_version_post(old_exe_path: str = None) -> None:
|
||||
delta_updates = data.get("delta_updates") or {}
|
||||
if not isinstance(delta_updates, dict):
|
||||
return
|
||||
# 找到需要填 new_exe_sha256 的那一项(上一版本)
|
||||
# 找到需要补齐元数据的那一项(上一版本)
|
||||
old_version = None
|
||||
for k, v in delta_updates.items():
|
||||
if isinstance(v, dict) and not (v.get("new_exe_sha256") or "").strip():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
kind = str(v.get("type") or "").strip().lower().replace("_", "-")
|
||||
if kind == "bsdiff-exe":
|
||||
if not (v.get("new_exe_sha256") or "").strip():
|
||||
old_version = k.strip()
|
||||
break
|
||||
elif not (v.get("sha256") or v.get("package_sha256") or v.get("packageSha256") or "").strip():
|
||||
old_version = k.strip()
|
||||
break
|
||||
if not old_version:
|
||||
print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta)")
|
||||
write_version_json(data)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
delta_entry = delta_updates.get(old_version) or {}
|
||||
delta_kind = str(delta_entry.get("type") or "").strip().lower().replace("_", "-")
|
||||
|
||||
# 支持目录模式 (dist/main/main.exe) 与单文件 (dist/main.exe)
|
||||
new_exe = ROOT / "src" / "dist" / "main" / "main.exe"
|
||||
if not new_exe.exists():
|
||||
new_exe = ROOT / "src" / "dist" / "main.exe"
|
||||
if not new_exe.exists():
|
||||
print(" [i] 未找到 src/dist/main.exe 或 src/dist/main/main.exe,跳过增量包")
|
||||
if delta_kind != "bsdiff-exe":
|
||||
if not old_dist_path:
|
||||
print(" [i] 未提供上一版 dist 目录(使用 --old-dist 指定可生成文件级增量包),本次仅全量")
|
||||
data["delta_updates"] = {}
|
||||
data.pop("deltaUpdates", None)
|
||||
write_version_json(data)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
try:
|
||||
delta_file, delta_meta = generate_file_delta(Path(old_dist_path), old_version, new_version)
|
||||
except Exception as e:
|
||||
print(f" [!] 生成文件级增量包失败: {e},本次仅全量", file=sys.stderr)
|
||||
data["delta_updates"] = {}
|
||||
data.pop("deltaUpdates", None)
|
||||
write_version_json(data)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
delta_entry.update({
|
||||
"type": "file-zip",
|
||||
"format": FILE_DELTA_FORMAT,
|
||||
"fromVersion": old_version,
|
||||
"from_version": old_version,
|
||||
"targetVersion": new_version,
|
||||
"target_version": new_version,
|
||||
"sha256": delta_meta["sha256"],
|
||||
"package_sha256": delta_meta["sha256"],
|
||||
"packageSha256": delta_meta["sha256"],
|
||||
"sizeBytes": delta_meta["sizeBytes"],
|
||||
"size": delta_meta["size"],
|
||||
"fileCount": delta_meta["fileCount"],
|
||||
"deleteCount": delta_meta["deleteCount"],
|
||||
})
|
||||
delta_updates[old_version] = delta_entry
|
||||
data["delta_updates"] = delta_updates
|
||||
data["deltaUpdates"] = delta_updates
|
||||
write_version_json(data)
|
||||
print(
|
||||
f" [OK] 已生成文件级增量包: {delta_file} "
|
||||
f"({delta_meta['size']}, {delta_meta['fileCount']} changed, {delta_meta['deleteCount']} deleted)"
|
||||
)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
print(" 发版准备完成。")
|
||||
return
|
||||
|
||||
# 支持目录模式 (dist/ServerManager/ServerManager.exe) 与单文件 (dist/ServerManager.exe)
|
||||
new_exe = ROOT / "src" / "dist" / APP_DIST_NAME / APP_EXE_NAME
|
||||
if not new_exe.exists():
|
||||
new_exe = ROOT / "src" / "dist" / APP_EXE_NAME
|
||||
if not new_exe.exists():
|
||||
print(f" [i] 未找到 src/dist/{APP_EXE_NAME} 或 src/dist/{APP_DIST_NAME}/{APP_EXE_NAME},跳过增量包")
|
||||
write_version_json(data)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
@@ -201,6 +363,7 @@ def set_version_post(old_exe_path: str = None) -> None:
|
||||
if not old_exe_path or not old_exe or not old_exe.is_file():
|
||||
print(f" [i] 未提供上一版 exe(使用 --old-exe 指定路径可生成增量包),仅全量")
|
||||
write_version_json(data)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
@@ -208,6 +371,7 @@ def set_version_post(old_exe_path: str = None) -> None:
|
||||
import bsdiff4
|
||||
except ImportError:
|
||||
print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr)
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
@@ -224,9 +388,11 @@ def set_version_post(old_exe_path: str = None) -> None:
|
||||
new_sha = hashlib.sha256(new_data).hexdigest().lower()
|
||||
delta_updates[old_version]["new_exe_sha256"] = new_sha
|
||||
data["delta_updates"] = delta_updates
|
||||
data["deltaUpdates"] = delta_updates
|
||||
write_version_json(data)
|
||||
print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...")
|
||||
|
||||
save_dist_snapshot(new_version)
|
||||
_copy_version_to_output()
|
||||
print(" 发版准备完成。")
|
||||
|
||||
@@ -251,14 +417,16 @@ def clean_generated_artifacts() -> None:
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="一键发版:更新版本号 / 打包后生成增量并填 SHA256")
|
||||
parser.add_argument("version", nargs="?", help="新版本号,如 1.0.4")
|
||||
parser.add_argument("--post-build", action="store_true", help="打包完成后执行:复制 version.json;若提供 --old-exe 则生成增量并填 SHA256")
|
||||
parser.add_argument("--old-exe", default=None, help="上一版 main.exe 路径,用于生成增量 patch(与 Cursor 一致,本地不保存 exe)")
|
||||
parser.add_argument("--with-delta", action="store_true", help="生成 main.exe 增量项;仅在确认依赖目录无需变化时使用")
|
||||
parser.add_argument("--post-build", action="store_true", help="打包完成后执行:复制 version.json;若提供 --old-dist 则生成文件级增量包")
|
||||
parser.add_argument("--old-dist", default=None, help="上一版 dist/ServerManager 目录,用于生成文件级增量 zip")
|
||||
parser.add_argument("--old-exe", default=None, help="上一版 ServerManager.exe 路径,用于兼容旧 bsdiff exe patch")
|
||||
parser.add_argument("--with-delta", action="store_true", help="生成增量更新项;默认文件级增量 zip")
|
||||
parser.add_argument("--delta-kind", choices=("file-zip", "exe-patch"), default="file-zip", help="增量类型,默认 file-zip")
|
||||
parser.add_argument("--notes", "-n", default=None, help="可选:release_notes 更新说明")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.post_build:
|
||||
set_version_post(old_exe_path=args.old_exe)
|
||||
set_version_post(old_exe_path=args.old_exe, old_dist_path=args.old_dist)
|
||||
clean_generated_artifacts()
|
||||
return
|
||||
|
||||
@@ -266,7 +434,7 @@ def main():
|
||||
print("用法: python scripts/package/release.py <版本号> 例如: python scripts/package/release.py 1.0.4", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
new_ver = args.version.strip()
|
||||
old_ver = set_version_pre(new_ver, args.notes, with_delta=args.with_delta)
|
||||
old_ver = set_version_pre(new_ver, args.notes, with_delta=args.with_delta, delta_kind=args.delta_kind)
|
||||
if args.with_delta:
|
||||
print(f"\n 当前版本 {old_ver} -> 新版本 {new_ver}。若使用 scripts/package/release.bat,接下来将编译安装包并执行 --post-build。")
|
||||
else:
|
||||
|
||||
+3
-3
@@ -14,10 +14,10 @@ rem Run source modules through the new entrypoints package.
|
||||
cd /d "%ROOT%"
|
||||
set "PYTHONPATH=%ROOT%\src;%PYTHONPATH%"
|
||||
|
||||
rem Prefer packaged main.exe when present.
|
||||
rem Prefer packaged ServerManager.exe when present.
|
||||
set "SM_MAIN="
|
||||
if defined SERVER_MANAGER_MAIN if exist "%SERVER_MANAGER_MAIN%" set "SM_MAIN=%SERVER_MANAGER_MAIN%"
|
||||
if not defined SM_MAIN if exist "%ROOT%\src\dist\main\main.exe" set "SM_MAIN=%ROOT%\src\dist\main\main.exe"
|
||||
if not defined SM_MAIN if exist "%ROOT%\src\dist\ServerManager\ServerManager.exe" set "SM_MAIN=%ROOT%\src\dist\ServerManager\ServerManager.exe"
|
||||
|
||||
rem No arguments enters interactive CLI mode.
|
||||
if "%~1"=="" goto :run_interactive
|
||||
@@ -102,7 +102,7 @@ echo.
|
||||
echo More help: python -m entrypoints.cli --help
|
||||
if defined SM_MAIN (
|
||||
echo.
|
||||
echo Packaged main: %SM_MAIN%
|
||||
echo Packaged program: %SM_MAIN%
|
||||
)
|
||||
goto :end
|
||||
|
||||
|
||||
+3
-3
@@ -20,10 +20,10 @@ export PYTHONPATH="$ROOT/src${PYTHONPATH:+:$PYTHONPATH}"
|
||||
# 默认以项目根目录作为服务器根目录;部署到其它项目时可通过 SERVER_MANAGER_ROOT 覆盖。
|
||||
SERVER_ROOT="${SERVER_MANAGER_ROOT:-$ROOT}"
|
||||
|
||||
# 优先使用 PyInstaller 打包的 main(Linux):环境变量 SERVER_MANAGER_MAIN,或常见构建路径 src/dist/main/main
|
||||
# 优先使用 PyInstaller 打包的 ServerManager(Linux):环境变量 SERVER_MANAGER_MAIN,或常见构建路径 src/dist/ServerManager/ServerManager
|
||||
SM_MAIN="${SERVER_MANAGER_MAIN:-}"
|
||||
if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/main/main" ]]; then
|
||||
SM_MAIN="$ROOT/src/dist/main/main"
|
||||
if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/ServerManager/ServerManager" ]]; then
|
||||
SM_MAIN="$ROOT/src/dist/ServerManager/ServerManager"
|
||||
fi
|
||||
|
||||
# 颜色输出
|
||||
|
||||
+195
-30
@@ -8,10 +8,15 @@
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
# 仅标准库:PID 检测用 ctypes(Windows)或 os.kill(Unix)
|
||||
if sys.platform == "win32":
|
||||
@@ -63,17 +68,173 @@ def is_process_alive(pid: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
DELTA_FORMAT = "server-manager-file-delta-v1"
|
||||
|
||||
|
||||
def _sha256_file(file_path: str) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest().lower()
|
||||
|
||||
|
||||
def _safe_relative_path(value: str) -> str:
|
||||
rel = str(value or "").replace("\\", "/").strip("/")
|
||||
parts = [part for part in rel.split("/") if part]
|
||||
if not parts or any(part in (".", "..") for part in parts):
|
||||
raise ValueError("unsafe relative path: %s" % value)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _safe_join(root: str, rel_path: str) -> str:
|
||||
root_abs = os.path.abspath(root)
|
||||
rel = _safe_relative_path(rel_path)
|
||||
target = os.path.abspath(os.path.join(root_abs, *rel.split("/")))
|
||||
if target != root_abs and not target.startswith(root_abs + os.sep):
|
||||
raise ValueError("path escapes install dir: %s" % rel_path)
|
||||
return target
|
||||
|
||||
|
||||
def _read_delta_manifest(delta_zip_path: str) -> dict:
|
||||
with zipfile.ZipFile(delta_zip_path, "r") as zf:
|
||||
with zf.open("delta_manifest.json") as f:
|
||||
manifest = json.loads(f.read().decode("utf-8-sig"))
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("delta manifest must be an object")
|
||||
if manifest.get("format") != DELTA_FORMAT:
|
||||
raise ValueError("unsupported delta format: %s" % manifest.get("format"))
|
||||
if not isinstance(manifest.get("files"), list):
|
||||
raise ValueError("delta manifest files must be a list")
|
||||
if not isinstance(manifest.get("delete"), list):
|
||||
manifest["delete"] = []
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_base_files(install_dir: str, manifest: dict) -> None:
|
||||
for entry in manifest.get("files", []):
|
||||
rel = _safe_relative_path(entry.get("path"))
|
||||
old_sha = str(entry.get("old_sha256") or "").lower()
|
||||
target = _safe_join(install_dir, rel)
|
||||
if old_sha:
|
||||
if not os.path.isfile(target):
|
||||
raise ValueError("base file missing: %s" % rel)
|
||||
if _sha256_file(target) != old_sha:
|
||||
raise ValueError("base file hash mismatch: %s" % rel)
|
||||
|
||||
for entry in manifest.get("delete", []):
|
||||
rel = _safe_relative_path(entry.get("path"))
|
||||
old_sha = str(entry.get("old_sha256") or "").lower()
|
||||
target = _safe_join(install_dir, rel)
|
||||
if os.path.exists(target) and old_sha and os.path.isfile(target) and _sha256_file(target) != old_sha:
|
||||
raise ValueError("delete file hash mismatch: %s" % rel)
|
||||
|
||||
|
||||
def _extract_delta_files(delta_zip_path: str, manifest: dict) -> str:
|
||||
staging_dir = tempfile.mkdtemp(prefix="server_manager_delta_")
|
||||
with zipfile.ZipFile(delta_zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
for entry in manifest.get("files", []):
|
||||
rel = _safe_relative_path(entry.get("path"))
|
||||
zip_path = _safe_relative_path(entry.get("zip_path") or entry.get("source") or rel)
|
||||
expected_sha = str(entry.get("sha256") or "").lower()
|
||||
if zip_path not in names:
|
||||
raise ValueError("delta zip entry missing: %s" % zip_path)
|
||||
staged = _safe_join(staging_dir, rel)
|
||||
os.makedirs(os.path.dirname(staged), exist_ok=True)
|
||||
with zf.open(zip_path) as src, open(staged, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
if expected_sha and _sha256_file(staged) != expected_sha:
|
||||
raise ValueError("delta file hash mismatch: %s" % rel)
|
||||
return staging_dir
|
||||
|
||||
|
||||
def apply_delta_zip(install_dir: str, delta_zip_path: str) -> bool:
|
||||
"""Apply a file-level delta zip after the main process has exited."""
|
||||
backup_dir = ""
|
||||
staged_dir = ""
|
||||
moved_backups = []
|
||||
created_targets = []
|
||||
try:
|
||||
manifest = _read_delta_manifest(delta_zip_path)
|
||||
_validate_base_files(install_dir, manifest)
|
||||
staged_dir = _extract_delta_files(delta_zip_path, manifest)
|
||||
backup_dir = os.path.join(install_dir, ".update_backup", str(int(time.time())))
|
||||
|
||||
for entry in manifest.get("delete", []):
|
||||
rel = _safe_relative_path(entry.get("path"))
|
||||
target = _safe_join(install_dir, rel)
|
||||
if os.path.exists(target):
|
||||
backup = _safe_join(backup_dir, rel)
|
||||
os.makedirs(os.path.dirname(backup), exist_ok=True)
|
||||
shutil.move(target, backup)
|
||||
moved_backups.append((target, backup))
|
||||
|
||||
for entry in manifest.get("files", []):
|
||||
rel = _safe_relative_path(entry.get("path"))
|
||||
source = _safe_join(staged_dir, rel)
|
||||
target = _safe_join(install_dir, rel)
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
if os.path.exists(target):
|
||||
backup = _safe_join(backup_dir, rel)
|
||||
os.makedirs(os.path.dirname(backup), exist_ok=True)
|
||||
shutil.move(target, backup)
|
||||
moved_backups.append((target, backup))
|
||||
else:
|
||||
created_targets.append(target)
|
||||
shutil.move(source, target)
|
||||
|
||||
try:
|
||||
if os.path.isdir(staged_dir):
|
||||
shutil.rmtree(staged_dir, ignore_errors=True)
|
||||
if os.path.isdir(backup_dir):
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
if os.path.isfile(delta_zip_path):
|
||||
os.remove(delta_zip_path)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "apply delta zip failed", e)
|
||||
for target in reversed(created_targets):
|
||||
try:
|
||||
if os.path.exists(target):
|
||||
os.remove(target)
|
||||
except Exception:
|
||||
pass
|
||||
for target, backup in reversed(moved_backups):
|
||||
try:
|
||||
if os.path.exists(target):
|
||||
if os.path.isdir(target):
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
else:
|
||||
os.remove(target)
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
if os.path.exists(backup):
|
||||
shutil.move(backup, target)
|
||||
except Exception as restore_error:
|
||||
_log_error(install_dir, "restore backup failed: %s" % target, restore_error)
|
||||
if staged_dir:
|
||||
shutil.rmtree(staged_dir, ignore_errors=True)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="独立更新守护:等主进程退出后替换 exe 并重启")
|
||||
parser.add_argument("--pid", type=int, required=True, help="主程序进程 ID,必须等其完全退出")
|
||||
parser.add_argument("--install-dir", type=str, required=True, help="主程序安装根目录")
|
||||
parser.add_argument("--new-exe-path", type=str, required=True, help="已准备好的新版本 main.exe 的完整路径(通常在 TEMP)")
|
||||
parser.add_argument("--target-exe-name", type=str, default="main.exe", help="主程序文件名,如 main.exe")
|
||||
parser.add_argument("--new-exe-path", type=str, default="", help="已准备好的新版本 ServerManager.exe 的完整路径(通常在 TEMP)")
|
||||
parser.add_argument("--delta-zip-path", type=str, default="", help="文件级增量包 zip 路径")
|
||||
parser.add_argument("--target-exe-name", type=str, default="ServerManager.exe", help="主程序文件名,如 ServerManager.exe")
|
||||
args = parser.parse_args()
|
||||
|
||||
install_dir = os.path.abspath(args.install_dir)
|
||||
new_exe_path = os.path.abspath(args.new_exe_path)
|
||||
target_exe_name = args.target_exe_name.strip() or "main.exe"
|
||||
new_exe_path = os.path.abspath(args.new_exe_path) if args.new_exe_path else ""
|
||||
delta_zip_path = os.path.abspath(args.delta_zip_path) if args.delta_zip_path else ""
|
||||
if bool(new_exe_path) == bool(delta_zip_path):
|
||||
_log_error(install_dir, "exactly one of --new-exe-path or --delta-zip-path is required")
|
||||
sys.exit(1)
|
||||
target_exe_name = args.target_exe_name.strip() or "ServerManager.exe"
|
||||
target_exe_full = os.path.join(install_dir, target_exe_name)
|
||||
old_exe_full = target_exe_full + ".old"
|
||||
|
||||
@@ -93,36 +254,40 @@ def main() -> None:
|
||||
_log_error(install_dir, "切换到安装目录失败", e)
|
||||
sys.exit(1)
|
||||
|
||||
# 若存在上次遗留的 .old,先静默删除
|
||||
if os.path.isfile(old_exe_full):
|
||||
if delta_zip_path:
|
||||
if not apply_delta_zip(install_dir, delta_zip_path):
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 若存在上次遗留的 .old,先静默删除
|
||||
if os.path.isfile(old_exe_full):
|
||||
try:
|
||||
os.remove(old_exe_full)
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
|
||||
|
||||
# 将当前主程序重命名为 .old,腾出位置
|
||||
if not os.path.isfile(target_exe_full):
|
||||
_log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
|
||||
sys.exit(1)
|
||||
try:
|
||||
os.remove(old_exe_full)
|
||||
os.rename(target_exe_full, old_exe_full)
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
|
||||
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
|
||||
sys.exit(1)
|
||||
|
||||
# 将当前主程序重命名为 .old,腾出位置
|
||||
if not os.path.isfile(target_exe_full):
|
||||
_log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
|
||||
sys.exit(1)
|
||||
try:
|
||||
os.rename(target_exe_full, old_exe_full)
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
|
||||
sys.exit(1)
|
||||
|
||||
# 将新 exe 移入安装目录并命名为 target-exe-name
|
||||
if not os.path.isfile(new_exe_path):
|
||||
_log_error(install_dir, "新 exe 不存在: %s" % new_exe_path)
|
||||
sys.exit(1)
|
||||
try:
|
||||
shutil.move(new_exe_path, target_exe_full)
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "移动新 exe 到安装目录失败", e)
|
||||
# 将新 exe 移入安装目录并命名为 target-exe-name
|
||||
if not os.path.isfile(new_exe_path):
|
||||
_log_error(install_dir, "新 exe 不存在: %s" % new_exe_path)
|
||||
sys.exit(1)
|
||||
try:
|
||||
os.rename(old_exe_full, target_exe_full)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
shutil.move(new_exe_path, target_exe_full)
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "移动新 exe 到安装目录失败", e)
|
||||
try:
|
||||
os.rename(old_exe_full, target_exe_full)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"""
|
||||
仿 Cursor / VS Code 的无感增量更新模块(Tkinter 入口用)
|
||||
|
||||
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 main.exe 与 _internal 等依赖。
|
||||
【增量范围】.patch 仅针对 main.exe:在 TEMP 合成 main_new.exe 并校验 SHA256;
|
||||
替换时只覆盖安装目录下的 main.exe,绝不修改 _internal 或其它文件。
|
||||
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 main_new.exe,不留下垃圾。
|
||||
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 ServerManager.exe 与 _internal 等依赖。
|
||||
【增量范围】.patch 仅针对 ServerManager.exe:在 TEMP 合成 ServerManager_new.exe 并校验 SHA256;
|
||||
替换时只覆盖安装目录下的 ServerManager.exe,绝不修改 _internal 或其它文件。
|
||||
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 ServerManager_new.exe,不留下垃圾。
|
||||
|
||||
依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。
|
||||
远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。
|
||||
@@ -91,9 +91,9 @@ def apply_update_and_restart(
|
||||
if not os.path.isfile(new_exe_path):
|
||||
return
|
||||
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
|
||||
target_name = (target_exe_name or os.path.basename(current_exe_path) or "main.exe").strip() or "main.exe"
|
||||
target_name = (target_exe_name or os.path.basename(current_exe_path) or "ServerManager.exe").strip() or "ServerManager.exe"
|
||||
|
||||
# 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径
|
||||
# 优先使用安装目录下的 mini_updater.exe(与 ServerManager.exe 同目录),必须用绝对路径
|
||||
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
|
||||
if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
|
||||
try:
|
||||
@@ -185,14 +185,14 @@ def _get_delta_map(manifest: dict) -> dict:
|
||||
class SeamlessUpdater:
|
||||
"""
|
||||
隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」,
|
||||
用户确认后极速替换 main.exe(或静默安装完整包)并退出。
|
||||
用户确认后极速替换 ServerManager.exe(或静默安装完整包)并退出。
|
||||
"""
|
||||
|
||||
def __init__(self, root: tk.Tk, local_version: str, version_url: str = None):
|
||||
self.root = root
|
||||
self.local_version = (local_version or "").strip()
|
||||
self.version_url = (version_url or VERSION_JSON_URL).strip()
|
||||
self._ready_delta_path = None # TEMP 下 main_new.exe 路径(增量就绪)
|
||||
self._ready_delta_path = None # TEMP 下 ServerManager_new.exe 路径(增量就绪)
|
||||
self._ready_full_path = None # TEMP 下 Setup.exe 路径(全量就绪)
|
||||
self._ready_remote_version = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -242,8 +242,8 @@ class SeamlessUpdater:
|
||||
|
||||
def _try_delta_path(self, manifest: dict) -> bool:
|
||||
"""
|
||||
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 main_new.exe -> 校验 SHA256。
|
||||
仅针对 main.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
|
||||
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 ServerManager_new.exe -> 校验 SHA256。
|
||||
仅针对 ServerManager.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
|
||||
成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。
|
||||
"""
|
||||
delta_map = _get_delta_map(manifest)
|
||||
@@ -386,7 +386,7 @@ import tkinter as tk
|
||||
from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL
|
||||
|
||||
# 从 version.json 或常量读取本地版本号
|
||||
LOCAL_VERSION = "1.1.3" # 需与 version.json / 安装包一致
|
||||
LOCAL_VERSION = "1.1.8" # 需与 version.json / 安装包一致
|
||||
|
||||
def main():
|
||||
root = tk.Tk()
|
||||
@@ -408,20 +408,20 @@ if __name__ == "__main__":
|
||||
# 开发者:如何生成 .patch 文件(发版流程)
|
||||
# ---------------------------------------------------------------------------
|
||||
"""
|
||||
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 main.exe,不包含 _internal;
|
||||
用户端替换时只覆盖 main.exe,绝不修改 _internal 或其它文件。
|
||||
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 ServerManager.exe,不包含 _internal;
|
||||
用户端替换时只覆盖 ServerManager.exe,绝不修改 _internal 或其它文件。
|
||||
|
||||
1. 打包:使用 PyInstaller 文件夹模式生成当前版本:
|
||||
pyinstaller -D -w your_main.py
|
||||
得到 dist/your_main/main.exe(或 dist/main.exe)。
|
||||
pyinstaller -D -w -n ServerManager entrypoints/main.py
|
||||
得到 dist/ServerManager/ServerManager.exe。
|
||||
|
||||
2. 保存上一版本的 main.exe(仅此单文件,无需 _internal):
|
||||
将上一发版时的 main.exe 保存为 build/output/main_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
|
||||
2. 保存上一版本的 ServerManager.exe(仅此单文件,无需 _internal):
|
||||
将上一发版时的 ServerManager.exe 保存为 build/output/ServerManager_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
|
||||
|
||||
3. 生成差分补丁(需安装 bsdiff4:pip install bsdiff4):
|
||||
import bsdiff4
|
||||
old = open('build/output/main_1.0.2.exe', 'rb').read()
|
||||
new = open('dist/your_main/main.exe', 'rb').read()
|
||||
old = open('build/output/ServerManager_1.0.2.exe', 'rb').read()
|
||||
new = open('dist/ServerManager/ServerManager.exe', 'rb').read()
|
||||
patch = bsdiff4.diff(old, new)
|
||||
open('build/output/patches/v1.0.2_to_v1.0.3.patch', 'wb').write(patch)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user