仓库初始化
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
@echo off
|
||||
REM ASCII-only batch: reliable under cmd.exe from PowerShell/Python (no UTF-8 BOM issues).
|
||||
chcp 65001 >nul 2>&1
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
title Server Manager - build
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "SRC=%ROOT%\src"
|
||||
set "RESOURCE_CONFIG=%ROOT%\resources\config"
|
||||
pushd "%SRC%" || (
|
||||
echo [ERROR] src directory not found: %SRC%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ============================================================
|
||||
echo Server Manager - build EXE
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Python not found. Install Python 3.8+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/6] pip deps...
|
||||
pip install PyQt6 pyinstaller -q 2>nul
|
||||
pip install pymysql Pillow -q 2>nul
|
||||
|
||||
python -c "import PyQt6" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] pip install PyQt6
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
python -c "import PyInstaller" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] pip install pyinstaller
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/6] icon...
|
||||
python "%ROOT%\resources\icon\convert_icon.py"
|
||||
if not exist "%ROOT%\resources\icon\icon.ico" (
|
||||
echo [WARN] no icon.ico, using --icon NONE
|
||||
set "ICON_PARAM=--icon NONE"
|
||||
) else (
|
||||
set "ICON_PARAM=--icon %ROOT%\resources\icon\icon.ico"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
echo [4/6] PyInstaller main (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
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] main 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
|
||||
) 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^)...
|
||||
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
|
||||
) else (
|
||||
echo [WARN] ..\resources\config missing, Inno install will fail if resources\config is absent at compile time
|
||||
)
|
||||
|
||||
echo [5/6] PyInstaller mini_updater...
|
||||
pyinstaller --noconfirm --onefile --windowed --name mini_updater --distpath dist --workpath build_mini --specpath build_mini "%ROOT%\scripts\update\mini_updater.py"
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] mini_updater PyInstaller failed
|
||||
pause
|
||||
exit /b 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
|
||||
|
||||
rd /s /q build 2>nul
|
||||
rd /s /q build_mini 2>nul
|
||||
del /q *.spec 2>nul
|
||||
del /q build_mini\*.spec 2>nul
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo OK: dist\main\ dist\mini_updater.exe
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
if /i not "%~1"=="nopause" pause
|
||||
popd
|
||||
endlocal
|
||||
exit /b 0
|
||||
@@ -0,0 +1,96 @@
|
||||
; ============================================================
|
||||
; ServerManager - Inno Setup 安装包脚本
|
||||
; 用于生成外层安装包、卸载程序,支持静默更新
|
||||
; ============================================================
|
||||
|
||||
; 本地测试包:使用 scripts\package\pack_local_test.bat 或 ISCC /DLOCAL_TEST=1
|
||||
; 输出到 build\output_local\ServerManager_Setup_LOCAL.exe,独立 AppId,与正式安装并存、不覆盖 build\output\
|
||||
#ifdef LOCAL_TEST
|
||||
#define MyAppName "ServerManager (本地测试)"
|
||||
#define MyAppId "{{B2C3D4E5-F6A7-8901-BCDE-F123456789ABC}"
|
||||
#define MyOutputDir "..\..\build\output_local"
|
||||
#define MyOutputBase "ServerManager_Setup_LOCAL"
|
||||
#else
|
||||
#define MyAppName "ServerManager"
|
||||
#define MyAppId "{{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
#define MyOutputDir "..\..\build\output"
|
||||
#define MyOutputBase "ServerManager_Setup"
|
||||
#endif
|
||||
|
||||
#define MyAppVersion "1.1.3"
|
||||
#define MyAppExeName "main.exe"
|
||||
#define MyAppPublisher "ServerManager"
|
||||
#define MyAppURL "http://your-server.com/"
|
||||
|
||||
[Setup]
|
||||
; 基础信息
|
||||
AppId={#MyAppId}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
; 输出(正式:build\output\;本地测试:build\output_local\)
|
||||
OutputDir={#MyOutputDir}
|
||||
OutputBaseFilename={#MyOutputBase}
|
||||
SetupIconFile=
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
; 静默安装由命令行参数控制:/VERYSILENT /SUPPRESSMSGBOXES /FORCECLOSEAPPLICATIONS
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
; 控制面板中显示
|
||||
UninstallDisplayName={#MyAppName} {#MyAppVersion}
|
||||
; 安装完成后运行主程序
|
||||
CloseApplications=force
|
||||
RestartApplications=no
|
||||
|
||||
; 静默参数说明:
|
||||
; /VERYSILENT - 完全静默,不显示安装向导
|
||||
; /SUPPRESSMSGBOXES - 抑制消息框
|
||||
; /FORCECLOSEAPPLICATIONS - 强制关闭正在运行的程序
|
||||
|
||||
[Languages]
|
||||
Name: "default"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
|
||||
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 同目录
|
||||
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)
|
||||
Source: "..\..\resources\config\*"; DestDir: "{app}\config"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Dirs]
|
||||
; 安装后展开软件所需目录:配置与日志
|
||||
Name: "{app}\config"; Flags: uninsneveruninstall
|
||||
Name: "{app}\logs"; Flags: uninsneveruninstall
|
||||
|
||||
[Icons]
|
||||
; 开始菜单
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}"
|
||||
; 桌面(根据用户选择)
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
; 快速启动栏
|
||||
Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon
|
||||
|
||||
[Run]
|
||||
; 安装完成后自动运行主程序(含静默更新后也启动,实现“退出并重启”)
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "运行 {#MyAppName}"; Flags: nowait postinstall
|
||||
|
||||
[UninstallDelete]
|
||||
; 删除安装目录下的所有内容(Inno 默认会删除 {app},此处列出以防需要额外清理)
|
||||
Type: filesandordirs; Name: "{app}"
|
||||
; 彻底清理应用产生的缓存目录
|
||||
Type: filesandordirs; Name: "{localappdata}\{#MyAppName}"
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Server Manager - Linux 下用 PyInstaller 打包 main(与 build.bat 的 onedir 主程序对应)
|
||||
# 用法: chmod +x build_linux.sh && ./build_linux.sh
|
||||
# 产物: src/dist/main/main (可将该目录加入 PATH,或设置 SERVER_MANAGER_MAIN 供 scripts/run/run.sh 自动调用)
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
SRC="$ROOT/src"
|
||||
RESOURCE_CONFIG="$ROOT/resources/config"
|
||||
cd "$SRC"
|
||||
|
||||
echo "============================================================"
|
||||
echo " Server Manager - build Linux (PyInstaller onedir)"
|
||||
echo "============================================================"
|
||||
echo
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "[ERROR] Need python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/5] pip deps..."
|
||||
python3 -m pip install -q PyQt6 pyinstaller pymysql Pillow requests
|
||||
|
||||
echo "[2/5] icon..."
|
||||
python3 "$ROOT/resources/icon/convert_icon.py" || true
|
||||
|
||||
ICON_ARG=(--icon NONE)
|
||||
if [[ -f "$ROOT/resources/icon/icon.ico" ]]; then
|
||||
ICON_ARG=(--icon "$ROOT/resources/icon/icon.ico")
|
||||
fi
|
||||
|
||||
echo "[3/5] clean dist/build..."
|
||||
rm -rf dist build ./*.spec 2>/dev/null || true
|
||||
|
||||
echo "[4/5] PyInstaller main (onedir)..."
|
||||
# add-data 在 Linux 下用冒号分隔
|
||||
python3 -m PyInstaller --noconfirm --onedir --name main \
|
||||
"${ICON_ARG[@]}" \
|
||||
--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 bsdiff4 \
|
||||
--hidden-import requests \
|
||||
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
|
||||
fi
|
||||
if [[ -d "$RESOURCE_CONFIG" ]]; then
|
||||
cp -a "$RESOURCE_CONFIG/." dist/main/config/
|
||||
echo " copied resources/config -> dist/main/config"
|
||||
else
|
||||
echo " [WARN] resources/config missing"
|
||||
fi
|
||||
|
||||
rm -rf build 2>/dev/null || true
|
||||
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 "或设置 SERVER_MANAGER_MAIN 指向该文件,配合 scripts/run/run.sh 使用"
|
||||
echo "============================================================"
|
||||
echo
|
||||
@@ -0,0 +1,116 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title Server Manager - 打包便携版
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "SRC=%ROOT%\src"
|
||||
pushd "%SRC%" || (
|
||||
echo [错误] 未找到 src 目录: %SRC%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ============================================================
|
||||
echo Server Manager - 打包便携版 EXE
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
:: 检查 Python 是否安装
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 Python,请先安装 Python 3.8+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/5] 检查并安装依赖...
|
||||
pip install PyQt6 pyinstaller Pillow -q
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [错误] 安装依赖失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/5] 生成图标文件...
|
||||
python "%ROOT%\resources\icon\convert_icon.py"
|
||||
if not exist "%ROOT%\resources\icon\icon.ico" (
|
||||
echo [警告] 无法生成 ICO 图标,将使用默认图标
|
||||
set "ICON_PARAM="
|
||||
) else (
|
||||
set "ICON_PARAM=--icon %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"
|
||||
|
||||
echo [4/5] 开始打包(单文件版本)...
|
||||
pyinstaller --noconfirm --onefile --windowed ^
|
||||
--name "main" ^
|
||||
%ICON_PARAM% ^
|
||||
--add-data "%ROOT%\resources\icon;icon" ^
|
||||
--hidden-import "PyQt6.QtWidgets" ^
|
||||
--hidden-import "PyQt6.QtCore" ^
|
||||
--hidden-import "PyQt6.QtGui" ^
|
||||
--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 "requests" ^
|
||||
--hidden-import "bsdiff4" ^
|
||||
--collect-all "PyQt6" ^
|
||||
--collect-all "bsdiff4" ^
|
||||
entrypoints\main.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [错误] 打包失败,尝试使用目录模式...
|
||||
|
||||
pyinstaller --noconfirm --onedir --windowed ^
|
||||
--name "main" ^
|
||||
%ICON_PARAM% ^
|
||||
--add-data "%ROOT%\resources\icon;icon" ^
|
||||
--hidden-import "PyQt6.QtWidgets" ^
|
||||
--hidden-import "PyQt6.QtCore" ^
|
||||
--hidden-import "PyQt6.QtGui" ^
|
||||
--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 "requests" ^
|
||||
--hidden-import "bsdiff4" ^
|
||||
--collect-all "bsdiff4" ^
|
||||
entrypoints\main.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [错误] 打包失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
pause
|
||||
popd
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Remove generated packaging intermediates after installer builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _is_inside_root(path: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(ROOT.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _remove_dir(path: Path) -> None:
|
||||
if path.exists() and path.is_dir() and _is_inside_root(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
print(f" removed dir: {path.relative_to(ROOT)}")
|
||||
|
||||
|
||||
def _remove_file(path: Path) -> None:
|
||||
if path.exists() and path.is_file() and _is_inside_root(path):
|
||||
try:
|
||||
path.unlink()
|
||||
print(f" removed file: {path.relative_to(ROOT)}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[cleanup] removing generated packaging intermediates...")
|
||||
|
||||
for rel in (
|
||||
"src/dist",
|
||||
"src/build",
|
||||
"src/build_mini",
|
||||
"build/output/config",
|
||||
):
|
||||
_remove_dir(ROOT / rel)
|
||||
|
||||
for pycache in ROOT.rglob("__pycache__"):
|
||||
_remove_dir(pycache)
|
||||
|
||||
for pattern in ("*.pyc", "*.pyo", "*.spec"):
|
||||
for item in ROOT.rglob(pattern):
|
||||
_remove_file(item)
|
||||
|
||||
print("[cleanup] done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
|
||||
title Server Manager - pack all to build\output
|
||||
|
||||
:: 发版前请先打本地测试包验证: scripts\package\pack_local_test.bat 或 python scripts/package/pack_local_test.py
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
set "OUTPUT_DIR=%ROOT%\build\output"
|
||||
|
||||
echo(============================================================
|
||||
echo( Server Manager - 一键打包
|
||||
echo( EXE + Inno Setup 安装包
|
||||
echo(============================================================
|
||||
echo(
|
||||
|
||||
:: Step 1: PyInstaller 打包 main.exe(在 src 目录执行)
|
||||
echo([Step 1/2] PyInstaller 打包 main.exe ...
|
||||
echo(
|
||||
call "%ROOT%\scripts\package\build.bat" nopause
|
||||
if errorlevel 1 (
|
||||
echo(
|
||||
echo([失败] PyInstaller 打包未成功
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo(
|
||||
|
||||
:: Step 2: Inno Setup 编译安装包
|
||||
echo([Step 2/2] Inno Setup 编译安装包 ...
|
||||
echo(
|
||||
if not exist "%ISCC%" goto :missing_iscc
|
||||
|
||||
"%ISCC%" "%ROOT%\scripts\package\build_installer.iss"
|
||||
if errorlevel 1 goto :iscc_failed
|
||||
|
||||
:: 将正式产物放在 build\output
|
||||
if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%"
|
||||
copy /y "%ROOT%\resources\version.json" "%OUTPUT_DIR%\version.json" >nul
|
||||
if not exist "%OUTPUT_DIR%\patches" mkdir "%OUTPUT_DIR%\patches"
|
||||
echo( 已复制: version.json -> build\output\version.json
|
||||
echo( 增量包目录: build\output\patches\
|
||||
|
||||
echo(
|
||||
echo([清理] 删除打包中间产物 ...
|
||||
python "%ROOT%\scripts\package\clean_artifacts.py"
|
||||
|
||||
echo(
|
||||
echo(============================================================
|
||||
echo(一键打包完成!
|
||||
echo( 新发布目录:
|
||||
echo( - build\output\ServerManager_Setup.exe 完整安装包
|
||||
echo( - build\output\version.json 版本与更新配置
|
||||
echo( - build\output\patches\*.patch 增量补丁(可选)
|
||||
echo(============================================================
|
||||
echo(
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:missing_iscc
|
||||
echo([错误] 未找到 Inno Setup 编译器: %ISCC%
|
||||
echo( 请安装 Inno Setup 6 或修改本脚本中的 ISCC 路径
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:iscc_failed
|
||||
echo(
|
||||
echo([失败] Inno Setup 编译未成功
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,62 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
|
||||
title Server Manager - 本地测试包(不发版、不覆盖 build\output)
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
set "OUTPUT_LOCAL_DIR=%ROOT%\build\output_local"
|
||||
|
||||
echo ============================================================
|
||||
echo Server Manager - 本地测试包
|
||||
echo build\output_local\ServerManager_Setup_LOCAL.exe
|
||||
echo 不修改版本号,不覆盖正式目录 build\output\
|
||||
echo 测试通过后请再执行 scripts\package\release.bat 或 scripts\package\pack_all.bat
|
||||
echo ============================================================
|
||||
echo(
|
||||
|
||||
echo([Step 1/3] PyInstaller 打包 main + mini_updater ...
|
||||
call "%ROOT%\scripts\package\build.bat" nopause
|
||||
if errorlevel 1 (
|
||||
echo(
|
||||
echo([失败] PyInstaller 打包未成功
|
||||
echo(若在本终端乱码/解析失败,请改用 CMD 窗口双击本脚本,或执行: python scripts/package/pack_local_test.py
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo(
|
||||
|
||||
echo([Step 2/3] Inno Setup 编译【本地测试】安装包 ^(LOCAL_TEST^) ...
|
||||
if not exist "%ISCC%" goto :missing_iscc
|
||||
"%ISCC%" /DLOCAL_TEST=1 "%ROOT%\scripts\package\build_installer.iss"
|
||||
if errorlevel 1 goto :iscc_failed
|
||||
|
||||
echo(
|
||||
echo([Step 3/3] 复制 version.json 到 build\output_local ...
|
||||
if not exist "%OUTPUT_LOCAL_DIR%" mkdir "%OUTPUT_LOCAL_DIR%"
|
||||
copy /y "%ROOT%\resources\version.json" "%OUTPUT_LOCAL_DIR%\version.json" >nul
|
||||
|
||||
echo(
|
||||
echo([清理] 删除打包中间产物 ...
|
||||
python "%ROOT%\scripts\package\clean_artifacts.py"
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo 本地测试包已生成,请安装验证
|
||||
echo %OUTPUT_LOCAL_DIR%\ServerManager_Setup_LOCAL.exe
|
||||
echo 验证通过后,再执行正式发版
|
||||
echo ============================================================
|
||||
echo.
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:missing_iscc
|
||||
echo([错误] 未找到 Inno Setup: %ISCC%
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:iscc_failed
|
||||
echo([失败] Inno Setup 编译未成功
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
本地测试包:与 scripts/package/pack_local_test.bat 相同流程,适合在 PowerShell / CI 中调用。
|
||||
不修改版本号,输出到 build\output_local\,不覆盖正式 build\output\。
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_LOCAL_DIR = ROOT / "build" / "output_local"
|
||||
ISCC = os.environ.get(
|
||||
"INNO_SETUP_ISCC",
|
||||
r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if any(arg in ("-h", "--help") for arg in sys.argv[1:]):
|
||||
print("用法: python scripts/package/pack_local_test.py")
|
||||
print("生成 build\\output_local\\ServerManager_Setup_LOCAL.exe,不修改正式 build\\output\\。")
|
||||
return 0
|
||||
|
||||
build_script = ROOT / "scripts" / "package" / "build.bat"
|
||||
if not build_script.exists():
|
||||
print("错误: 未找到 scripts\\package\\build.bat", file=sys.stderr)
|
||||
return 1
|
||||
if not Path(ISCC).is_file():
|
||||
print(f"错误: 未找到 Inno Setup 编译器: {ISCC}", file=sys.stderr)
|
||||
print("可设置环境变量 INNO_SETUP_ISCC 指向 ISCC.exe", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("[1/3] PyInstaller: scripts\\package\\build.bat nopause ...")
|
||||
# Use cmd /d /c call ... so exit /b propagates.
|
||||
r = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "call", str(build_script), "nopause"],
|
||||
cwd=str(ROOT),
|
||||
shell=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print("[失败] PyInstaller 步骤退出码:", r.returncode, file=sys.stderr)
|
||||
return r.returncode
|
||||
|
||||
print("[2/3] Inno Setup: LOCAL_TEST -> build\\output_local\\ ...")
|
||||
r = subprocess.run(
|
||||
[ISCC, "/DLOCAL_TEST=1", str(ROOT / "scripts" / "package" / "build_installer.iss")],
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print("[失败] Inno Setup 退出码:", r.returncode, file=sys.stderr)
|
||||
return r.returncode
|
||||
|
||||
out_local = OUTPUT_LOCAL_DIR
|
||||
out_local.mkdir(parents=True, exist_ok=True)
|
||||
vj = ROOT / "resources" / "version.json"
|
||||
if vj.exists():
|
||||
shutil.copy2(vj, out_local / "version.json")
|
||||
print("[3/3] 已复制 version.json -> build\\output_local\\")
|
||||
|
||||
cleanup_script = ROOT / "scripts" / "package" / "clean_artifacts.py"
|
||||
if cleanup_script.exists():
|
||||
print("[cleanup] 删除打包中间产物 ...")
|
||||
subprocess.run([sys.executable, str(cleanup_script)], cwd=str(ROOT), check=False)
|
||||
|
||||
exe = out_local / "ServerManager_Setup_LOCAL.exe"
|
||||
print()
|
||||
print("本地测试包:", exe)
|
||||
print("验证通过后,再执行正式发版(scripts\\package\\release.bat / scripts\\package\\pack_all.bat + scripts\\package\\release.py)。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
|
||||
title Server Manager - 一键发版
|
||||
|
||||
echo(============================================================
|
||||
echo( Server Manager - 一键发版
|
||||
echo( 顺序:PyInstaller 打包 ^> 改版本号并同步 dist ^> Inno ^> 收尾
|
||||
echo( 默认全量更新,产物统一输出到 build\output
|
||||
echo(============================================================
|
||||
echo( 提示:发版前请先打本地测试包验证 ^(scripts\package\pack_local_test.bat^)
|
||||
echo(============================================================
|
||||
echo(
|
||||
|
||||
set "NEW_VER=%~1"
|
||||
set "OUTPUT_DIR=%ROOT%\build\output"
|
||||
if "%NEW_VER%"=="" (
|
||||
set /p "NEW_VER=请输入新版本号 (x.y.z,例如 1.0.4): "
|
||||
)
|
||||
if "%NEW_VER%"=="" (
|
||||
echo([错误] 未输入版本号
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo(
|
||||
echo([阶段 1/4] PyInstaller 打包 main.exe ...
|
||||
call "%ROOT%\scripts\package\build.bat" nopause
|
||||
if errorlevel 1 (
|
||||
echo([失败] PyInstaller 打包未成功
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo(
|
||||
|
||||
echo([阶段 2/4] 更新版本号、全量更新配置并同步 version.json 到 dist ...
|
||||
python "%ROOT%\scripts\package\release.py" "%NEW_VER%"
|
||||
if errorlevel 1 (
|
||||
echo([失败] 版本号更新失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo(
|
||||
|
||||
echo([阶段 3/4] Inno Setup 编译安装包 ...
|
||||
if not exist "%ISCC%" goto :missing_iscc
|
||||
"%ISCC%" "%ROOT%\scripts\package\build_installer.iss"
|
||||
if errorlevel 1 goto :iscc_failed
|
||||
if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%"
|
||||
if not exist "%OUTPUT_DIR%\patches" mkdir "%OUTPUT_DIR%\patches"
|
||||
echo(
|
||||
|
||||
echo([阶段 4/4] 复制 version.json 至 build\output ...
|
||||
python "%ROOT%\scripts\package\release.py" --post-build
|
||||
echo(
|
||||
|
||||
echo([清理] 删除打包中间产物 ...
|
||||
python "%ROOT%\scripts\package\clean_artifacts.py"
|
||||
echo(
|
||||
|
||||
echo(============================================================
|
||||
echo(一键发版完成!
|
||||
echo( 新产出目录 build\output\
|
||||
echo( - ServerManager_Setup.exe 完整安装包
|
||||
echo( - version.json 版本与更新配置
|
||||
echo( - patches\*.patch 增量补丁(结构未变时才手动启用)
|
||||
echo(============================================================
|
||||
echo(
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:missing_iscc
|
||||
echo([错误] 未找到 Inno Setup: %ISCC%
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:iscc_failed
|
||||
echo([失败] Inno Setup 编译未成功
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
发版与版本号:推荐顺序见 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]
|
||||
本地测试包: scripts/package/pack_local_test.bat 或 python scripts/package/pack_local_test.py(见 RELEASE.md)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
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"
|
||||
|
||||
|
||||
def read_text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def write_text(p: Path, s: str) -> None:
|
||||
p.write_text(s, encoding="utf-8")
|
||||
|
||||
|
||||
def read_version_from_json(p: Path) -> str:
|
||||
if not p.exists():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(read_text(p))
|
||||
except Exception:
|
||||
return ""
|
||||
return (data.get("version") or "").strip()
|
||||
|
||||
|
||||
def get_current_version() -> str:
|
||||
"""读取当前已发布版本,以 build/output/version.json 为准。"""
|
||||
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 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
|
||||
return data
|
||||
|
||||
|
||||
def write_version_json(data: dict) -> None:
|
||||
data = ensure_compat_manifest(data)
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
write_text(VERSION_JSON, text)
|
||||
write_text(ROOT_VERSION_JSON, text)
|
||||
|
||||
|
||||
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False) -> str:
|
||||
"""更新版本号并同步 manifest。默认全量更新;显式 --with-delta 才生成 delta 项。"""
|
||||
vj = VERSION_JSON
|
||||
if not vj.exists():
|
||||
print("错误: 未找到 version.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
data = json.loads(read_text(vj))
|
||||
old_version = read_version_from_json(OUTPUT_DIR / "version.json")
|
||||
if with_delta and not old_version:
|
||||
print("错误: 未找到 build/output/version.json 或其中无 version 字段", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if old_version:
|
||||
print(f" [i] 旧版本以 build/output/version.json 为准: {old_version}")
|
||||
|
||||
# build_installer.iss
|
||||
iss = ROOT / "scripts" / "package" / "build_installer.iss"
|
||||
if iss.exists():
|
||||
t = read_text(iss)
|
||||
t = re.sub(r'(#define MyAppVersion\s+")[^"]+(")', r'\g<1>' + new_version + r'\g<2>', t)
|
||||
write_text(iss, t)
|
||||
print(f" [OK] build_installer.iss -> {new_version}")
|
||||
|
||||
# Tk demo/updater examples
|
||||
for rel in ("src/entrypoints/tk_update_demo.py", "scripts/update/tk_updater.py"):
|
||||
main_py = ROOT / rel
|
||||
if not main_py.exists():
|
||||
continue
|
||||
t = read_text(main_py)
|
||||
t = re.sub(r'(LOCAL_VERSION\s*=\s*")[^"]+(")', r'\g<1>' + new_version + r'\g<2>', t)
|
||||
write_text(main_py, t)
|
||||
print(f" [OK] {rel} -> LOCAL_VERSION {new_version}")
|
||||
|
||||
# version.json
|
||||
data["version"] = new_version
|
||||
if release_notes is not None:
|
||||
data["release_notes"] = release_notes
|
||||
else:
|
||||
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: {
|
||||
"patch_url": patch_url,
|
||||
"new_exe_sha256": "",
|
||||
}
|
||||
}
|
||||
print(f" [OK] version.json -> version {new_version}, delta_updates[{old_version}] 已添加")
|
||||
else:
|
||||
data["delta_updates"] = {}
|
||||
data.pop("delta", None)
|
||||
print(f" [OK] version.json -> version {new_version},本次使用全量更新")
|
||||
|
||||
write_version_json(data)
|
||||
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"):
|
||||
dest = ROOT / rel
|
||||
if dest.parent.is_dir():
|
||||
shutil.copy2(vj, dest)
|
||||
print(f" [OK] 已同步 version.json -> {dest.relative_to(ROOT)}")
|
||||
return old_version
|
||||
|
||||
|
||||
def set_version_post(old_exe_path: str = None) -> None:
|
||||
"""打包完成后:若提供 --old-exe(上一版 main.exe 路径)则生成 patch 并填 new_exe_sha256;否则仅复制 version.json。本地不保存 exe。"""
|
||||
vj = VERSION_JSON
|
||||
if not vj.exists():
|
||||
return
|
||||
data = json.loads(read_text(vj))
|
||||
data = ensure_compat_manifest(data)
|
||||
new_version = (data.get("version") or "").strip()
|
||||
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():
|
||||
old_version = k.strip()
|
||||
break
|
||||
if not old_version:
|
||||
print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta)")
|
||||
write_version_json(data)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
# 支持目录模式 (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,跳过增量包")
|
||||
write_version_json(data)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
# 仅当显式传入上一版 exe 路径时生成 patch(与 Cursor 一致,本地不保存 exe)
|
||||
old_exe = Path(old_exe_path).resolve() if old_exe_path else 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)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
try:
|
||||
import bsdiff4
|
||||
except ImportError:
|
||||
print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr)
|
||||
_copy_version_to_output()
|
||||
return
|
||||
|
||||
patch_dir = OUTPUT_DIR / "patches"
|
||||
patch_dir.mkdir(parents=True, exist_ok=True)
|
||||
patch_file = patch_dir / f"v{old_version}_to_v{new_version}.patch"
|
||||
|
||||
old_data = old_exe.read_bytes()
|
||||
new_data = new_exe.read_bytes()
|
||||
patch_data = bsdiff4.diff(old_data, new_data)
|
||||
patch_file.write_bytes(patch_data)
|
||||
print(f" [OK] 已生成: {patch_file}")
|
||||
|
||||
new_sha = hashlib.sha256(new_data).hexdigest().lower()
|
||||
delta_updates[old_version]["new_exe_sha256"] = new_sha
|
||||
data["delta_updates"] = delta_updates
|
||||
write_version_json(data)
|
||||
print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...")
|
||||
|
||||
_copy_version_to_output()
|
||||
print(" 发版准备完成。")
|
||||
|
||||
|
||||
def _copy_version_to_output() -> None:
|
||||
src = VERSION_JSON
|
||||
dst = OUTPUT_DIR / "version.json"
|
||||
if src.exists() and dst.parent.exists():
|
||||
shutil.copy2(src, dst)
|
||||
print(" [OK] 已复制 version.json -> build/output/")
|
||||
if src.exists():
|
||||
shutil.copy2(src, ROOT_VERSION_JSON)
|
||||
print(" [OK] 已同步 version.json -> 根目录")
|
||||
|
||||
|
||||
def clean_generated_artifacts() -> None:
|
||||
cleanup_script = ROOT / "scripts" / "package" / "clean_artifacts.py"
|
||||
if cleanup_script.exists():
|
||||
subprocess.run([sys.executable, str(cleanup_script)], cwd=str(ROOT), check=False)
|
||||
|
||||
|
||||
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("--notes", "-n", default=None, help="可选:release_notes 更新说明")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.post_build:
|
||||
set_version_post(old_exe_path=args.old_exe)
|
||||
clean_generated_artifacts()
|
||||
return
|
||||
|
||||
if not args.version or not re.match(r"^\d+\.\d+\.\d+$", args.version.strip()):
|
||||
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)
|
||||
if args.with_delta:
|
||||
print(f"\n 当前版本 {old_ver} -> 新版本 {new_ver}。若使用 scripts/package/release.bat,接下来将编译安装包并执行 --post-build。")
|
||||
else:
|
||||
print(f"\n 新版本 {new_ver} 将通过 build/output 下的完整安装包更新客户端。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,147 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal EnableExtensions
|
||||
title Server Manager
|
||||
|
||||
rem Resolve repository root from scripts\run\.
|
||||
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
|
||||
|
||||
rem SERVER_MANAGER_ROOT can override the default managed server root.
|
||||
set "SERVER_ROOT=%ROOT%"
|
||||
if defined SERVER_MANAGER_ROOT set "SERVER_ROOT=%SERVER_MANAGER_ROOT%"
|
||||
|
||||
rem Run source modules through the new entrypoints package.
|
||||
cd /d "%ROOT%"
|
||||
set "PYTHONPATH=%ROOT%\src;%PYTHONPATH%"
|
||||
|
||||
rem Prefer packaged main.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"
|
||||
|
||||
rem No arguments enters interactive CLI mode.
|
||||
if "%~1"=="" goto :run_interactive
|
||||
|
||||
rem Command dispatch.
|
||||
if /i "%~1"=="help" goto :show_help
|
||||
if /i "%~1"=="--help" goto :show_help
|
||||
if /i "%~1"=="-h" goto :show_help
|
||||
if /i "%~1"=="gui" goto :run_gui
|
||||
if /i "%~1"=="mcp" goto :run_mcp
|
||||
|
||||
rem Pass commands to CLI entrypoint.
|
||||
echo [INFO] 服务器根目录: %SERVER_ROOT%
|
||||
if defined SM_MAIN (
|
||||
echo [INFO] 使用打包程序: %SM_MAIN%
|
||||
"%SM_MAIN%" --root "%SERVER_ROOT%" %*
|
||||
goto :end
|
||||
)
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 Python,请先安装 Python 3.8+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
python -m entrypoints.cli --root "%SERVER_ROOT%" %*
|
||||
goto :end
|
||||
|
||||
:run_interactive
|
||||
echo [INFO] 服务器根目录: %SERVER_ROOT%
|
||||
if defined SM_MAIN (
|
||||
echo [INFO] 使用打包程序: %SM_MAIN%
|
||||
"%SM_MAIN%" --root "%SERVER_ROOT%" interactive
|
||||
goto :end
|
||||
)
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 Python,请先安装 Python 3.8+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
python -m entrypoints.cli --root "%SERVER_ROOT%" interactive
|
||||
goto :end
|
||||
|
||||
:show_help
|
||||
echo Server Manager - Windows
|
||||
echo.
|
||||
echo Usage: %~nx0 [command] [args]
|
||||
echo.
|
||||
echo Server root: %SERVER_ROOT%
|
||||
echo.
|
||||
echo Commands:
|
||||
echo list
|
||||
echo create
|
||||
echo start
|
||||
echo stop
|
||||
echo connect
|
||||
echo compile
|
||||
echo regen
|
||||
echo info
|
||||
echo status
|
||||
echo config
|
||||
echo logs
|
||||
echo command
|
||||
echo ids
|
||||
echo doctor
|
||||
echo migrate
|
||||
echo mcp
|
||||
echo gui
|
||||
echo.
|
||||
echo Examples:
|
||||
echo %~nx0 list
|
||||
echo %~nx0 create --type game --id 1
|
||||
echo %~nx0 start --server ddxq_game_s1 --background
|
||||
echo %~nx0 compile --target proto
|
||||
echo %~nx0 status --json
|
||||
echo %~nx0 config show --server ddxq_game_s1
|
||||
echo %~nx0 logs search ddxq_game_s1 error
|
||||
echo %~nx0 command start ddxq_game_s1
|
||||
echo %~nx0 mcp
|
||||
echo %~nx0 gui
|
||||
echo.
|
||||
echo More help: python -m entrypoints.cli --help
|
||||
if defined SM_MAIN (
|
||||
echo.
|
||||
echo Packaged main: %SM_MAIN%
|
||||
)
|
||||
goto :end
|
||||
|
||||
:run_gui
|
||||
echo [INFO] 服务器根目录: %SERVER_ROOT%
|
||||
if defined SM_MAIN (
|
||||
echo [INFO] 启动 GUI(打包程序)...
|
||||
"%SM_MAIN%"
|
||||
goto :end
|
||||
)
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 Python,请先安装 Python 3.8+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
rem Check PyQt6 for source GUI mode.
|
||||
python -c "import PyQt6" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [提示] 正在安装 PyQt6...
|
||||
pip install PyQt6
|
||||
)
|
||||
echo [INFO] 启动 GUI 界面...
|
||||
python -m entrypoints
|
||||
goto :end
|
||||
|
||||
:run_mcp
|
||||
if defined SM_MAIN (
|
||||
"%SM_MAIN%" --root "%SERVER_ROOT%" mcp
|
||||
goto :end
|
||||
)
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Python not found. Install Python 3.8+
|
||||
exit /b 1
|
||||
)
|
||||
python -m entrypoints.cli --root "%SERVER_ROOT%" mcp
|
||||
goto :end
|
||||
|
||||
:end
|
||||
endlocal
|
||||
exit /b 0
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/bin/bash
|
||||
# Server Manager - Linux 启动脚本
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/run/run.sh # 进入交互式菜单
|
||||
# ./scripts/run/run.sh list # 列出所有服务器
|
||||
# ./scripts/run/run.sh create -t game -i 1
|
||||
# ./scripts/run/run.sh start -s ddxq_game_s1
|
||||
# ./scripts/run/run.sh stop -s ddxq_game_s1
|
||||
# ./scripts/run/run.sh gui # 启动 GUI(需要桌面环境)
|
||||
|
||||
set -e
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
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
|
||||
SM_MAIN="${SERVER_MANAGER_MAIN:-}"
|
||||
if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/main/main" ]]; then
|
||||
SM_MAIN="$ROOT/src/dist/main/main"
|
||||
fi
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 检查 Python
|
||||
check_python() {
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON=python3
|
||||
elif command -v python &> /dev/null; then
|
||||
PYTHON=python
|
||||
else
|
||||
print_error "未找到 Python,请先安装 Python 3.6+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查版本(兼容所有 Python 版本)
|
||||
PY_VERSION=$($PYTHON -c 'import sys; print(str(sys.version_info[0]) + "." + str(sys.version_info[1]))')
|
||||
print_info "使用 Python $PY_VERSION"
|
||||
|
||||
# 检查是否为 Python 3.6+
|
||||
PY_MAJOR=$($PYTHON -c 'import sys; print(sys.version_info[0])')
|
||||
PY_MINOR=$($PYTHON -c 'import sys; print(sys.version_info[1])')
|
||||
if [ "$PY_MAJOR" -lt 3 ] || ([ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 6 ]); then
|
||||
print_error "需要 Python 3.6+,当前版本: $PY_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查并安装依赖
|
||||
check_dependencies() {
|
||||
# 检查是否需要 GUI 模式
|
||||
if [ "$1" == "gui" ]; then
|
||||
if ! $PYTHON -c "import PyQt6" 2>/dev/null; then
|
||||
print_warn "正在安装 PyQt6..."
|
||||
pip3 install PyQt6 || pip install PyQt6
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 显示帮助
|
||||
show_help() {
|
||||
echo "Server Manager - Linux 版本"
|
||||
echo ""
|
||||
echo "用法: $0 [命令] [参数]"
|
||||
echo ""
|
||||
echo "服务器根目录: $SERVER_ROOT"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " (无参数) 进入交互式菜单"
|
||||
echo " list 列出所有服务器"
|
||||
echo " create 创建服务器"
|
||||
echo " start 启动服务器"
|
||||
echo " stop 停止服务器"
|
||||
echo " connect 连接到服务器"
|
||||
echo " compile 编译代码"
|
||||
echo " regen 重新生成 sys.config"
|
||||
echo " info 显示项目路径、版本和工具信息"
|
||||
echo " status 检查服务器节点在线状态"
|
||||
echo " config 查看或修改项目/服务器配置"
|
||||
echo " logs 列出、读取或检索服务器日志"
|
||||
echo " command 只生成并打印启动/停止/连接命令"
|
||||
echo " ids 查看已有服务器 ID 和下一个可用 ID"
|
||||
echo " doctor 检查目录、配置模板和外部工具"
|
||||
echo " migrate 手动执行项目配置迁移"
|
||||
echo " gui 启动 GUI 界面(需要桌面环境)"
|
||||
echo " mcp 启动 MCP stdio 服务"
|
||||
echo ""
|
||||
echo "start 命令参数:"
|
||||
echo " --server, -s 服务器目录名 (必填)"
|
||||
echo " --quick, -q 快速启动 (直接用 erl,不使用 rebar3)"
|
||||
echo " --background, -b 后台运行 (打开新窗口/screen)"
|
||||
echo ""
|
||||
echo "compile 命令参数:"
|
||||
echo " --target, -t 编译目标 (all/code/proto/table/tbllog, 默认 all)"
|
||||
echo " --type 服务器类型 (game/login, 默认 game)"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 # 交互式模式"
|
||||
echo " $0 list # 列出服务器"
|
||||
echo " $0 create --type game --id 1"
|
||||
echo " $0 start -s ddxq_game_s1 # rebar3 模式,前台运行"
|
||||
echo " $0 start -s ddxq_game_s1 -q # 快速启动,前台运行"
|
||||
echo " $0 start -s ddxq_game_s1 -b # rebar3 模式,后台运行"
|
||||
echo " $0 start -s ddxq_game_s1 -q -b # 快速启动,后台运行"
|
||||
echo " $0 stop -s ddxq_game_s1"
|
||||
echo " $0 compile # 编译游戏服"
|
||||
echo " $0 compile --type login # 编译登录服"
|
||||
echo " $0 compile -t code # 只编译游戏服代码"
|
||||
echo " $0 compile -t code --type login # 只编译登录服代码"
|
||||
echo " $0 status --json # JSON 输出节点状态"
|
||||
echo " $0 config show -s ddxq_game_s1 # 查看单服合并配置"
|
||||
echo " $0 logs search ddxq_game_s1 error"
|
||||
echo " $0 command start ddxq_game_s1 # 只打印启动命令"
|
||||
echo " $0 mcp # 启动 MCP 服务"
|
||||
echo ""
|
||||
echo "更多帮助: $PYTHON -m entrypoints.cli --help"
|
||||
if [[ -n "${SM_MAIN:-}" ]]; then
|
||||
echo ""
|
||||
echo "已检测到打包程序: $SM_MAIN"
|
||||
echo " 也可直接: $SM_MAIN list"
|
||||
echo " GUI: $SM_MAIN (无参数)"
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
if [ "${1:-}" = "mcp" ]; then
|
||||
if [[ -n "${SM_MAIN:-}" ]]; then
|
||||
exec "$SM_MAIN" --root "$SERVER_ROOT" mcp
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
exec python3 -m entrypoints.cli --root "$SERVER_ROOT" mcp
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
exec python -m entrypoints.cli --root "$SERVER_ROOT" mcp
|
||||
else
|
||||
echo "[ERROR] Python not found. Install Python 3.6+" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
check_python
|
||||
|
||||
print_info "服务器根目录: $SERVER_ROOT"
|
||||
|
||||
# 如果没有参数,进入交互式模式
|
||||
if [ $# -eq 0 ]; then
|
||||
if [[ -n "${SM_MAIN:-}" ]]; then
|
||||
print_info "使用打包程序: $SM_MAIN"
|
||||
exec "$SM_MAIN" --root "$SERVER_ROOT" interactive
|
||||
fi
|
||||
$PYTHON -m entrypoints.cli --root "$SERVER_ROOT" interactive
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
gui)
|
||||
check_dependencies gui
|
||||
print_info "启动 GUI 界面..."
|
||||
if [[ -n "${SM_MAIN:-}" ]]; then
|
||||
exec "$SM_MAIN"
|
||||
fi
|
||||
$PYTHON -m entrypoints
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
# 其他命令传递给 CLI 工具,自动传递服务器根目录
|
||||
if [[ -n "${SM_MAIN:-}" ]]; then
|
||||
exec "$SM_MAIN" --root "$SERVER_ROOT" "$@"
|
||||
fi
|
||||
$PYTHON -m entrypoints.cli --root "$SERVER_ROOT" "$@"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore bytes from an ASCII 0/1 binary-text representation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
text = args.input.read_text(encoding="ascii")
|
||||
bits = "".join(ch for ch in text if ch in "01")
|
||||
if len(bits) % 8 != 0:
|
||||
raise SystemExit(f"Invalid binary text length: {len(bits)} bits is not divisible by 8")
|
||||
data = bytes(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8))
|
||||
args.output.write_bytes(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a file to an ASCII 0/1 binary-text representation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--line-width", type=int, default=0, help="Insert newlines every N bits; 0 means no wrapping")
|
||||
args = parser.parse_args()
|
||||
|
||||
data = args.input.read_bytes()
|
||||
bits = "".join(f"{byte:08b}" for byte in data)
|
||||
if args.line_width and args.line_width > 0:
|
||||
bits = "\n".join(bits[i : i + args.line_width] for i in range(0, len(bits), args.line_width))
|
||||
bits += "\n"
|
||||
args.output.write_text(bits, encoding="ascii")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a QRBackup-style paper backup PDF for a file.
|
||||
|
||||
The QR payload format is intentionally simple and printable:
|
||||
|
||||
QB1/<index>/<total>/GZ/<raw_size>/<sha256>/<base45_chunk>/END
|
||||
|
||||
Each payload only uses QR alphanumeric characters so QR codes stay compact.
|
||||
The generated restore script can rebuild the original file from scanned payloads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import qrcode
|
||||
from qrcode.exceptions import DataOverflowError
|
||||
|
||||
|
||||
BASE45_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
|
||||
|
||||
|
||||
def base45_encode(data: bytes) -> str:
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i + 1 < len(data):
|
||||
value = data[i] * 256 + data[i + 1]
|
||||
out.append(BASE45_ALPHABET[value % 45])
|
||||
out.append(BASE45_ALPHABET[(value // 45) % 45])
|
||||
out.append(BASE45_ALPHABET[value // (45 * 45)])
|
||||
i += 2
|
||||
if i < len(data):
|
||||
value = data[i]
|
||||
out.append(BASE45_ALPHABET[value % 45])
|
||||
out.append(BASE45_ALPHABET[value // 45])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def chunk_text(text: str, size: int) -> list[str]:
|
||||
return [text[i : i + size] for i in range(0, len(text), size)]
|
||||
|
||||
|
||||
def make_payloads(encoded: str, chunk_size: int, raw_size: int, sha256: str, compression: str) -> list[str]:
|
||||
chunks = chunk_text(encoded, chunk_size)
|
||||
total = len(chunks)
|
||||
return [
|
||||
f"QB1/{index:04d}/{total:04d}/{compression}/{raw_size}/{sha256}/{chunk}/END"
|
||||
for index, chunk in enumerate(chunks, start=1)
|
||||
]
|
||||
|
||||
|
||||
ERROR_CORRECTION_LEVELS = {
|
||||
"L": qrcode.constants.ERROR_CORRECT_L,
|
||||
"M": qrcode.constants.ERROR_CORRECT_M,
|
||||
"Q": qrcode.constants.ERROR_CORRECT_Q,
|
||||
"H": qrcode.constants.ERROR_CORRECT_H,
|
||||
}
|
||||
|
||||
|
||||
def build_qr(payload: str, box_size: int = 5, error_correction: str = "Q") -> Image.Image:
|
||||
qr = qrcode.QRCode(
|
||||
version=40,
|
||||
error_correction=ERROR_CORRECTION_LEVELS[error_correction],
|
||||
box_size=box_size,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(payload, optimize=0)
|
||||
qr.make(fit=False)
|
||||
return qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
||||
|
||||
|
||||
def payloads_that_fit(encoded: str, raw_size: int, sha256: str, start_chunk_size: int, error_correction: str, compression: str) -> tuple[int, list[str]]:
|
||||
chunk_size = start_chunk_size
|
||||
while chunk_size >= 800:
|
||||
payloads = make_payloads(encoded, chunk_size, raw_size, sha256, compression)
|
||||
try:
|
||||
for payload in payloads:
|
||||
build_qr(payload, box_size=1, error_correction=error_correction)
|
||||
return chunk_size, payloads
|
||||
except DataOverflowError:
|
||||
chunk_size -= 100
|
||||
raise RuntimeError("Unable to fit payloads into QR version 40-Q codes")
|
||||
|
||||
|
||||
def load_font(size: int) -> ImageFont.ImageFont:
|
||||
candidates = [
|
||||
"arial.ttf",
|
||||
"calibri.ttf",
|
||||
"C:/Windows/Fonts/arial.ttf",
|
||||
"C:/Windows/Fonts/calibri.ttf",
|
||||
]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size=size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_wrapped(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, font: ImageFont.ImageFont, width: int, fill=(0, 0, 0)) -> int:
|
||||
x, y = xy
|
||||
for paragraph in text.splitlines():
|
||||
if not paragraph:
|
||||
y += 24
|
||||
continue
|
||||
for line in textwrap.wrap(paragraph, width=width):
|
||||
draw.text((x, y), line, font=font, fill=fill)
|
||||
y += 30
|
||||
return y
|
||||
|
||||
|
||||
def make_pages(payloads: list[str], source: Path, raw_size: int, payload_size: int, sha256: str, per_page: int, error_correction: str, compression: str) -> list[Image.Image]:
|
||||
page_w, page_h = 2480, 3508 # A4 at 300 DPI
|
||||
margin = 115
|
||||
header_h = 260
|
||||
gap_x = 90
|
||||
gap_y = 95
|
||||
cols = 2
|
||||
rows = max(1, math.ceil(per_page / cols))
|
||||
cell_w = (page_w - 2 * margin - gap_x) // cols
|
||||
cell_h = (page_h - header_h - 2 * margin - (rows - 1) * gap_y) // rows
|
||||
|
||||
title_font = load_font(46)
|
||||
body_font = load_font(24)
|
||||
small_font = load_font(22)
|
||||
label_font = load_font(28)
|
||||
pages: list[Image.Image] = []
|
||||
total_pages = math.ceil(len(payloads) / per_page)
|
||||
|
||||
for page_index in range(total_pages):
|
||||
page = Image.new("RGB", (page_w, page_h), "white")
|
||||
draw = ImageDraw.Draw(page)
|
||||
draw.text((margin, 70), "QRBACKUP PAPER BACKUP", font=title_font, fill=(0, 0, 0))
|
||||
info = (
|
||||
f"FILE: {source.name} QR: {len(payloads)} PAGE: {page_index + 1}/{total_pages}\n"
|
||||
f"RAW: {raw_size} BYTES PAYLOAD: {payload_size} BYTES MODE: {compression} ECC: {error_correction} SHA256: {sha256}"
|
||||
)
|
||||
draw_wrapped(draw, (margin, 135), info, body_font, width=138)
|
||||
|
||||
page_payloads = payloads[page_index * per_page : (page_index + 1) * per_page]
|
||||
for local_index, payload in enumerate(page_payloads):
|
||||
row = local_index // cols
|
||||
col = local_index % cols
|
||||
x0 = margin + col * (cell_w + gap_x)
|
||||
y0 = header_h + margin + row * (cell_h + gap_y)
|
||||
global_index = page_index * per_page + local_index + 1
|
||||
|
||||
qr_img = build_qr(payload, box_size=5, error_correction=error_correction)
|
||||
qr_x = x0 + (cell_w - qr_img.width) // 2
|
||||
qr_y = y0 + 12
|
||||
page.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
label = f"QR {global_index:04d}/{len(payloads):04d}"
|
||||
label_bbox = draw.textbbox((0, 0), label, font=label_font)
|
||||
label_w = label_bbox[2] - label_bbox[0]
|
||||
draw.text((x0 + (cell_w - label_w) // 2, qr_y + qr_img.height + 18), label, font=label_font, fill=(0, 0, 0))
|
||||
|
||||
short_hash = hashlib.sha256(payload.encode("ascii")).hexdigest().upper()[:16]
|
||||
draw.text((x0 + 12, y0 + cell_h - 34), f"PAYLOAD-SHA256-16: {short_hash}", font=small_font, fill=(0, 0, 0))
|
||||
|
||||
footer = "Scan all QR payloads, one per line, then run restore_from_qr_payloads.py."
|
||||
draw.text((margin, page_h - 72), footer, font=body_font, fill=(0, 0, 0))
|
||||
pages.append(page)
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
def write_restore_script(path: Path) -> None:
|
||||
script = r'''#!/usr/bin/env python3
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
BASE45_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
|
||||
BASE45_INDEX = {ch: i for i, ch in enumerate(BASE45_ALPHABET)}
|
||||
|
||||
def base45_decode(text: str) -> bytes:
|
||||
out = bytearray()
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if i + 2 < len(text):
|
||||
value = BASE45_INDEX[text[i]] + BASE45_INDEX[text[i + 1]] * 45 + BASE45_INDEX[text[i + 2]] * 45 * 45
|
||||
out.extend(divmod(value, 256))
|
||||
i += 3
|
||||
elif i + 1 < len(text):
|
||||
value = BASE45_INDEX[text[i]] + BASE45_INDEX[text[i + 1]] * 45
|
||||
out.append(value)
|
||||
i += 2
|
||||
else:
|
||||
raise ValueError("Invalid base45 length")
|
||||
return bytes(out)
|
||||
|
||||
def parse_payload(line: str):
|
||||
tag, index, total, compression, raw_size, sha256, chunk_and_end = line.strip().split("/", 6)
|
||||
if tag != "QB1":
|
||||
raise ValueError(f"Unsupported payload tag: {tag}")
|
||||
if not chunk_and_end.endswith("/END"):
|
||||
raise ValueError("Payload missing /END sentinel")
|
||||
chunk = chunk_and_end[:-4]
|
||||
return int(index), int(total), compression, int(raw_size), sha256.upper(), chunk
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("payload_text", help="Text file containing scanned QR payloads, one per line")
|
||||
parser.add_argument("-o", "--output", default="PROJECT_REPLICATION_PROMPT.restored.md")
|
||||
args = parser.parse_args()
|
||||
|
||||
entries = []
|
||||
for raw_line in Path(args.payload_text).read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if not line.startswith("QB1/"):
|
||||
continue
|
||||
entries.append(parse_payload(line))
|
||||
|
||||
if not entries:
|
||||
raise SystemExit("No QB1 payloads found")
|
||||
|
||||
total = entries[0][1]
|
||||
compression = entries[0][2]
|
||||
raw_size = entries[0][3]
|
||||
expected_sha = entries[0][4]
|
||||
by_index = {}
|
||||
for index, item_total, item_compression, item_raw_size, item_sha, chunk in entries:
|
||||
if item_total != total or item_compression != compression or item_raw_size != raw_size or item_sha != expected_sha:
|
||||
raise SystemExit("Payload metadata mismatch")
|
||||
by_index[index] = chunk
|
||||
|
||||
missing = [i for i in range(1, total + 1) if i not in by_index]
|
||||
if missing:
|
||||
raise SystemExit(f"Missing QR payloads: {missing}")
|
||||
|
||||
encoded = "".join(by_index[i] for i in range(1, total + 1))
|
||||
payload_data = base45_decode(encoded)
|
||||
if compression == "GZ":
|
||||
data = gzip.decompress(payload_data)
|
||||
elif compression == "RAW":
|
||||
data = payload_data
|
||||
else:
|
||||
raise SystemExit(f"Unsupported compression: {compression}")
|
||||
actual_sha = hashlib.sha256(data).hexdigest().upper()
|
||||
if len(data) != raw_size or actual_sha != expected_sha:
|
||||
raise SystemExit(f"Verification failed: size={len(data)} sha256={actual_sha}")
|
||||
Path(args.output).write_bytes(data)
|
||||
print(f"Restored {args.output} ({len(data)} bytes, sha256={actual_sha})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
path.write_text(script, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("-o", "--output-dir", type=Path, default=Path("qrbackup_output"))
|
||||
parser.add_argument("--chunk-size", type=int, default=2200)
|
||||
parser.add_argument("--per-page", type=int, default=4)
|
||||
parser.add_argument("--ecc", choices=sorted(ERROR_CORRECTION_LEVELS), default="Q", help="QR error correction level: L has the fewest codes, H is most robust")
|
||||
parser.add_argument("--no-gzip", action="store_true", help="Store the input bytes directly; useful when the input is already compressed")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = args.input.resolve()
|
||||
output_dir = args.output_dir.resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
raw = source.read_bytes()
|
||||
compressed = raw if args.no_gzip else gzip.compress(raw, compresslevel=9)
|
||||
compression = "RAW" if args.no_gzip else "GZ"
|
||||
sha256 = hashlib.sha256(raw).hexdigest().upper()
|
||||
encoded = base45_encode(compressed)
|
||||
chunk_size, payloads = payloads_that_fit(encoded, len(raw), sha256, args.chunk_size, args.ecc, compression)
|
||||
|
||||
base_name = source.stem
|
||||
payload_text = output_dir / f"{base_name}.qrpayloads.txt"
|
||||
payload_text.write_text("\n".join(payloads) + "\n", encoding="ascii")
|
||||
|
||||
manifest = {
|
||||
"format": "QB1",
|
||||
"source": str(source),
|
||||
"file_name": source.name,
|
||||
"raw_size": len(raw),
|
||||
"payload_size": len(compressed),
|
||||
"compression": compression,
|
||||
"base45_size": len(encoded),
|
||||
"chunk_size": chunk_size,
|
||||
"qr_count": len(payloads),
|
||||
"per_page": args.per_page,
|
||||
"sha256": sha256,
|
||||
"error_correction": args.ecc,
|
||||
"qr_version": 40,
|
||||
}
|
||||
manifest_path = output_dir / f"{base_name}.qrbackup_manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
restore_script = output_dir / "restore_from_qr_payloads.py"
|
||||
write_restore_script(restore_script)
|
||||
|
||||
readme = output_dir / "README_RESTORE.txt"
|
||||
readme.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"QRBACKUP PAPER BACKUP",
|
||||
"",
|
||||
f"Original file: {source.name}",
|
||||
f"Original size: {len(raw)} bytes",
|
||||
f"Gzip size: {len(compressed)} bytes",
|
||||
f"QR count: {len(payloads)}",
|
||||
f"SHA256: {sha256}",
|
||||
"",
|
||||
"Restore steps:",
|
||||
"1. Scan every QR code and save each QR payload as one line in a text file.",
|
||||
"2. Run: python restore_from_qr_payloads.py scanned_payloads.txt -o PROJECT_REPLICATION_PROMPT.restored.md",
|
||||
"3. The script verifies size and SHA256 before writing the restored file.",
|
||||
"",
|
||||
"The generated .qrpayloads.txt file contains the exact QR payloads and can be used to self-test the restore script.",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
pages = make_pages(payloads, source, len(raw), len(compressed), sha256, args.per_page, args.ecc, compression)
|
||||
combined_pdf = output_dir / f"{base_name}.qrbackup.all_pages.pdf"
|
||||
pages[0].save(combined_pdf, "PDF", save_all=True, append_images=pages[1:], resolution=300)
|
||||
|
||||
for index, page in enumerate(pages, start=1):
|
||||
page_pdf = output_dir / f"{base_name}.qrbackup.page_{index:03d}.pdf"
|
||||
page.save(page_pdf, "PDF", resolution=300)
|
||||
|
||||
print(json.dumps({**manifest, "output_dir": str(output_dir), "combined_pdf": str(combined_pdf), "pages": len(pages)}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
独立进程外更新器 (Out-of-Process Updater)
|
||||
|
||||
无第三方依赖(仅标准库 + 可选 ctypes 探测 PID),
|
||||
打包为 mini_updater.exe 后置于安装目录,由主程序在“确认重启”后拉起。
|
||||
负责:等待主进程退出 → 重命名替换 exe → 以正确 cwd 与 DETACHED_PROCESS 启动新进程。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 仅标准库:PID 检测用 ctypes(Windows)或 os.kill(Unix)
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
try:
|
||||
_kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
_kernel32 = None
|
||||
else:
|
||||
_kernel32 = None
|
||||
|
||||
|
||||
def _log_error(install_dir: str, message: str, exc: Exception = None) -> None:
|
||||
"""将错误写入安装目录下的 logs/update_error.log,便于排查。"""
|
||||
try:
|
||||
log_dir = os.path.join(install_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, "update_error.log")
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
from datetime import datetime
|
||||
line = "[%s] %s" % (datetime.now().isoformat(), message)
|
||||
if exc is not None:
|
||||
line += " (%s)" % exc
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def is_process_alive(pid: int) -> bool:
|
||||
"""
|
||||
跨平台判断指定 PID 是否仍存活。
|
||||
Windows: ctypes 调用 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION);
|
||||
Unix: os.kill(pid, 0)。
|
||||
"""
|
||||
if sys.platform == "win32" and _kernel32 is not None:
|
||||
# PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
handle = _kernel32.OpenProcess(0x1000, False, pid)
|
||||
if handle is None or handle == 0:
|
||||
return False
|
||||
try:
|
||||
_kernel32.CloseHandle(handle)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError, PermissionError):
|
||||
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")
|
||||
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"
|
||||
target_exe_full = os.path.join(install_dir, target_exe_name)
|
||||
old_exe_full = target_exe_full + ".old"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. 死神凝视:每隔 0.5 秒检测 --pid 是否存活,必须等到进程彻底消失
|
||||
# -------------------------------------------------------------------------
|
||||
while is_process_alive(args.pid):
|
||||
time.sleep(0.5)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. 障眼法替换 (The Swap)
|
||||
# -------------------------------------------------------------------------
|
||||
import shutil
|
||||
try:
|
||||
os.chdir(install_dir)
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "切换到安装目录失败", e)
|
||||
sys.exit(1)
|
||||
|
||||
# 若存在上次遗留的 .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.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)
|
||||
try:
|
||||
os.rename(old_exe_full, target_exe_full)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS
|
||||
# -------------------------------------------------------------------------
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform == "win32":
|
||||
# DETACHED_PROCESS = 0x00000008,不继承控制台与父进程句柄
|
||||
creationflags = 0x00000008
|
||||
p = subprocess.Popen(
|
||||
[target_exe_full],
|
||||
cwd=install_dir,
|
||||
close_fds=True,
|
||||
creationflags=creationflags,
|
||||
shell=False,
|
||||
)
|
||||
# 不等待子进程,更新器功成身退
|
||||
except Exception as e:
|
||||
_log_error(install_dir, "拉起新主程序失败", e)
|
||||
sys.exit(1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 4. 功成身退
|
||||
# -------------------------------------------------------------------------
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,434 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
仿 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,不留下垃圾。
|
||||
|
||||
依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。
|
||||
远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import requests
|
||||
_HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
_HAS_REQUESTS = False
|
||||
|
||||
try:
|
||||
import bsdiff4
|
||||
_HAS_BSDIFF = True
|
||||
except ImportError:
|
||||
_HAS_BSDIFF = False
|
||||
|
||||
# 远端 version.json 地址
|
||||
VERSION_JSON_URL = "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json"
|
||||
REQUEST_TIMEOUT = 15
|
||||
DOWNLOAD_TIMEOUT = 120
|
||||
|
||||
|
||||
def clean_up_old_version():
|
||||
"""
|
||||
启动时调用:删除安装目录(或当前 exe 所在目录)下以 .old 结尾的残留文件,
|
||||
即上一次「重命名大法」更新退下来的旧程序,静默删除不报错。
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
install_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
install_dir = os.path.dirname(os.path.abspath(__file__)) or os.getcwd()
|
||||
try:
|
||||
for name in os.listdir(install_dir) or []:
|
||||
if name.endswith(".old"):
|
||||
path = os.path.join(install_dir, name)
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_update_log(install_dir: str, message: str) -> None:
|
||||
"""向安装目录下的 logs/update_launcher.log 写入,便于确认是否走了 mini_updater。"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
log_dir = os.path.join(install_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, "update_launcher.log")
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write("[%s] %s\n" % (datetime.now().isoformat(), message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def apply_update_and_restart(
|
||||
new_exe_path: str,
|
||||
target_exe_name: str = None,
|
||||
) -> None:
|
||||
"""
|
||||
独立进程外更新:拉起 mini_updater.exe,由其等待本进程退出后替换 exe 并以正确 cwd 重启。
|
||||
若 mini_updater.exe 不存在则回退到“死循环重试”的 .bat。
|
||||
调用后本进程会立即 os._exit(0),不会返回。
|
||||
"""
|
||||
new_exe_path = os.path.abspath(new_exe_path)
|
||||
current_exe_path = os.path.abspath(sys.executable)
|
||||
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"
|
||||
|
||||
# 优先使用安装目录下的 mini_updater.exe(与 main.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:
|
||||
pid = os.getpid()
|
||||
creationflags = 0x00000008 # DETACHED_PROCESS
|
||||
subprocess.Popen(
|
||||
[
|
||||
mini_updater_exe,
|
||||
"--pid", str(pid),
|
||||
"--install-dir", install_dir,
|
||||
"--new-exe-path", new_exe_path,
|
||||
"--target-exe-name", target_name,
|
||||
],
|
||||
cwd=install_dir,
|
||||
close_fds=True,
|
||||
creationflags=creationflags,
|
||||
shell=False,
|
||||
)
|
||||
_write_update_log(install_dir, "已拉起 mini_updater.exe (pid=%s)" % pid)
|
||||
os._exit(0)
|
||||
return
|
||||
except Exception as e:
|
||||
_write_update_log(install_dir, "拉起 mini_updater.exe 失败: %s,改用 .bat" % e)
|
||||
else:
|
||||
_write_update_log(
|
||||
install_dir,
|
||||
"mini_updater.exe 未找到 (路径: %s),使用 .bat 回退" % mini_updater_exe,
|
||||
)
|
||||
|
||||
# 回退:.bat 死循环重试(仅 Windows)或 shell 脚本(Unix)
|
||||
temp_dir = os.environ.get("TEMP") or os.path.expandvars("%TEMP%")
|
||||
bat_path = os.path.join(temp_dir, "apply_update_%s.bat" % os.getpid())
|
||||
bat_lines = [
|
||||
"@echo off",
|
||||
":retry",
|
||||
'move /y "%s" "%s" >nul 2>&1' % (new_exe_path, current_exe_path),
|
||||
"if errorlevel 1 (",
|
||||
" ping 127.0.0.1 -n 2 >nul",
|
||||
" goto retry",
|
||||
")",
|
||||
'cd /d "%s"' % install_dir,
|
||||
'start "" "%s"' % current_exe_path,
|
||||
'del "%~f0"',
|
||||
]
|
||||
try:
|
||||
with open(bat_path, "w", encoding="utf-8") as f:
|
||||
f.write("\r\n".join(bat_lines))
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", bat_path],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
|
||||
shell=False,
|
||||
)
|
||||
else:
|
||||
script = (
|
||||
"while ! mv -f '%s' '%s' 2>/dev/null; do sleep 1; done; cd '%s' && exec '%s'"
|
||||
% (new_exe_path, current_exe_path, install_dir, current_exe_path)
|
||||
)
|
||||
subprocess.Popen(["sh", "-c", script], shell=False)
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _parse_version(v: str):
|
||||
"""'x.y.z' -> (x, y, z)"""
|
||||
try:
|
||||
parts = v.strip().split(".")
|
||||
return (
|
||||
int(parts[0]) if len(parts) > 0 else 0,
|
||||
int(parts[1]) if len(parts) > 1 else 0,
|
||||
int(parts[2]) if len(parts) > 2 else 0,
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def _version_less(a: str, b: str) -> bool:
|
||||
"""True 表示 远端 b > 本地 a"""
|
||||
return _parse_version(a) < _parse_version(b)
|
||||
|
||||
|
||||
def _get_delta_map(manifest: dict) -> dict:
|
||||
"""兼容 delta 与 delta_updates 两种键名"""
|
||||
d = manifest.get("delta") or manifest.get("delta_updates") or {}
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
class SeamlessUpdater:
|
||||
"""
|
||||
隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」,
|
||||
用户确认后极速替换 main.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_full_path = None # TEMP 下 Setup.exe 路径(全量就绪)
|
||||
self._ready_remote_version = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _fetch_manifest(self) -> dict:
|
||||
"""请求远端 version.json,失败返回空 dict"""
|
||||
try:
|
||||
if _HAS_REQUESTS:
|
||||
r = requests.get(
|
||||
self.version_url,
|
||||
headers={"User-Agent": "ServerManager-Updater/1.0"},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json() if isinstance(r.json(), dict) else {}
|
||||
req = Request(self.version_url, headers={"User-Agent": "ServerManager-Updater/1.0"})
|
||||
with urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (URLError, HTTPError, OSError, json.JSONDecodeError, Exception):
|
||||
return {}
|
||||
|
||||
def _download_to_path(self, url: str, dest_path: str) -> bool:
|
||||
"""静默下载 url 到 dest_path,无 UI。"""
|
||||
dest_path = Path(dest_path)
|
||||
try:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if _HAS_REQUESTS:
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"User-Agent": "ServerManager-Updater/1.0"},
|
||||
stream=True,
|
||||
timeout=DOWNLOAD_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
with open(dest_path, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
else:
|
||||
req = Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"})
|
||||
with urlopen(req, timeout=DOWNLOAD_TIMEOUT) as resp:
|
||||
dest_path.write_bytes(resp.read())
|
||||
return dest_path.is_file()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _try_delta_path(self, manifest: dict) -> bool:
|
||||
"""
|
||||
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 main_new.exe -> 校验 SHA256。
|
||||
仅针对 main.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
|
||||
成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。
|
||||
"""
|
||||
delta_map = _get_delta_map(manifest)
|
||||
delta = delta_map.get(self.local_version)
|
||||
if not isinstance(delta, dict):
|
||||
return False
|
||||
patch_url = (delta.get("patch_url") or "").strip()
|
||||
new_exe_sha256 = (delta.get("new_exe_sha256") or "").strip().lower()
|
||||
if not patch_url or not new_exe_sha256 or len(new_exe_sha256) != 64:
|
||||
return False
|
||||
if not _HAS_BSDIFF or not getattr(sys, "frozen", False):
|
||||
return False
|
||||
|
||||
temp_dir = os.environ.get("TEMP") or os.path.expandvars("%TEMP%")
|
||||
if not temp_dir or not os.path.isdir(temp_dir):
|
||||
return False
|
||||
patch_path = os.path.join(temp_dir, "ServerManager_update.patch")
|
||||
new_exe_path = os.path.join(temp_dir, "ServerManager_new.exe")
|
||||
|
||||
if not self._download_to_path(patch_url, patch_path):
|
||||
return False
|
||||
if not os.path.isfile(patch_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(sys.executable, "rb") as f:
|
||||
old_data = f.read()
|
||||
with open(patch_path, "rb") as f:
|
||||
patch_data = f.read()
|
||||
new_data = bsdiff4.patch(old_data, patch_data)
|
||||
got_sha = hashlib.sha256(new_data).hexdigest().lower()
|
||||
if got_sha != new_exe_sha256:
|
||||
return False
|
||||
with open(new_exe_path, "wb") as f:
|
||||
f.write(new_data)
|
||||
with self._lock:
|
||||
self._ready_delta_path = new_exe_path
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
if os.path.isfile(patch_path):
|
||||
os.remove(patch_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _try_full_path(self, manifest: dict) -> bool:
|
||||
"""静默下载 full_installer_url 到 TEMP,成功则设置 _ready_full_path。"""
|
||||
full_url = (
|
||||
(manifest.get("full_installer_url") or manifest.get("download_url") or "").strip()
|
||||
)
|
||||
if not full_url:
|
||||
return False
|
||||
temp_dir = os.environ.get("TEMP") or os.path.expandvars("%TEMP%")
|
||||
if not temp_dir or not os.path.isdir(temp_dir):
|
||||
return False
|
||||
name = os.path.basename(urlparse(full_url).path) or "ServerManager_Setup.exe"
|
||||
local_path = os.path.join(temp_dir, name)
|
||||
if not self._download_to_path(full_url, local_path):
|
||||
return False
|
||||
if not os.path.isfile(local_path):
|
||||
return False
|
||||
with self._lock:
|
||||
self._ready_full_path = local_path
|
||||
return True
|
||||
|
||||
def _worker(self):
|
||||
"""后台线程:检测 -> 优先增量静默准备,失败则全量静默准备 -> 就绪后主线程弹窗"""
|
||||
manifest = self._fetch_manifest()
|
||||
if not manifest:
|
||||
return
|
||||
remote_version = (manifest.get("version") or "").strip()
|
||||
if not remote_version or not _version_less(self.local_version, remote_version):
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._ready_remote_version = remote_version
|
||||
self._ready_delta_path = None
|
||||
self._ready_full_path = None
|
||||
|
||||
# 优先增量:静默下载 + 合成 + 校验
|
||||
if self._try_delta_path(manifest):
|
||||
self.root.after(0, self._show_restart_dialog)
|
||||
return
|
||||
# 兜底:静默下载完整安装包
|
||||
if self._try_full_path(manifest):
|
||||
self.root.after(0, self._show_restart_dialog)
|
||||
return
|
||||
|
||||
def _show_restart_dialog(self):
|
||||
"""主线程:仅当有 _ready_delta_path 或 _ready_full_path 时弹出「退出并重启」"""
|
||||
with self._lock:
|
||||
remote = self._ready_remote_version or "?"
|
||||
delta_path = self._ready_delta_path
|
||||
full_path = self._ready_full_path
|
||||
if not delta_path and not full_path:
|
||||
return
|
||||
msg = "新版本 v{} 已经准备就绪。点击「是」退出并重启以应用更新。".format(remote)
|
||||
if not messagebox.askyesno("更新就绪", msg, default="yes"):
|
||||
return
|
||||
if delta_path and os.path.isfile(delta_path):
|
||||
self._apply_delta_and_exit(delta_path)
|
||||
elif full_path and os.path.isfile(full_path):
|
||||
self._run_silent_install_and_exit(full_path)
|
||||
|
||||
def _apply_delta_and_exit(self, temp_new_exe_path: str):
|
||||
"""增量路径:优先调用独立进程外更新器 mini_updater.exe,否则回退到 .bat 重试。"""
|
||||
apply_update_and_restart(temp_new_exe_path, target_exe_name=os.path.basename(sys.executable))
|
||||
|
||||
def _run_silent_install_and_exit(self, setup_exe_path: str):
|
||||
"""全量路径:静默参数启动 Setup.exe,主进程立即退出"""
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[
|
||||
setup_exe_path,
|
||||
"/VERYSILENT",
|
||||
"/SUPPRESSMSGBOXES",
|
||||
"/FORCECLOSEAPPLICATIONS",
|
||||
],
|
||||
shell=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
def start_background_worker(self):
|
||||
"""启动后台守护线程(静默检测 + 静默准备),不阻塞主线程。"""
|
||||
t = threading.Thread(target=self._worker, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# entrypoints/tk_update_demo.py 调用示例(软件启动时调用)
|
||||
# ---------------------------------------------------------------------------
|
||||
"""
|
||||
# 在 entrypoints/tk_update_demo.py 中(Tkinter 入口):
|
||||
|
||||
import tkinter as tk
|
||||
from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL
|
||||
|
||||
# 从 version.json 或常量读取本地版本号
|
||||
LOCAL_VERSION = "1.1.3" # 需与 version.json / 安装包一致
|
||||
|
||||
def main():
|
||||
root = tk.Tk()
|
||||
root.withdraw() # 若主界面不是 Tk,可先隐藏,仅用其 messagebox/after
|
||||
|
||||
# 启动无感更新:后台静默准备,就绪后弹「重启以更新」
|
||||
SeamlessUpdater(root, LOCAL_VERSION, VERSION_JSON_URL).start_background_worker()
|
||||
|
||||
# 你的主界面(Tk 或其它)
|
||||
root.deiconify()
|
||||
root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 开发者:如何生成 .patch 文件(发版流程)
|
||||
# ---------------------------------------------------------------------------
|
||||
"""
|
||||
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 main.exe,不包含 _internal;
|
||||
用户端替换时只覆盖 main.exe,绝不修改 _internal 或其它文件。
|
||||
|
||||
1. 打包:使用 PyInstaller 文件夹模式生成当前版本:
|
||||
pyinstaller -D -w your_main.py
|
||||
得到 dist/your_main/main.exe(或 dist/main.exe)。
|
||||
|
||||
2. 保存上一版本的 main.exe(仅此单文件,无需 _internal):
|
||||
将上一发版时的 main.exe 保存为 build/output/main_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()
|
||||
patch = bsdiff4.diff(old, new)
|
||||
open('build/output/patches/v1.0.2_to_v1.0.3.patch', 'wb').write(patch)
|
||||
|
||||
4. 计算新 exe 的 SHA256 并写入 version.json:
|
||||
import hashlib
|
||||
new_sha256 = hashlib.sha256(new).hexdigest().lower()
|
||||
# 将 new_sha256 填入 version.json 的 delta["1.0.2"].new_exe_sha256
|
||||
|
||||
5. 上传 build/output/patches/*.patch 与 version.json 到服务器;用户端即可通过增量路径静默更新。
|
||||
"""
|
||||
Reference in New Issue
Block a user