仓库初始化

This commit is contained in:
lwt
2026-05-22 00:16:08 +08:00
commit 95c96dce42
118 changed files with 23005 additions and 0 deletions
View File
View File
+116
View File
@@ -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
+96
View File
@@ -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}"
+85
View File
@@ -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
+116
View File
@@ -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
+60
View File
@@ -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())
+72
View File
@@ -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
+62
View File
@@ -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
+76
View File
@@ -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())
+84
View File
@@ -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
+277
View File
@@ -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()
View File
+147
View File
@@ -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
+199
View File
@@ -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 打包的 mainLinux):环境变量 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 "$@"
View File
+25
View File
@@ -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()
+26
View File
@@ -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()
+343
View File
@@ -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()
View File
+154
View File
@@ -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 检测用 ctypesWindows)或 os.killUnix
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()
+434
View File
@@ -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. 生成差分补丁(需安装 bsdiff4pip 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 到服务器;用户端即可通过增量路径静默更新。
"""