换位置

This commit is contained in:
lwt
2026-06-09 14:01:20 +08:00
parent 6d86678e11
commit 0b34f2e3d0
53 changed files with 1762 additions and 603 deletions
+2 -2
View File
@@ -13,10 +13,10 @@ set PYTHONPATH=E:\learn\web\backend\storage\server_manager\src
python -m entrypoints.cli --root E:\game\server_root mcp
```
打包后可直接使用安装目录或 `src\dist\main\main.exe`
打包后可直接使用安装目录或 `src\dist\ServerManager\ServerManager.exe`
```bat
main.exe --root E:\game\server_root mcp
ServerManager.exe --root E:\game\server_root mcp
```
## 客户端配置示例
+96 -95
View File
@@ -10,11 +10,11 @@
- Linux/Windows 都提供命令行入口,支持 `list``create``start``stop``connect``compile``regen``interactive` 等命令。
- 工具面向一个 Erlang/rebar3 游戏服务器工程,约定服务器工程根目录包含 `run/``config/``rebar3` 等内容。
- 每个服务器实例位于 `<服务器项目根>/run/<prefix>_<type>_s<id>/`,实例差异配置写入 `config/kv.config`,启动前根据模板生成 `config/sys.config`
- 项目级工具配置位于 `<服务器项目根>/.server_manager/config/`,首次打开项目时从软件自带模板目录同步 `default.kv``sys_*.config.example`
- 项目级工具配置位于 `<服务器项目根>/.server_manager/config/`,首次打开项目时从软件自带模板目录同步 `default.config``sys_*.config.example`
- 应用级配置位于 `%LOCALAPPDATA%/ServerManager``~/.config/ServerManager`,用于保存最近项目列表和应用日志。
- 打包使用 PyInstaller 目录模式生成 `src/dist/main/main.exe`Inno Setup 生成 `output/ServerManager_Setup.exe`
- 打包使用 PyInstaller 目录模式生成 `src/dist/ServerManager/ServerManager.exe`Inno Setup 生成 `output/ServerManager_Setup.exe`
- 实际 GUI/CLI 主入口是 `src/server_manager_gui.py`;根目录 `main.py` 是一个 Tkinter 自动更新集成示例/遗留入口。
- 更新机制支持 `version.json` 清单、全量安装包更新,以及基于 `bsdiff4``main.exe` 增量补丁;替换 exe 时由独立 `mini_updater.exe` 等待主进程退出后完成替换并重启。
- 更新机制支持 `version.json` 清单、全量安装包更新,以及基于 `bsdiff4``ServerManager.exe` 增量补丁;替换 exe 时由独立 `mini_updater.exe` 等待主进程退出后完成替换并重启。
- 日志查看支持单文件加载、玩家协议日志、日期归档日志、ripgrep 全局搜索、正文高亮、匹配行过滤和 less 风格快捷键。
## 核心文件树
@@ -37,7 +37,7 @@ server_manager/
├── rg.exe # 可选,ripgrep Windows 可执行文件
├── output/
│ ├── config/
│ │ ├── default.kv
│ │ ├── default.config
│ │ ├── sys_game.config.example
│ │ ├── sys_login.config.example
│ │ ├── sys_center.config.example
@@ -83,8 +83,8 @@ server_manager/
2. Windows 下提供 PyQt6 GUIWindows/Linux 下提供 CLI。
3. GUI 无参数启动;命令行带任意参数时进入 CLI,行为与 src/server_manager_cli.py 一致。
4. 工具管理一个外部 Erlang 游戏服务器项目,项目根目录通常含 run、config、rebar3、apps 等。
5. 首次打开服务器项目时,将软件自带模板 output/config 下的 default.kv 与 sys_*.config.example 同步到 <项目根>/.server_manager/config/。
6. 所有项目级配置保存在 <项目根>/.server_manager/config/tool.config;应用级配置和日志保存在 %LOCALAPPDATA%/ServerManager 或 ~/.config/ServerManager。
5. 首次打开服务器项目时,将软件自带模板 output/config 下的 default.config 与 sys_*.config.example 同步到 <项目根>/.server_manager/config/。
6. 所有项目级配置保存在 <项目根>/.server_manager/config/user.config;应用级配置和日志保存在 %LOCALAPPDATA%/ServerManager 或 ~/.config/ServerManager。
7. 支持服务器创建、启动、快速启动、停止、remsh 连接、编译、配置编辑、sys.config 重新生成、本地/远程服务器列表、节点状态批量检查、日志查看、日志搜索、自动更新、打包发版。
二、技术栈与依赖
@@ -109,12 +109,12 @@ server_manager/
- version.json:版本清单,当前版本为 1.0.46,字段包含 version、update_url、release_notes、full_installer_url、delta_updates。
- main.pyTkinter 示例/遗留入口,LOCAL_VERSION = "1.0.46",演示 updater 自动更新集成;真实桌面工具入口仍以 src/server_manager_gui.py 为准。
- updater.py:保留一个 Tkinter/独立入口可用的无感更新模块,兼容 delta 与 delta_updates。
- mini_updater.py:独立进程外更新器,等待主进程 PID 退出后替换 main.exe 并重启。
- mini_updater.py:独立进程外更新器,等待主进程 PID 退出后替换 ServerManager.exe 并重启。
- release.py:发版脚本,负责更新 build_installer.iss、main.py、version.json,生成 delta_updates,打包后可生成 bsdiff4 patch 并填 new_exe_sha256。
- release.bat:一键正式发版脚本,顺序为 PyInstaller -> release.py 版本预处理 -> Inno Setup -> release.py --post-build。
- pack_all.bat:正式打包但不改版本号,生成 output/ServerManager_Setup.exe,并复制 version.json 到 output/。
- pack_local_test.py 与 pack_local_test.bat:生成 output_local/ServerManager_Setup_LOCAL.exe,不改版本号、不覆盖 output/。
- build_installer.issInno Setup 脚本,正式版 AppName=ServerManager,测试版 AppName=ServerManager (本地测试),安装 src/dist/main/*、src/dist/mini_updater.exe、version.json、output/config/*。
- build_installer.issInno Setup 脚本,正式版 AppName=ServerManager,测试版 AppName=ServerManager (本地测试),安装 src/dist/ServerManager/*、src/dist/mini_updater.exe、version.json、output/config/*。
- .gitignore:忽略 output_local/,可注明 release_backup/ 可选忽略。
- rg.exe:可选,不需要生成二进制;文档说明放置在根目录即可被打包复制。
@@ -135,7 +135,7 @@ src 目录:
- icon/convert_icon.py、icon/icon.png、icon/icon.ico:图标资源和转换脚本。
output/config 目录:
- default.kv:默认配置和配置项定义。
- default.config:默认配置和配置项定义。
- sys_game.config.example、sys_login.config.example、sys_center.config.example、sys_cross.config.example、sys_client.config.exampleErlang sys.config 模板,使用 ${key} 占位符。
四、version.json 内容
@@ -159,23 +159,23 @@ output/config 目录:
1. 项目级配置目录固定为 <项目根>/.server_manager/config。
2. 软件自带模板目录:
- 打包后:main.exe 同级的 config/。
- 打包后:ServerManager.exe 同级的 config/。
- 源码运行:仓库根目录 output/config/。
3. 受管模板文件:
- default.kv
- default.config
- sys_center.config.example
- sys_client.config.example
- sys_cross.config.example
- sys_game.config.example
- sys_login.config.example
4. 打开项目时必须确保 .server_manager/config 存在并同步上述模板;保留用户自己的 tool.config。
4. 打开项目时必须确保 .server_manager/config 存在并同步上述模板;保留用户自己的 user.config。
5. 配置合并优先级从低到高:
- .server_manager/config/default.kv
- .server_manager/config/tool.config
- run_dir/config/tool.config 或 run_dir/tool.config
- .server_manager/config/default.config
- .server_manager/config/user.config
- run_dir/config/user.config 或 run_dir/user.config
- run_dir/<server_dir>/config/kv.config
6. 合并配置后强制用当前机器 IP 覆盖 ip 和 game_host。
7. tool.config 是 key=value 文件,至少写入:
7. user.config 是 key=value 文件,至少写入:
- server_root、run_dir
- r25_path
- db_host、db_port、db_user、db_pass
@@ -184,12 +184,12 @@ output/config 目录:
8. 应用级配置:
- Windows: %LOCALAPPDATA%/ServerManager/app_config.json
- Linux/macOS: ~/.config/ServerManager/app_config.json
- 保存最近 10 个有效项目,判断有效项目时需存在 .server_manager/config/default.kv 或旧式 config/default.kv
- 保存最近 10 个有效项目,判断有效项目时需存在 .server_manager/config/default.config 或 config/project.config
- 应用日志写到同目录 logs/,日志文件名 server_manager_YYYYMMDD.log 或 server_manager_cli_YYYYMMDD.log,保留最近 7 天。
六、default.kv 要求
六、default.config 要求
default.kv 必须含以下默认值和配置定义:
default.config 必须含以下默认值和配置定义:
- prefix=ddxq2
- server_id=1
@@ -259,7 +259,7 @@ default.kv 必须含以下默认值和配置定义:
- client_server -> sys_client.config.example
- center_server -> sys_center.config.example
- cross_server -> sys_cross.config.example
4. 生成 sys.config 前需要将 log_dir 和 role_log 归一化为 run/<server_dir>/<tail>。如果 default.kv 中是 run/server/log,则实际应变为 run/<server_dir>/log。
4. 生成 sys.config 前需要将 log_dir 和 role_log 归一化为 run/<server_dir>/<tail>。如果 default.config 中是 run/server/log,则实际应变为 run/<server_dir>/log。
5. 若模板中仍有未替换占位符,要输出警告但不要崩溃。
八、服务器创建逻辑
@@ -350,7 +350,7 @@ default.kv 必须含以下默认值和配置定义:
CLI 初始化时自动检测 server_root
1. 命令行 --root 优先。
2. 读取 tool.config 的 server_root/run_dir 次之。
2. 读取 user.config 的 server_root/run_dir 次之。
3. 当前工作目录、脚本目录、上级目录中查找 apps 与 config。
4. 加载配置后执行 run_startup_migrations。
@@ -412,7 +412,7 @@ GUI 图标:
- centerauto_reload、open_time、login_node
- crossauto_reload、open_time、center_node
- client:连接服务器ID、连接服务器端口、登录服地址、登录服HTTP端口、auto_reload、login_node
4. ID 范围从 default.kv 的 <type>_id_min/max/default 读取,越界时自动修正并提示。
4. ID 范围从 default.config 的 <type>_id_min/max/default 读取,越界时自动修正并提示。
5. 服务器 ID 改变时自动更新服务器名与默认端口。
6. 数据库和节点支持“使用默认配置”复选框,默认勾选并禁用手工输入。
7. 点击创建前展示确认信息,再调用 server_creator.create_server。
@@ -426,7 +426,7 @@ GUI 图标:
4. 远程操作:从登录服加载、刷新状态、搜索过滤、双击或按钮 remsh 连接。
5. 远程列表通过登录服节点 rpc:call 获取 server_temp_info/server_info_lib 数据;输出需解析 server_id、server_name、server_node。
6. 状态刷新使用批量 check_nodes_status,并写入 NodeStatusCache。
7. 编辑配置打开 KvConfigEditDialog,读取 kv.config,允许添加/删除 default.kv 中声明的可编辑配置项;保存后重新生成 sys.config。
7. 编辑配置打开 KvConfigEditDialog,读取 kv.config,允许添加/删除 default.config 中声明的可编辑配置项;保存后重新生成 sys.config。
日志查看页:
1. 顶级“日志查看”内有两个子页签:
@@ -468,12 +468,12 @@ GUI 图标:
- Erlang 与节点配置:Erlang 路径、Erlang 版本、登录服节点、中心服节点、Cookie。
2. 提供“检查版本”按钮,比对系统 erl 和配置路径 erl 的 OTP 版本。
3. 登录服节点、中心服节点提供“测试连接”,使用 net_adm:ping。
4. “填充默认值”从 default.kv 填入表单。
5. “保存配置”写入 tool.config;若 Erlang 路径变化,询问是否删除 server_root/_build 并执行 rebar3 as game_server_dev compile。
4. “填充默认值”从 default.config 填入表单。
5. “保存配置”写入 user.config;若 Erlang 路径变化,询问是否删除 server_root/_build 并执行 rebar3 as game_server_dev compile。
欢迎页:
1. 无项目时显示欢迎页,提供打开项目、最近项目、定位项目、帮助入口。
2. 最近项目来自应用级 app_config.json,只显示仍存在且含 default.kv 的项目。
2. 最近项目来自应用级 app_config.json,只显示仍存在且含 default.config 的项目。
更新机制:
1. src/updater.py 提供:
@@ -486,11 +486,11 @@ GUI 图标:
- clean_up_old_version
- apply_delta_patch
- run_installer_and_exit
2. 打包后 version.json 优先从 main.exe 同目录读取,其次从 _MEIPASS。
2. 打包后 version.json 优先从 ServerManager.exe 同目录读取,其次从 _MEIPASS。
3. GUI 启动 1.5 秒后后台检查 update_url;远端 version 与本地不同则询问是否更新。
4. 当前版本有 delta_updates 且 patch_url、new_exe_sha256 完整时优先增量,否则下载 full_installer_url 或 download_url。
5. 下载时显示 QProgressDialog;可取消。
6. 增量使用 bsdiff4.patch(当前 main.exe, patch) 生成临时新 exe,校验 sha256,通过 mini_updater.exe 替换并重启。
6. 增量使用 bsdiff4.patch(当前 ServerManager.exe, patch) 生成临时新 exe,校验 sha256,通过 mini_updater.exe 替换并重启。
7. 全量更新下载安装包后用 /VERYSILENT /SUPPRESSMSGBOXES /FORCECLOSEAPPLICATIONS 静默安装并退出。
8. mini_updater.py 只依赖标准库,参数:
- --pid
@@ -508,30 +508,30 @@ GUI 图标:
- add-data: ../version.json、config.json、server_commands.py、server_creator.py、icon。
- hidden-import: PyQt6.QtWidgets/Core/Gui、pymysql、server_commands、server_creator、server_manager_cli、updater、app_config、bsdiff4、requests。
- collect-all PyQt6 与 bsdiff4。
- 若根目录有 rg.exe,复制到 dist/main/rg.exe。
- 复制 ../output/config 到 dist/main/config。
- 若根目录有 rg.exe,复制到 dist/ServerManager/rg.exe。
- 复制 ../output/config 到 dist/ServerManager/config。
- 再用 PyInstaller onefile windowed 打包 ../mini_updater.py 到 dist/mini_updater.exe。
2. src/build_linux.sh 与 build.bat 等价,但使用 python3、Linux add-data 冒号分隔,输出 dist/main/main
2. src/build_linux.sh 与 build.bat 等价,但使用 python3、Linux add-data 冒号分隔,输出 dist/ServerManager/ServerManager
3. src/build_portable.bat 提供单文件便携版尝试,失败回退 onedir。
4. build_installer.iss
- 正式输出 output/ServerManager_Setup.exe。
- 本地测试 /DLOCAL_TEST=1 输出 output_local/ServerManager_Setup_LOCAL.exe,独立 AppId。
- 安装 src/dist/main/* 到 {app},安装 mini_updater.exe、version.json、output/config/*。
- 安装 src/dist/ServerManager/* 到 {app},安装 mini_updater.exe、version.json、output/config/*。
- 创建开始菜单/可选桌面快捷方式。
- 安装完成后运行 main.exe。
- 安装完成后运行 ServerManager.exe。
5. release.py
- get_current_version 以 output/version.json 为准读取旧版本。
- set_version_pre(new_version, notes):更新 build_installer.iss 的 MyAppVersion、main.py 的 LOCAL_VERSION、version.json 的 version/release_notes/delta_updates,并同步到 src/dist/main/version.json 和 _internal/version.json。
- set_version_post(old_exe):复制 version.json 到 output;若提供旧版 main.exe 且安装 bsdiff4,生成 output/patches/v<old>_to_v<new>.patch 并填 new_exe_sha256。
- set_version_pre(new_version, notes):更新 build_installer.iss 的 MyAppVersion、main.py 的 LOCAL_VERSION、version.json 的 version/release_notes/delta_updates,并同步到 src/dist/ServerManager/version.json 和 _internal/version.json。
- set_version_post(old_exe):复制 version.json 到 output;若提供旧版 ServerManager.exe 且安装 bsdiff4,生成 output/patches/v<old>_to_v<new>.patch 并填 new_exe_sha256。
入口脚本:
1. src/run.bat
- 自动计算 SERVER_ROOT 为脚本目录向上两级。
- 优先使用 SERVER_MANAGER_MAIN、同目录 main.exe、dist/main/main.exe。
- 优先使用 SERVER_MANAGER_MAIN、同目录 ServerManager.exe、dist/ServerManager/ServerManager.exe。
- 无参数进入 interactive。
- gui 启动 GUI。
- 其他参数转发 CLI,并附加 --root SERVER_ROOT。
2. src/run.sh 同理,优先 SERVER_MANAGER_MAIN、同目录 main、dist/main/main
2. src/run.sh 同理,优先 SERVER_MANAGER_MAIN、同目录 ServerManager、dist/ServerManager/ServerManager
十二、核心代码复刻细则
@@ -588,7 +588,7 @@ GUI 图标:
- IS_WINDOWS = platform.system() == "Windows"
- IS_LINUX = platform.system() == "Linux"
- REBAR3_CMD = "rebar3.cmd" if IS_WINDOWS else "./rebar3"
- MANAGED_CONFIG_TEMPLATE_FILES = ("default.kv", "sys_center.config.example", "sys_client.config.example", "sys_cross.config.example", "sys_game.config.example", "sys_login.config.example")
- MANAGED_CONFIG_TEMPLATE_FILES = ("default.config", "sys_center.config.example", "sys_client.config.example", "sys_cross.config.example", "sys_game.config.example", "sys_login.config.example")
必须提供这些函数:
@@ -606,25 +606,26 @@ GUI 图标:
- get_server_manager_config_dir 返回 Path(server_root).resolve() / ".server_manager" / "config"。
- get_bundled_tool_config_template_dir 打包后返回 Path(sys.executable).parent / "config",源码运行返回 Path(__file__).parent.parent / "output" / "config"。
- sync_server_manager_config_templates 必须检查模板目录存在、default.kv 存在、每个 MANAGED_CONFIG_TEMPLATE_FILES 都存在;逐个 copy2 到项目 .server_manager/config。
- sync_server_manager_config_templates 必须检查模板目录存在、default.config 存在、每个 MANAGED_CONFIG_TEMPLATE_FILES 都存在;逐个 copy2 到项目 .server_manager/config。
- read_config_file 读取 key=value,忽略空行、# 注释、% 注释;去除空白和首尾双引号。
- read_merged_config 合并顺序为 default.kv -> tool.config -> run_dir tool.config -> kv.config,最后强制 merged["ip"] 和 merged["game_host"] 为 get_local_ip()。
- read_merged_config 合并顺序为 default.config -> config/project.config -> user.config -> run_dir user.config -> kv.config,最后强制 merged["ip"] 和 merged["game_host"] 为 get_local_ip()。
read_merged_config 伪代码:
def read_merged_config(server_root, server_dir=None, run_dir=None):
cfg_dir = get_server_manager_config_dir(server_root)
merged = {}
if (cfg_dir / "default.kv").exists():
merged.update(read_config_file(str(cfg_dir / "default.kv")))
elif (cfg_dir / "default.config").exists():
if (cfg_dir / "default.config").exists():
merged.update(read_config_file(str(cfg_dir / "default.config")))
project_cfg = Path(server_root) / "config" / "project.config"
if project_cfg.exists():
merged.update(read_config_file(str(project_cfg)))
if (cfg_dir / "tool.config").exists():
merged.update(read_config_file(str(cfg_dir / "tool.config")))
if (cfg_dir / "user.config").exists():
merged.update(read_config_file(str(cfg_dir / "user.config")))
if run_dir:
for p in (Path(run_dir) / "config" / "tool.config", Path(run_dir) / "tool.config"):
for p in (Path(run_dir) / "config" / "user.config", Path(run_dir) / "user.config"):
if p.exists():
merged.update(read_config_file(str(p)))
break
@@ -926,7 +927,7 @@ GUI 图标:
- detected_root = _detect_server_root()
- configured_root = _read_configured_server_root(detected_root)
- server_root 优先级:参数 server_root > configured_root > detected_root
- run_dir 优先级:tool.config 的 run_dir > server_root/run
- run_dir 优先级:user.config 的 run_dir > server_root/run
- config_path = get_server_manager_config_dir(server_root)
- 加载配置后设置 prefix、db_host、db_port、db_user、db_pass、cookie。
- 执行 run_startup_migrations(server_root)。
@@ -969,10 +970,10 @@ GUI 图标:
- __init__(project_root=None):无项目时 config_error="未打开项目"。
- open_project(project_root):调用 ensure_and_get_server_manager_config_dir;成功后设置 tool_dir、tool_config_path、data。
- _load_tool_config:读 key=value。
- _save_tool_config:按固定分组写 tool.config。
- _load_default_kv(server_root):读 .server_manager/config/default.kv
- _load_configtool.config 为空时从 default.kv 补齐;首次运行自动创建 run_dir;需要保存补齐后的 tool.config。
- save:写 tool.config 前刷新 ip/game_host 为 get_local_ip()。
- _save_tool_config:按固定分组写 user.config。
- _load_default_kv(server_root):读 .server_manager/config/default.config
- _load_configuser.config 为空时从 default.config 补齐;首次运行自动创建 run_dir;需要保存补齐后的 user.config。
- save:写 user.config 前刷新 ip/game_host 为 get_local_ip()。
- get/set 支持嵌套 key。
- has_project/is_config_complete/is_configured/get_missing_config。
@@ -997,7 +998,7 @@ GUI 图标:
- CreateServerTab:创建服务器。
- ServerManageTab:本地/远程服务器管理。
- ConfigTab:工具设置。
- DefaultKvTabdefault.kv 编辑。
- DefaultKvTabdefault.config 编辑。
- UpdateCheckWorker/UpdateDownloadWorker:自动更新后台工作线程。
- WelcomePage:无项目欢迎页。
- MainWindow:主窗口、菜单、欢迎页/项目页切换、更新检查、项目迁移、状态缓存。
@@ -1034,7 +1035,7 @@ GUI 图标:
find_rg_executable 查找顺序:
- RG_PATH 环境变量。
- 打包后 main.exe 同目录的 rg.exe/rg。
- 打包后 ServerManager.exe 同目录的 rg.exe/rg。
- 源码仓库根目录的 rg.exe/rg。
- PATH 中的 rg。
@@ -1090,7 +1091,7 @@ GUI 图标:
src/updater.py 必须实现:
- _version_json_path:打包后优先 main.exe 同目录 version.json,再 sys._MEIPASS/version.json;源码运行用项目根 version.json。
- _version_json_path:打包后优先 ServerManager.exe 同目录 version.json,再 sys._MEIPASS/version.json;源码运行用项目根 version.json。
- get_current_version:读取 version 字段,失败返回 0.0.0。
- get_version_info:读取完整 JSON,失败返回 {"version": "0.0.0", "release_notes": ""}。
- _parse_version/version_less:将版本字符串转 int tuple 比较。
@@ -1131,11 +1132,11 @@ GUI 图标:
- version.json 设置 version、release_notes。
- full_installer_url 固定指向 ServerManager_Setup.exe。
- delta_updates 只保留 old_version -> new_version 一项,patch_url 格式为 output/patches/v<old>_to_v<new>.patch。
- 如果 src/dist/main/_internal/version.json 或 src/dist/main/version.json 存在,则同步复制。
- 如果 src/dist/ServerManager/_internal/version.json 或 src/dist/ServerManager/version.json 存在,则同步复制。
set_version_post 必须:
- 找新 exesrc/dist/main/main.exe,找不到再找 src/dist/main.exe。
- 找新 exesrc/dist/ServerManager/ServerManager.exe,找不到再找 src/dist/ServerManager.exe。
- 没有 old_exe_path 时只复制 version.json 到 output。
- 有 old_exe_path 且 bsdiff4 可用时生成 output/patches/v<old>_to_v<new>.patch。
- 计算新 exe sha256,填回 version.json 的 delta_updates[old_version].new_exe_sha256。
@@ -1548,12 +1549,12 @@ GUI 图标:
- 项目简介:Windows GUI + Linux CLI。
- 功能特性:控制台、创建服务器、服务器管理、日志查看、工具设置、自动更新。
- 打包与发版维护者流程:先 pack_local_test,再 release/pack_all。
- rg.exe 放置说明:根目录与 src 同级;build.bat 会复制到 dist/main
- rg.exe 放置说明:根目录与 src 同级;build.bat 会复制到 dist/ServerManager
- 安装说明:Windows 安装包、源码运行 Windows/Linux。
- 快速上手:打开项目时选择服务器项目根目录,不要选 run/config/.server_manager/config。
- 首次打开项目会复制模板到 .server_manager/config。
- 重点路径:服务器根目录、运行目录、Erlang 路径。
- CLI 用法表:main.exe/main 无参数 GUI,带参数 CLI。
- CLI 用法表:ServerManager.exe/ServerManager 无参数 GUI,带参数 CLI。
- 命令行参数完整说明。
- version.json 字段说明、download/update URL 规则。
- 配置说明:项目级配置、单服配置、模板来源。
@@ -1567,7 +1568,7 @@ GUI 图标:
- 推荐流程:本地测试包 -> 本地安装验证 -> 正式发版。
- pack_local_test 不改版本、不覆盖 output。
- release.bat 顺序:PyInstaller -> 改版本 -> Inno -> post-build。
- 本地不保存 main_*.exe;若要增量,需手动提供上一版 main.exe。
- 本地不保存 ServerManager_*.exe;若要增量,需手动提供上一版 ServerManager.exe。
- 手动发版命令:
- cd src
- build.bat
@@ -1869,7 +1870,7 @@ GUI 图标:
2. 选择是则 shutil.rmtree(_build)。
3. 执行 `<rebar3> as game_server_dev compile`,输出到控制台。
- emit config_saved。
- 提示“项目配置已保存到当前项目的 .server_manager/config/tool.config”。
- 提示“项目配置已保存到当前项目的 .server_manager/config/user.config”。
31. CLI argparse 精确结构细则
@@ -1942,7 +1943,7 @@ GUI 图标:
Windows `src/build.bat` PyInstaller 主命令必须等价于:
- `pyinstaller --noconfirm --onedir --console --name main`
- `pyinstaller --noconfirm --onedir --console --name ServerManager`
- `--icon icon\icon.ico`,没有 icon.ico 时用 `--icon NONE`
- `--add-data "..\version.json;."`
- `--add-data "config.json;."`
@@ -1967,11 +1968,11 @@ GUI 图标:
Windows build 后处理:
- 如果 `..\rg.exe` 存在,复制到 `dist\main\rg.exe`。
- 如果 `..\output\config` 存在,复制到 `dist\main\config\`。
- 如果 `..\rg.exe` 存在,复制到 `dist\ServerManager\rg.exe`。
- 如果 `..\output\config` 存在,复制到 `dist\ServerManager\config\`。
- 生成 mini_updater
`pyinstaller --noconfirm --onefile --windowed --name mini_updater --distpath dist --workpath build_mini --specpath build_mini ..\mini_updater.py`
- 如果 `config\tool.config` 存在,复制到 `dist\main\config\tool.config`。
- 如果 `config\user.config` 存在,复制到 `dist\ServerManager\config\user.config`。
- 清理 build、build_mini、spec。
Linux `build_linux.sh`
@@ -1983,8 +1984,8 @@ GUI 图标:
- `server_commands.py:.`
- `server_creator.py:.`
- `icon:icon`
- 输出 `dist/main/main`。
- 复制 `../output/config/.` 到 `dist/main/config/`。
- 输出 `dist/ServerManager/ServerManager`。
- 复制 `../output/config/.` 到 `dist/ServerManager/config/`。
Inno Setup `build_installer.iss`
@@ -1998,9 +1999,9 @@ GUI 图标:
- OutputDir output
- OutputBaseFilename ServerManager_Setup
- MyAppVersion "1.0.46"。
- MyAppExeName "main.exe"。
- MyAppExeName "ServerManager.exe"。
- Files:
- Source "src\dist\main\*" DestDir "{app}" recursesubdirs createallsubdirs
- Source "src\dist\ServerManager\*" DestDir "{app}" recursesubdirs createallsubdirs
- Source "src\dist\mini_updater.exe" DestDir "{app}"
- Source "version.json" DestDir "{app}"
- Source "output\config\*" DestDir "{app}\config" recursesubdirs createallsubdirs
@@ -2013,12 +2014,12 @@ GUI 图标:
- 可选桌面快捷方式
- 可选快速启动栏
- Run:
- 安装完成后 nowait postinstall 启动 `{app}\main.exe`
- 安装完成后 nowait postinstall 启动 `{app}\ServerManager.exe`
- UninstallDelete:
- 删除 `{app}`
- 删除 `{localappdata}\{#MyAppName}`
33. default.kv 与 sys 模板生成细则
33. default.config 与 sys 模板生成细则
如果无法还原真实 Erlang sys_*.config.example 的完整内容,也必须生成结构化模板,满足以下要求:
@@ -2111,7 +2112,7 @@ GUI 图标:
- `python -m py_compile src/server_manager_gui.py src/server_manager_cli.py src/server_commands.py src/server_creator.py src/log_viewer_tab.py src/log_viewer_pane.py src/rg_search.py src/app_config.py src/updater.py`
- `python src/server_manager_cli.py --help`
- `python -c "from server_commands import read_config_file; print(read_config_file('output/config/default.kv').get('prefix'))"`
- `python -c "from server_commands import read_config_file; print(read_config_file('output/config/default.config').get('prefix'))"`
- `python -c "from server_creator import generate_server_dir_name; print(generate_server_dir_name('ddxq2','game',100))"`
- `python -c "from rg_search import find_rg_executable; print(find_rg_executable())"`
@@ -2119,7 +2120,7 @@ GUI 图标:
- 无项目启动欢迎页。
- 打开一个临时服务器项目目录后生成 `.server_manager/config`。
- 工具设置保存后存在 `.server_manager/config/tool.config`。
- 工具设置保存后存在 `.server_manager/config/user.config`。
- 创建服务器后存在 `run/<server>/config/kv.config` 与 `sys.config`。
- 日志页在无日志文件时给出“文件不存在”而不是崩溃。
@@ -2245,11 +2246,11 @@ GUI 图标:
36. DefaultKvTab 细则
DefaultKvTab 用于编辑当前项目 `.server_manager/config/default.kv`。
DefaultKvTab 用于编辑当前项目 `.server_manager/config/default.config`。
UI
- 标题:`默认配置管理 (.server_manager/config/default.kv)`,颜色 #25c88916px,加粗。
- 标题:`默认配置管理 (.server_manager/config/default.config)`,颜色 #25c88916px,加粗。
- 描述:`此文件定义创建服务器时的默认值,支持 ${key} 占位符替换`。
- 横向 QSplitter
- 左侧:分类列表,最大宽 200;底部 `+ 新增配置项`。
@@ -2261,7 +2262,7 @@ GUI 图标:
load_default_kv
- server_root 为空时控制台输出 `[警告] 请先配置服务器根目录`。
- default_kv_path = get_server_manager_config_dir(server_root) / "default.kv"。
- default_kv_path = get_server_manager_config_dir(server_root) / "default.config"。
- 文件不存在时输出警告。
- 读取文件时:
- current_category 初始为“其他”。
@@ -2308,7 +2309,7 @@ GUI 图标:
- 成功输出 `[成功] 配置已保存: <path>` 并弹窗。
- 失败输出错误并 QMessageBox.critical。
注意:此页保存会重写 default.kv,因此注释和空行不会完全保留;复刻时应保持这个行为。
注意:此页保存会重写 default.config,因此注释和空行不会完全保留;复刻时应保持这个行为。
37. CreateServerTab 动态字段与校验细则
@@ -2333,7 +2334,7 @@ GUI 图标:
- 数据库配置框内提供 db_game_name、db_log_name 输入框;game/login/center/cross 显示,client 隐藏。
- db_game_name 默认 `{prefix}_{type}_s{id}`db_log_name 默认 `{prefix}_{type}_log_s{id}`ID 或类型变化时同步默认值,但创建前可手动修改。
- auto_reload 默认 default_kv.auto_reload == "true"。
- 登录/中心节点优先 tool.config,其次 default.kv,去掉首尾单引号。
- 登录/中心节点优先 user.config,其次 default.config,去掉首尾单引号。
`_on_server_id_changed(value)`
@@ -2399,8 +2400,8 @@ GUI 图标:
- 切回 SCRIPT_DIR。
- 查找打包主程序优先级:
1. 环境变量 SERVER_MANAGER_MAIN 且文件存在。
2. `%SCRIPT_DIR%\main.exe`
3. `%SCRIPT_DIR%\dist\main\main.exe`
2. `%SCRIPT_DIR%\ServerManager.exe`
3. `%SCRIPT_DIR%\dist\ServerManager\ServerManager.exe`
- 无参数:
- 打包程序存在:`"%SM_MAIN%" --root "%SERVER_ROOT%" interactive`
- 否则:`python server_manager_cli.py --root "%SERVER_ROOT%" interactive`
@@ -2421,7 +2422,7 @@ GUI 图标:
- SM_MAIN 优先:
1. SERVER_MANAGER_MAIN
2. SCRIPT_DIR/main 且可执行
3. SCRIPT_DIR/dist/main/main 且可执行
3. SCRIPT_DIR/dist/ServerManager/ServerManager 且可执行
- check_python 支持 python3 或 python,要求 Python 3.6+。
- gui 模式检查 PyQt6,缺失时 pip3 install PyQt6 或 pip install PyQt6。
- 无参数进入 interactive。
@@ -2579,7 +2580,7 @@ GUI 图标:
配置项发现函数必须按以下优先级实现:
- `_read_default_kv(server_root)` 读取 `read_merged_config(server_root)`,即合并后的项目配置,不直接只读 default.kv
- `_read_default_kv(server_root)` 读取 `read_merged_config(server_root)`,即合并后的项目配置,不直接只读 default.config
- `get_required_keys_from_kv(server_root, server_type)` 从 `required_keys_base` 和 `required_keys_<type>` 读取逗号分隔列表;若 base 不存在则返回 None。
- `get_config_keys_from_kv(server_root, server_type)` 读取所有 `config_keys_base_<key>` 与 `config_keys_<type>_<key>`,返回 `{key: 中文说明}`;一个都没有则返回 None。
- `get_editable_config_from_default_kv(server_root)` 读取所有 `editable_config_<key>`,用于编辑对话框的可添加配置项。
@@ -2620,7 +2621,7 @@ GUI 图标:
- 调用 `generate_start_config(server_root, server_dir)` 生成完整 sys.config。
- 成功返回 `(True, '服务器 xxx 创建成功', merged_config)`;异常返回 `(False, '创建服务器失败: xxx', None)`。
43. Config 类 tool.config 读写与首次打开项目细则
43. Config 类 user.config 读写与首次打开项目细则
`server_manager_gui.py` 中 `Config` 类负责“当前项目配置”,不能把项目配置混入应用级 app_config。
@@ -2645,11 +2646,11 @@ GUI 图标:
- `project_root = Path(project_root).resolve()`。
- 调用 `ensure_and_get_server_manager_config_dir(project_root)`,该函数负责创建/同步 `.server_manager/config` 和受管模板。
- 失败时保存 `config_error` 并返回 False。
- 成功时设置 `tool_dir=project_root``tool_config_path=cfg_dir / "tool.config"`,清空 `config_error`,调用 `_load_config()` 后返回 True。
- 成功时设置 `tool_dir=project_root``tool_config_path=cfg_dir / "user.config"`,清空 `config_error`,调用 `_load_config()` 后返回 True。
`_load_tool_config()`
- tool.config 不存在返回空 dict。
- user.config 不存在返回空 dict。
- 逐行读取 UTF-8strip 空白,跳过空行和 `#` 注释。
- 只解析第一处 `=`,左右 strip 后存入 dict。
- 出错返回空 dict。
@@ -2667,14 +2668,14 @@ GUI 图标:
`_load_config()` 的合并规则:
- `tool_cfg = _load_tool_config()``is_first_run = not tool_config_path.exists()`。
- `server_root` 默认当前项目目录,tool.config 中 server_root 非空则覆盖。
- `server_root` 默认当前项目目录,user.config 中 server_root 非空则覆盖。
- `run_dir` 为空时默认 `server_root/run`。
- 首次运行且 run_dir 不存在时尝试创建运行目录,失败只忽略,不阻止 GUI 打开。
- 读取 `get_server_manager_config_dir(server_root) / 'default.kv'` 作为默认值来源。
- 辅助 `get_value(tool_key, default_key, fallback)`:优先 tool.config;为空时读 default.kv;如果 default.kv 值是首尾单引号包裹,要去掉单引号;最后仍为空则 fallback。
- 读取 `get_server_manager_config_dir(server_root) / 'default.config'` 作为默认值来源。
- 辅助 `get_value(tool_key, default_key, fallback)`:优先 user.config;为空时读 default.config;如果 default.config 值是首尾单引号包裹,要去掉单引号;最后仍为空则 fallback。
- database.port、login_db.server_id、login_db.port 要转 int,空值分别回退 3306、900、0。
- 每次加载都用 `get_local_ip()` 写入 server.ip 和 server.game_host。
- 首次运行或发现 db_host、db_user、db_pass、prefix、login_node、center_node、cookie 从 default.kv 补齐时,调用 `save()` 回写 tool.config。
- 首次运行或发现 db_host、db_user、db_pass、prefix、login_node、center_node、cookie 从 default.config 补齐时,调用 `save()` 回写 user.config。
`save()`
@@ -2795,7 +2796,7 @@ GUI 图标:
- `len(sys.argv) > 1` 时调用 `_run_cli_from_argv()`,由 `server_manager_cli.main()` 处理 list/create/start/stop 等命令。
- 无参数时调用 GUI `main()`。
- 这样打包后的 `main.exe list` 与 `python server_manager_cli.py list` 行为一致。
- 这样打包后的 `ServerManager.exe list` 与 `python server_manager_cli.py list` 行为一致。
GUI `main()` 规则:
@@ -2891,13 +2892,13 @@ GUI 图标:
在基础验收之外,复刻项目还必须满足以下实现级检查:
- 无项目启动 GUI 时,不访问空的 `tool_config_path`,欢迎页可正常显示最近项目。
- 第一次打开项目时,如果 `.server_manager/config/tool.config` 不存在,会创建 run 目录并把 default.kv 中的数据库、prefix、cookie 等默认值写入 tool.config。
- 再次打开同一项目时,不覆盖已有 tool.config 中用户手动设置的值。
- 第一次打开项目时,如果 `.server_manager/config/user.config` 不存在,会创建 run 目录并把 default.config 中的数据库、prefix、cookie 等默认值写入 user.config。
- 再次打开同一项目时,不覆盖已有 user.config 中用户手动设置的值。
- 最近项目写入的是应用级 JSON,不写到项目目录;删除或无效项目不会出现在欢迎页最近列表。
- CLI `start --background` 会打开新窗口,CLI `start` 默认在当前窗口前台运行。
- GUI 和 CLI 生成的 `run/<server>/config/kv.config` 字段一致,且随后都能生成 sys.config。
- 停止服务器命令使用参数列表执行,不能依赖 shell 拼接引号。
- `main.exe list`、`main.exe --help`、`python src/server_manager_gui.py list` 均进入 CLI,不弹 GUI。
- `ServerManager.exe list`、`ServerManager.exe --help`、`python src/server_manager_gui.py list` 均进入 CLI,不弹 GUI。
- `python src/server_manager_gui.py` 无参数进入 GUI,并自动打开最近的有效项目;没有最近项目则停留欢迎页。
- 切换项目时,旧 tab、旧 console、旧 worker 引用不会继续显示在新项目界面。
- frozen 模式下退出不会弹 PyInstaller 临时目录删除失败的系统错误框。
@@ -2919,15 +2920,15 @@ GUI 图标:
十四、验收标准
1. `python src/server_manager_gui.py` 无参数能打开 GUI 欢迎页。
2. `python src/server_manager_gui.py list` 或打包后的 `main.exe list` 能进入 CLI。
2. `python src/server_manager_gui.py list` 或打包后的 `ServerManager.exe list` 能进入 CLI。
3. `python src/server_manager_cli.py --help` 显示完整命令。
4. 打开一个服务器项目后能生成/同步 `<项目根>/.server_manager/config`。
5. 工具设置保存后能写入 tool.config。
5. 工具设置保存后能写入 user.config。
6. 创建服务器能生成 run/<server>/config/kv.config 与 sys.config。
7. rebar3 启动命令、快速 erl 启动命令、stop/remsh 命令格式正确。
8. 服务器管理能扫描本地 run 目录并显示分类列表。
9. 日志页能加载普通日志和归档日志;rg 存在时能全局搜索。
10. `src/build.bat` 能生成 `src/dist/main/main.exe` 与 `src/dist/mini_updater.exe`。
10. `src/build.bat` 能生成 `src/dist/ServerManager/ServerManager.exe` 与 `src/dist/mini_updater.exe`。
11. Inno Setup 能生成 output/ServerManager_Setup.exe。
12. version.json、release.py、mini_updater.py 支持全量/增量更新流程。
```
+19 -16
View File
@@ -41,7 +41,7 @@
1. `scripts\package\pack_local_test.bat``python scripts/package/pack_local_test.py` → 产出 `build\output_local\ServerManager_Setup_LOCAL.exe`(不覆盖正式 `build\output\`)。
2. 本地安装测试通过后,再按 **[RELEASE.md](RELEASE.md)** 执行版本号与 `scripts\package\pack_all.bat` / `scripts\package\release.bat` 发版。
**`rg.exe`(可选但推荐)**:将 [ripgrep](https://github.com/BurntSushi/ripgrep/releases) 的 `rg.exe` 放在 `resources\bin\rg.exe`,执行 `scripts\package\build.bat` 时会自动复制进 `src\dist\main\`,安装后与 `main.exe` 同目录,日志检索与文件搜索可直接调用。
**`rg.exe`(可选但推荐)**:将 [ripgrep](https://github.com/BurntSushi/ripgrep/releases) 的 `rg.exe` 放在 `resources\bin\rg.exe`,执行 `scripts\package\build.bat` 时会自动复制进 `src\dist\ServerManager\`,安装后与 `ServerManager.exe` 同目录,日志检索与文件搜索可直接调用。
## 软件安装说明
@@ -79,8 +79,10 @@
Windows 下推荐直接使用图形界面,第一次使用按下面流程即可:
完整使用教程见 [docs/USER_GUIDE.md](docs/USER_GUIDE.md)。
1. 启动工具:
- 安装版:从开始菜单、桌面快捷方式,或安装目录下的 `main.exe` 启动。
- 安装版:从开始菜单、桌面快捷方式,或安装目录下的 `ServerManager.exe` 启动。
- 源码版:在项目根目录执行 `scripts\run\run.bat gui`,或设置 `PYTHONPATH=src` 后执行 `python -m entrypoints`。
2. 打开项目:
- 在欢迎页点击“打开项目”,或在菜单 `文件 -> 打开项目` 中选择目录。
@@ -136,17 +138,17 @@ pip install PyQt6
### 打包后主程序直接命令行(Windows / Linux,推荐)
安装或本地打包后的 **`main.exe`Windows**、**`main`Linux** 与源码里的 `entrypoints` 统一入口一致:**无参数启动 GUI;带任意参数则走命令行**(`list``create``start``--help` 等)。
安装或本地打包后的 **`ServerManager.exe`Windows**、**`ServerManager`Linux** 与源码里的 `entrypoints` 统一入口一致:**无参数启动 GUI;带任意参数则走命令行**(`list``create``start``--help` 等)。
| 场景 | Windows | Linux |
|------|---------|--------|
| 图形界面 | `main.exe` | `./main` |
| 命令行 | `main.exe list` | `./main list` |
| 帮助 | `main.exe --help` | `./main --help` |
| 图形界面 | `ServerManager.exe` | `./ServerManager` |
| 命令行 | `ServerManager.exe list` | `./ServerManager list` |
| 帮助 | `ServerManager.exe --help` | `./ServerManager --help` |
- **Windows**:安装目录下的 `main.exe`,或执行 `scripts\package\build.bat` 后使用 `src\dist\main\main.exe`
- **Linux**:执行 `scripts/package/build_linux.sh` 后使用 `src/dist/main/main`;可将该路径加入 `PATH`,或设置环境变量 **`SERVER_MANAGER_MAIN`** 指向该可执行文件。
- **`scripts\run\run.bat` / `scripts/run/run.sh`**:若检测到同目录或 `src/dist/main/` 下的打包主程序,会自动用它执行(并附带脚本中的 `--root` 服务器根目录),与上面「直接调用」等价、只是多了根目录约定。
- **Windows**:安装目录下的 `ServerManager.exe`,或执行 `scripts\package\build.bat` 后使用 `src\dist\ServerManager\ServerManager.exe`
- **Linux**:执行 `scripts/package/build_linux.sh` 后使用 `src/dist/ServerManager/ServerManager`;可将该路径加入 `PATH`,或设置环境变量 **`SERVER_MANAGER_MAIN`** 指向该可执行文件。
- **`scripts\run\run.bat` / `scripts/run/run.sh`**:若检测到同目录或 `src/dist/ServerManager/` 下的打包主程序,会自动用它执行(并附带脚本中的 `--root` 服务器根目录),与上面「直接调用」等价、只是多了根目录约定。
### Linux 命令行(推荐)
@@ -259,7 +261,7 @@ python -m entrypoints.cli --root E:\game\server_root mcp
**Windows**
```batch
scripts\package\build.bat # 标准打包,产出 src\dist\main\main.exe
scripts\package\build.bat # 标准打包,产出 src\dist\ServerManager\ServerManager.exe
scripts\package\build_portable.bat # 便携版打包
```
@@ -267,7 +269,7 @@ scripts\package\build_portable.bat # 便携版打包
```bash
chmod +x scripts/package/build_linux.sh
./scripts/package/build_linux.sh # 产出 src/dist/main/main
./scripts/package/build_linux.sh # 产出 src/dist/ServerManager/ServerManager
```
## 命令行工具参数
@@ -362,22 +364,23 @@ compile 参数:
结构调整后的发布要求:
- 正式发布统一使用 `server_manager/build/output/version.json``server_manager/build/output/ServerManager_Setup.exe`
- 本次目录结构和文件名变化较大,必须走完整安装包更新;不要给旧版本生成只替换 `main.exe` 的增量补丁,否则旧安装目录里的依赖文件不会一起更新。
- 本次目录结构和文件名变化较大,必须走完整安装包更新;不要给旧版本生成只替换 `ServerManager.exe` 的增量补丁,否则旧安装目录里的依赖文件不会一起更新。
- `scripts\package\release.bat` 默认生成全量更新 manifest,产物只输出到 `build/output`
## 配置说明
- **打开项目时选择的目录**:应当是服务器项目根目录,不是 `run``config``.server_manager\config` 子目录。
- **项目级工具配置目录**`<项目根目录>\.server_manager\config\`
- `default.kv`:默认配置模板,首次打开项目时自动复制到这里
- `tool.config`:工具保存的路径、数据库、节点、Erlang 等配置
- `default.config`:默认配置模板,首次打开项目时自动复制到这里
- `user.config`:工具保存的路径、数据库、节点、Erlang 等用户覆盖配置
- `<项目根>\config\project.config`:项目共享配置,优先级低于 `user.config`、高于 `default.config`
- **运行目录**:通常为 `<项目根目录>\run`,里面是各个服务器实例目录。
- **单服配置文件**:通常位于 `<运行目录>\<服务器目录>\config\kv.config`
- **模板来源**
- 安装版运行时:读取 `main.exe` 同级的 `config\`
- 安装版运行时:读取 `ServerManager.exe` 同级的 `config\`
- 源码运行时:读取仓库根目录的 `resources\config\`
首次运行时,程序会自动在当前项目下准备 `.server_manager\config`;如果自动创建失败,请先检查安装目录或仓库 `resources\config` 下是否存在完整模板,至少要包含 `default.kv`
首次运行时,程序会自动在当前项目下准备 `.server_manager\config`;如果自动创建失败,请先检查安装目录或仓库 `resources\config` 下是否存在完整模板,至少要包含 `default.config`
## 文件结构
+30 -34
View File
@@ -24,7 +24,7 @@
### ripgrep`rg.exe`
日志检索、匹配高亮与「文件搜索」优先使用 **ripgrep**。打包前请将 **`rg.exe`** 放在 `resources\bin\rg.exe`(可从 [ripgrep Releases](https://github.com/BurntSushi/ripgrep/releases) 下载 Windows 预编译包)。
`scripts\package\build.bat` 在主程序 PyInstaller 完成后会自动把 `resources\bin\rg.exe` 复制到 `src\dist\main\rg.exe`(与 `main.exe` 同目录);Inno Setup 随 `src\dist\main\*` 递归打入安装包。若未放置 `rg.exe`,打包会提示警告,安装后仍可通过环境变量 **`RG_PATH`** 或系统 PATH 中的 `rg` 使用。
`scripts\package\build.bat` 在主程序 PyInstaller 完成后会自动把 `resources\bin\rg.exe` 复制到 `src\dist\ServerManager\rg.exe`(与 `ServerManager.exe` 同目录);Inno Setup 随 `src\dist\ServerManager\*` 递归打入安装包。若未放置 `rg.exe`,打包会提示警告,安装后仍可通过环境变量 **`RG_PATH`** 或系统 PATH 中的 `rg` 使用。
---
@@ -38,13 +38,11 @@ scripts\package\release.bat 1.0.4
或双击 `scripts\package\release.bat`,按提示输入版本号(如 `1.0.4`)。
脚本将自动完成:**PyInstaller 打包 main.exe → 更新版本号与全量更新 manifest(并同步到 `src\dist\main\` 内)→ Inno Setup 打安装包 → `--post-build`(复制 version.json 到 `build\output`)→ 清理打包中间产物**。
脚本将自动完成:**PyInstaller 打包 ServerManager.exe → 更新版本号与 manifest(并同步到 `src\dist\ServerManager\` 内)→ Inno Setup 打安装包 → `--post-build`(复制 version.json 到 `build\output`,并保存 dist 快照)→ 清理打包中间产物**。
> 正式发版统一使用 `build/output` 作为发布目录,manifest 和安装包 URL 均指向 `server_manager/build/output/...`。
> 正式发版统一使用 `build/output` 作为发布目录,manifest 和安装包 URL 均指向 `https://www.sumi.work/api/software/server-manager/...`。
**说明**与 Cursor 一致,**本地与远端均不保存 main_*.exe**。当前结构调整版本不要生成增量包;若以后确认只替换 `main.exe` 足够,先执行 `python scripts/package/release.py x.y.z --with-delta`,再在打包完成后手动执行:
`python scripts/package/release.py --post-build --old-exe <上一版 main.exe 的路径>`
(上一版 exe 可来自本机备份、CI 构建产物等,由你自行保留。)
**增量说明**默认仍可全量发版。需要减少带宽时使用文件级增量 zip,它会比较上一版 `dist\ServerManager` 快照和新版 dist,只打包新增/变化文件,并保留完整安装包作为回退。
---
@@ -52,25 +50,28 @@ scripts\package\release.bat 1.0.4
### 1. 修改版本号(三处)
(若使用 `scripts\package\release.bat``python scripts/package/release.py x.y.z`,请在 **PyInstaller 已成功生成 `src\dist\main\` 之后** 执行,以便 `version.json` 被同步进安装包目录。)
(若使用 `scripts\package\release.bat``python scripts/package/release.py x.y.z`,请在 **PyInstaller 已成功生成 `src\dist\ServerManager\` 之后** 执行,以便 `version.json` 被同步进安装包目录。)
- **build_installer.iss**`#define MyAppVersion "x.y.z"` 改为新版本。
- **src\entrypoints\tk_update_demo.py** / **scripts\update\tk_updater.py**`LOCAL_VERSION = "x.y.z"` 改为新版本。
- **version.json**
- `version`:新版本号。
- `release_notes`:本次更新说明。
- `download_url` / `full_installer_url`:固定为 `.../ServerManager_Setup.exe`(安装包不带版本号,每次打包覆盖即可)
- **delta_updates**结构调整版本保持 `{}`,确保客户端走全量安装包。
- **仅当结构未变且确认只替换 main.exe 足够时**,才使用 `--with-delta` 生成「上一版本 → 当前版本」一项,例如从 1.0.3 升到 1.0.4 时:
- `download_url` / `full_installer_url`:固定为 `https://www.sumi.work/api/software/server-manager/update-download`
- **delta_updates**不使用增量时保持 `{}`;使用文件级增量时,生成「上一版本 → 当前版本」一项,例如从 1.0.3 升到 1.0.4
```json
"delta_updates": {
"1.0.3": {
"patch_url": "http://.../path=server_manager/build/output/patches/v1.0.3_to_v1.0.4.patch",
"new_exe_sha256": ""
"type": "file-zip",
"format": "server-manager-file-delta-v1",
"package_url": "https://www.sumi.work/api/software/server-manager/delta-download?from=1.0.3",
"patch_url": "https://www.sumi.work/api/software/server-manager/delta-download?from=1.0.3",
"sha256": "",
"sizeBytes": 0
}
}
```
`new_exe_sha256` 在步骤 3 生成补丁后填入。
`sha256` / `sizeBytes` 在步骤 3 生成增量包后自动填入。
---
@@ -85,7 +86,7 @@ python scripts/package/release.py 1.0.x --notes "说明"
python scripts/package/release.py --post-build
```
或仅打安装包(不执行 `scripts\package\release.py`)时用 `scripts\package\pack_all.bat`。得到 `src\dist\main\`、`build\output\ServerManager_Setup.exe``build\output\version.json`
或仅打安装包(不执行 `scripts\package\release.py`)时用 `scripts\package\pack_all.bat`。得到 `src\dist\ServerManager\`、`build\output\ServerManager_Setup.exe``build\output\version.json`
## 自动更新发布路径
@@ -94,35 +95,30 @@ python scripts/package/release.py --post-build
- manifest`server_manager/build/output/version.json`
- 完整安装包:`server_manager/build/output/ServerManager_Setup.exe`
发布时只需要同步 `build/output/` 到后端存储对应路径。客户端检测到更高版本后会下载 `download_url` / `full_installer_url` 指向的完整安装包并静默安装
发布时上传完整安装包、`version.json`,若启用增量还要上传 `build/output/patches/*.smdelta.zip`。客户端检测到更高版本后会优先下载当前版本对应的增量包;增量校验或应用失败时,回退到 `download_url` / `full_installer_url` 指向的完整安装包。
---
### 3. 生成增量补丁(可选)
### 3. 生成文件级增量包(可选)
仅当需要「上一版本 → 当前版本」的增量更新时执行。
1. **准备上一版本的 main.exe**(本地不自动保存;请自行保留一份,如从 CI 产物或备份拷贝)。
1. **准备上一版本 dist 快照**`release.py --post-build` 会自动保存当前版本到:
`build/output/snapshots/ServerManager-x.y.z`
2. **生成 .patch 并填 SHA256**:打包完成后执行
`python scripts/package/release.py --post-build --old-exe <上一版 main.exe 路径>`
或手动执行:
2. **生成增量 manifest 项**:在 PyInstaller 成功后、Inno Setup 前执行:
```python
import bsdiff4
import hashlib
from pathlib import Path
old = Path("path/to/main_1.0.3.exe").read_bytes()
new = Path("src/dist/main.exe").read_bytes()
patch = bsdiff4.diff(old, new)
Path("build/output/patches/v1.0.3_to_v1.0.4.patch").write_bytes(patch)
new_sha = hashlib.sha256(new).hexdigest().lower()
print("new_exe_sha256:", new_sha) # 填回 version.json 对应 delta 项
```bat
python scripts/package/release.py 1.0.4 --with-delta --notes "说明"
```
3. 将 `new_exe_sha256` 填回 **version.json** 的 `delta_updates["1.0.3"].new_exe_sha256`,再复制一次 `version.json` 到 `build\output\`。
3. **打安装包后生成 `.smdelta.zip` 并填 SHA256**
```bat
python scripts/package/release.py --post-build --old-dist build/output/snapshots/ServerManager-1.0.3
```
生成产物:`build/output/patches/v1.0.3_to_v1.0.4.smdelta.zip`。如果未提供 `--old-dist` 或生成失败,脚本会清空 `delta_updates` 并回退全量更新。
---
@@ -132,7 +128,7 @@ python scripts/package/release.py --post-build
- `build/output/ServerManager_Setup.exe`
- `build/output/version.json`
- `build/output/patches/*.patch`(若使用增量更新)
- `build/output/patches/*.smdelta.zip`(若使用增量更新)
用户通过 `update_url` 拉取 `version.json`,客户端更新服务会后台静默准备增量或全量,就绪后弹「重启以更新」。
+249
View File
@@ -0,0 +1,249 @@
# Server Manager 用户使用教程
这份教程面向日常使用者,按“安装启动 -> 打开项目 -> 配置 -> 创建和管理服务器”的顺序走一遍。
## 1. 安装与启动
### Windows 安装版
1. 获取安装包 `build/output/ServerManager_Setup.exe`
2. 双击安装,按向导完成安装。
3. 从开始菜单、桌面快捷方式,或安装目录下的 `ServerManager.exe` 启动。
### 源码运行
Windows
```bat
pip install -r requirements.txt
scripts\run\run.bat gui
```
Linux
```bash
pip install -r requirements.txt
chmod +x scripts/run/run.sh
./scripts/run/run.sh gui
```
没有桌面环境时可以直接使用命令行:
```bash
./scripts/run/run.sh list
./scripts/run/run.sh --help
```
## 2. 打开服务器项目
启动后在欢迎页点击“打开项目”,选择服务器项目根目录。
正确的项目根目录通常包含这些内容:
- `run` 目录
- `config` 目录
- `rebar3``rebar3.cmd`
- `apps` 目录
不要选择这些子目录:
- `run`
- `config`
- `.server_manager/config`
首次打开项目后,工具会在项目下准备:
```text
<项目根>/.server_manager/config/
```
其中会包含默认模板和工具配置文件。
## 3. 配置文件说明
项目相关配置都随项目保存,不放在安装目录里。
常用文件:
- `<项目根>/.server_manager/config/default.config`
默认配置模板,定义创建服务器和生成配置时的默认值。
- `<项目根>/config/project.config`
项目共享配置,适合放团队共用的数据库、节点、前缀等基础值。
- `<项目根>/.server_manager/config/user.config`
当前用户配置,由工具设置页保存,优先级最高。
- `<运行目录>/<服务器目录>/config/kv.config`
单个服务器的差异化配置,创建服务器时生成。
读取优先级从低到高:
```text
default.config < project.config < user.config < 单服 kv.config
```
也就是说:
- 通用默认值放 `default.config`
- 团队共享值放 `config/project.config`
- 自己机器上的路径、数据库、Erlang 路径放 `user.config`
- 某一台服务器独有的端口、库名、节点等放对应的 `kv.config`
## 4. 工具设置
打开“工具设置”页后,重点确认以下区域。
### 项目目录配置
服务器根目录和运行目录只用于查看,不能在这里修改。
如果目录不对,请回到欢迎页或菜单中重新打开正确的项目根目录。
### 启动窗口配置
Windows 下可以选择:
- 自动
- cmd
- PowerShell
- 其他运行 EXE
选择“其他”时,填写终端程序路径即可,工具会自动生成实际启动脚本。
### 数据库配置
填写数据库主机、端口、用户和密码后,点击“测试连接”。
测试成功表示当前工具能连上数据库;测试失败时,检查:
- 主机和端口是否正确
- 数据库账号和密码是否正确
- 当前机器是否能访问数据库网络
- 防火墙或数据库白名单是否放行
### Erlang 与节点配置
填写 Erlang/OTP 安装根目录,例如:
```text
E:\app\erlang\Erlang_OTP_25
```
登录服节点、中心服节点和 Cookie 会用于启动、连接、状态检查等功能。
配置完成后点击“保存配置”,内容会写入:
```text
<项目根>/.server_manager/config/user.config
```
## 5. 创建服务器
进入“创建服务器”页:
1. 选择服务器类型:游戏服、登录服、中心服、跨服服或客户端服。
2. 设置服务器 ID。
3. 确认数据库名、端口、节点、开服时间等字段。
4. 点击“创建服务器”。
创建成功后,工具会在运行目录下生成对应服务器目录,并写入:
```text
<运行目录>/<服务器目录>/config/kv.config
<运行目录>/<服务器目录>/config/sys.config
```
如果同类型同 ID 的服务器已经存在,工具会提示选择其他 ID 或确认覆盖。
## 6. 服务器管理
“服务器管理”页用于查看和操作已创建的服务器。
常用操作:
- 启动服务器
- 快速启动服务器
- 停止服务器
- 连接远程节点
- 查看账号
- 清档
如果节点连接失败,优先检查:
- Cookie 是否一致
- 节点名格式是否正确,例如 `name@ip`
- Erlang 路径是否可用
- 网络和防火墙是否放行
## 7. 日志查看
“日志查看”页可以检索服务器日志。
推荐将 `rg.exe` 放在:
```text
resources/bin/rg.exe
```
打包后工具会优先使用 ripgrep 做日志搜索,速度更快。
## 8. 命令行常用命令
Windows
```bat
scripts\run\run.bat list
scripts\run\run.bat create --type game --id 1
scripts\run\run.bat start --server ddxq_game_s1
scripts\run\run.bat stop --server ddxq_game_s1
scripts\run\run.bat connect --server ddxq_game_s1
scripts\run\run.bat doctor
```
Linux
```bash
./scripts/run/run.sh list
./scripts/run/run.sh create --type game --id 1
./scripts/run/run.sh start --server ddxq_game_s1 --background
./scripts/run/run.sh stop --server ddxq_game_s1
./scripts/run/run.sh connect --server ddxq_game_s1
./scripts/run/run.sh doctor
```
查看配置:
```bash
./scripts/run/run.sh config files
./scripts/run/run.sh config show
./scripts/run/run.sh config show --server ddxq_game_s1
```
## 9. 常见问题
### 打开项目后路径不对
通常是打开项目时选错了目录。请重新选择服务器项目根目录,不要选择 `run` 子目录。
### 创建服务器后无法启动
检查:
- `run` 目录是否存在
- 单服 `config/kv.config` 是否生成
- `config/sys.config` 是否生成
- Erlang 路径是否正确
- `rebar3``rebar3.cmd` 是否可用
### 数据库连接失败
先在“工具设置”页点击“测试连接”。如果失败,按提示检查数据库地址、端口、账号、密码和网络连通性。
### 配置改了但启动结果没变化
确认改的是正确层级:
- 全局默认值改 `default.config`
- 项目共享值改 `config/project.config`
- 当前用户值改 `user.config`
- 单台服务器值改对应服务器的 `kv.config`
单服配置优先级最高,如果 `kv.config` 中已有同名配置,会覆盖上层配置。
+4 -4
View File
@@ -1,6 +1,6 @@
[
%% Center server config
{center_server, [
{game_server, [
{server_type, center_server},
{server_id, ${server_id}},
{login_node, '${login_node}'},
@@ -27,7 +27,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 2000,
@@ -52,7 +52,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 10000,
@@ -144,7 +144,7 @@
{logger_level, ${logger_level}},
{logger, [
{filters, stop, [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}},
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}},
{http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]},
{handler, default, logger_std_h, #{
+3 -3
View File
@@ -27,7 +27,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 2000,
@@ -52,7 +52,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 10000,
@@ -71,7 +71,7 @@
{logger_level, ${logger_level}},
{logger, [
{filters, stop, [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
]},
{handler, default, logger_std_h, #{
level => ${logger_level},
+4 -4
View File
@@ -1,6 +1,6 @@
[
%% Cross server config
{cross_server, [
{game_server, [
{server_type, cross_server},
{server_id, ${server_id}},
{center_node, '${center_node}'},
@@ -27,7 +27,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 2000,
@@ -52,7 +52,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 10000,
@@ -144,7 +144,7 @@
{logger_level, ${logger_level}},
{logger, [
{filters, stop, [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}},
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}},
{http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]},
{handler, default, logger_std_h, #{
+3 -3
View File
@@ -40,7 +40,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 2000,
@@ -65,7 +65,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 10000,
@@ -157,7 +157,7 @@
{logger_level, ${logger_level}},
{logger, [
{filters, stop, [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}},
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}},
{http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]},
{handler, default, logger_std_h, #{
+3 -3
View File
@@ -21,7 +21,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 2000,
@@ -46,7 +46,7 @@
},
filter_default => stop,
filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}}
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
],
formatter => {logger_formatter, #{
max_size => 10000,
@@ -118,7 +118,7 @@
{logger_level, ${logger_level}},
{logger, [
{filters, stop, [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}},
{no_domain, {fun logger_filters:domain/2, {log, undefined, []}}},
{http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]},
{handler, default, logger_std_h, #{
+7 -6
View File
@@ -1,8 +1,9 @@
{
"version": "1.1.3",
"update_url": "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json",
"release_notes": "增加启停按钮",
"download_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe",
"full_installer_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe",
"delta_updates": {}
"version": "1.1.8",
"update_url": "https://www.sumi.work/api/software/server-manager/version.json",
"release_notes": "支持文件级增量更新包,减少后续版本更新带宽。",
"download_url": "https://www.sumi.work/api/software/server-manager/update-download",
"full_installer_url": "https://www.sumi.work/api/software/server-manager/update-download",
"delta_updates": {},
"deltaUpdates": {}
}
+15 -14
View File
@@ -7,6 +7,7 @@ title Server Manager - build
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
set "SRC=%ROOT%\src"
set "RESOURCE_CONFIG=%ROOT%\resources\config"
set "APP_NAME=ServerManager"
pushd "%SRC%" || (
echo [ERROR] src directory not found: %SRC%
exit /b 1
@@ -55,32 +56,32 @@ echo [3/6] clean dist\build...
if exist "dist" rd /s /q dist
if exist "build" rd /s /q build
if exist "*.spec" del /q *.spec 2>nul
REM Pre-create work dir for PyInstaller warn-main.txt on some Windows setups
if not exist "build\main" mkdir "build\main"
REM Pre-create work dir for PyInstaller warn file on some Windows setups
if not exist "build\%APP_NAME%" mkdir "build\%APP_NAME%"
echo [4/6] PyInstaller main (onedir)...
echo [4/6] PyInstaller %APP_NAME% (onedir)...
REM Single line: no ^ line-continuation (avoids trailing-space / encoding breakage)
pyinstaller --noconfirm --onedir --console --name main !ICON_PARAM! --add-data "%ROOT%\resources\version.json;." --add-data "%ROOT%\resources\config.json;." --add-data "%ROOT%\resources\icon;icon" --hidden-import PyQt6.QtWidgets --hidden-import PyQt6.QtCore --hidden-import PyQt6.QtGui --hidden-import pymysql --hidden-import entrypoints.cli.main --hidden-import entrypoints.cli.extended --hidden-import entrypoints.gui.bootstrap --hidden-import mcp.server --hidden-import mcp.tools --hidden-import models.app_config --hidden-import services.commands --hidden-import services.server_creator --hidden-import services.hot_update --hidden-import services.rg_search --hidden-import bsdiff4 --collect-all PyQt6 --collect-all bsdiff4 --hidden-import requests entrypoints\main.py
pyinstaller --noconfirm --onedir --console --name %APP_NAME% !ICON_PARAM! --add-data "%ROOT%\resources\version.json;." --add-data "%ROOT%\resources\config.json;." --add-data "%ROOT%\resources\icon;icon" --hidden-import PyQt6.QtWidgets --hidden-import PyQt6.QtCore --hidden-import PyQt6.QtGui --hidden-import pymysql --hidden-import entrypoints.cli.main --hidden-import entrypoints.cli.extended --hidden-import entrypoints.gui.bootstrap --hidden-import mcp.server --hidden-import mcp.tools --hidden-import models.app_config --hidden-import services.commands --hidden-import services.server_creator --hidden-import services.hot_update --hidden-import services.rg_search --hidden-import bsdiff4 --collect-all PyQt6 --collect-all bsdiff4 --hidden-import requests entrypoints\main.py
if errorlevel 1 (
echo [ERROR] main PyInstaller failed
echo [ERROR] %APP_NAME% PyInstaller failed
pause
exit /b 1
)
echo [4b/6] copy rg.exe...
if exist "%ROOT%\resources\bin\rg.exe" (
copy /y "%ROOT%\resources\bin\rg.exe" "dist\main\rg.exe" >nul
echo copied resources\bin\rg.exe to dist\main\rg.exe
copy /y "%ROOT%\resources\bin\rg.exe" "dist\%APP_NAME%\rg.exe" >nul
echo copied resources\bin\rg.exe to dist\%APP_NAME%\rg.exe
) else (
echo [WARN] resources\bin\rg.exe missing, search will not use ripgrep
)
echo [4c/6] copy resources\config templates to dist\main\config ^(same as installer {app}\config^)...
echo [4c/6] copy resources\config templates to dist\%APP_NAME%\config ^(same as installer {app}\config^)...
if exist "%RESOURCE_CONFIG%" (
if not exist "dist\main\config" mkdir "dist\main\config"
xcopy "%RESOURCE_CONFIG%\*" "dist\main\config\" /E /I /Y /Q >nul
echo copied ..\resources\config to dist\main\config
if not exist "dist\%APP_NAME%\config" mkdir "dist\%APP_NAME%\config"
xcopy "%RESOURCE_CONFIG%\*" "dist\%APP_NAME%\config\" /E /I /Y /Q >nul
echo copied ..\resources\config to dist\%APP_NAME%\config
) else (
echo [WARN] ..\resources\config missing, Inno install will fail if resources\config is absent at compile time
)
@@ -96,8 +97,8 @@ if errorlevel 1 (
echo [6/6] done.
if not exist "dist\main\config" mkdir "dist\main\config"
if exist "config\tool.config" copy /y "config\tool.config" "dist\main\config\tool.config" >nul
if not exist "dist\%APP_NAME%\config" mkdir "dist\%APP_NAME%\config"
if exist "config\user.config" copy /y "config\user.config" "dist\%APP_NAME%\config\user.config" >nul
rd /s /q build 2>nul
rd /s /q build_mini 2>nul
@@ -106,7 +107,7 @@ del /q build_mini\*.spec 2>nul
echo.
echo ============================================================
echo OK: dist\main\ dist\mini_updater.exe
echo OK: dist\%APP_NAME%\ dist\mini_updater.exe
echo ============================================================
echo.
+6 -6
View File
@@ -17,8 +17,8 @@
#define MyOutputBase "ServerManager_Setup"
#endif
#define MyAppVersion "1.1.3"
#define MyAppExeName "main.exe"
#define MyAppVersion "1.1.8"
#define MyAppExeName "ServerManager.exe"
#define MyAppPublisher "ServerManager"
#define MyAppURL "http://your-server.com/"
@@ -62,13 +62,13 @@ Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription:
Name: "quicklaunchicon"; Description: "创建快速启动栏快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
[Files]
; 主程序目录模式(src\dist\main\ 含 main.exe 与 _internal 等依赖,无 _MEI 解压,避免 DLL 加载失败)
Source: "..\..\src\dist\main\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 独立进程外更新器:与 main.exe 同目录
; 主程序目录模式(src\dist\ServerManager\ 含 ServerManager.exe 与 _internal 等依赖,无 _MEI 解压,避免 DLL 加载失败)
Source: "..\..\src\dist\ServerManager\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 独立进程外更新器:与 ServerManager.exe 同目录
Source: "..\..\src\dist\mini_updater.exe"; DestDir: "{app}"; Flags: ignoreversion
; 版本与更新配置
Source: "..\..\resources\version.json"; DestDir: "{app}"; Flags: ignoreversion
; 默认配置模板(default.kv、sys_*.config.example 等,与 resources\config 同源;build.bat 会同步拷入 dist\main\config
; 默认配置模板(default.config、sys_*.config.example 等,与 resources\config 同源;build.bat 会同步拷入 dist\ServerManager\config
Source: "..\..\resources\config\*"; DestDir: "{app}\config"; Flags: ignoreversion recursesubdirs createallsubdirs
[Dirs]
+12 -12
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Server Manager - Linux 下用 PyInstaller 打包 main(与 build.bat 的 onedir 主程序对应)
# Server Manager - Linux 下用 PyInstaller 打包 ServerManager(与 build.bat 的 onedir 主程序对应)
# 用法: chmod +x build_linux.sh && ./build_linux.sh
# 产物: src/dist/main/main (可将该目录加入 PATH,或设置 SERVER_MANAGER_MAIN 供 scripts/run/run.sh 自动调用)
# 产物: src/dist/ServerManager/ServerManager (可将该目录加入 PATH,或设置 SERVER_MANAGER_MAIN 供 scripts/run/run.sh 自动调用)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -34,9 +34,9 @@ fi
echo "[3/5] clean dist/build..."
rm -rf dist build ./*.spec 2>/dev/null || true
echo "[4/5] PyInstaller main (onedir)..."
echo "[4/5] PyInstaller ServerManager (onedir)..."
# add-data 在 Linux 下用冒号分隔
python3 -m PyInstaller --noconfirm --onedir --name main \
python3 -m PyInstaller --noconfirm --onedir --name ServerManager \
"${ICON_ARG[@]}" \
--add-data "$ROOT/resources/version.json:." \
--add-data "$ROOT/resources/config.json:." \
@@ -61,13 +61,13 @@ python3 -m PyInstaller --noconfirm --onedir --name main \
entrypoints/main.py
echo "[5/5] done."
mkdir -p dist/main/config
if [[ -f config/tool.config ]]; then
cp -f config/tool.config dist/main/config/tool.config || true
mkdir -p dist/ServerManager/config
if [[ -f config/user.config ]]; then
cp -f config/user.config dist/ServerManager/config/user.config || true
fi
if [[ -d "$RESOURCE_CONFIG" ]]; then
cp -a "$RESOURCE_CONFIG/." dist/main/config/
echo " copied resources/config -> dist/main/config"
cp -a "$RESOURCE_CONFIG/." dist/ServerManager/config/
echo " copied resources/config -> dist/ServerManager/config"
else
echo " [WARN] resources/config missing"
fi
@@ -77,9 +77,9 @@ rm -f ./*.spec 2>/dev/null || true
echo
echo "============================================================"
echo "OK: dist/main/main"
echo "CLI: dist/main/main list"
echo "GUI: dist/main/main"
echo "OK: dist/ServerManager/ServerManager"
echo "CLI: dist/ServerManager/ServerManager list"
echo "GUI: dist/ServerManager/ServerManager"
echo "或设置 SERVER_MANAGER_MAIN 指向该文件,配合 scripts/run/run.sh 使用"
echo "============================================================"
echo
+8 -7
View File
@@ -3,6 +3,7 @@ chcp 65001 >nul
title Server Manager - 打包便携版
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
set "SRC=%ROOT%\src"
set "APP_NAME=ServerManager"
pushd "%SRC%" || (
echo [错误] 未找到 src 目录: %SRC%
exit /b 1
@@ -42,11 +43,11 @@ if not exist "%ROOT%\resources\icon\icon.ico" (
echo [3/5] 清理旧的构建文件...
if exist "dist" rd /s /q dist
if exist "build" rd /s /q build
if exist "main.spec" del /q "main.spec"
if exist "%APP_NAME%.spec" del /q "%APP_NAME%.spec"
echo [4/5] 开始打包(单文件版本)...
pyinstaller --noconfirm --onefile --windowed ^
--name "main" ^
--name "%APP_NAME%" ^
%ICON_PARAM% ^
--add-data "%ROOT%\resources\icon;icon" ^
--hidden-import "PyQt6.QtWidgets" ^
@@ -72,7 +73,7 @@ if errorlevel 1 (
echo [错误] 打包失败,尝试使用目录模式...
pyinstaller --noconfirm --onedir --windowed ^
--name "main" ^
--name "%APP_NAME%" ^
%ICON_PARAM% ^
--add-data "%ROOT%\resources\icon;icon" ^
--hidden-import "PyQt6.QtWidgets" ^
@@ -104,10 +105,10 @@ echo [5/5] 打包完成!
echo.
echo ============================================================
if exist "dist\main.exe" (
echo 打包成功!单文件版本: dist\main.exe
) else if exist "dist\main\main.exe" (
echo 打包成功!目录版本: dist\main\main.exe
if exist "dist\%APP_NAME%.exe" (
echo 打包成功!单文件版本: dist\%APP_NAME%.exe
) else if exist "dist\%APP_NAME%\%APP_NAME%.exe" (
echo 打包成功!目录版本: dist\%APP_NAME%\%APP_NAME%.exe
)
echo ============================================================
echo.
+2 -2
View File
@@ -16,8 +16,8 @@ echo( EXE + Inno Setup 安装包
echo(============================================================
echo(
:: Step 1: PyInstaller 打包 main.exe(在 src 目录执行)
echo([Step 1/2] PyInstaller 打包 main.exe ...
:: Step 1: PyInstaller 打包 ServerManager.exe(在 src 目录执行)
echo([Step 1/2] PyInstaller 打包 ServerManager.exe ...
echo(
call "%ROOT%\scripts\package\build.bat" nopause
if errorlevel 1 (
+1 -1
View File
@@ -28,7 +28,7 @@ if "%NEW_VER%"=="" (
)
echo(
echo([阶段 1/4] PyInstaller 打包 main.exe ...
echo([阶段 1/4] PyInstaller 打包 ServerManager.exe ...
call "%ROOT%\scripts\package\build.bat" nopause
if errorlevel 1 (
echo([失败] PyInstaller 打包未成功
+227 -59
View File
@@ -4,7 +4,7 @@
发版与版本号:推荐顺序见 scripts/package/release.bat — 先 PyInstaller 打包,再更新版本号并同步到 dist,再 Inno Setup,最后 --post-build。
单独用法:
仅改版本号(通常在 build.bat 成功之后): python scripts/package/release.py 1.0.4 [--notes "说明"]
打包完成后: python scripts/package/release.py --post-build [--old-exe 上一版main.exe]
打包完成后: python scripts/package/release.py --post-build [--old-exe 上一版 ServerManager.exe]
本地测试包: scripts/package/pack_local_test.bat 或 python scripts/package/pack_local_test.py(见 RELEASE.md
"""
@@ -15,14 +15,20 @@ import re
import shutil
import subprocess
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
VERSION_JSON = ROOT / "resources" / "version.json"
ROOT_VERSION_JSON = ROOT / "version.json"
OUTPUT_DIR = ROOT / "build" / "output"
PUBLIC_MANIFEST_PATH = "server_manager/build/output/version.json"
PUBLIC_INSTALLER_PATH = "server_manager/build/output/ServerManager_Setup.exe"
PUBLIC_MANIFEST_URL = "https://www.sumi.work/api/software/server-manager/version.json"
PUBLIC_INSTALLER_URL = "https://www.sumi.work/api/software/server-manager/update-download"
PUBLIC_DELTA_URL = "https://www.sumi.work/api/software/server-manager/delta-download?from={from_version}"
APP_DIST_NAME = "ServerManager"
APP_EXE_NAME = "ServerManager.exe"
FILE_DELTA_FORMAT = "server-manager-file-delta-v1"
def read_text(p: Path) -> str:
@@ -48,34 +54,119 @@ def get_current_version() -> str:
return read_version_from_json(OUTPUT_DIR / "version.json")
def _set_url_path(url: str, api_kind: str, public_path: str) -> str:
fallback = f"http://172.18.180.94:3000/api/{api_kind}?path={public_path}"
if not url:
return fallback
url = url.strip()
url = url.replace("/api/file?", f"/api/{api_kind}?")
url = url.replace("/api/download?", f"/api/{api_kind}?")
if "path=" in url:
return re.sub(r"path=[^&]+", lambda _m: f"path={public_path}", url, count=1)
return fallback
def format_size(bytes_count: int) -> str:
return f"{bytes_count / 1024 / 1024:.1f} MB"
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def rel_path(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def collect_dist_files(root: Path) -> dict:
files = {}
if not root.is_dir():
return files
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
rel = rel_path(p, root)
if rel.startswith(".update_backup/") or rel.startswith("logs/") or rel.endswith(".old"):
continue
stat = p.stat()
files[rel] = {
"path": p,
"sha256": sha256_file(p),
"size": stat.st_size,
}
return files
def generate_file_delta(old_dist_path: Path, old_version: str, new_version: str) -> tuple[Path, dict]:
old_root = Path(old_dist_path).resolve()
new_root = ROOT / "src" / "dist" / APP_DIST_NAME
if not old_root.is_dir():
raise FileNotFoundError(f"old dist not found: {old_root}")
if not new_root.is_dir():
raise FileNotFoundError(f"new dist not found: {new_root}")
old_files = collect_dist_files(old_root)
new_files = collect_dist_files(new_root)
changed = []
deleted = []
for rel, meta in sorted(new_files.items()):
old_meta = old_files.get(rel)
if not old_meta or old_meta["sha256"] != meta["sha256"]:
changed.append({
"path": rel,
"zip_path": f"files/{rel}",
"sha256": meta["sha256"],
"size": meta["size"],
"old_sha256": old_meta["sha256"] if old_meta else "",
"old_size": old_meta["size"] if old_meta else 0,
})
for rel, meta in sorted(old_files.items()):
if rel not in new_files:
deleted.append({
"path": rel,
"old_sha256": meta["sha256"],
"old_size": meta["size"],
})
patch_dir = OUTPUT_DIR / "patches"
patch_dir.mkdir(parents=True, exist_ok=True)
delta_file = patch_dir / f"v{old_version}_to_v{new_version}.smdelta.zip"
manifest = {
"format": FILE_DELTA_FORMAT,
"base_version": old_version,
"target_version": new_version,
"app": APP_DIST_NAME,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
"files": changed,
"delete": deleted,
}
with zipfile.ZipFile(delta_file, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
zf.writestr("delta_manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
for entry in changed:
zf.write(new_files[entry["path"]]["path"], entry["zip_path"])
size = delta_file.stat().st_size
return delta_file, {
"sha256": sha256_file(delta_file),
"sizeBytes": size,
"size": format_size(size),
"fileCount": len(changed),
"deleteCount": len(deleted),
}
def save_dist_snapshot(version: str) -> None:
src = ROOT / "src" / "dist" / APP_DIST_NAME
if not src.is_dir():
return
dst = OUTPUT_DIR / "snapshots" / f"{APP_DIST_NAME}-{version}"
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
print(f" [OK] 已保存 dist 快照 -> {dst.relative_to(ROOT)}")
def ensure_compat_manifest(data: dict) -> dict:
"""补齐客户端更新字段,并固定到 build/output 发布路径。"""
seed = (
data.get("update_url")
or data.get("full_installer_url")
or data.get("download_url")
or ""
)
data["update_url"] = _set_url_path(seed, "file", PUBLIC_MANIFEST_PATH)
full_url = _set_url_path(
data.get("full_installer_url") or data.get("download_url") or seed,
"download",
PUBLIC_INSTALLER_PATH,
)
data["full_installer_url"] = full_url
data["download_url"] = full_url
data["update_url"] = PUBLIC_MANIFEST_URL
data["full_installer_url"] = PUBLIC_INSTALLER_URL
data["download_url"] = PUBLIC_INSTALLER_URL
return data
@@ -86,7 +177,7 @@ def write_version_json(data: dict) -> None:
write_text(ROOT_VERSION_JSON, text)
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False) -> str:
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False, delta_kind: str = "file-zip") -> str:
"""更新版本号并同步 manifest。默认全量更新;显式 --with-delta 才生成 delta 项。"""
vj = VERSION_JSON
if not vj.exists():
@@ -126,28 +217,38 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
data["release_notes"] = data.get("release_notes", "") or f"Server Manager {new_version} 更新。"
data = ensure_compat_manifest(data)
full_url = data.get("full_installer_url", "")
if with_delta:
# delta_updates: 只保留 上一版本 -> 当前版本 一项。
# 目录模式下 delta 只替换 main.exe;只有确认依赖目录无需变化时才启用。
patch_path = f"patches/v{old_version}_to_v{new_version}.patch"
if full_url and "build/output/" in full_url:
patch_url = re.sub(r"build/output/[^?]+", "build/output/" + patch_path, full_url)
elif full_url and "output/" in full_url:
patch_url = re.sub(r"output/[^?]+", "build/output/" + patch_path, full_url)
else:
patch_url = full_url.replace("ServerManager_Setup.exe", patch_path) if full_url else ""
data["delta_updates"] = {
old_version: {
# delta_updates: 只保留 上一版本 -> 当前版本 一项。默认使用文件级增量 zip。
if delta_kind == "exe-patch":
patch_url = PUBLIC_DELTA_URL.format(from_version=old_version)
delta_entry = {
"type": "bsdiff-exe",
"patch_url": patch_url,
"new_exe_sha256": "",
}
}
else:
delta_url = PUBLIC_DELTA_URL.format(from_version=old_version)
delta_entry = {
"type": "file-zip",
"format": FILE_DELTA_FORMAT,
"fromVersion": old_version,
"from_version": old_version,
"targetVersion": new_version,
"target_version": new_version,
"package_url": delta_url,
"packageUrl": delta_url,
"patch_url": delta_url,
"patchUrl": delta_url,
"sha256": "",
"sizeBytes": 0,
"size": "",
}
data["delta_updates"] = {old_version: delta_entry}
data["deltaUpdates"] = data["delta_updates"]
print(f" [OK] version.json -> version {new_version}, delta_updates[{old_version}] 已添加")
else:
data["delta_updates"] = {}
data["deltaUpdates"] = {}
data.pop("delta", None)
print(f" [OK] version.json -> version {new_version},本次使用全量更新")
@@ -155,7 +256,10 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
print(" [OK] 已同步 version.json -> resources/version.json 与根目录 version.json")
# 若已打过 PyInstaller,把新版本同步进 dist,避免安装包内仍是旧 version.json
for rel in ("src/dist/main/_internal/version.json", "src/dist/main/version.json"):
for rel in (
f"src/dist/{APP_DIST_NAME}/_internal/version.json",
f"src/dist/{APP_DIST_NAME}/version.json",
):
dest = ROOT / rel
if dest.parent.is_dir():
shutil.copy2(vj, dest)
@@ -163,8 +267,8 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
return old_version
def set_version_post(old_exe_path: str = None) -> None:
"""打包完成后:若提供 --old-exe(上一版 main.exe 路径)则生成 patch 并填 new_exe_sha256;否则仅复制 version.json。本地不保存 exe。"""
def set_version_post(old_exe_path: str = None, old_dist_path: str = None) -> None:
"""打包完成后:生成文件级增量 zip 或兼容旧 exe patch,并复制 version.json。"""
vj = VERSION_JSON
if not vj.exists():
return
@@ -174,25 +278,83 @@ def set_version_post(old_exe_path: str = None) -> None:
delta_updates = data.get("delta_updates") or {}
if not isinstance(delta_updates, dict):
return
# 找到需要填 new_exe_sha256 的那一项(上一版本)
# 找到需要补齐元数据的那一项(上一版本)
old_version = None
for k, v in delta_updates.items():
if isinstance(v, dict) and not (v.get("new_exe_sha256") or "").strip():
if not isinstance(v, dict):
continue
kind = str(v.get("type") or "").strip().lower().replace("_", "-")
if kind == "bsdiff-exe":
if not (v.get("new_exe_sha256") or "").strip():
old_version = k.strip()
break
elif not (v.get("sha256") or v.get("package_sha256") or v.get("packageSha256") or "").strip():
old_version = k.strip()
break
if not old_version:
print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta")
write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
delta_entry = delta_updates.get(old_version) or {}
delta_kind = str(delta_entry.get("type") or "").strip().lower().replace("_", "-")
# 支持目录模式 (dist/main/main.exe) 与单文件 (dist/main.exe)
new_exe = ROOT / "src" / "dist" / "main" / "main.exe"
if not new_exe.exists():
new_exe = ROOT / "src" / "dist" / "main.exe"
if not new_exe.exists():
print(" [i] 未找到 src/dist/main.exe 或 src/dist/main/main.exe,跳过增量包")
if delta_kind != "bsdiff-exe":
if not old_dist_path:
print(" [i] 未提供上一版 dist 目录(使用 --old-dist 指定可生成文件级增量包),本次仅全量")
data["delta_updates"] = {}
data.pop("deltaUpdates", None)
write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
try:
delta_file, delta_meta = generate_file_delta(Path(old_dist_path), old_version, new_version)
except Exception as e:
print(f" [!] 生成文件级增量包失败: {e},本次仅全量", file=sys.stderr)
data["delta_updates"] = {}
data.pop("deltaUpdates", None)
write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
delta_entry.update({
"type": "file-zip",
"format": FILE_DELTA_FORMAT,
"fromVersion": old_version,
"from_version": old_version,
"targetVersion": new_version,
"target_version": new_version,
"sha256": delta_meta["sha256"],
"package_sha256": delta_meta["sha256"],
"packageSha256": delta_meta["sha256"],
"sizeBytes": delta_meta["sizeBytes"],
"size": delta_meta["size"],
"fileCount": delta_meta["fileCount"],
"deleteCount": delta_meta["deleteCount"],
})
delta_updates[old_version] = delta_entry
data["delta_updates"] = delta_updates
data["deltaUpdates"] = delta_updates
write_version_json(data)
print(
f" [OK] 已生成文件级增量包: {delta_file} "
f"({delta_meta['size']}, {delta_meta['fileCount']} changed, {delta_meta['deleteCount']} deleted)"
)
save_dist_snapshot(new_version)
_copy_version_to_output()
print(" 发版准备完成。")
return
# 支持目录模式 (dist/ServerManager/ServerManager.exe) 与单文件 (dist/ServerManager.exe)
new_exe = ROOT / "src" / "dist" / APP_DIST_NAME / APP_EXE_NAME
if not new_exe.exists():
new_exe = ROOT / "src" / "dist" / APP_EXE_NAME
if not new_exe.exists():
print(f" [i] 未找到 src/dist/{APP_EXE_NAME} 或 src/dist/{APP_DIST_NAME}/{APP_EXE_NAME},跳过增量包")
write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
@@ -201,6 +363,7 @@ def set_version_post(old_exe_path: str = None) -> None:
if not old_exe_path or not old_exe or not old_exe.is_file():
print(f" [i] 未提供上一版 exe(使用 --old-exe 指定路径可生成增量包),仅全量")
write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
@@ -208,6 +371,7 @@ def set_version_post(old_exe_path: str = None) -> None:
import bsdiff4
except ImportError:
print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
@@ -224,9 +388,11 @@ def set_version_post(old_exe_path: str = None) -> None:
new_sha = hashlib.sha256(new_data).hexdigest().lower()
delta_updates[old_version]["new_exe_sha256"] = new_sha
data["delta_updates"] = delta_updates
data["deltaUpdates"] = delta_updates
write_version_json(data)
print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...")
save_dist_snapshot(new_version)
_copy_version_to_output()
print(" 发版准备完成。")
@@ -251,14 +417,16 @@ def clean_generated_artifacts() -> None:
def main():
parser = argparse.ArgumentParser(description="一键发版:更新版本号 / 打包后生成增量并填 SHA256")
parser.add_argument("version", nargs="?", help="新版本号,如 1.0.4")
parser.add_argument("--post-build", action="store_true", help="打包完成后执行:复制 version.json;若提供 --old-exe 则生成增量并填 SHA256")
parser.add_argument("--old-exe", default=None, help="上一版 main.exe 路径,用于生成增量 patch(与 Cursor 一致,本地不保存 exe)")
parser.add_argument("--with-delta", action="store_true", help="生成 main.exe 增量项;仅在确认依赖目录无需变化时使用")
parser.add_argument("--post-build", action="store_true", help="打包完成后执行:复制 version.json;若提供 --old-dist 则生成文件级增量包")
parser.add_argument("--old-dist", default=None, help="上一版 dist/ServerManager 目录,用于生成文件级增量 zip")
parser.add_argument("--old-exe", default=None, help="上一版 ServerManager.exe 路径,用于兼容旧 bsdiff exe patch")
parser.add_argument("--with-delta", action="store_true", help="生成增量更新项;默认文件级增量 zip")
parser.add_argument("--delta-kind", choices=("file-zip", "exe-patch"), default="file-zip", help="增量类型,默认 file-zip")
parser.add_argument("--notes", "-n", default=None, help="可选:release_notes 更新说明")
args = parser.parse_args()
if args.post_build:
set_version_post(old_exe_path=args.old_exe)
set_version_post(old_exe_path=args.old_exe, old_dist_path=args.old_dist)
clean_generated_artifacts()
return
@@ -266,7 +434,7 @@ def main():
print("用法: python scripts/package/release.py <版本号> 例如: python scripts/package/release.py 1.0.4", file=sys.stderr)
sys.exit(1)
new_ver = args.version.strip()
old_ver = set_version_pre(new_ver, args.notes, with_delta=args.with_delta)
old_ver = set_version_pre(new_ver, args.notes, with_delta=args.with_delta, delta_kind=args.delta_kind)
if args.with_delta:
print(f"\n 当前版本 {old_ver} -> 新版本 {new_ver}。若使用 scripts/package/release.bat,接下来将编译安装包并执行 --post-build。")
else:
+3 -3
View File
@@ -14,10 +14,10 @@ rem Run source modules through the new entrypoints package.
cd /d "%ROOT%"
set "PYTHONPATH=%ROOT%\src;%PYTHONPATH%"
rem Prefer packaged main.exe when present.
rem Prefer packaged ServerManager.exe when present.
set "SM_MAIN="
if defined SERVER_MANAGER_MAIN if exist "%SERVER_MANAGER_MAIN%" set "SM_MAIN=%SERVER_MANAGER_MAIN%"
if not defined SM_MAIN if exist "%ROOT%\src\dist\main\main.exe" set "SM_MAIN=%ROOT%\src\dist\main\main.exe"
if not defined SM_MAIN if exist "%ROOT%\src\dist\ServerManager\ServerManager.exe" set "SM_MAIN=%ROOT%\src\dist\ServerManager\ServerManager.exe"
rem No arguments enters interactive CLI mode.
if "%~1"=="" goto :run_interactive
@@ -102,7 +102,7 @@ echo.
echo More help: python -m entrypoints.cli --help
if defined SM_MAIN (
echo.
echo Packaged main: %SM_MAIN%
echo Packaged program: %SM_MAIN%
)
goto :end
+3 -3
View File
@@ -20,10 +20,10 @@ export PYTHONPATH="$ROOT/src${PYTHONPATH:+:$PYTHONPATH}"
# 默认以项目根目录作为服务器根目录;部署到其它项目时可通过 SERVER_MANAGER_ROOT 覆盖。
SERVER_ROOT="${SERVER_MANAGER_ROOT:-$ROOT}"
# 优先使用 PyInstaller 打包的 mainLinux):环境变量 SERVER_MANAGER_MAIN,或常见构建路径 src/dist/main/main
# 优先使用 PyInstaller 打包的 ServerManagerLinux):环境变量 SERVER_MANAGER_MAIN,或常见构建路径 src/dist/ServerManager/ServerManager
SM_MAIN="${SERVER_MANAGER_MAIN:-}"
if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/main/main" ]]; then
SM_MAIN="$ROOT/src/dist/main/main"
if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/ServerManager/ServerManager" ]]; then
SM_MAIN="$ROOT/src/dist/ServerManager/ServerManager"
fi
# 颜色输出
+195 -30
View File
@@ -8,10 +8,15 @@
"""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import tempfile
import zipfile
# 仅标准库:PID 检测用 ctypesWindows)或 os.killUnix
if sys.platform == "win32":
@@ -63,17 +68,173 @@ def is_process_alive(pid: int) -> bool:
return False
DELTA_FORMAT = "server-manager-file-delta-v1"
def _sha256_file(file_path: str) -> str:
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def _safe_relative_path(value: str) -> str:
rel = str(value or "").replace("\\", "/").strip("/")
parts = [part for part in rel.split("/") if part]
if not parts or any(part in (".", "..") for part in parts):
raise ValueError("unsafe relative path: %s" % value)
return "/".join(parts)
def _safe_join(root: str, rel_path: str) -> str:
root_abs = os.path.abspath(root)
rel = _safe_relative_path(rel_path)
target = os.path.abspath(os.path.join(root_abs, *rel.split("/")))
if target != root_abs and not target.startswith(root_abs + os.sep):
raise ValueError("path escapes install dir: %s" % rel_path)
return target
def _read_delta_manifest(delta_zip_path: str) -> dict:
with zipfile.ZipFile(delta_zip_path, "r") as zf:
with zf.open("delta_manifest.json") as f:
manifest = json.loads(f.read().decode("utf-8-sig"))
if not isinstance(manifest, dict):
raise ValueError("delta manifest must be an object")
if manifest.get("format") != DELTA_FORMAT:
raise ValueError("unsupported delta format: %s" % manifest.get("format"))
if not isinstance(manifest.get("files"), list):
raise ValueError("delta manifest files must be a list")
if not isinstance(manifest.get("delete"), list):
manifest["delete"] = []
return manifest
def _validate_base_files(install_dir: str, manifest: dict) -> None:
for entry in manifest.get("files", []):
rel = _safe_relative_path(entry.get("path"))
old_sha = str(entry.get("old_sha256") or "").lower()
target = _safe_join(install_dir, rel)
if old_sha:
if not os.path.isfile(target):
raise ValueError("base file missing: %s" % rel)
if _sha256_file(target) != old_sha:
raise ValueError("base file hash mismatch: %s" % rel)
for entry in manifest.get("delete", []):
rel = _safe_relative_path(entry.get("path"))
old_sha = str(entry.get("old_sha256") or "").lower()
target = _safe_join(install_dir, rel)
if os.path.exists(target) and old_sha and os.path.isfile(target) and _sha256_file(target) != old_sha:
raise ValueError("delete file hash mismatch: %s" % rel)
def _extract_delta_files(delta_zip_path: str, manifest: dict) -> str:
staging_dir = tempfile.mkdtemp(prefix="server_manager_delta_")
with zipfile.ZipFile(delta_zip_path, "r") as zf:
names = set(zf.namelist())
for entry in manifest.get("files", []):
rel = _safe_relative_path(entry.get("path"))
zip_path = _safe_relative_path(entry.get("zip_path") or entry.get("source") or rel)
expected_sha = str(entry.get("sha256") or "").lower()
if zip_path not in names:
raise ValueError("delta zip entry missing: %s" % zip_path)
staged = _safe_join(staging_dir, rel)
os.makedirs(os.path.dirname(staged), exist_ok=True)
with zf.open(zip_path) as src, open(staged, "wb") as dst:
shutil.copyfileobj(src, dst)
if expected_sha and _sha256_file(staged) != expected_sha:
raise ValueError("delta file hash mismatch: %s" % rel)
return staging_dir
def apply_delta_zip(install_dir: str, delta_zip_path: str) -> bool:
"""Apply a file-level delta zip after the main process has exited."""
backup_dir = ""
staged_dir = ""
moved_backups = []
created_targets = []
try:
manifest = _read_delta_manifest(delta_zip_path)
_validate_base_files(install_dir, manifest)
staged_dir = _extract_delta_files(delta_zip_path, manifest)
backup_dir = os.path.join(install_dir, ".update_backup", str(int(time.time())))
for entry in manifest.get("delete", []):
rel = _safe_relative_path(entry.get("path"))
target = _safe_join(install_dir, rel)
if os.path.exists(target):
backup = _safe_join(backup_dir, rel)
os.makedirs(os.path.dirname(backup), exist_ok=True)
shutil.move(target, backup)
moved_backups.append((target, backup))
for entry in manifest.get("files", []):
rel = _safe_relative_path(entry.get("path"))
source = _safe_join(staged_dir, rel)
target = _safe_join(install_dir, rel)
os.makedirs(os.path.dirname(target), exist_ok=True)
if os.path.exists(target):
backup = _safe_join(backup_dir, rel)
os.makedirs(os.path.dirname(backup), exist_ok=True)
shutil.move(target, backup)
moved_backups.append((target, backup))
else:
created_targets.append(target)
shutil.move(source, target)
try:
if os.path.isdir(staged_dir):
shutil.rmtree(staged_dir, ignore_errors=True)
if os.path.isdir(backup_dir):
shutil.rmtree(backup_dir, ignore_errors=True)
if os.path.isfile(delta_zip_path):
os.remove(delta_zip_path)
except Exception:
pass
return True
except Exception as e:
_log_error(install_dir, "apply delta zip failed", e)
for target in reversed(created_targets):
try:
if os.path.exists(target):
os.remove(target)
except Exception:
pass
for target, backup in reversed(moved_backups):
try:
if os.path.exists(target):
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=True)
else:
os.remove(target)
os.makedirs(os.path.dirname(target), exist_ok=True)
if os.path.exists(backup):
shutil.move(backup, target)
except Exception as restore_error:
_log_error(install_dir, "restore backup failed: %s" % target, restore_error)
if staged_dir:
shutil.rmtree(staged_dir, ignore_errors=True)
return False
def main() -> None:
parser = argparse.ArgumentParser(description="独立更新守护:等主进程退出后替换 exe 并重启")
parser.add_argument("--pid", type=int, required=True, help="主程序进程 ID,必须等其完全退出")
parser.add_argument("--install-dir", type=str, required=True, help="主程序安装根目录")
parser.add_argument("--new-exe-path", type=str, required=True, help="已准备好的新版本 main.exe 的完整路径(通常在 TEMP)")
parser.add_argument("--target-exe-name", type=str, default="main.exe", help="主程序文件名,如 main.exe")
parser.add_argument("--new-exe-path", type=str, default="", help="已准备好的新版本 ServerManager.exe 的完整路径(通常在 TEMP)")
parser.add_argument("--delta-zip-path", type=str, default="", help="文件级增量包 zip 路径")
parser.add_argument("--target-exe-name", type=str, default="ServerManager.exe", help="主程序文件名,如 ServerManager.exe")
args = parser.parse_args()
install_dir = os.path.abspath(args.install_dir)
new_exe_path = os.path.abspath(args.new_exe_path)
target_exe_name = args.target_exe_name.strip() or "main.exe"
new_exe_path = os.path.abspath(args.new_exe_path) if args.new_exe_path else ""
delta_zip_path = os.path.abspath(args.delta_zip_path) if args.delta_zip_path else ""
if bool(new_exe_path) == bool(delta_zip_path):
_log_error(install_dir, "exactly one of --new-exe-path or --delta-zip-path is required")
sys.exit(1)
target_exe_name = args.target_exe_name.strip() or "ServerManager.exe"
target_exe_full = os.path.join(install_dir, target_exe_name)
old_exe_full = target_exe_full + ".old"
@@ -93,36 +254,40 @@ def main() -> None:
_log_error(install_dir, "切换到安装目录失败", e)
sys.exit(1)
# 若存在上次遗留的 .old,先静默删除
if os.path.isfile(old_exe_full):
if delta_zip_path:
if not apply_delta_zip(install_dir, delta_zip_path):
sys.exit(1)
else:
# 若存在上次遗留的 .old,先静默删除
if os.path.isfile(old_exe_full):
try:
os.remove(old_exe_full)
except Exception as e:
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
# 将当前主程序重命名为 .old,腾出位置
if not os.path.isfile(target_exe_full):
_log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
sys.exit(1)
try:
os.remove(old_exe_full)
os.rename(target_exe_full, old_exe_full)
except Exception as e:
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
sys.exit(1)
# 将当前主程序重命名为 .old,腾出位置
if not os.path.isfile(target_exe_full):
_log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
sys.exit(1)
try:
os.rename(target_exe_full, old_exe_full)
except Exception as e:
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
sys.exit(1)
# 将新 exe 移入安装目录并命名为 target-exe-name
if not os.path.isfile(new_exe_path):
_log_error(install_dir, "新 exe 不存在: %s" % new_exe_path)
sys.exit(1)
try:
shutil.move(new_exe_path, target_exe_full)
except Exception as e:
_log_error(install_dir, "移动新 exe 到安装目录失败", e)
# 将新 exe 移入安装目录并命名为 target-exe-name
if not os.path.isfile(new_exe_path):
_log_error(install_dir, " exe 不存在: %s" % new_exe_path)
sys.exit(1)
try:
os.rename(old_exe_full, target_exe_full)
except Exception:
pass
sys.exit(1)
shutil.move(new_exe_path, target_exe_full)
except Exception as e:
_log_error(install_dir, "移动新 exe 到安装目录失败", e)
try:
os.rename(old_exe_full, target_exe_full)
except Exception:
pass
sys.exit(1)
# -------------------------------------------------------------------------
# 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS
+19 -19
View File
@@ -2,10 +2,10 @@
"""
仿 Cursor / VS Code 的无感增量更新模块(Tkinter 入口用)
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 main.exe 与 _internal 等依赖。
【增量范围】.patch 仅针对 main.exe:在 TEMP 合成 main_new.exe 并校验 SHA256
替换时只覆盖安装目录下的 main.exe,绝不修改 _internal 或其它文件。
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 main_new.exe,不留下垃圾。
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 ServerManager.exe 与 _internal 等依赖。
【增量范围】.patch 仅针对 ServerManager.exe:在 TEMP 合成 ServerManager_new.exe 并校验 SHA256
替换时只覆盖安装目录下的 ServerManager.exe,绝不修改 _internal 或其它文件。
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 ServerManager_new.exe,不留下垃圾。
依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。
远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。
@@ -91,9 +91,9 @@ def apply_update_and_restart(
if not os.path.isfile(new_exe_path):
return
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
target_name = (target_exe_name or os.path.basename(current_exe_path) or "main.exe").strip() or "main.exe"
target_name = (target_exe_name or os.path.basename(current_exe_path) or "ServerManager.exe").strip() or "ServerManager.exe"
# 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径
# 优先使用安装目录下的 mini_updater.exe(与 ServerManager.exe 同目录),必须用绝对路径
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
try:
@@ -185,14 +185,14 @@ def _get_delta_map(manifest: dict) -> dict:
class SeamlessUpdater:
"""
隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」,
用户确认后极速替换 main.exe(或静默安装完整包)并退出。
用户确认后极速替换 ServerManager.exe(或静默安装完整包)并退出。
"""
def __init__(self, root: tk.Tk, local_version: str, version_url: str = None):
self.root = root
self.local_version = (local_version or "").strip()
self.version_url = (version_url or VERSION_JSON_URL).strip()
self._ready_delta_path = None # TEMP 下 main_new.exe 路径(增量就绪)
self._ready_delta_path = None # TEMP 下 ServerManager_new.exe 路径(增量就绪)
self._ready_full_path = None # TEMP 下 Setup.exe 路径(全量就绪)
self._ready_remote_version = None
self._lock = threading.Lock()
@@ -242,8 +242,8 @@ class SeamlessUpdater:
def _try_delta_path(self, manifest: dict) -> bool:
"""
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 main_new.exe -> 校验 SHA256。
仅针对 main.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 ServerManager_new.exe -> 校验 SHA256。
仅针对 ServerManager.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。
"""
delta_map = _get_delta_map(manifest)
@@ -386,7 +386,7 @@ import tkinter as tk
from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL
# 从 version.json 或常量读取本地版本号
LOCAL_VERSION = "1.1.3" # 需与 version.json / 安装包一致
LOCAL_VERSION = "1.1.8" # 需与 version.json / 安装包一致
def main():
root = tk.Tk()
@@ -408,20 +408,20 @@ if __name__ == "__main__":
# 开发者:如何生成 .patch 文件(发版流程)
# ---------------------------------------------------------------------------
"""
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 main.exe,不包含 _internal
用户端替换时只覆盖 main.exe,绝不修改 _internal 或其它文件。
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 ServerManager.exe,不包含 _internal
用户端替换时只覆盖 ServerManager.exe,绝不修改 _internal 或其它文件。
1. 打包:使用 PyInstaller 文件夹模式生成当前版本:
pyinstaller -D -w your_main.py
得到 dist/your_main/main.exe(或 dist/main.exe
pyinstaller -D -w -n ServerManager entrypoints/main.py
得到 dist/ServerManager/ServerManager.exe。
2. 保存上一版本的 main.exe(仅此单文件,无需 _internal):
将上一发版时的 main.exe 保存为 build/output/main_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
2. 保存上一版本的 ServerManager.exe(仅此单文件,无需 _internal):
将上一发版时的 ServerManager.exe 保存为 build/output/ServerManager_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
3. 生成差分补丁(需安装 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()
old = open('build/output/ServerManager_1.0.2.exe', 'rb').read()
new = open('dist/ServerManager/ServerManager.exe', 'rb').read()
patch = bsdiff4.diff(old, new)
open('build/output/patches/v1.0.2_to_v1.0.3.patch', 'wb').write(patch)
+52 -2
View File
@@ -204,7 +204,22 @@ class ClearDatabaseThread(QThread):
error_signal = pyqtSignal(str) # 错误信息
status_signal = pyqtSignal(str) # 状态更新
def __init__(self, server_name, stop_cmd, db_host, db_port, db_user, db_pass, db_game, db_log):
def __init__(
self,
server_name,
stop_cmd,
db_host,
db_port,
db_user,
db_pass,
db_game,
db_log,
server_id=None,
center_node='',
login_node='',
cookie='ddxq2-node',
erl_path=None,
):
super().__init__()
self.server_name = server_name
self.stop_cmd = stop_cmd
@@ -214,6 +229,11 @@ class ClearDatabaseThread(QThread):
self.db_pass = db_pass
self.db_game = db_game
self.db_log = db_log
self.server_id = server_id
self.center_node = center_node
self.login_node = login_node
self.cookie = cookie
self.erl_path = erl_path
def run(self):
import time
@@ -227,6 +247,12 @@ class ClearDatabaseThread(QThread):
try:
logger.info(f"开始清档服务器: {self.server_name}")
logger.debug(f"数据库信息: host={self.db_host}, port={self.db_port}, game={self.db_game}, log={self.db_log}")
if self.server_id is None or str(self.server_id).strip() == '':
raise ValueError("无法获取服务器ID,已取消清档")
if not str(self.center_node or '').strip():
raise ValueError("无法获取中心服节点,已取消清档")
if not str(self.login_node or '').strip():
raise ValueError("无法获取登录服节点,已取消清档")
# 1. 停止服务器
self.status_signal.emit("正在停止服务器...")
@@ -242,7 +268,31 @@ class ClearDatabaseThread(QThread):
except Exception as e:
logger.warning(f"停止命令执行失败: {e},继续清档")
# 2. 删除数据库
# 2. 通知中心服和登录服清档
self.status_signal.emit("正在通知中心服和登录服清档...")
logger.info(
"通知中心服/登录服清档: server_id=%s, center_node=%s, login_node=%s",
self.server_id,
self.center_node,
self.login_node,
)
code, stdout, stderr, cmd_line = rpc_clear_server_on_center_and_login(
self.center_node,
self.login_node,
self.server_id,
self.cookie,
erl_path=self.erl_path,
)
logger.debug(f"清档RPC命令: {cmd_line}")
if stdout.strip():
logger.info(f"清档RPC输出: {stdout.strip()}")
if stderr.strip():
logger.warning(f"清档RPC错误输出: {stderr.strip()}")
if code != 0:
detail = (stderr or stdout or f"exit code {code}").strip()
raise RuntimeError(f"通知中心服/登录服清档失败: {detail[:1000]}")
# 3. 删除数据库
self.status_signal.emit("正在删除数据库...")
logger.info(f"连接数据库: {self.db_host}:{self.db_port}")
+2 -2
View File
@@ -8,7 +8,7 @@ import json
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from services.commands import flatten_server_dirs
from services.commands import USER_CONFIG_FILE, flatten_server_dirs
SENSITIVE_KEY_PARTS = ("pass", "password", "secret", "token", "cookie")
@@ -61,7 +61,7 @@ def all_servers(cli: Any) -> List[str]:
def config_target_path(cli: Any, server: Optional[str] = None) -> Path:
if server:
return Path(cli.run_dir) / server / "config" / "kv.config"
return Path(cli.config_path) / "tool.config"
return Path(cli.config_path) / USER_CONFIG_FILE
def update_key_value_file(path: Path, updates: Optional[Dict[str, str]] = None,
+21 -15
View File
@@ -7,7 +7,13 @@ import argparse
from pathlib import Path
from typing import Any
from services.commands import read_merged_config, sync_server_manager_config_templates
from services.commands import (
DEFAULT_CONFIG_FILE,
PROJECT_CONFIG_FILE,
USER_CONFIG_FILE,
read_merged_config,
sync_server_manager_config_templates,
)
from .common import (
config_target_path,
@@ -43,15 +49,15 @@ def add_config_subparsers(parent: argparse.ArgumentParser) -> None:
cfg_get.add_argument("--include-sensitive", action="store_true", help="允许输出敏感字段")
_add_json_flag(cfg_get)
cfg_set = config_sub.add_parser("set", help="设置项目 tool.config 或单服 kv.config")
cfg_set = config_sub.add_parser("set", help="设置项目 user.config 或单服 kv.config")
cfg_set.add_argument("key")
cfg_set.add_argument("value")
cfg_set.add_argument("--server", "-s", help="写入指定服务器的 config/kv.config;默认写入项目 tool.config")
cfg_set.add_argument("--server", "-s", help="写入指定服务器的 config/kv.config;默认写入项目 user.config")
_add_json_flag(cfg_set)
cfg_unset = config_sub.add_parser("unset", help="删除项目 tool.config 或单服 kv.config 中的配置项")
cfg_unset = config_sub.add_parser("unset", help="删除项目 user.config 或单服 kv.config 中的配置项")
cfg_unset.add_argument("key")
cfg_unset.add_argument("--server", "-s", help="从指定服务器的 config/kv.config 删除;默认从项目 tool.config 删除")
cfg_unset.add_argument("--server", "-s", help="从指定服务器的 config/kv.config 删除;默认从项目 user.config 删除")
_add_json_flag(cfg_unset)
@@ -117,17 +123,17 @@ def handle_config(args: argparse.Namespace, cli: Any) -> None:
def _handle_config_files(args: argparse.Namespace, cli: Any) -> None:
config_dir = Path(cli.config_path)
files = []
names = [
"default.kv",
"tool.config",
"sys_game.config.example",
"sys_login.config.example",
"sys_center.config.example",
"sys_cross.config.example",
"sys_client.config.example",
files_to_check = [
(DEFAULT_CONFIG_FILE, config_dir / DEFAULT_CONFIG_FILE),
(PROJECT_CONFIG_FILE, Path(cli.server_root) / "config" / PROJECT_CONFIG_FILE),
(USER_CONFIG_FILE, config_dir / USER_CONFIG_FILE),
("sys_game.config.example", config_dir / "sys_game.config.example"),
("sys_login.config.example", config_dir / "sys_login.config.example"),
("sys_center.config.example", config_dir / "sys_center.config.example"),
("sys_cross.config.example", config_dir / "sys_cross.config.example"),
("sys_client.config.example", config_dir / "sys_client.config.example"),
]
for name in names:
path = config_dir / name
for name, path in files_to_check:
files.append({"name": name, "path": str(path), "exists": path.exists()})
data = {"config_dir": str(config_dir), "files": files}
if is_json_output(args):
+2 -1
View File
@@ -12,6 +12,7 @@ from typing import Any, Dict, List
from services.commands import (
check_node_status,
check_nodes_status,
DEFAULT_CONFIG_FILE,
generate_start_config,
get_erl_cmd,
get_local_ip,
@@ -123,7 +124,7 @@ def handle_doctor(args: argparse.Namespace, cli: Any) -> None:
_check("server_root", Path(cli.server_root).is_dir(), cli.server_root),
_check("run_dir", Path(cli.run_dir).is_dir(), cli.run_dir),
_check("config_dir", config_dir.is_dir(), str(config_dir)),
_check("default_kv", (config_dir / "default.kv").is_file(), str(config_dir / "default.kv")),
_check("default_config", (config_dir / DEFAULT_CONFIG_FILE).is_file(), str(config_dir / DEFAULT_CONFIG_FILE)),
_check("erl", _tool_exists(get_erl_cmd(cli.config.get("erl_path"))), get_erl_cmd(cli.config.get("erl_path"))),
_check("rg", find_rg_executable() is not None, str(find_rg_executable() or "")),
]
+34 -28
View File
@@ -13,13 +13,13 @@ class ServerManagerCLI:
server_root: 服务器根目录(命令行指定)
config_path: 配置文件路径
"""
# 先自动检测找到 tool.config 所在目录
# 先自动检测找到项目配置所在目录
detected_root = self._detect_server_root()
# 尝试从 tool.config 读取配置的 server_root
# 尝试从 user.config 读取配置的 server_root
configured_root = self._read_configured_server_root(detected_root)
# 优先级:命令行参数 > tool.config配置 > 自动检测
# 优先级:命令行参数 > user.config 配置 > 自动检测
if server_root:
self.server_root = str(Path(server_root).resolve())
elif configured_root:
@@ -27,7 +27,7 @@ class ServerManagerCLI:
else:
self.server_root = detected_root
# 从 tool.config 读取 run_dir,如果没有则使用默认值
# 从 user.config 读取 run_dir,如果没有则使用默认值
configured_run_dir = self._read_configured_run_dir(self.server_root)
if configured_run_dir:
run_path = Path(configured_run_dir)
@@ -61,42 +61,48 @@ class ServerManagerCLI:
logger.error(f"执行启动迁移失败: {e}", exc_info=True)
def _read_tool_config_value(self, detected_root: str, key: str) -> str:
"""tool.config 读取指定配置项
"""user.config 读取指定配置项,兼容旧项目的历史用户配置文件。
Args:
detected_root: 自动检测的根目录(用于定位 tool.config
detected_root: 自动检测的根目录(用于定位项目配置
key: 配置项名称
Returns:
配置值,如果没有配置返回 None
"""
candidates = (
get_server_manager_config_dir(detected_root) / 'tool.config',
Path(detected_root) / 'config' / 'tool.config',
get_server_manager_config_dir(detected_root) / USER_CONFIG_FILE,
get_server_manager_config_dir(detected_root) / LEGACY_USER_CONFIG_FILE,
Path(detected_root) / 'config' / USER_CONFIG_FILE,
Path(detected_root) / 'config' / LEGACY_USER_CONFIG_FILE,
Path(detected_root) / 'config' / PROJECT_CONFIG_FILE,
get_server_manager_config_dir(detected_root) / DEFAULT_CONFIG_FILE,
get_server_manager_config_dir(detected_root) / LEGACY_DEFAULT_CONFIG_FILE,
Path(detected_root) / 'config' / DEFAULT_CONFIG_FILE,
Path(detected_root) / 'config' / LEGACY_DEFAULT_CONFIG_FILE,
)
tool_config_path = next((p for p in candidates if p.exists()), None)
if tool_config_path is None:
return None
try:
with open(tool_config_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line.startswith(f'{key}='):
value = line.split('=', 1)[1].strip()
if value:
return value
except Exception:
pass
for config_path in candidates:
if not config_path.exists():
continue
try:
with open(config_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line.startswith(f'{key}='):
value = line.split('=', 1)[1].strip()
if value:
return value
except Exception:
pass
return None
def _read_configured_server_root(self, detected_root: str) -> str:
"""tool.config 读取配置的服务器根目录"""
"""user.config 读取配置的服务器根目录"""
return self._read_tool_config_value(detected_root, 'server_root')
def _read_configured_run_dir(self, detected_root: str) -> str:
"""tool.config 读取配置的运行目录"""
"""user.config 读取配置的运行目录"""
return self._read_tool_config_value(detected_root, 'run_dir')
def _detect_server_root(self) -> str:
@@ -129,7 +135,7 @@ class ServerManagerCLI:
def _load_config(self, config_path: str = None):
"""加载配置文件
优先级(从低到高):default.kv < tool.config < 指定的config_path
优先级(从低到高):default.config < project.config < user.config < 指定的 config_path
"""
# 优先使用指定的配置文件
if config_path and Path(config_path).exists():
@@ -141,14 +147,14 @@ class ServerManagerCLI:
if cfg is None:
if not self.quiet:
print("警告: 找不到有效的服务器根目录(缺少 config/ 或 .server_manager/config/default.kv")
print(f"警告: 找不到有效的服务器根目录(缺少 config/ 或 .server_manager/config/{DEFAULT_CONFIG_FILE}")
print(f" 服务器根目录: {server_root}")
if err:
print(f" 原因: {err}")
self.config = {}
return
# 合并配置:default.kv < tool.config
# 合并配置:default.config < project.config < user.config
self.config = read_merged_config(str(server_root), run_dir=self.run_dir)
# 从配置中读取常用设置
@@ -160,7 +166,7 @@ class ServerManagerCLI:
self.cookie = self.config.get('cookie', 'ddxq2-node')
def _apply_terminal_launch_config(self):
"""让 CLI 后台启动也使用 tool.config 中的窗口设置。"""
"""让 CLI 后台启动也使用 user.config 中的窗口设置。"""
mode = (self.config.get('terminal_launch_mode') or '').strip().lower()
terminal_exe = (
self.config.get('terminal_exe_path')
+12 -2
View File
@@ -15,9 +15,9 @@ if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
def _help_example_prog():
"""帮助「示例」中的命令前缀:打包为 main.exe 时与源码 python 调用区分。"""
"""帮助「示例」中的命令前缀:打包与源码 python 调用区分。"""
if getattr(sys, "frozen", False):
return Path(sys.argv[0]).name if sys.argv else "main.exe"
return Path(sys.argv[0]).name if sys.argv else "ServerManager.exe"
return "python -m entrypoints.cli"
@@ -58,7 +58,12 @@ logger = setup_logging()
from services.commands import (
get_local_ip,
ensure_and_get_server_manager_config_dir,
find_existing_default_config_path,
find_existing_user_config_path,
get_default_config_path,
get_project_config_path,
get_server_manager_config_dir,
get_user_config_path,
build_start_command,
build_start_command_by_config,
build_stop_command,
@@ -75,6 +80,11 @@ from services.commands import (
IS_WINDOWS,
IS_LINUX,
REBAR3_CMD,
DEFAULT_CONFIG_FILE,
LEGACY_DEFAULT_CONFIG_FILE,
USER_CONFIG_FILE,
LEGACY_USER_CONFIG_FILE,
PROJECT_CONFIG_FILE,
)
from services.server_creator import (
+1 -1
View File
@@ -7,7 +7,7 @@ import tkinter as tk
from tkinter import ttk
# 本地版本号,需与安装包/version.json 中的版本约定一致
LOCAL_VERSION = "1.1.3"
LOCAL_VERSION = "1.1.8"
# 延迟导入,避免在未安装更新依赖时影响启动
try:
+1 -1
View File
@@ -3,7 +3,7 @@
"""
软件配置(与软件一起走):存放于本机固定目录,与当前打开的项目无关。
- 最近打开的项目列表、应用级偏好、日志等。
项目配置(与项目一起走):存放于项目目录 .server_manager/config/ 下,如 tool.config、default.kv(首次打开从软件安装目录 config/ 复制)。
项目配置(与项目一起走):存放于项目目录 .server_manager/config/ 下,如 user.config、default.config(首次打开从软件安装目录 config/ 复制)。
"""
import json
+54 -70
View File
@@ -24,8 +24,8 @@ def _empty_config_data():
class Config:
"""项目配置管理类:读写当前打开项目下的 .server_manager/config/tool.config(与项目一起走)。
打开项目时会将软件目录下受管模板同步到 .server_manager/config,并保留项目自己的 tool.config。
"""项目配置管理类:读写当前打开项目下的 .server_manager/config/user.config(与项目一起走)。
打开项目时会将软件目录下受管模板同步到 .server_manager/config,并保留项目自己的 user.config。
支持“无项目”启动:project_root=None 时仅显示欢迎页;通过 open_project(path) 打开项目后加载该项目配置。
"""
@@ -38,6 +38,7 @@ class Config:
self._exe_dir = self._script_dir.parent.parent
self.tool_dir = None
self.user_config_path = None
self.tool_config_path = None
self.config_error = None
self.data = _empty_config_data()
@@ -61,7 +62,8 @@ class Config:
self.config_error = err
return False
self.tool_dir = project_root
self.tool_config_path = cfg_dir / "tool.config"
self.user_config_path = cfg_dir / USER_CONFIG_FILE
self.tool_config_path = self.user_config_path
self.config_error = None
self.data = self._load_config()
return True
@@ -81,21 +83,31 @@ class Config:
]
for path in candidates:
if (get_server_manager_config_dir(path) / "default.kv").exists():
if find_existing_default_config_path(path):
return path
if (path / "config" / "default.kv").exists():
if (path / "config" / PROJECT_CONFIG_FILE).exists():
return path
if (path / "config" / DEFAULT_CONFIG_FILE).exists():
return path
if (path / "config" / LEGACY_DEFAULT_CONFIG_FILE).exists():
return path
return None
def _load_tool_config(self) -> dict:
"""加载 tool.config 文件"""
if self.tool_config_path is None or not self.tool_config_path.exists():
"""加载 user.config 文件,兼容旧项目的历史用户配置文件。"""
if self.user_config_path is None:
return {}
config_path = self.user_config_path
if not config_path.exists():
legacy_path = config_path.parent / LEGACY_USER_CONFIG_FILE
if not legacy_path.exists():
return {}
config_path = legacy_path
config = {}
try:
with open(self.tool_config_path, 'r', encoding='utf-8') as f:
with open(config_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
@@ -108,7 +120,7 @@ class Config:
return config
def _save_tool_config(self, config: dict):
"""保存 tool.config 文件"""
"""保存 user.config 文件"""
lines = [
"# Server Manager config",
"# 此文件配置工具运行所需的环境路径",
@@ -148,45 +160,44 @@ class Config:
f"cookie={config.get('cookie', 'ddxq2-node')}",
]
self.tool_config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.tool_config_path, 'w', encoding='utf-8') as f:
config_path = self.user_config_path or self.tool_config_path
if config_path is None:
return
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
def _load_default_kv(self, server_root: str) -> dict:
"""从 default.kv 读取默认配置"""
"""从 default.config 和 project.config 读取默认/项目配置"""
if not server_root:
return {}
default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv'
if not default_kv_path.exists():
return {}
config = {}
try:
with open(default_kv_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
except Exception:
return {}
default_config_path = find_existing_default_config_path(server_root)
if default_config_path:
config.update(read_config_file(str(default_config_path)))
project_config_path = get_project_config_path(server_root)
if project_config_path.exists():
config.update(read_config_file(str(project_config_path)))
return config
def _load_config(self):
"""加载配置,从 tool.config 读取,空值从 default.kv 补充。
以当前已打开的项目目录(self.tool_dir)为 server_root 默认值,tool.config 可覆盖。
"""加载配置,从 user.config 读取,空值从 project.config/default.config 补充。
以当前已打开的项目目录(self.tool_dir)为 server_root 默认值,user.config 可覆盖。
"""
if self.tool_dir is None or self.tool_config_path is None:
if self.tool_dir is None or self.user_config_path is None:
return self.data
tool_cfg = self._load_tool_config()
is_first_run = not self.tool_config_path.exists()
is_first_run = not self.user_config_path.exists()
# 以当前项目目录为默认,tool.config 可覆盖
server_root = tool_cfg.get('server_root', '') or str(self.tool_dir)
run_dir = tool_cfg.get('run_dir', '')
# 以当前项目目录为默认,user.config 可覆盖;project.config/default.config 可补充。
default_kv = self._load_default_kv(str(self.tool_dir))
server_root = tool_cfg.get('server_root', '') or default_kv.get('server_root', '') or str(self.tool_dir)
if server_root != str(self.tool_dir):
default_kv = self._load_default_kv(server_root)
run_dir = tool_cfg.get('run_dir', '') or default_kv.get('run_dir', '')
if not run_dir and server_root:
run_dir = str(Path(server_root) / "run")
@@ -199,10 +210,7 @@ class Config:
except Exception:
pass
# 如果有 server_root,尝试从 default.kv 读取默认
default_kv = self._load_default_kv(server_root)
# 辅助函数:优先使用 tool.config 值,为空则使用 default.kv 值
# 辅助函数:优先使用 user.config 值,为空则使用 project.config/default.config
def get_value(tool_key, default_key, fallback=''):
val = tool_cfg.get(tool_key, '')
if not val and default_kv:
@@ -224,11 +232,11 @@ class Config:
login_db_user = get_value('login_db_user', 'login_db_user', '')
login_db_pass = get_value('login_db_pass', 'login_db_pass', '')
r25_path = get_value('r25_path', 'r25_path', '')
terminal_launch_mode = (tool_cfg.get('terminal_launch_mode', '') or 'auto').strip().lower()
terminal_exe_path = tool_cfg.get('terminal_exe_path', '')
terminal_launch_mode = (get_value('terminal_launch_mode', 'terminal_launch_mode', 'auto') or 'auto').strip().lower()
terminal_exe_path = get_value('terminal_exe_path', 'terminal_exe_path', '')
if not terminal_exe_path:
legacy_exe = tool_cfg.get('terminal_custom_command', '')
legacy_cwd = tool_cfg.get('terminal_custom_cwd', '')
legacy_exe = tool_cfg.get('terminal_custom_command', '') or default_kv.get('terminal_custom_command', '')
legacy_cwd = tool_cfg.get('terminal_custom_cwd', '') or default_kv.get('terminal_custom_cwd', '')
if legacy_exe and legacy_cwd and not any(sep in legacy_exe for sep in ('/', '\\')):
legacy_path = Path(os.path.expandvars(os.path.expanduser(legacy_cwd))) / legacy_exe
if legacy_path.is_file():
@@ -291,38 +299,14 @@ class Config:
},
}
# 每次启动都检查配置完整性,从 default.kv 补充缺失的值
need_save = is_first_run
if default_kv:
# 检查是否有从 default.kv 补充的值
fields_to_check = [
('db_host', db_host),
('db_user', db_user),
('db_pass', db_pass),
('prefix', prefix),
('login_node', login_node),
('center_node', center_node),
('cookie', cookie),
]
for field_name, new_value in fields_to_check:
old_value = tool_cfg.get(field_name, '')
if not old_value and new_value:
need_save = True
break
if need_save:
self.data = config_data
self.save()
return config_data
def has_default_kv(self) -> bool:
"""检查 default.kv 是否存在"""
"""检查 default.config 是否存在,兼容旧项目的历史默认配置文件。"""
server_root = self.get('workspace', 'server_root', default='')
if not server_root:
return False
default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv'
return default_kv_path.exists()
return find_existing_default_config_path(server_root) is not None
def is_config_complete(self) -> bool:
"""检查工具配置是否完整"""
@@ -333,8 +317,8 @@ class Config:
return bool(db_host and db_user and prefix)
def save(self):
"""保存配置到 tool.config"""
if self.tool_config_path is None:
"""保存配置到 user.config"""
if self.user_config_path is None:
return
local_ip = get_local_ip()
self.set(local_ip, 'server', 'ip')
+2
View File
@@ -4,6 +4,7 @@
from . import (
command_builders as _command_builders,
config_files as _config_files,
database as _database,
discovery as _discovery,
erlang as _erlang,
migrations as _migrations,
@@ -23,6 +24,7 @@ _modules = (
_terminal,
_window_registry,
_command_builders,
_database,
_config_files,
_migrations,
_discovery,
+9 -4
View File
@@ -24,6 +24,11 @@ def get_rebar_profile(server_type: str) -> tuple:
return ('game_server_dev', 'game_server')
def _build_rebar_eval_expr(app_name: str) -> str:
# Keep the leading dummy expression; rebar3/getopt can split --eval values on commas.
return f'-1,application:ensure_all_started({app_name}).'
def build_start_command_by_config(server_root: str, merged_config: Dict[str, str],
cookie: str, use_rebar: bool = True,
erl_path: str = None) -> str:
@@ -58,10 +63,10 @@ def build_start_command_by_config(server_root: str, merged_config: Dict[str, str
if use_rebar:
rebar3 = get_rebar3_cmd(server_root, erl_path)
eval_expr = f'application:ensure_all_started({app_name}).'
eval_expr = _build_rebar_eval_expr(app_name)
cmd = (
f'{rebar3} as {profile} shell '
f'--eval -1,"{eval_expr}" '
f'--eval "{eval_expr}" '
f'--setcookie {cookie} '
f'--name {node_name} '
f'--config "{config_file}"'
@@ -127,10 +132,10 @@ def build_start_command(server_root: str, server_name: str, cookie: str,
if use_rebar:
rebar3 = get_rebar3_cmd(server_root, erl_path)
eval_expr = f'application:ensure_all_started({app_name}).'
eval_expr = _build_rebar_eval_expr(app_name)
cmd = (
f'{rebar3} as {profile} shell '
f'--eval -1,"{eval_expr}" '
f'--eval "{eval_expr}" '
f'--setcookie {cookie} '
f'--name {server_name}@{ip} '
f'--config "{config_file}"'
+38 -24
View File
@@ -3,7 +3,11 @@
from .shared import *
from .erlang import get_local_ip
from .project_config import get_server_manager_config_dir
from .project_config import get_project_config_path, get_server_manager_config_dir
def _first_existing_config(paths: Tuple[Path, ...]) -> Optional[Path]:
return next((path for path in paths if path.exists()), None)
def read_merged_config(
server_root: str,
@@ -12,14 +16,14 @@ def read_merged_config(
) -> Dict[str, str]:
"""读取合并后的配置(优先级从低到高)
优先级:default.kv < tool.config(项目根/.server_manager/config) < 运行目录下配置(run_dir) < kv.config
优先级:default.config < config/project.config < user.config(项目根/.server_manager/config) < 运行目录下配置(run_dir) < kv.config
Args:
server_root: 服务器根目录
server_dir: 服务器目录名(可选,如 ddxq_game_s1)。
若提供,则读取 运行目录/{server_dir}/config/kv.config 作为最高优先级。
run_dir: 运行目录(可选)。若提供,则从运行目录下读取配置:
- run_dir/config/tool.config 或 run_dir/tool.config
- run_dir/config/user.config 或 run_dir/user.config(兼容旧用户配置文件)
- run_dir/server_dir/config/kv.config(与 server_dir 同时提供时)
用于命令行等场景下“用运行目录的配置”获取 IP 等。
@@ -27,28 +31,38 @@ def read_merged_config(
合并后的配置字典
"""
config_path = get_server_manager_config_dir(server_root)
merged = {}
# 1. default.kv(最低优先级)
default_kv_file = config_path / 'default.kv'
default_config_file = config_path / 'default.config'
if default_kv_file.exists():
merged = read_config_file(str(default_kv_file))
elif default_config_file.exists():
merged = read_config_file(str(default_config_file))
else:
merged = {}
# Priority, low to high: default.config < config/project.config < user.config.
default_config_file = _first_existing_config((
config_path / DEFAULT_CONFIG_FILE,
config_path / LEGACY_DEFAULT_CONFIG_FILE,
))
if default_config_file:
merged.update(read_config_file(str(default_config_file)))
# 2. tool.config.server_manager/config 下)
tool_config_file = config_path / 'tool.config'
if tool_config_file.exists():
merged.update(read_config_file(str(tool_config_file)))
project_config_file = get_project_config_path(server_root)
if project_config_file.exists():
merged.update(read_config_file(str(project_config_file)))
# 3. 运行目录下的配置(run_dir):优先用运行目录的 tool.config,便于命令行从运行目录取 IP 等
user_config_file = _first_existing_config((
config_path / USER_CONFIG_FILE,
config_path / LEGACY_USER_CONFIG_FILE,
))
if user_config_file:
merged.update(read_config_file(str(user_config_file)))
# Optional run-dir user config override for command-line/run-dir scenarios.
if run_dir:
run_path = Path(run_dir)
for tool_in_run in (run_path / 'config' / 'tool.config', run_path / 'tool.config'):
if tool_in_run.exists():
merged.update(read_config_file(str(tool_in_run)))
for user_in_run in (
run_path / 'config' / USER_CONFIG_FILE,
run_path / USER_CONFIG_FILE,
run_path / 'config' / LEGACY_USER_CONFIG_FILE,
run_path / LEGACY_USER_CONFIG_FILE,
):
if user_in_run.exists():
merged.update(read_config_file(str(user_in_run)))
break
# 4. kv.config(最高优先级):在运行目录或 server_root/run 下
@@ -218,9 +232,9 @@ def build_replace_map(config: Dict[str, str]) -> Dict[str, str]:
def generate_start_config(server_root: str, server_dir: str) -> Optional[Dict[str, str]]:
"""生成启动配置文件
读取 default.kv → tool.config 覆盖 → kv.config 覆盖 → 替换模板 → 生成 sys.config
读取 default.config → project.config → user.config → kv.config → 替换模板 → 生成 sys.config
优先级(从低到高):default.kv < tool.config < kv.config
优先级(从低到高):default.config < project.config < user.config < kv.config
Args:
server_root: 服务器根目录
@@ -242,7 +256,7 @@ def generate_start_config(server_root: str, server_dir: str) -> Optional[Dict[st
if not kv_config_file.exists():
return None
# 合并配置:default.kv < tool.config < kv.config
# 合并配置:default.config < project.config < user.config < kv.config
merged_config = read_merged_config(server_root, server_dir)
# 根据服务器类型选择模板
@@ -259,7 +273,7 @@ def generate_start_config(server_root: str, server_dir: str) -> Optional[Dict[st
# 不再依赖 file:set_cwd 改变运行时工作目录(为了兼容 Erlang shell 的 c() 命令)
def _normalize_run_log_path(raw: str, default_tail: str) -> str:
"""把配置里的日志路径统一成 run/<server_dir>/<tail>。
形如 run/xxx/log 时只取第 3 段及之后,避免 default.kv 中的模板值
形如 run/xxx/log 时只取第 3 段及之后,避免 default.config 中的模板值
run/server/log 污染具体服务器的实际路径。
"""
raw = (raw or '').strip('"').strip("'").strip()
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Database connectivity helpers."""
from .shared import Any, Dict
def test_mysql_connection(
db_host: str,
db_port: int,
db_user: str,
db_pass: str,
timeout: int = 5,
) -> Dict[str, Any]:
"""Open a real MySQL connection and run a lightweight probe query."""
try:
import pymysql
except ImportError as exc:
raise ImportError("需要安装 pymysql: pip install pymysql") from exc
conn = None
cursor = None
try:
conn = pymysql.connect(
host=str(db_host).strip(),
port=int(db_port),
user=str(db_user).strip(),
password=db_pass or "",
charset="utf8mb4",
connect_timeout=timeout,
read_timeout=timeout,
write_timeout=timeout,
autocommit=True,
)
conn.ping(reconnect=False)
cursor = conn.cursor()
cursor.execute("SELECT 1, VERSION(), CURRENT_USER()")
row = cursor.fetchone() or ()
return {
"ok": True,
"server_version": str(row[1]) if len(row) > 1 and row[1] is not None else "",
"current_user": str(row[2]) if len(row) > 2 and row[2] is not None else "",
}
finally:
if cursor is not None:
cursor.close()
if conn is not None:
conn.close()
+8 -1
View File
@@ -13,6 +13,7 @@ _MIGRATIONS = [
# 2026-04 版本:模板新增 ${role_log} 占位符,需要为所有已有服务器重新生成
# sys.config,让 role_log 替换为 run/<server_dir>/log。
'sys_config_role_log_v1',
'sys_config_logger_no_domain_v1',
]
@@ -91,15 +92,21 @@ def _migrate_sys_config_role_log_v1(server_root) -> Tuple[int, int, List[str]]:
对应改动:sys_game.config.example 新增 ${role_log} 占位符,游戏服旧版
sys.config 里还没有 role_log 字段;generate_start_config 会根据当前
default.kv 里的 role_log 自动计算出 run/<server_dir>/log 并替换。
default.config 里的 role_log 自动计算出 run/<server_dir>/log 并替换。
"""
return _regenerate_all_sys_configs(server_root)
def _migrate_sys_config_logger_no_domain_v1(server_root) -> Tuple[int, int, List[str]]:
"""Regenerate sys.config so existing servers use no_domain logger filters."""
return _regenerate_all_sys_configs(server_root)
# 迁移 id -> 执行函数。函数返回 True 表示迁移执行(即使个别服务器失败也算已执行)。
_MIGRATION_RUNNERS = {
'sys_config_log_dir_v2': _migrate_sys_config_log_dir_v2,
'sys_config_role_log_v1': _migrate_sys_config_role_log_v1,
'sys_config_logger_no_domain_v1': _migrate_sys_config_logger_no_domain_v1,
}
+84 -22
View File
@@ -3,51 +3,111 @@
from .shared import *
def get_server_manager_config_dir(server_root: Union[str, Path]) -> Path:
"""工具托管的项目配置目录:项目根/.server_manager/configdefault.kv、tool.config、模板等)。"""
"""Tool-managed project config directory: <project>/.server_manager/config."""
return Path(server_root).resolve() / '.server_manager' / 'config'
def get_bundled_tool_config_template_dir() -> Path:
"""软件自带的 config 模板目录(安装后与 main.exe 同级下的 config/)。
源码运行时使用仓库根目录的 resources/config(与安装包内容一致)。
"""
"""Bundled config template directory."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).resolve().parent / 'config'
return Path(__file__).resolve().parents[3] / 'resources' / 'config'
def get_default_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / DEFAULT_CONFIG_FILE
def get_legacy_default_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / LEGACY_DEFAULT_CONFIG_FILE
def get_user_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / USER_CONFIG_FILE
def get_legacy_user_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / LEGACY_USER_CONFIG_FILE
def get_project_config_path(project_root: Union[str, Path]) -> Path:
return Path(project_root).resolve() / 'config' / PROJECT_CONFIG_FILE
def _first_existing(paths: Tuple[Path, ...]) -> Optional[Path]:
return next((path for path in paths if path.exists()), None)
def find_existing_default_config_path(project_root: Union[str, Path]) -> Optional[Path]:
return _first_existing((
get_default_config_path(project_root),
get_legacy_default_config_path(project_root),
))
def find_existing_user_config_path(project_root: Union[str, Path]) -> Optional[Path]:
return _first_existing((
get_user_config_path(project_root),
get_legacy_user_config_path(project_root),
))
def _migrate_legacy_config_names(project_root: Path) -> None:
"""Move legacy project config names to the current names when possible."""
cfg = get_server_manager_config_dir(project_root)
renames = (
(cfg / LEGACY_USER_CONFIG_FILE, cfg / USER_CONFIG_FILE),
(cfg / LEGACY_DEFAULT_CONFIG_FILE, cfg / DEFAULT_CONFIG_FILE),
)
for old_path, new_path in renames:
if old_path.exists() and not new_path.exists():
try:
shutil.move(str(old_path), str(new_path))
except Exception:
pass
def sync_server_manager_config_templates(project_root: Path) -> Tuple[bool, str]:
"""将软件自带的受管模板文件同步到项目 .server_manager/config"""
"""Sync bundled managed template files into <project>/.server_manager/config."""
project_root = Path(project_root).resolve()
src = get_bundled_tool_config_template_dir()
dst = get_server_manager_config_dir(project_root)
if not src.is_dir():
return False, (
f'找不到软件自带的配置模板目录: {src}\n'
'请确认安装目录下存在 config 文件夹,或开发环境已准备 resources/config'
f'Cannot find bundled config template directory: {src}\n'
'Please make sure the install directory or resources/config is complete.'
)
if not (src / 'default.kv').exists():
default_src = src / DEFAULT_CONFIG_FILE
if not default_src.exists():
legacy_default_src = src / LEGACY_DEFAULT_CONFIG_FILE
default_src = legacy_default_src if legacy_default_src.exists() else default_src
if not default_src.exists():
return False, (
f'软件配置模板不完整(缺少 default.kv): {src}'
f'Bundled config templates are incomplete; missing {DEFAULT_CONFIG_FILE}: {src}'
)
try:
dst.mkdir(parents=True, exist_ok=True)
_migrate_legacy_config_names(project_root)
for file_name in MANAGED_CONFIG_TEMPLATE_FILES:
src_file = src / file_name
src_file = default_src if file_name == DEFAULT_CONFIG_FILE else src / file_name
if not src_file.exists():
return False, f'软件配置模板不完整(缺少 {file_name}: {src}'
shutil.copy2(src_file, dst / file_name)
return False, f'Bundled config templates are incomplete; missing {file_name}: {src}'
dst_file = dst / file_name
if file_name == DEFAULT_CONFIG_FILE and dst_file.exists():
continue
shutil.copy2(src_file, dst_file)
except Exception as e:
return False, f'同步软件配置模板到项目失败: {e}'
return False, f'Failed to sync config templates into project: {e}'
return True, ""
def ensure_server_manager_config(project_root: Path) -> Tuple[bool, str]:
"""确保项目存在 .server_manager/config,并同步受管模板文件。"""
"""Ensure .server_manager/config exists and bundled templates have been synced."""
project_root = Path(project_root).resolve()
sm = get_server_manager_config_dir(project_root)
@@ -55,29 +115,31 @@ def ensure_server_manager_config(project_root: Path) -> Tuple[bool, str]:
if not sm.is_dir():
(project_root / '.server_manager').mkdir(parents=True, exist_ok=True)
except Exception as e:
return False, f'创建项目配置目录失败: {e}'
return False, f'Failed to create project config directory: {e}'
return sync_server_manager_config_templates(project_root)
def ensure_and_get_server_manager_config_dir(project_root: Path) -> Tuple[Optional[Path], str]:
"""确保存在工具配置目录且含 default.kv;成功返回该目录 Path。"""
"""Ensure the tool config directory exists and contains default.config."""
ok, err = ensure_server_manager_config(project_root)
if not ok:
return None, err
cfg = get_server_manager_config_dir(project_root)
if not (cfg / 'default.kv').exists():
if not (cfg / DEFAULT_CONFIG_FILE).exists():
return None, (
'项目配置不完整:.server_manager/config 下缺少 default.kv'
'(请检查软件安装目录下 config 模板是否完整)'
f'Project config is incomplete: .server_manager/config is missing {DEFAULT_CONFIG_FILE}'
)
return cfg, ""
def project_has_default_kv_for_manager(project_root: Union[str, Path]) -> bool:
"""用于最近项目列表等:是否存在可识别的 default.kv(含尚未迁移的 config/)。"""
"""Backward-compatible project validity check used by recent-project lists."""
p = Path(project_root)
return (
(get_server_manager_config_dir(p) / 'default.kv').exists()
or (p / 'config' / 'default.kv').exists()
(get_server_manager_config_dir(p) / DEFAULT_CONFIG_FILE).exists()
or (get_server_manager_config_dir(p) / LEGACY_DEFAULT_CONFIG_FILE).exists()
or (p / 'config' / PROJECT_CONFIG_FILE).exists()
or (p / 'config' / DEFAULT_CONFIG_FILE).exists()
or (p / 'config' / LEGACY_DEFAULT_CONFIG_FILE).exists()
)
+74
View File
@@ -340,6 +340,80 @@ def _argv_to_shell_line(args: List[str]) -> str:
return shlex.join(args)
def rpc_clear_server_on_center_and_login(
center_node: str,
login_node: str,
server_id: Union[int, str],
cookie: str,
erl_path: Optional[str] = None,
timeout: int = 45,
) -> Tuple[int, str, str, str]:
"""Notify center/login nodes to clear a game server.
Returns:
(returncode, stdout, stderr, full_command_line)
"""
clean_center_node = str(center_node or "").strip().strip("'\"")
clean_login_node = str(login_node or "").strip().strip("'\"")
if not clean_center_node:
raise ValueError("center_node is empty")
if not clean_login_node:
raise ValueError("login_node is empty")
try:
sid = int(str(server_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"invalid server_id: {server_id!r}") from exc
cookie_arg = (cookie or "").strip() or "ddxq2-node"
if any(c in cookie_arg for c in " \t\r\n'\""):
raise ValueError("cookie cannot contain whitespace or quotes")
erl = get_erl_cmd(erl_path)
local_ip = get_local_ip()
ping_node = f"clear_{os.getpid()}_{time.time_ns() % 100000000}"
center_atom = _erl_quoted_atom(clean_center_node)
login_atom = _erl_quoted_atom(clean_login_node)
eval_code = (
f"CenterNode = {center_atom}, "
f"LoginNode = {login_atom}, "
f"ServerId = {sid}, "
"CenterResult = rpc:call(CenterNode, center_node_gs_lib, clear_server, [ServerId]), "
"LoginResult = rpc:call(LoginNode, login_server_app, clear_server, [ServerId]), "
"io:format(\"CENTER: ~p~nLOGIN: ~p~n\", [CenterResult, LoginResult]), "
"case {CenterResult, LoginResult} of "
"{{badrpc, _}, _} -> erlang:halt(2, [{flush, true}]); "
"{_, {badrpc, _}} -> erlang:halt(3, [{flush, true}]); "
"_ -> erlang:halt(0, [{flush, true}]) "
"end."
)
args = [
erl,
"-noshell",
"-name", f"{ping_node}@{local_ip}",
"-setcookie", cookie_arg,
"-eval", eval_code,
]
cmd_line = _argv_to_shell_line(args)
try:
_ensure_epmd_daemon(erl_path)
result = subprocess.run(
args,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
)
return result.returncode, (result.stdout or ""), (result.stderr or ""), cmd_line
except subprocess.TimeoutExpired:
return -1, "", f"RPC timeout after {timeout}s", cmd_line
except Exception as e:
return -1, "", str(e), cmd_line
def rpc_role_gs_trace_network(
server_name: str,
cookie: str,
+7 -1
View File
@@ -25,8 +25,14 @@ IS_LINUX = platform.system() == 'Linux'
# rebar3 命令:Linux 使用当前目录下的 ./rebar3Windows 使用 rebar3.cmd
REBAR3_CMD = 'rebar3.cmd' if IS_WINDOWS else './rebar3'
DEFAULT_CONFIG_FILE = 'default.config'
LEGACY_DEFAULT_CONFIG_FILE = 'default.kv'
USER_CONFIG_FILE = 'user.config'
LEGACY_USER_CONFIG_FILE = 'tool.config'
PROJECT_CONFIG_FILE = 'project.config'
MANAGED_CONFIG_TEMPLATE_FILES = (
'default.kv',
DEFAULT_CONFIG_FILE,
'sys_center.config.example',
'sys_client.config.example',
'sys_cross.config.example',
+159 -8
View File
@@ -22,9 +22,11 @@
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
@@ -48,7 +50,7 @@ def _version_json_path() -> Path:
未打包:从项目根目录或运行目录读取。
"""
if getattr(sys, "frozen", False):
# 安装包会将 version.json 展开到 {app},与 main.exe 同目录
# 安装包会将 version.json 展开到 {app},与 ServerManager.exe 同目录
install_dir = Path(sys.executable).resolve().parent
external = install_dir / "version.json"
if external.exists():
@@ -138,16 +140,61 @@ def fetch_update_manifest(update_url: str, timeout: int = 15) -> Optional[Dict[s
return None
def _manifest_delta_map(manifest: Dict[str, Any]) -> Dict[str, Any]:
delta_updates = manifest.get("delta_updates") or manifest.get("deltaUpdates") or manifest.get("delta") or {}
return delta_updates if isinstance(delta_updates, dict) else {}
def get_delta_kind(delta: Dict[str, Any]) -> str:
"""Return 'file_zip', 'bsdiff_exe', or '' for unsupported delta entries."""
if not isinstance(delta, dict):
return ""
kind = str(delta.get("type") or delta.get("kind") or "").strip().lower().replace("_", "-")
url = get_delta_download_url(delta)
if kind in {"file-zip", "file-delta", "archive", "zip"}:
return "file_zip"
if kind in {"bsdiff", "bsdiff-exe", "exe-patch"}:
return "bsdiff_exe"
if url.lower().endswith((".zip", ".smdelta")) or delta.get("format") == "server-manager-file-delta-v1":
return "file_zip"
if delta.get("new_exe_sha256") and (delta.get("patch_url") or delta.get("patchUrl")):
return "bsdiff_exe"
return ""
def get_delta_download_url(delta: Dict[str, Any]) -> str:
"""Return the URL field used by both new file-level deltas and legacy exe patches."""
if not isinstance(delta, dict):
return ""
return (
delta.get("package_url")
or delta.get("packageUrl")
or delta.get("delta_url")
or delta.get("deltaUrl")
or delta.get("patch_url")
or delta.get("patchUrl")
or ""
).strip()
def get_delta_expected_sha256(delta: Dict[str, Any]) -> str:
kind = get_delta_kind(delta)
if kind == "file_zip":
return str(delta.get("sha256") or delta.get("package_sha256") or delta.get("packageSha256") or "").strip().lower()
if kind == "bsdiff_exe":
return str(delta.get("new_exe_sha256") or "").strip().lower()
return ""
def get_delta_for_current(manifest: Dict[str, Any], current_version: str) -> Optional[Dict[str, Any]]:
"""
从 manifest 的 delta_updates 中取当前版本对应的增量信息。
返回 { "patch_url": "...", "new_exe_sha256": "..." } 或 None
支持新的文件级增量 zip,也兼容旧的 ServerManager.exe bsdiff patch
"""
delta_updates = manifest.get("delta_updates") or {}
if not isinstance(delta_updates, dict):
d = _manifest_delta_map(manifest).get(current_version.strip())
if not isinstance(d, dict):
return None
d = delta_updates.get(current_version.strip())
if isinstance(d, dict) and d.get("patch_url") and d.get("new_exe_sha256"):
if get_delta_kind(d) and get_delta_download_url(d):
return d
return None
@@ -256,9 +303,9 @@ def apply_update_and_restart(
if not os.path.isfile(new_exe_path):
return
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
target_name = (target_exe_name or os.path.basename(current_exe_path) or "main.exe").strip() or "main.exe"
target_name = (target_exe_name or os.path.basename(current_exe_path) or "ServerManager.exe").strip() or "ServerManager.exe"
# 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径
# 优先使用安装目录下的 mini_updater.exe(与 ServerManager.exe 同目录),必须用绝对路径
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
try:
@@ -316,6 +363,110 @@ def apply_update_and_restart(
os._exit(0)
DELTA_PACKAGE_FORMAT = "server-manager-file-delta-v1"
def _sha256_path(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def _safe_delta_rel(value: str) -> str:
rel = str(value or "").replace("\\", "/").strip("/")
parts = [part for part in rel.split("/") if part]
if not parts or any(part in (".", "..") for part in parts):
raise ValueError(f"unsafe path: {value}")
return "/".join(parts)
def _safe_install_path(install_dir: Path, rel_path: str) -> Path:
rel = _safe_delta_rel(rel_path)
root = install_dir.resolve()
target = (root / Path(*rel.split("/"))).resolve()
if target != root and root not in target.parents:
raise ValueError(f"path escapes install dir: {rel_path}")
return target
def _validate_delta_package(delta_zip_path: Path, expected_sha256: str = "") -> Tuple[bool, str]:
if not delta_zip_path.is_file():
return False, "增量包文件不存在"
expected_sha = (expected_sha256 or "").strip().lower()
if expected_sha and _sha256_path(delta_zip_path) != expected_sha:
return False, "增量包 SHA256 校验失败,请使用全量更新"
try:
with zipfile.ZipFile(delta_zip_path, "r") as zf:
with zf.open("delta_manifest.json") as f:
manifest = json.loads(f.read().decode("utf-8-sig"))
if not isinstance(manifest, dict) or manifest.get("format") != DELTA_PACKAGE_FORMAT:
return False, "增量包格式不受支持,请使用全量更新"
if not isinstance(manifest.get("files"), list):
return False, "增量包文件清单无效,请使用全量更新"
if getattr(sys, "frozen", False):
install_dir = Path(sys.executable).resolve().parent
for entry in manifest.get("files", []):
rel = _safe_delta_rel(entry.get("path"))
old_sha = str(entry.get("old_sha256") or "").strip().lower()
if old_sha:
target = _safe_install_path(install_dir, rel)
if not target.is_file() or _sha256_path(target) != old_sha:
return False, f"当前安装文件与增量包来源版本不一致: {rel}"
for entry in manifest.get("delete", []) if isinstance(manifest.get("delete"), list) else []:
rel = _safe_delta_rel(entry.get("path"))
old_sha = str(entry.get("old_sha256") or "").strip().lower()
target = _safe_install_path(install_dir, rel)
if old_sha and target.is_file() and _sha256_path(target) != old_sha:
return False, f"当前安装文件与增量包来源版本不一致: {rel}"
except Exception as e:
return False, f"增量包解析失败: {e!s}"
return True, ""
def apply_file_delta_package(delta_zip_path: Path, expected_sha256: str = "") -> Tuple[bool, str]:
"""
应用文件级增量包:主进程验证 zip 后,复制 mini_updater 到 TEMP 并在退出后替换安装目录文件。
成功时本函数不会返回;失败时返回 (False, reason) 供 UI 回退全量更新。
"""
if not getattr(sys, "frozen", False):
return False, "当前未以打包方式运行"
delta_zip_path = Path(delta_zip_path)
ok, reason = _validate_delta_package(delta_zip_path, expected_sha256)
if not ok:
return False, reason
current_exe_path = os.path.abspath(sys.executable)
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
if sys.platform != "win32" or not os.path.isfile(mini_updater_exe):
return False, "未找到 mini_updater.exe,请使用全量更新"
try:
temp_updater = os.path.join(tempfile.gettempdir(), f"ServerManager_mini_updater_{os.getpid()}.exe")
shutil.copy2(mini_updater_exe, temp_updater)
pid = os.getpid()
subprocess.Popen(
[
temp_updater,
"--pid", str(pid),
"--install-dir", install_dir,
"--delta-zip-path", str(delta_zip_path.resolve()),
"--target-exe-name", os.path.basename(current_exe_path) or "ServerManager.exe",
],
cwd=install_dir,
close_fds=True,
creationflags=0x00000008,
shell=False,
)
_write_update_log(install_dir, "已拉起文件级增量更新器 (pid=%s)" % pid)
os._exit(0)
except Exception as e:
_write_update_log(install_dir, "拉起文件级增量更新器失败: %s" % e)
return False, f"启动增量更新器失败: {e!s}"
def apply_delta_patch(patch_path: Path, expected_new_sha256: str) -> Tuple[bool, str]:
"""
使用 bsdiff4 应用增量补丁:当前 exe + patch -> 新 exe,校验 sha256 后在本进程内
+16 -16
View File
@@ -126,7 +126,7 @@ SERVER_TYPE_SPECIFIC_KEYS = {
def _read_default_kv(server_root: str) -> Dict[str, str]:
"""读取合并配置(tool.config > default.kv
"""读取合并配置(user.config > project.config > default.config
Args:
server_root: 服务器根目录
@@ -141,7 +141,7 @@ def _read_default_kv(server_root: str) -> Dict[str, str]:
def get_required_keys_from_kv(server_root: str, server_type: str) -> Optional[Set[str]]:
"""从 default.kv 读取必须配置项定义
"""从 default.config 读取必须配置项定义
Args:
server_root: 服务器根目录
@@ -170,7 +170,7 @@ def get_required_keys_from_kv(server_root: str, server_type: str) -> Optional[Se
def get_config_keys_from_kv(server_root: str, server_type: str) -> Optional[Dict[str, str]]:
"""从 default.kv 读取可编辑配置项定义
"""从 default.config 读取可编辑配置项定义
Args:
server_root: 服务器根目录
@@ -205,7 +205,7 @@ def get_config_keys_from_kv(server_root: str, server_type: str) -> Optional[Dict
def get_editable_config_from_default_kv(server_root: str) -> Dict[str, str]:
"""从 default.kv 读取编辑对话框可选配置项定义
"""从 default.config 读取编辑对话框可选配置项定义
Args:
server_root: 服务器根目录
@@ -230,16 +230,16 @@ def get_editable_config_from_default_kv(server_root: str) -> Dict[str, str]:
def get_allowed_config_keys(server_type: str, server_root: str = '') -> Dict[str, str]:
"""获取指定服务器类型允许编辑的配置项
优先从 default.kv 读取 config_keys_* 定义,如果没有则使用代码中的默认定义
优先从 default.config 读取 config_keys_* 定义,如果没有则使用代码中的默认定义
Args:
server_type: 服务器类型 (game/login/client/center/cross)
server_root: 服务器根目录(用于读取 default.kv
server_root: 服务器根目录(用于读取 default.config
Returns:
配置项字典 {key: 中文说明}
"""
# 优先从 default.kv 读取
# 优先从 default.config 读取
if server_root:
config_from_kv = get_config_keys_from_kv(server_root, server_type)
if config_from_kv:
@@ -256,12 +256,12 @@ def get_all_valid_keys(server_root: str = '') -> Set[str]:
"""获取所有有效的配置键
Args:
server_root: 服务器根目录(用于读取 default.kv
server_root: 服务器根目录(用于读取 default.config
Returns:
所有有效配置键的集合
"""
# 优先从 default.kv 读取
# 优先从 default.config 读取
if server_root:
kv_config = _read_default_kv(server_root)
if kv_config:
@@ -287,16 +287,16 @@ def get_all_valid_keys(server_root: str = '') -> Set[str]:
def get_required_keys(server_type: str, server_root: str = '') -> Set[str]:
"""获取指定服务器类型必须的配置项(不可删除)
优先从 default.kv 读取 required_keys_* 定义,如果没有则使用代码中的默认定义
优先从 default.config 读取 required_keys_* 定义,如果没有则使用代码中的默认定义
Args:
server_type: 服务器类型 (game/login/client/center/cross)
server_root: 服务器根目录(用于读取 default.kv
server_root: 服务器根目录(用于读取 default.config
Returns:
必须的配置项集合
"""
# 优先从 default.kv 读取
# 优先从 default.config 读取
if server_root:
required_from_kv = get_required_keys_from_kv(server_root, server_type)
if required_from_kv:
@@ -378,8 +378,8 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
"""根据模板文件获取可配置的参数列表
优先级:
1. default.kv 中的 editable_config_* 定义(编辑对话框专用)
2. default.kv 中的 config_keys_* 定义(完整配置项)
1. default.config 中的 editable_config_* 定义(编辑对话框专用)
2. default.config 中的 config_keys_* 定义(完整配置项)
3. 从模板文件提取
4. 代码中的默认定义
@@ -390,7 +390,7 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
Returns:
配置项字典 {key: 中文说明}
"""
# 优先从 default.kv 读取 editable_config_* (编辑对话框专用)
# 优先从 default.config 读取 editable_config_* (编辑对话框专用)
editable_from_kv = get_editable_config_from_default_kv(server_root)
config_from_kv = get_config_keys_from_kv(server_root, server_type)
if editable_from_kv:
@@ -401,7 +401,7 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
merged['auto_reload'] = label or BASE_CONFIG_KEYS.get('auto_reload', '自动热更')
return merged
# 其次从 default.kv 读取 config_keys_* (完整配置项定义)
# 其次从 default.config 读取 config_keys_* (完整配置项定义)
if config_from_kv:
return config_from_kv
+16
View File
@@ -19,8 +19,13 @@ SRC_DIR = Path(__file__).resolve().parents[1]
from services.commands import (
IS_WINDOWS,
ensure_and_get_server_manager_config_dir,
find_existing_default_config_path,
find_existing_user_config_path,
get_default_config_path,
get_local_ip,
get_project_config_path,
get_server_manager_config_dir,
get_user_config_path,
get_ebin_paths,
get_erl_cmd,
get_escript_cmd,
@@ -45,11 +50,18 @@ from services.commands import (
get_config_list,
get_server_list,
query_remote_servers_from_login_rpc,
rpc_clear_server_on_center_and_login,
get_login_db_name,
get_node_status_cache,
check_nodes_status,
check_node_status,
test_mysql_connection,
REBAR3_CMD,
DEFAULT_CONFIG_FILE,
LEGACY_DEFAULT_CONFIG_FILE,
USER_CONFIG_FILE,
LEGACY_USER_CONFIG_FILE,
PROJECT_CONFIG_FILE,
)
from services.server_creator import (
@@ -73,9 +85,13 @@ from services.hot_update import (
fetch_update_manifest,
version_less,
get_delta_for_current,
get_delta_kind,
get_delta_download_url,
get_delta_expected_sha256,
download_file as updater_download_file,
run_installer_and_exit,
apply_delta_patch,
apply_file_delta_package,
clean_up_old_version,
)
+38 -6
View File
@@ -104,8 +104,9 @@ class ClearDatabaseDialog(QDialog):
f"即将清档: {server_name}\n\n"
"操作步骤:\n"
"1. 关闭服务器\n"
"2. 删除游戏数据库\n"
"3. 删除日志数据库\n\n"
"2. 通知中心服和登录服清档\n"
"3. 删除游戏数据库\n"
"4. 删除日志数据库\n\n"
"是否继续?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
@@ -135,8 +136,11 @@ class ClearDatabaseDialog(QDialog):
QMessageBox.critical(self, "错误", f"找不到配置: {kv_config_file}")
return
# 合并配置(kv.config > tool.config > default.kv
merged_cfg = read_merged_config(server_root, server_name) if server_root else read_config_file(str(kv_config_file))
# 合并配置(kv.config > user.config > project.config > default.config
if server_root:
merged_cfg = read_merged_config(server_root, server_name, run_dir=run_dir)
else:
merged_cfg = read_config_file(str(kv_config_file))
db_host = merged_cfg.get('db_host', '127.0.0.1')
db_port = int(merged_cfg.get('db_port', '3306'))
db_user = merged_cfg.get('db_user', 'root')
@@ -144,11 +148,34 @@ class ClearDatabaseDialog(QDialog):
db_game = merged_cfg.get('db_game_name', '')
db_log = merged_cfg.get('db_log_name', '')
def clean_value(value):
return str(value or '').strip().strip("'\"")
server_id = clean_value(merged_cfg.get('server_id', ''))
center_node = clean_value(merged_cfg.get('center_node', ''))
login_node = clean_value(merged_cfg.get('login_node', ''))
if not db_game:
QMessageBox.critical(self, "错误", "无法获取游戏数据库名")
return
if not server_id:
QMessageBox.critical(self, "错误", "无法获取服务器ID")
return
try:
int(server_id)
except ValueError:
QMessageBox.critical(self, "错误", f"服务器ID无效: {server_id}")
return
if not center_node:
QMessageBox.critical(self, "错误", "无法获取中心服节点")
return
if not login_node:
QMessageBox.critical(self, "错误", "无法获取登录服节点")
return
cookie = self.config.get('server', 'cookie', default='ddxq2-node')
cookie = clean_value(merged_cfg.get('cookie', '')) or clean_value(
self.config.get('server', 'cookie', default='ddxq2-node')
) or 'ddxq2-node'
erl_path = self.config.get('erlang', 'r25_path', default='') or None
stop_cmd = build_stop_command(server_name, cookie, erl_path=erl_path)
@@ -161,7 +188,12 @@ class ClearDatabaseDialog(QDialog):
db_user=db_user,
db_pass=db_pass,
db_game=db_game,
db_log=db_log
db_log=db_log,
server_id=server_id,
center_node=center_node,
login_node=login_node,
cookie=cookie,
erl_path=erl_path
)
self.clear_thread.finished_signal.connect(self._on_clear_finished)
self.clear_thread.error_signal.connect(self._on_clear_error)
+1 -1
View File
@@ -296,7 +296,7 @@ class KvConfigEditDialog(QDialog):
pass
return '18001'
# 其他配置项从合并配置读取(kv.config > tool.config > default.kv
# 其他配置项从合并配置读取(kv.config > user.config > project.config > default.config
merged = read_merged_config(self.server_root)
return merged.get(key, '')
+1 -1
View File
@@ -66,7 +66,7 @@ class RemoteConnectDialog(QDialog):
self.ip_input.setText(get_local_ip())
form_layout.addRow("目标 IP:", self.ip_input)
# Cookie 输入(kv.config > tool.config > default.kv
# Cookie 输入(kv.config > user.config > project.config > default.config
self.cookie_input = QLineEdit()
default_cookie = self.config.get('server', 'cookie', default='')
if not default_cookie:
+1 -1
View File
@@ -123,7 +123,7 @@ class ViewAccountsDialog(QDialog):
self.status_label.setText("错误: 需要安装 pymysql")
return
# 从合并配置获取数据库信息(kv.config > tool.config > default.kv
# 从合并配置获取数据库信息(kv.config > user.config > project.config > default.config
server_root = self.config.get('workspace', 'server_root', default='')
run_dir = self.config.get('workspace', 'run_dir', default='')
kv_config_file = Path(run_dir) / server_name / 'config' / 'kv.config'
+70 -24
View File
@@ -14,6 +14,7 @@ class MainWindow(QMainWindow):
"""主窗口。支持“无项目”启动:先显示欢迎页,打开项目后加载并显示功能页。"""
CONSOLE_PANEL_HEIGHT = 345
AUTO_UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
def __init__(self, config: Optional[Config] = None):
super().__init__()
@@ -21,6 +22,9 @@ class MainWindow(QMainWindow):
self._project_content_widget = None # 项目内容容器,首次打开项目时创建
self._cache_init_worker = None
self._startup_update_check_started = False
self._update_check_worker = None
self._update_prompt_active = False
self._last_prompted_update_version = ""
self.init_ui()
def init_ui(self):
@@ -255,6 +259,9 @@ class MainWindow(QMainWindow):
self._status_timer.timeout.connect(self._update_status_time)
self._status_timer.start(1000)
self._update_status_time()
self._auto_update_timer = QTimer(self)
self._auto_update_timer.setInterval(self.AUTO_UPDATE_CHECK_INTERVAL_MS)
self._auto_update_timer.timeout.connect(self._start_startup_update_check)
# 菜单栏:保证在窗口内可见(非系统原生菜单),便于看到“文件”“帮助”
menubar = self.menuBar()
@@ -306,49 +313,77 @@ class MainWindow(QMainWindow):
def showEvent(self, event):
super().showEvent(event)
# 首次打开后延迟检查远端版本清单,版本不一致则弹窗询问是否更新
# 首次打开后延迟检查一次,随后每 5 分钟在后台检查远端版本清单。
if not self._startup_update_check_started:
self._startup_update_check_started = True
self._auto_update_timer.start()
QTimer.singleShot(1500, self._start_startup_update_check)
def _start_startup_update_check(self):
"""启动时在后台检查远端版本清单(仅当配置了 update_url 时)"""
"""在后台检查远端版本清单(仅当配置了 update_url 时)"""
info = get_version_info()
update_url = (info.get("update_url") or "").strip()
if not update_url:
return
self._startup_check_worker = UpdateCheckWorker(update_url, self)
self._startup_check_worker.finished_signal.connect(self._on_startup_update_checked)
self._startup_check_worker.start()
if self._update_check_worker and self._update_check_worker.isRunning():
return
worker = UpdateCheckWorker(update_url, self)
self._update_check_worker = worker
worker.finished_signal.connect(self._on_startup_update_checked)
worker.finished.connect(lambda w=worker: self._clear_update_check_worker(w))
worker.finished.connect(worker.deleteLater)
worker.start()
def _clear_update_check_worker(self, worker):
if self._update_check_worker is worker:
self._update_check_worker = None
def _on_startup_update_checked(self, manifest):
"""启动检查结果:若本地版本与远端版本不一致则弹窗询问是否更新"""
"""后台检查结果:若远端版本更高则弹窗询问是否更新"""
if not manifest:
return
current = get_current_version()
remote_version = (manifest.get("version") or "").strip()
if not remote_version or remote_version == current:
if not remote_version or not version_less(current, remote_version):
if remote_version == current:
self._last_prompted_update_version = ""
return
if remote_version == self._last_prompted_update_version:
return
if self._update_prompt_active:
return
if hasattr(self, "_update_worker") and self._update_worker and self._update_worker.isRunning():
return
self._last_prompted_update_version = remote_version
self._update_prompt_active = True
release_notes = (manifest.get("release_notes") or "").strip()
msg = f"远程版本 {remote_version} 与当前版本 {current} 不一致"
msg = f"发现新版本 {remote_version}(当前 {current}"
if release_notes:
msg += f"\n\n{release_notes}"
msg += "\n\n是否立即更新?"
ret = QMessageBox.question(
self,
"检查更新",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
try:
ret = QMessageBox.question(
self,
"检查更新",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
finally:
self._update_prompt_active = False
if ret != QMessageBox.StandardButton.Yes:
return
delta = get_delta_for_current(manifest, current)
has_delta = delta and delta.get("patch_url") and delta.get("new_exe_sha256")
has_delta = bool(delta)
full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip()
self._update_manifest = manifest # 保存 manifest,增量失败时可用来发起全量更新
if has_delta:
self._start_update_download(delta["patch_url"], use_delta=True, expected_sha256=delta.get("new_exe_sha256"))
self._start_update_download(
get_delta_download_url(delta),
use_delta=True,
expected_sha256=get_delta_expected_sha256(delta),
delta_kind=get_delta_kind(delta),
)
elif full_url:
self._start_update_download(full_url, use_delta=False)
else:
@@ -411,6 +446,7 @@ class MainWindow(QMainWindow):
def _on_close_project(self):
self.config.tool_dir = None
self.config.user_config_path = None
self.config.tool_config_path = None
self.config.config_error = "未打开项目"
self.config.data = _empty_config_data()
@@ -465,7 +501,7 @@ class MainWindow(QMainWindow):
release_notes = manifest.get("release_notes", "")
full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip()
delta = get_delta_for_current(manifest, current)
has_delta = bool(delta and delta.get("patch_url") and delta.get("new_exe_sha256"))
has_delta = bool(delta)
if not full_url and not has_delta:
QMessageBox.warning(
self,
@@ -492,9 +528,10 @@ class MainWindow(QMainWindow):
self._update_manifest = manifest
if clicked == btn_delta and has_delta:
self._start_update_download(
delta["patch_url"],
get_delta_download_url(delta),
use_delta=True,
expected_sha256=delta.get("new_exe_sha256"),
expected_sha256=get_delta_expected_sha256(delta),
delta_kind=get_delta_kind(delta),
)
else:
self._start_update_download(full_url, use_delta=False)
@@ -504,12 +541,17 @@ class MainWindow(QMainWindow):
download_url: str,
use_delta: bool = False,
expected_sha256: Optional[str] = None,
delta_kind: str = "",
):
"""下载更新包(全量 exe 或增量 patch),完成后全量则启动安装,增量则 bsdiff 打补丁并替换"""
dest = Path(tempfile.gettempdir()) / "ServerManager_Update"
dest.mkdir(parents=True, exist_ok=True)
suffix = Path(download_url).suffix or (".patch" if use_delta else ".exe")
from urllib.parse import urlparse
suffix = Path(urlparse(download_url).path).suffix
if not suffix:
suffix = ".smdelta" if use_delta and delta_kind == "file_zip" else (".patch" if use_delta else ".exe")
dest_file = dest / ("ServerManager_patch" + suffix if use_delta else f"ServerManager_Setup{suffix}")
self._update_delta_kind = delta_kind
self._update_download_path = dest_file
self._update_progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self)
self._update_progress.setWindowTitle("检查更新")
@@ -542,13 +584,17 @@ class MainWindow(QMainWindow):
if not path or not Path(path).exists():
QMessageBox.warning(self, "检查更新", "下载失败,请稍后重试或手动下载。")
return
if is_delta and expected_sha256:
if is_delta:
QMessageBox.information(
self,
"检查更新",
"增量包已下载,即将退出并重启以应用补丁"
"增量包已下载,即将退出并重启以应用更新"
)
ok, reason = apply_delta_patch(Path(path), expected_sha256)
delta_kind = getattr(self, "_update_delta_kind", "") or "bsdiff_exe"
if delta_kind == "file_zip":
ok, reason = apply_file_delta_package(Path(path), expected_sha256 or "")
else:
ok, reason = apply_delta_patch(Path(path), expected_sha256 or "")
if ok:
pass # 成功时进程会退出
else:
+73 -20
View File
@@ -43,7 +43,7 @@ class ConfigTab(QWidget):
self.init_ui()
def _load_default_kv(self):
"""读取合并配置(tool.config > default.kv"""
"""读取合并配置(user.config > project.config > default.config"""
server_root = self.config.get('workspace', 'server_root', default='')
if not server_root:
return
@@ -59,17 +59,17 @@ class ConfigTab(QWidget):
return value
def _get_config_value(self, config_keys: tuple, default_kv_key: str, fallback=''):
"""获取配置值:优先从 tool.config 读取,没有值则从 default.kv 读取
"""获取配置值:优先从 user.config 读取,没有值则从 project.config/default.config 读取
Args:
config_keys: tool.config 的键路径,如 ('database', 'host')
default_kv_key: default.kv 中的键名,如 'db_host'
config_keys: user.config 的键路径,如 ('database', 'host')
default_kv_key: default.config 中的键名,如 'db_host'
fallback: 最终的默认值
"""
# 先从 tool.config 读取
# 先从 user.config 读取
value = self.config.get(*config_keys, default=None)
# 如果为空,从 default.kv 读取
# 如果为空,从 project.config/default.config 读取
if value is None or value == '' or value == 0:
value = self._get_default(default_kv_key, str(fallback) if fallback else '')
@@ -125,6 +125,12 @@ class ConfigTab(QWidget):
form.addRow(label, row)
return btn
def add_readonly_row(form: QFormLayout, label: str, line_edit: QLineEdit):
line_edit.setReadOnly(True)
line_edit.setToolTip("由当前打开的项目目录决定")
line_edit.setCursorPosition(0)
form.addRow(label, line_edit)
def make_form_group(title: str, icon_kind: str = "settings") -> tuple[QFrame, QFormLayout]:
group = QFrame()
group.setObjectName("settingsFormCard")
@@ -174,8 +180,8 @@ class ConfigTab(QWidget):
project_group, project_layout = make_form_group("项目目录配置", "file")
self.server_root = QLineEdit(self.config.get('workspace', 'server_root', default=''))
self.run_dir = QLineEdit(self.config.get('workspace', 'run_dir', default=''))
add_browse_row(project_layout, "服务器根目录:", self.server_root)
add_browse_row(project_layout, "运行目录:", self.run_dir)
add_readonly_row(project_layout, "服务器根目录:", self.server_root)
add_readonly_row(project_layout, "运行目录:", self.run_dir)
content_layout.addWidget(project_group)
# 启动窗口配置
@@ -215,6 +221,15 @@ class ConfigTab(QWidget):
db_layout.addRow("数据库端口:", self.db_port)
db_layout.addRow("数据库用户:", self.db_user)
db_layout.addRow("数据库密码:", self.db_pass)
db_test_row = QHBoxLayout()
db_test_row.setContentsMargins(0, 0, 0, 0)
db_test_row.addStretch()
self.test_db_btn = QPushButton("测试连接")
apply_button_style(self.test_db_btn, "outline", compact=True)
set_button_icon(self.test_db_btn, "link", "#4f8cff", 14)
self.test_db_btn.clicked.connect(self._test_database_connection)
db_test_row.addWidget(self.test_db_btn)
db_layout.addRow("", db_test_row)
content_layout.addWidget(db_group)
# 服务器默认配置
@@ -557,24 +572,65 @@ class ConfigTab(QWidget):
"请核对节点名、Cookie、网络与防火墙,并确认上方 Erlang 路径可用。"
)
def _test_database_connection(self):
"""测试当前表单中的 MySQL 连接是否可用。"""
host = self.db_host.text().strip()
port = self.db_port.value()
user = self.db_user.text().strip()
password = self.db_pass.text()
if not host:
QMessageBox.warning(self, "提示", "请先填写数据库主机")
return
if not user:
QMessageBox.warning(self, "提示", "请先填写数据库用户")
return
self.test_db_btn.setEnabled(False)
old_text = self.test_db_btn.text()
self.test_db_btn.setText("测试中...")
QApplication.processEvents()
try:
result = test_mysql_connection(host, port, user, password, timeout=5)
version = result.get("server_version") or "未知"
current_user = result.get("current_user") or user
self._log(f"[数据库] MySQL 连接成功: {host}:{port}, user={current_user}, version={version}\n")
QMessageBox.information(
self,
"连接成功",
f"MySQL 连接成功:\n{host}:{port}\n\n"
f"版本:{version}\n"
f"当前用户:{current_user}",
)
except ImportError as e:
QMessageBox.critical(self, "缺少依赖", str(e))
except Exception as e:
self._log(f"[数据库] MySQL 连接失败: {host}:{port}, error={e}\n")
QMessageBox.warning(self, "连接失败", f"无法连接 MySQL\n{e}")
finally:
self.test_db_btn.setText(old_text)
self.test_db_btn.setEnabled(True)
def _fill_default_values(self):
"""从 default.kv 填充默认值到表单"""
"""从 default.config 填充默认值到表单"""
server_root = self.server_root.text()
if not server_root:
QMessageBox.warning(self, "警告", "请先设置服务器根目录")
return
default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv'
if not default_kv_path.exists():
default_kv_path = find_existing_default_config_path(server_root)
if not default_kv_path:
expected_path = get_default_config_path(server_root)
QMessageBox.warning(self, "警告",
f"找不到默认配置文件:\n{default_kv_path}\n\n请检查工作路径是否正确")
f"找不到默认配置文件:\n{expected_path}\n\n请检查工作路径是否正确")
return
# 重新加载 default.kv
# 重新加载 default.config
self.default_kv = read_config_file(str(default_kv_path))
if not self.default_kv:
QMessageBox.warning(self, "警告", "default.kv 文件为空或格式错误")
QMessageBox.warning(self, "警告", "default.config 文件为空或格式错误")
return
# 填充数据库配置
@@ -612,10 +668,10 @@ class ConfigTab(QWidget):
if 'cookie' in self.default_kv:
self.cookie.setText(self.default_kv['cookie'])
QMessageBox.information(self, "成功", "已从 default.kv 填充默认值\n\n请检查后点击「保存配置」")
QMessageBox.information(self, "成功", "已从 default.config 填充默认值\n\n请检查后点击「保存配置」")
def save_config(self):
"""保存配置到 .server_manager/config/tool.config"""
"""保存配置到 .server_manager/config/user.config"""
# 保存前记录当前 Erlang 路径,用于判断版本是否变更
old_r25_path = (self.config.get('erlang', 'r25_path', default='') or '').strip()
new_r25_path = self.r25_path.text().strip()
@@ -637,9 +693,6 @@ class ConfigTab(QWidget):
QMessageBox.warning(self, "警告", f"未在 PATH 中找到运行 EXE:\n{terminal_exe_path}")
return
self.config.set(self.server_root.text(), 'workspace', 'server_root')
self.config.set(self.run_dir.text(), 'workspace', 'run_dir')
self.config.set(self.db_host.text(), 'database', 'host')
self.config.set(self.db_user.text(), 'database', 'user')
self.config.set(self.db_pass.text(), 'database', 'password')
@@ -694,4 +747,4 @@ class ConfigTab(QWidget):
# 发出配置保存信号,通知其他页签刷新
self.config_saved.emit()
QMessageBox.information(self, "成功", "项目配置已保存到当前项目的 .server_manager/config/tool.config")
QMessageBox.information(self, "成功", "项目配置已保存到当前项目的 .server_manager/config/user.config")
+9 -9
View File
@@ -42,7 +42,7 @@ class CreateServerTab(QWidget):
self._initializing = False # 初始化完成
def _load_default_kv(self):
"""读取合并配置(tool.config > default.kv"""
"""读取合并配置(user.config > project.config > default.config"""
server_root = self.config.get('workspace', 'server_root', default='')
if not server_root:
return
@@ -97,7 +97,7 @@ class CreateServerTab(QWidget):
type_layout.addRow(self.server_id_label, self.server_id)
# 服务器名称(仅游戏服需要)
# 优先从 tool.config 读取,读不到再从 default.kv 读取
# 优先从 user.config 读取,读不到再从 project.config/default.config 读取
default_server_name = self.config.get('server', 'server_name', default='')
if not default_server_name:
default_server_name = self.default_kv.get('server_name', '未命名服_1')
@@ -173,7 +173,7 @@ class CreateServerTab(QWidget):
self.use_default_db.stateChanged.connect(self._toggle_db_fields)
db_layout.addRow("", self.use_default_db)
# 从 tool.config (self.config) 读取数据库配置
# 从 user.config (self.config) 读取数据库配置
default_db_host = self.config.get('database', 'host', default='')
default_db_user = self.config.get('database', 'user', default='')
default_db_pass = self.config.get('database', 'password', default='')
@@ -224,7 +224,7 @@ class CreateServerTab(QWidget):
self.use_default_nodes.stateChanged.connect(self._toggle_node_fields)
node_layout.addRow("", self.use_default_nodes)
# 优先从 tool.config 读取登录节点,读不到再从 default.kv 读取
# 优先从 user.config 读取登录节点,读不到再从 project.config/default.config 读取
default_login_node = self.config.get('server', 'login_server_node', default='')
if not default_login_node:
default_login_node = self.default_kv.get('login_node', '')
@@ -234,7 +234,7 @@ class CreateServerTab(QWidget):
self.login_node.setMinimumHeight(30)
self.login_node_label = QLabel("登录服节点:")
# 优先从 tool.config 读取中心服节点,读不到再从 default.kv 读取
# 优先从 user.config 读取中心服节点,读不到再从 project.config/default.config 读取
default_center_node = self.config.get('server', 'center_server_node', default='')
if not default_center_node:
default_center_node = self.default_kv.get('center_node', '')
@@ -451,7 +451,7 @@ class CreateServerTab(QWidget):
"""根据服务器类型显示/隐藏配置项"""
server_type = self.server_type_combo.currentData()
# 根据服务器类型设置ID范围(从 default.kv 读取)
# 根据服务器类型设置ID范围(从 default.config 读取)
# 格式: {type}_id_min, {type}_id_max, {type}_id_default
default_ranges = {
'game': (100, 9999, 100),
@@ -609,7 +609,7 @@ class CreateServerTab(QWidget):
return
# 获取节点配置 - 直接使用输入框中显示的值
# 输入框的值已经在初始化时从 tool.config 或 default.kv 读取
# 输入框的值已经在初始化时从 user.config 或 project.config/default.config 读取
login_node = self.login_node.text().strip()
center_node = self.center_node.text().strip()
@@ -780,10 +780,10 @@ class CreateServerTab(QWidget):
def refresh_config(self):
"""刷新配置(当工具设置保存后调用)"""
# 重新加载 tool.config
# 重新加载 user.config
self.config.data = self.config._load_config()
# 重新加载 default.kv
# 重新加载 default.config/project.config
self._load_default_kv()
# 更新数据库配置输入框
+8 -7
View File
@@ -29,7 +29,7 @@ from views.dialogs import (
)
class DefaultKvTab(QWidget):
"""默认配置编辑页面 - 编辑 .server_manager/config/default.kv"""
"""默认配置编辑页面 - 编辑 .server_manager/config/default.config"""
def __init__(self, config: Config, console: OutputConsole):
super().__init__()
@@ -45,7 +45,7 @@ class DefaultKvTab(QWidget):
layout.setSpacing(10)
# 标题和说明
title = QLabel("默认配置管理 (.server_manager/config/default.kv)")
title = QLabel("默认配置管理 (.server_manager/config/default.config)")
title.setStyleSheet("font-size: 16px; font-weight: bold; color: #25c889;")
layout.addWidget(title)
@@ -123,14 +123,15 @@ class DefaultKvTab(QWidget):
layout.addLayout(btn_layout)
def load_default_kv(self):
"""加载 default.kv 配置"""
"""加载 default.config 配置"""
server_root = self.config.get('workspace', 'server_root', default='')
if not server_root:
self.console.append_output("[警告] 请先配置服务器根目录\n")
return
self.default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv'
if not self.default_kv_path.exists():
load_path = find_existing_default_config_path(server_root)
self.default_kv_path = get_default_config_path(server_root)
if not load_path:
self.console.append_output(f"[警告] 默认配置文件不存在: {self.default_kv_path}\n")
return
@@ -139,7 +140,7 @@ class DefaultKvTab(QWidget):
self.categories = {}
current_category = "其他"
with open(self.default_kv_path, 'r', encoding='utf-8') as f:
with open(load_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line.startswith('# ===') and line.endswith('==='):
@@ -259,7 +260,7 @@ class DefaultKvTab(QWidget):
self.console.append_output(f"[信息] 已删除配置项: {key}\n")
def save_default_kv(self):
"""保存配置到 default.kv"""
"""保存配置到 default.config"""
if not self.default_kv_path:
QMessageBox.warning(self, "警告", "请先加载配置文件")
return
+1 -1
View File
@@ -344,7 +344,7 @@ class ServerManageRemoteMixin:
def _connect_to_remote_node(self, server_node: str):
"""连接到远程节点"""
# 读取 cookiekv.config > tool.config > default.kv
# 读取 cookiekv.config > user.config > project.config > default.config
server_root = self.config.get('workspace', 'server_root', default='')
cookie = self.config.get('server', 'cookie', default='')
if not cookie and server_root:
+7 -6
View File
@@ -1,8 +1,9 @@
{
"version": "1.1.3",
"update_url": "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json",
"release_notes": "增加启停按钮",
"download_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe",
"full_installer_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe",
"delta_updates": {}
"version": "1.1.8",
"update_url": "https://www.sumi.work/api/software/server-manager/version.json",
"release_notes": "支持文件级增量更新包,减少后续版本更新带宽。",
"download_url": "https://www.sumi.work/api/software/server-manager/update-download",
"full_installer_url": "https://www.sumi.work/api/software/server-manager/update-download",
"delta_updates": {},
"deltaUpdates": {}
}