换位置

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 python -m entrypoints.cli --root E:\game\server_root mcp
``` ```
打包后可直接使用安装目录或 `src\dist\main\main.exe` 打包后可直接使用安装目录或 `src\dist\ServerManager\ServerManager.exe`
```bat ```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` 等命令。 - Linux/Windows 都提供命令行入口,支持 `list``create``start``stop``connect``compile``regen``interactive` 等命令。
- 工具面向一个 Erlang/rebar3 游戏服务器工程,约定服务器工程根目录包含 `run/``config/``rebar3` 等内容。 - 工具面向一个 Erlang/rebar3 游戏服务器工程,约定服务器工程根目录包含 `run/``config/``rebar3` 等内容。
- 每个服务器实例位于 `<服务器项目根>/run/<prefix>_<type>_s<id>/`,实例差异配置写入 `config/kv.config`,启动前根据模板生成 `config/sys.config` - 每个服务器实例位于 `<服务器项目根>/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`,用于保存最近项目列表和应用日志。 - 应用级配置位于 `%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 自动更新集成示例/遗留入口。 - 实际 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 风格快捷键。 - 日志查看支持单文件加载、玩家协议日志、日期归档日志、ripgrep 全局搜索、正文高亮、匹配行过滤和 less 风格快捷键。
## 核心文件树 ## 核心文件树
@@ -37,7 +37,7 @@ server_manager/
├── rg.exe # 可选,ripgrep Windows 可执行文件 ├── rg.exe # 可选,ripgrep Windows 可执行文件
├── output/ ├── output/
│ ├── config/ │ ├── config/
│ │ ├── default.kv │ │ ├── default.config
│ │ ├── sys_game.config.example │ │ ├── sys_game.config.example
│ │ ├── sys_login.config.example │ │ ├── sys_login.config.example
│ │ ├── sys_center.config.example │ │ ├── sys_center.config.example
@@ -83,8 +83,8 @@ server_manager/
2. Windows 下提供 PyQt6 GUIWindows/Linux 下提供 CLI。 2. Windows 下提供 PyQt6 GUIWindows/Linux 下提供 CLI。
3. GUI 无参数启动;命令行带任意参数时进入 CLI,行为与 src/server_manager_cli.py 一致。 3. GUI 无参数启动;命令行带任意参数时进入 CLI,行为与 src/server_manager_cli.py 一致。
4. 工具管理一个外部 Erlang 游戏服务器项目,项目根目录通常含 run、config、rebar3、apps 等。 4. 工具管理一个外部 Erlang 游戏服务器项目,项目根目录通常含 run、config、rebar3、apps 等。
5. 首次打开服务器项目时,将软件自带模板 output/config 下的 default.kv 与 sys_*.config.example 同步到 <项目根>/.server_manager/config/。 5. 首次打开服务器项目时,将软件自带模板 output/config 下的 default.config 与 sys_*.config.example 同步到 <项目根>/.server_manager/config/。
6. 所有项目级配置保存在 <项目根>/.server_manager/config/tool.config;应用级配置和日志保存在 %LOCALAPPDATA%/ServerManager 或 ~/.config/ServerManager。 6. 所有项目级配置保存在 <项目根>/.server_manager/config/user.config;应用级配置和日志保存在 %LOCALAPPDATA%/ServerManager 或 ~/.config/ServerManager。
7. 支持服务器创建、启动、快速启动、停止、remsh 连接、编译、配置编辑、sys.config 重新生成、本地/远程服务器列表、节点状态批量检查、日志查看、日志搜索、自动更新、打包发版。 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。 - 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 为准。 - main.pyTkinter 示例/遗留入口,LOCAL_VERSION = "1.0.46",演示 updater 自动更新集成;真实桌面工具入口仍以 src/server_manager_gui.py 为准。
- updater.py:保留一个 Tkinter/独立入口可用的无感更新模块,兼容 delta 与 delta_updates。 - 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.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。 - release.bat:一键正式发版脚本,顺序为 PyInstaller -> release.py 版本预处理 -> Inno Setup -> release.py --post-build。
- pack_all.bat:正式打包但不改版本号,生成 output/ServerManager_Setup.exe,并复制 version.json 到 output/。 - 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/。 - 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/ 可选忽略。 - .gitignore:忽略 output_local/,可注明 release_backup/ 可选忽略。
- rg.exe:可选,不需要生成二进制;文档说明放置在根目录即可被打包复制。 - rg.exe:可选,不需要生成二进制;文档说明放置在根目录即可被打包复制。
@@ -135,7 +135,7 @@ src 目录:
- icon/convert_icon.py、icon/icon.png、icon/icon.ico:图标资源和转换脚本。 - icon/convert_icon.py、icon/icon.png、icon/icon.ico:图标资源和转换脚本。
output/config 目录: 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} 占位符。 - 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 内容 四、version.json 内容
@@ -159,23 +159,23 @@ output/config 目录:
1. 项目级配置目录固定为 <项目根>/.server_manager/config。 1. 项目级配置目录固定为 <项目根>/.server_manager/config。
2. 软件自带模板目录: 2. 软件自带模板目录:
- 打包后:main.exe 同级的 config/。 - 打包后:ServerManager.exe 同级的 config/。
- 源码运行:仓库根目录 output/config/。 - 源码运行:仓库根目录 output/config/。
3. 受管模板文件: 3. 受管模板文件:
- default.kv - default.config
- sys_center.config.example - sys_center.config.example
- sys_client.config.example - sys_client.config.example
- sys_cross.config.example - sys_cross.config.example
- sys_game.config.example - sys_game.config.example
- sys_login.config.example - sys_login.config.example
4. 打开项目时必须确保 .server_manager/config 存在并同步上述模板;保留用户自己的 tool.config。 4. 打开项目时必须确保 .server_manager/config 存在并同步上述模板;保留用户自己的 user.config。
5. 配置合并优先级从低到高: 5. 配置合并优先级从低到高:
- .server_manager/config/default.kv - .server_manager/config/default.config
- .server_manager/config/tool.config - .server_manager/config/user.config
- 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 - run_dir/<server_dir>/config/kv.config
6. 合并配置后强制用当前机器 IP 覆盖 ip 和 game_host。 6. 合并配置后强制用当前机器 IP 覆盖 ip 和 game_host。
7. tool.config 是 key=value 文件,至少写入: 7. user.config 是 key=value 文件,至少写入:
- server_root、run_dir - server_root、run_dir
- r25_path - r25_path
- db_host、db_port、db_user、db_pass - db_host、db_port、db_user、db_pass
@@ -184,12 +184,12 @@ output/config 目录:
8. 应用级配置: 8. 应用级配置:
- Windows: %LOCALAPPDATA%/ServerManager/app_config.json - Windows: %LOCALAPPDATA%/ServerManager/app_config.json
- Linux/macOS: ~/.config/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 天。 - 应用日志写到同目录 logs/,日志文件名 server_manager_YYYYMMDD.log 或 server_manager_cli_YYYYMMDD.log,保留最近 7 天。
六、default.kv 要求 六、default.config 要求
default.kv 必须含以下默认值和配置定义: default.config 必须含以下默认值和配置定义:
- prefix=ddxq2 - prefix=ddxq2
- server_id=1 - server_id=1
@@ -259,7 +259,7 @@ default.kv 必须含以下默认值和配置定义:
- client_server -> sys_client.config.example - client_server -> sys_client.config.example
- center_server -> sys_center.config.example - center_server -> sys_center.config.example
- cross_server -> sys_cross.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. 若模板中仍有未替换占位符,要输出警告但不要崩溃。 5. 若模板中仍有未替换占位符,要输出警告但不要崩溃。
八、服务器创建逻辑 八、服务器创建逻辑
@@ -350,7 +350,7 @@ default.kv 必须含以下默认值和配置定义:
CLI 初始化时自动检测 server_root CLI 初始化时自动检测 server_root
1. 命令行 --root 优先。 1. 命令行 --root 优先。
2. 读取 tool.config 的 server_root/run_dir 次之。 2. 读取 user.config 的 server_root/run_dir 次之。
3. 当前工作目录、脚本目录、上级目录中查找 apps 与 config。 3. 当前工作目录、脚本目录、上级目录中查找 apps 与 config。
4. 加载配置后执行 run_startup_migrations。 4. 加载配置后执行 run_startup_migrations。
@@ -412,7 +412,7 @@ GUI 图标:
- centerauto_reload、open_time、login_node - centerauto_reload、open_time、login_node
- crossauto_reload、open_time、center_node - crossauto_reload、open_time、center_node
- client:连接服务器ID、连接服务器端口、登录服地址、登录服HTTP端口、auto_reload、login_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 改变时自动更新服务器名与默认端口。 5. 服务器 ID 改变时自动更新服务器名与默认端口。
6. 数据库和节点支持“使用默认配置”复选框,默认勾选并禁用手工输入。 6. 数据库和节点支持“使用默认配置”复选框,默认勾选并禁用手工输入。
7. 点击创建前展示确认信息,再调用 server_creator.create_server。 7. 点击创建前展示确认信息,再调用 server_creator.create_server。
@@ -426,7 +426,7 @@ GUI 图标:
4. 远程操作:从登录服加载、刷新状态、搜索过滤、双击或按钮 remsh 连接。 4. 远程操作:从登录服加载、刷新状态、搜索过滤、双击或按钮 remsh 连接。
5. 远程列表通过登录服节点 rpc:call 获取 server_temp_info/server_info_lib 数据;输出需解析 server_id、server_name、server_node。 5. 远程列表通过登录服节点 rpc:call 获取 server_temp_info/server_info_lib 数据;输出需解析 server_id、server_name、server_node。
6. 状态刷新使用批量 check_nodes_status,并写入 NodeStatusCache。 6. 状态刷新使用批量 check_nodes_status,并写入 NodeStatusCache。
7. 编辑配置打开 KvConfigEditDialog,读取 kv.config,允许添加/删除 default.kv 中声明的可编辑配置项;保存后重新生成 sys.config。 7. 编辑配置打开 KvConfigEditDialog,读取 kv.config,允许添加/删除 default.config 中声明的可编辑配置项;保存后重新生成 sys.config。
日志查看页: 日志查看页:
1. 顶级“日志查看”内有两个子页签: 1. 顶级“日志查看”内有两个子页签:
@@ -468,12 +468,12 @@ GUI 图标:
- Erlang 与节点配置:Erlang 路径、Erlang 版本、登录服节点、中心服节点、Cookie。 - Erlang 与节点配置:Erlang 路径、Erlang 版本、登录服节点、中心服节点、Cookie。
2. 提供“检查版本”按钮,比对系统 erl 和配置路径 erl 的 OTP 版本。 2. 提供“检查版本”按钮,比对系统 erl 和配置路径 erl 的 OTP 版本。
3. 登录服节点、中心服节点提供“测试连接”,使用 net_adm:ping。 3. 登录服节点、中心服节点提供“测试连接”,使用 net_adm:ping。
4. “填充默认值”从 default.kv 填入表单。 4. “填充默认值”从 default.config 填入表单。
5. “保存配置”写入 tool.config;若 Erlang 路径变化,询问是否删除 server_root/_build 并执行 rebar3 as game_server_dev compile。 5. “保存配置”写入 user.config;若 Erlang 路径变化,询问是否删除 server_root/_build 并执行 rebar3 as game_server_dev compile。
欢迎页: 欢迎页:
1. 无项目时显示欢迎页,提供打开项目、最近项目、定位项目、帮助入口。 1. 无项目时显示欢迎页,提供打开项目、最近项目、定位项目、帮助入口。
2. 最近项目来自应用级 app_config.json,只显示仍存在且含 default.kv 的项目。 2. 最近项目来自应用级 app_config.json,只显示仍存在且含 default.config 的项目。
更新机制: 更新机制:
1. src/updater.py 提供: 1. src/updater.py 提供:
@@ -486,11 +486,11 @@ GUI 图标:
- clean_up_old_version - clean_up_old_version
- apply_delta_patch - apply_delta_patch
- run_installer_and_exit - run_installer_and_exit
2. 打包后 version.json 优先从 main.exe 同目录读取,其次从 _MEIPASS。 2. 打包后 version.json 优先从 ServerManager.exe 同目录读取,其次从 _MEIPASS。
3. GUI 启动 1.5 秒后后台检查 update_url;远端 version 与本地不同则询问是否更新。 3. GUI 启动 1.5 秒后后台检查 update_url;远端 version 与本地不同则询问是否更新。
4. 当前版本有 delta_updates 且 patch_url、new_exe_sha256 完整时优先增量,否则下载 full_installer_url 或 download_url。 4. 当前版本有 delta_updates 且 patch_url、new_exe_sha256 完整时优先增量,否则下载 full_installer_url 或 download_url。
5. 下载时显示 QProgressDialog;可取消。 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 静默安装并退出。 7. 全量更新下载安装包后用 /VERYSILENT /SUPPRESSMSGBOXES /FORCECLOSEAPPLICATIONS 静默安装并退出。
8. mini_updater.py 只依赖标准库,参数: 8. mini_updater.py 只依赖标准库,参数:
- --pid - --pid
@@ -508,30 +508,30 @@ GUI 图标:
- add-data: ../version.json、config.json、server_commands.py、server_creator.py、icon。 - 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。 - hidden-import: PyQt6.QtWidgets/Core/Gui、pymysql、server_commands、server_creator、server_manager_cli、updater、app_config、bsdiff4、requests。
- collect-all PyQt6 与 bsdiff4。 - collect-all PyQt6 与 bsdiff4。
- 若根目录有 rg.exe,复制到 dist/main/rg.exe。 - 若根目录有 rg.exe,复制到 dist/ServerManager/rg.exe。
- 复制 ../output/config 到 dist/main/config。 - 复制 ../output/config 到 dist/ServerManager/config。
- 再用 PyInstaller onefile windowed 打包 ../mini_updater.py 到 dist/mini_updater.exe。 - 再用 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。 3. src/build_portable.bat 提供单文件便携版尝试,失败回退 onedir。
4. build_installer.iss 4. build_installer.iss
- 正式输出 output/ServerManager_Setup.exe。 - 正式输出 output/ServerManager_Setup.exe。
- 本地测试 /DLOCAL_TEST=1 输出 output_local/ServerManager_Setup_LOCAL.exe,独立 AppId。 - 本地测试 /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 5. release.py
- get_current_version 以 output/version.json 为准读取旧版本。 - 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_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;若提供旧版 main.exe 且安装 bsdiff4,生成 output/patches/v<old>_to_v<new>.patch 并填 new_exe_sha256。 - 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 1. src/run.bat
- 自动计算 SERVER_ROOT 为脚本目录向上两级。 - 自动计算 SERVER_ROOT 为脚本目录向上两级。
- 优先使用 SERVER_MANAGER_MAIN、同目录 main.exe、dist/main/main.exe。 - 优先使用 SERVER_MANAGER_MAIN、同目录 ServerManager.exe、dist/ServerManager/ServerManager.exe。
- 无参数进入 interactive。 - 无参数进入 interactive。
- gui 启动 GUI。 - gui 启动 GUI。
- 其他参数转发 CLI,并附加 --root SERVER_ROOT。 - 其他参数转发 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_WINDOWS = platform.system() == "Windows"
- IS_LINUX = platform.system() == "Linux" - IS_LINUX = platform.system() == "Linux"
- REBAR3_CMD = "rebar3.cmd" if IS_WINDOWS else "./rebar3" - 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_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"。 - 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_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 伪代码: read_merged_config 伪代码:
def read_merged_config(server_root, server_dir=None, run_dir=None): def read_merged_config(server_root, server_dir=None, run_dir=None):
cfg_dir = get_server_manager_config_dir(server_root) cfg_dir = get_server_manager_config_dir(server_root)
merged = {} merged = {}
if (cfg_dir / "default.kv").exists(): if (cfg_dir / "default.config").exists():
merged.update(read_config_file(str(cfg_dir / "default.kv")))
elif (cfg_dir / "default.config").exists():
merged.update(read_config_file(str(cfg_dir / "default.config"))) 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(): if (cfg_dir / "user.config").exists():
merged.update(read_config_file(str(cfg_dir / "tool.config"))) merged.update(read_config_file(str(cfg_dir / "user.config")))
if run_dir: 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(): if p.exists():
merged.update(read_config_file(str(p))) merged.update(read_config_file(str(p)))
break break
@@ -926,7 +927,7 @@ GUI 图标:
- detected_root = _detect_server_root() - detected_root = _detect_server_root()
- configured_root = _read_configured_server_root(detected_root) - configured_root = _read_configured_server_root(detected_root)
- server_root 优先级:参数 server_root > configured_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) - config_path = get_server_manager_config_dir(server_root)
- 加载配置后设置 prefix、db_host、db_port、db_user、db_pass、cookie。 - 加载配置后设置 prefix、db_host、db_port、db_user、db_pass、cookie。
- 执行 run_startup_migrations(server_root)。 - 执行 run_startup_migrations(server_root)。
@@ -969,10 +970,10 @@ GUI 图标:
- __init__(project_root=None):无项目时 config_error="未打开项目"。 - __init__(project_root=None):无项目时 config_error="未打开项目"。
- open_project(project_root):调用 ensure_and_get_server_manager_config_dir;成功后设置 tool_dir、tool_config_path、data。 - open_project(project_root):调用 ensure_and_get_server_manager_config_dir;成功后设置 tool_dir、tool_config_path、data。
- _load_tool_config:读 key=value。 - _load_tool_config:读 key=value。
- _save_tool_config:按固定分组写 tool.config。 - _save_tool_config:按固定分组写 user.config。
- _load_default_kv(server_root):读 .server_manager/config/default.kv - _load_default_kv(server_root):读 .server_manager/config/default.config
- _load_configtool.config 为空时从 default.kv 补齐;首次运行自动创建 run_dir;需要保存补齐后的 tool.config。 - _load_configuser.config 为空时从 default.config 补齐;首次运行自动创建 run_dir;需要保存补齐后的 user.config。
- save:写 tool.config 前刷新 ip/game_host 为 get_local_ip()。 - save:写 user.config 前刷新 ip/game_host 为 get_local_ip()。
- get/set 支持嵌套 key。 - get/set 支持嵌套 key。
- has_project/is_config_complete/is_configured/get_missing_config。 - has_project/is_config_complete/is_configured/get_missing_config。
@@ -997,7 +998,7 @@ GUI 图标:
- CreateServerTab:创建服务器。 - CreateServerTab:创建服务器。
- ServerManageTab:本地/远程服务器管理。 - ServerManageTab:本地/远程服务器管理。
- ConfigTab:工具设置。 - ConfigTab:工具设置。
- DefaultKvTabdefault.kv 编辑。 - DefaultKvTabdefault.config 编辑。
- UpdateCheckWorker/UpdateDownloadWorker:自动更新后台工作线程。 - UpdateCheckWorker/UpdateDownloadWorker:自动更新后台工作线程。
- WelcomePage:无项目欢迎页。 - WelcomePage:无项目欢迎页。
- MainWindow:主窗口、菜单、欢迎页/项目页切换、更新检查、项目迁移、状态缓存。 - MainWindow:主窗口、菜单、欢迎页/项目页切换、更新检查、项目迁移、状态缓存。
@@ -1034,7 +1035,7 @@ GUI 图标:
find_rg_executable 查找顺序: find_rg_executable 查找顺序:
- RG_PATH 环境变量。 - RG_PATH 环境变量。
- 打包后 main.exe 同目录的 rg.exe/rg。 - 打包后 ServerManager.exe 同目录的 rg.exe/rg。
- 源码仓库根目录的 rg.exe/rg。 - 源码仓库根目录的 rg.exe/rg。
- PATH 中的 rg。 - PATH 中的 rg。
@@ -1090,7 +1091,7 @@ GUI 图标:
src/updater.py 必须实现: 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_current_version:读取 version 字段,失败返回 0.0.0。
- get_version_info:读取完整 JSON,失败返回 {"version": "0.0.0", "release_notes": ""}。 - get_version_info:读取完整 JSON,失败返回 {"version": "0.0.0", "release_notes": ""}。
- _parse_version/version_less:将版本字符串转 int tuple 比较。 - _parse_version/version_less:将版本字符串转 int tuple 比较。
@@ -1131,11 +1132,11 @@ GUI 图标:
- version.json 设置 version、release_notes。 - version.json 设置 version、release_notes。
- full_installer_url 固定指向 ServerManager_Setup.exe。 - full_installer_url 固定指向 ServerManager_Setup.exe。
- delta_updates 只保留 old_version -> new_version 一项,patch_url 格式为 output/patches/v<old>_to_v<new>.patch。 - 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 必须: 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 时只复制 version.json 到 output。
- 有 old_exe_path 且 bsdiff4 可用时生成 output/patches/v<old>_to_v<new>.patch。 - 有 old_exe_path 且 bsdiff4 可用时生成 output/patches/v<old>_to_v<new>.patch。
- 计算新 exe sha256,填回 version.json 的 delta_updates[old_version].new_exe_sha256。 - 计算新 exe sha256,填回 version.json 的 delta_updates[old_version].new_exe_sha256。
@@ -1548,12 +1549,12 @@ GUI 图标:
- 项目简介:Windows GUI + Linux CLI。 - 项目简介:Windows GUI + Linux CLI。
- 功能特性:控制台、创建服务器、服务器管理、日志查看、工具设置、自动更新。 - 功能特性:控制台、创建服务器、服务器管理、日志查看、工具设置、自动更新。
- 打包与发版维护者流程:先 pack_local_test,再 release/pack_all。 - 打包与发版维护者流程:先 pack_local_test,再 release/pack_all。
- rg.exe 放置说明:根目录与 src 同级;build.bat 会复制到 dist/main - rg.exe 放置说明:根目录与 src 同级;build.bat 会复制到 dist/ServerManager
- 安装说明:Windows 安装包、源码运行 Windows/Linux。 - 安装说明:Windows 安装包、源码运行 Windows/Linux。
- 快速上手:打开项目时选择服务器项目根目录,不要选 run/config/.server_manager/config。 - 快速上手:打开项目时选择服务器项目根目录,不要选 run/config/.server_manager/config。
- 首次打开项目会复制模板到 .server_manager/config。 - 首次打开项目会复制模板到 .server_manager/config。
- 重点路径:服务器根目录、运行目录、Erlang 路径。 - 重点路径:服务器根目录、运行目录、Erlang 路径。
- CLI 用法表:main.exe/main 无参数 GUI,带参数 CLI。 - CLI 用法表:ServerManager.exe/ServerManager 无参数 GUI,带参数 CLI。
- 命令行参数完整说明。 - 命令行参数完整说明。
- version.json 字段说明、download/update URL 规则。 - version.json 字段说明、download/update URL 规则。
- 配置说明:项目级配置、单服配置、模板来源。 - 配置说明:项目级配置、单服配置、模板来源。
@@ -1567,7 +1568,7 @@ GUI 图标:
- 推荐流程:本地测试包 -> 本地安装验证 -> 正式发版。 - 推荐流程:本地测试包 -> 本地安装验证 -> 正式发版。
- pack_local_test 不改版本、不覆盖 output。 - pack_local_test 不改版本、不覆盖 output。
- release.bat 顺序:PyInstaller -> 改版本 -> Inno -> post-build。 - release.bat 顺序:PyInstaller -> 改版本 -> Inno -> post-build。
- 本地不保存 main_*.exe;若要增量,需手动提供上一版 main.exe。 - 本地不保存 ServerManager_*.exe;若要增量,需手动提供上一版 ServerManager.exe。
- 手动发版命令: - 手动发版命令:
- cd src - cd src
- build.bat - build.bat
@@ -1869,7 +1870,7 @@ GUI 图标:
2. 选择是则 shutil.rmtree(_build)。 2. 选择是则 shutil.rmtree(_build)。
3. 执行 `<rebar3> as game_server_dev compile`,输出到控制台。 3. 执行 `<rebar3> as game_server_dev compile`,输出到控制台。
- emit config_saved。 - emit config_saved。
- 提示“项目配置已保存到当前项目的 .server_manager/config/tool.config”。 - 提示“项目配置已保存到当前项目的 .server_manager/config/user.config”。
31. CLI argparse 精确结构细则 31. CLI argparse 精确结构细则
@@ -1942,7 +1943,7 @@ GUI 图标:
Windows `src/build.bat` PyInstaller 主命令必须等价于: 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` - `--icon icon\icon.ico`,没有 icon.ico 时用 `--icon NONE`
- `--add-data "..\version.json;."` - `--add-data "..\version.json;."`
- `--add-data "config.json;."` - `--add-data "config.json;."`
@@ -1967,11 +1968,11 @@ GUI 图标:
Windows build 后处理: Windows build 后处理:
- 如果 `..\rg.exe` 存在,复制到 `dist\main\rg.exe`。 - 如果 `..\rg.exe` 存在,复制到 `dist\ServerManager\rg.exe`。
- 如果 `..\output\config` 存在,复制到 `dist\main\config\`。 - 如果 `..\output\config` 存在,复制到 `dist\ServerManager\config\`。
- 生成 mini_updater - 生成 mini_updater
`pyinstaller --noconfirm --onefile --windowed --name mini_updater --distpath dist --workpath build_mini --specpath build_mini ..\mini_updater.py` `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。 - 清理 build、build_mini、spec。
Linux `build_linux.sh` Linux `build_linux.sh`
@@ -1983,8 +1984,8 @@ GUI 图标:
- `server_commands.py:.` - `server_commands.py:.`
- `server_creator.py:.` - `server_creator.py:.`
- `icon:icon` - `icon:icon`
- 输出 `dist/main/main`。 - 输出 `dist/ServerManager/ServerManager`。
- 复制 `../output/config/.` 到 `dist/main/config/`。 - 复制 `../output/config/.` 到 `dist/ServerManager/config/`。
Inno Setup `build_installer.iss` Inno Setup `build_installer.iss`
@@ -1998,9 +1999,9 @@ GUI 图标:
- OutputDir output - OutputDir output
- OutputBaseFilename ServerManager_Setup - OutputBaseFilename ServerManager_Setup
- MyAppVersion "1.0.46"。 - MyAppVersion "1.0.46"。
- MyAppExeName "main.exe"。 - MyAppExeName "ServerManager.exe"。
- Files: - 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 "src\dist\mini_updater.exe" DestDir "{app}"
- Source "version.json" DestDir "{app}" - Source "version.json" DestDir "{app}"
- Source "output\config\*" DestDir "{app}\config" recursesubdirs createallsubdirs - Source "output\config\*" DestDir "{app}\config" recursesubdirs createallsubdirs
@@ -2013,12 +2014,12 @@ GUI 图标:
- 可选桌面快捷方式 - 可选桌面快捷方式
- 可选快速启动栏 - 可选快速启动栏
- Run: - Run:
- 安装完成后 nowait postinstall 启动 `{app}\main.exe` - 安装完成后 nowait postinstall 启动 `{app}\ServerManager.exe`
- UninstallDelete: - UninstallDelete:
- 删除 `{app}` - 删除 `{app}`
- 删除 `{localappdata}\{#MyAppName}` - 删除 `{localappdata}\{#MyAppName}`
33. default.kv 与 sys 模板生成细则 33. default.config 与 sys 模板生成细则
如果无法还原真实 Erlang sys_*.config.example 的完整内容,也必须生成结构化模板,满足以下要求: 如果无法还原真实 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 -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 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 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())"` - `python -c "from rg_search import find_rg_executable; print(find_rg_executable())"`
@@ -2119,7 +2120,7 @@ GUI 图标:
- 无项目启动欢迎页。 - 无项目启动欢迎页。
- 打开一个临时服务器项目目录后生成 `.server_manager/config`。 - 打开一个临时服务器项目目录后生成 `.server_manager/config`。
- 工具设置保存后存在 `.server_manager/config/tool.config`。 - 工具设置保存后存在 `.server_manager/config/user.config`。
- 创建服务器后存在 `run/<server>/config/kv.config` 与 `sys.config`。 - 创建服务器后存在 `run/<server>/config/kv.config` 与 `sys.config`。
- 日志页在无日志文件时给出“文件不存在”而不是崩溃。 - 日志页在无日志文件时给出“文件不存在”而不是崩溃。
@@ -2245,11 +2246,11 @@ GUI 图标:
36. DefaultKvTab 细则 36. DefaultKvTab 细则
DefaultKvTab 用于编辑当前项目 `.server_manager/config/default.kv`。 DefaultKvTab 用于编辑当前项目 `.server_manager/config/default.config`。
UI UI
- 标题:`默认配置管理 (.server_manager/config/default.kv)`,颜色 #25c88916px,加粗。 - 标题:`默认配置管理 (.server_manager/config/default.config)`,颜色 #25c88916px,加粗。
- 描述:`此文件定义创建服务器时的默认值,支持 ${key} 占位符替换`。 - 描述:`此文件定义创建服务器时的默认值,支持 ${key} 占位符替换`。
- 横向 QSplitter - 横向 QSplitter
- 左侧:分类列表,最大宽 200;底部 `+ 新增配置项`。 - 左侧:分类列表,最大宽 200;底部 `+ 新增配置项`。
@@ -2261,7 +2262,7 @@ GUI 图标:
load_default_kv load_default_kv
- server_root 为空时控制台输出 `[警告] 请先配置服务器根目录`。 - 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 初始为“其他”。 - current_category 初始为“其他”。
@@ -2308,7 +2309,7 @@ GUI 图标:
- 成功输出 `[成功] 配置已保存: <path>` 并弹窗。 - 成功输出 `[成功] 配置已保存: <path>` 并弹窗。
- 失败输出错误并 QMessageBox.critical。 - 失败输出错误并 QMessageBox.critical。
注意:此页保存会重写 default.kv,因此注释和空行不会完全保留;复刻时应保持这个行为。 注意:此页保存会重写 default.config,因此注释和空行不会完全保留;复刻时应保持这个行为。
37. CreateServerTab 动态字段与校验细则 37. CreateServerTab 动态字段与校验细则
@@ -2333,7 +2334,7 @@ GUI 图标:
- 数据库配置框内提供 db_game_name、db_log_name 输入框;game/login/center/cross 显示,client 隐藏。 - 数据库配置框内提供 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 或类型变化时同步默认值,但创建前可手动修改。 - db_game_name 默认 `{prefix}_{type}_s{id}`db_log_name 默认 `{prefix}_{type}_log_s{id}`ID 或类型变化时同步默认值,但创建前可手动修改。
- auto_reload 默认 default_kv.auto_reload == "true"。 - auto_reload 默认 default_kv.auto_reload == "true"。
- 登录/中心节点优先 tool.config,其次 default.kv,去掉首尾单引号。 - 登录/中心节点优先 user.config,其次 default.config,去掉首尾单引号。
`_on_server_id_changed(value)` `_on_server_id_changed(value)`
@@ -2399,8 +2400,8 @@ GUI 图标:
- 切回 SCRIPT_DIR。 - 切回 SCRIPT_DIR。
- 查找打包主程序优先级: - 查找打包主程序优先级:
1. 环境变量 SERVER_MANAGER_MAIN 且文件存在。 1. 环境变量 SERVER_MANAGER_MAIN 且文件存在。
2. `%SCRIPT_DIR%\main.exe` 2. `%SCRIPT_DIR%\ServerManager.exe`
3. `%SCRIPT_DIR%\dist\main\main.exe` 3. `%SCRIPT_DIR%\dist\ServerManager\ServerManager.exe`
- 无参数: - 无参数:
- 打包程序存在:`"%SM_MAIN%" --root "%SERVER_ROOT%" interactive` - 打包程序存在:`"%SM_MAIN%" --root "%SERVER_ROOT%" interactive`
- 否则:`python server_manager_cli.py --root "%SERVER_ROOT%" interactive` - 否则:`python server_manager_cli.py --root "%SERVER_ROOT%" interactive`
@@ -2421,7 +2422,7 @@ GUI 图标:
- SM_MAIN 优先: - SM_MAIN 优先:
1. SERVER_MANAGER_MAIN 1. SERVER_MANAGER_MAIN
2. SCRIPT_DIR/main 且可执行 2. SCRIPT_DIR/main 且可执行
3. SCRIPT_DIR/dist/main/main 且可执行 3. SCRIPT_DIR/dist/ServerManager/ServerManager 且可执行
- check_python 支持 python3 或 python,要求 Python 3.6+。 - check_python 支持 python3 或 python,要求 Python 3.6+。
- gui 模式检查 PyQt6,缺失时 pip3 install PyQt6 或 pip install PyQt6。 - gui 模式检查 PyQt6,缺失时 pip3 install PyQt6 或 pip install PyQt6。
- 无参数进入 interactive。 - 无参数进入 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_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_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>`,用于编辑对话框的可添加配置项。 - `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。 - 调用 `generate_start_config(server_root, server_dir)` 生成完整 sys.config。
- 成功返回 `(True, '服务器 xxx 创建成功', merged_config)`;异常返回 `(False, '创建服务器失败: xxx', None)`。 - 成功返回 `(True, '服务器 xxx 创建成功', merged_config)`;异常返回 `(False, '创建服务器失败: xxx', None)`。
43. Config 类 tool.config 读写与首次打开项目细则 43. Config 类 user.config 读写与首次打开项目细则
`server_manager_gui.py` 中 `Config` 类负责“当前项目配置”,不能把项目配置混入应用级 app_config。 `server_manager_gui.py` 中 `Config` 类负责“当前项目配置”,不能把项目配置混入应用级 app_config。
@@ -2645,11 +2646,11 @@ GUI 图标:
- `project_root = Path(project_root).resolve()`。 - `project_root = Path(project_root).resolve()`。
- 调用 `ensure_and_get_server_manager_config_dir(project_root)`,该函数负责创建/同步 `.server_manager/config` 和受管模板。 - 调用 `ensure_and_get_server_manager_config_dir(project_root)`,该函数负责创建/同步 `.server_manager/config` 和受管模板。
- 失败时保存 `config_error` 并返回 False。 - 失败时保存 `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()` `_load_tool_config()`
- tool.config 不存在返回空 dict。 - user.config 不存在返回空 dict。
- 逐行读取 UTF-8strip 空白,跳过空行和 `#` 注释。 - 逐行读取 UTF-8strip 空白,跳过空行和 `#` 注释。
- 只解析第一处 `=`,左右 strip 后存入 dict。 - 只解析第一处 `=`,左右 strip 后存入 dict。
- 出错返回空 dict。 - 出错返回空 dict。
@@ -2667,14 +2668,14 @@ GUI 图标:
`_load_config()` 的合并规则: `_load_config()` 的合并规则:
- `tool_cfg = _load_tool_config()``is_first_run = not tool_config_path.exists()`。 - `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` 为空时默认 `server_root/run`。
- 首次运行且 run_dir 不存在时尝试创建运行目录,失败只忽略,不阻止 GUI 打开。 - 首次运行且 run_dir 不存在时尝试创建运行目录,失败只忽略,不阻止 GUI 打开。
- 读取 `get_server_manager_config_dir(server_root) / 'default.kv'` 作为默认值来源。 - 读取 `get_server_manager_config_dir(server_root) / 'default.config'` 作为默认值来源。
- 辅助 `get_value(tool_key, default_key, fallback)`:优先 tool.config;为空时读 default.kv;如果 default.kv 值是首尾单引号包裹,要去掉单引号;最后仍为空则 fallback。 - 辅助 `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。 - database.port、login_db.server_id、login_db.port 要转 int,空值分别回退 3306、900、0。
- 每次加载都用 `get_local_ip()` 写入 server.ip 和 server.game_host。 - 每次加载都用 `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()` `save()`
@@ -2795,7 +2796,7 @@ GUI 图标:
- `len(sys.argv) > 1` 时调用 `_run_cli_from_argv()`,由 `server_manager_cli.main()` 处理 list/create/start/stop 等命令。 - `len(sys.argv) > 1` 时调用 `_run_cli_from_argv()`,由 `server_manager_cli.main()` 处理 list/create/start/stop 等命令。
- 无参数时调用 GUI `main()`。 - 无参数时调用 GUI `main()`。
- 这样打包后的 `main.exe list` 与 `python server_manager_cli.py list` 行为一致。 - 这样打包后的 `ServerManager.exe list` 与 `python server_manager_cli.py list` 行为一致。
GUI `main()` 规则: GUI `main()` 规则:
@@ -2891,13 +2892,13 @@ GUI 图标:
在基础验收之外,复刻项目还必须满足以下实现级检查: 在基础验收之外,复刻项目还必须满足以下实现级检查:
- 无项目启动 GUI 时,不访问空的 `tool_config_path`,欢迎页可正常显示最近项目。 - 无项目启动 GUI 时,不访问空的 `tool_config_path`,欢迎页可正常显示最近项目。
- 第一次打开项目时,如果 `.server_manager/config/tool.config` 不存在,会创建 run 目录并把 default.kv 中的数据库、prefix、cookie 等默认值写入 tool.config。 - 第一次打开项目时,如果 `.server_manager/config/user.config` 不存在,会创建 run 目录并把 default.config 中的数据库、prefix、cookie 等默认值写入 user.config。
- 再次打开同一项目时,不覆盖已有 tool.config 中用户手动设置的值。 - 再次打开同一项目时,不覆盖已有 user.config 中用户手动设置的值。
- 最近项目写入的是应用级 JSON,不写到项目目录;删除或无效项目不会出现在欢迎页最近列表。 - 最近项目写入的是应用级 JSON,不写到项目目录;删除或无效项目不会出现在欢迎页最近列表。
- CLI `start --background` 会打开新窗口,CLI `start` 默认在当前窗口前台运行。 - CLI `start --background` 会打开新窗口,CLI `start` 默认在当前窗口前台运行。
- GUI 和 CLI 生成的 `run/<server>/config/kv.config` 字段一致,且随后都能生成 sys.config。 - GUI 和 CLI 生成的 `run/<server>/config/kv.config` 字段一致,且随后都能生成 sys.config。
- 停止服务器命令使用参数列表执行,不能依赖 shell 拼接引号。 - 停止服务器命令使用参数列表执行,不能依赖 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,并自动打开最近的有效项目;没有最近项目则停留欢迎页。 - `python src/server_manager_gui.py` 无参数进入 GUI,并自动打开最近的有效项目;没有最近项目则停留欢迎页。
- 切换项目时,旧 tab、旧 console、旧 worker 引用不会继续显示在新项目界面。 - 切换项目时,旧 tab、旧 console、旧 worker 引用不会继续显示在新项目界面。
- frozen 模式下退出不会弹 PyInstaller 临时目录删除失败的系统错误框。 - frozen 模式下退出不会弹 PyInstaller 临时目录删除失败的系统错误框。
@@ -2919,15 +2920,15 @@ GUI 图标:
十四、验收标准 十四、验收标准
1. `python src/server_manager_gui.py` 无参数能打开 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` 显示完整命令。 3. `python src/server_manager_cli.py --help` 显示完整命令。
4. 打开一个服务器项目后能生成/同步 `<项目根>/.server_manager/config`。 4. 打开一个服务器项目后能生成/同步 `<项目根>/.server_manager/config`。
5. 工具设置保存后能写入 tool.config。 5. 工具设置保存后能写入 user.config。
6. 创建服务器能生成 run/<server>/config/kv.config 与 sys.config。 6. 创建服务器能生成 run/<server>/config/kv.config 与 sys.config。
7. rebar3 启动命令、快速 erl 启动命令、stop/remsh 命令格式正确。 7. rebar3 启动命令、快速 erl 启动命令、stop/remsh 命令格式正确。
8. 服务器管理能扫描本地 run 目录并显示分类列表。 8. 服务器管理能扫描本地 run 目录并显示分类列表。
9. 日志页能加载普通日志和归档日志;rg 存在时能全局搜索。 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。 11. Inno Setup 能生成 output/ServerManager_Setup.exe。
12. version.json、release.py、mini_updater.py 支持全量/增量更新流程。 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\`)。 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` 发版。 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 下推荐直接使用图形界面,第一次使用按下面流程即可: Windows 下推荐直接使用图形界面,第一次使用按下面流程即可:
完整使用教程见 [docs/USER_GUIDE.md](docs/USER_GUIDE.md)。
1. 启动工具: 1. 启动工具:
- 安装版:从开始菜单、桌面快捷方式,或安装目录下的 `main.exe` 启动。 - 安装版:从开始菜单、桌面快捷方式,或安装目录下的 `ServerManager.exe` 启动。
- 源码版:在项目根目录执行 `scripts\run\run.bat gui`,或设置 `PYTHONPATH=src` 后执行 `python -m entrypoints`。 - 源码版:在项目根目录执行 `scripts\run\run.bat gui`,或设置 `PYTHONPATH=src` 后执行 `python -m entrypoints`。
2. 打开项目: 2. 打开项目:
- 在欢迎页点击“打开项目”,或在菜单 `文件 -> 打开项目` 中选择目录。 - 在欢迎页点击“打开项目”,或在菜单 `文件 -> 打开项目` 中选择目录。
@@ -136,17 +138,17 @@ pip install PyQt6
### 打包后主程序直接命令行(Windows / Linux,推荐) ### 打包后主程序直接命令行(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 | | 场景 | Windows | Linux |
|------|---------|--------| |------|---------|--------|
| 图形界面 | `main.exe` | `./main` | | 图形界面 | `ServerManager.exe` | `./ServerManager` |
| 命令行 | `main.exe list` | `./main list` | | 命令行 | `ServerManager.exe list` | `./ServerManager list` |
| 帮助 | `main.exe --help` | `./main --help` | | 帮助 | `ServerManager.exe --help` | `./ServerManager --help` |
- **Windows**:安装目录下的 `main.exe`,或执行 `scripts\package\build.bat` 后使用 `src\dist\main\main.exe` - **Windows**:安装目录下的 `ServerManager.exe`,或执行 `scripts\package\build.bat` 后使用 `src\dist\ServerManager\ServerManager.exe`
- **Linux**:执行 `scripts/package/build_linux.sh` 后使用 `src/dist/main/main`;可将该路径加入 `PATH`,或设置环境变量 **`SERVER_MANAGER_MAIN`** 指向该可执行文件。 - **Linux**:执行 `scripts/package/build_linux.sh` 后使用 `src/dist/ServerManager/ServerManager`;可将该路径加入 `PATH`,或设置环境变量 **`SERVER_MANAGER_MAIN`** 指向该可执行文件。
- **`scripts\run\run.bat` / `scripts/run/run.sh`**:若检测到同目录或 `src/dist/main/` 下的打包主程序,会自动用它执行(并附带脚本中的 `--root` 服务器根目录),与上面「直接调用」等价、只是多了根目录约定。 - **`scripts\run\run.bat` / `scripts/run/run.sh`**:若检测到同目录或 `src/dist/ServerManager/` 下的打包主程序,会自动用它执行(并附带脚本中的 `--root` 服务器根目录),与上面「直接调用」等价、只是多了根目录约定。
### Linux 命令行(推荐) ### Linux 命令行(推荐)
@@ -259,7 +261,7 @@ python -m entrypoints.cli --root E:\game\server_root mcp
**Windows** **Windows**
```batch ```batch
scripts\package\build.bat # 标准打包,产出 src\dist\main\main.exe scripts\package\build.bat # 标准打包,产出 src\dist\ServerManager\ServerManager.exe
scripts\package\build_portable.bat # 便携版打包 scripts\package\build_portable.bat # 便携版打包
``` ```
@@ -267,7 +269,7 @@ scripts\package\build_portable.bat # 便携版打包
```bash ```bash
chmod +x scripts/package/build_linux.sh 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` - 正式发布统一使用 `server_manager/build/output/version.json``server_manager/build/output/ServerManager_Setup.exe`
- 本次目录结构和文件名变化较大,必须走完整安装包更新;不要给旧版本生成只替换 `main.exe` 的增量补丁,否则旧安装目录里的依赖文件不会一起更新。 - 本次目录结构和文件名变化较大,必须走完整安装包更新;不要给旧版本生成只替换 `ServerManager.exe` 的增量补丁,否则旧安装目录里的依赖文件不会一起更新。
- `scripts\package\release.bat` 默认生成全量更新 manifest,产物只输出到 `build/output` - `scripts\package\release.bat` 默认生成全量更新 manifest,产物只输出到 `build/output`
## 配置说明 ## 配置说明
- **打开项目时选择的目录**:应当是服务器项目根目录,不是 `run``config``.server_manager\config` 子目录。 - **打开项目时选择的目录**:应当是服务器项目根目录,不是 `run``config``.server_manager\config` 子目录。
- **项目级工具配置目录**`<项目根目录>\.server_manager\config\` - **项目级工具配置目录**`<项目根目录>\.server_manager\config\`
- `default.kv`:默认配置模板,首次打开项目时自动复制到这里 - `default.config`:默认配置模板,首次打开项目时自动复制到这里
- `tool.config`:工具保存的路径、数据库、节点、Erlang 等配置 - `user.config`:工具保存的路径、数据库、节点、Erlang 等用户覆盖配置
- `<项目根>\config\project.config`:项目共享配置,优先级低于 `user.config`、高于 `default.config`
- **运行目录**:通常为 `<项目根目录>\run`,里面是各个服务器实例目录。 - **运行目录**:通常为 `<项目根目录>\run`,里面是各个服务器实例目录。
- **单服配置文件**:通常位于 `<运行目录>\<服务器目录>\config\kv.config` - **单服配置文件**:通常位于 `<运行目录>\<服务器目录>\config\kv.config`
- **模板来源** - **模板来源**
- 安装版运行时:读取 `main.exe` 同级的 `config\` - 安装版运行时:读取 `ServerManager.exe` 同级的 `config\`
- 源码运行时:读取仓库根目录的 `resources\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`
日志检索、匹配高亮与「文件搜索」优先使用 **ripgrep**。打包前请将 **`rg.exe`** 放在 `resources\bin\rg.exe`(可从 [ripgrep Releases](https://github.com/BurntSushi/ripgrep/releases) 下载 Windows 预编译包)。 日志检索、匹配高亮与「文件搜索」优先使用 **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`)。 或双击 `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`,再在打包完成后手动执行: **增量说明**默认仍可全量发版。需要减少带宽时使用文件级增量 zip,它会比较上一版 `dist\ServerManager` 快照和新版 dist,只打包新增/变化文件,并保留完整安装包作为回退。
`python scripts/package/release.py --post-build --old-exe <上一版 main.exe 的路径>`
(上一版 exe 可来自本机备份、CI 构建产物等,由你自行保留。)
--- ---
@@ -52,25 +50,28 @@ scripts\package\release.bat 1.0.4
### 1. 修改版本号(三处) ### 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"` 改为新版本。 - **build_installer.iss**`#define MyAppVersion "x.y.z"` 改为新版本。
- **src\entrypoints\tk_update_demo.py** / **scripts\update\tk_updater.py**`LOCAL_VERSION = "x.y.z"` 改为新版本。 - **src\entrypoints\tk_update_demo.py** / **scripts\update\tk_updater.py**`LOCAL_VERSION = "x.y.z"` 改为新版本。
- **version.json** - **version.json**
- `version`:新版本号。 - `version`:新版本号。
- `release_notes`:本次更新说明。 - `release_notes`:本次更新说明。
- `download_url` / `full_installer_url`:固定为 `.../ServerManager_Setup.exe`(安装包不带版本号,每次打包覆盖即可) - `download_url` / `full_installer_url`:固定为 `https://www.sumi.work/api/software/server-manager/update-download`
- **delta_updates**结构调整版本保持 `{}`,确保客户端走全量安装包。 - **delta_updates**不使用增量时保持 `{}`;使用文件级增量时,生成「上一版本 → 当前版本」一项,例如从 1.0.3 升到 1.0.4
- **仅当结构未变且确认只替换 main.exe 足够时**,才使用 `--with-delta` 生成「上一版本 → 当前版本」一项,例如从 1.0.3 升到 1.0.4 时:
```json ```json
"delta_updates": { "delta_updates": {
"1.0.3": { "1.0.3": {
"patch_url": "http://.../path=server_manager/build/output/patches/v1.0.3_to_v1.0.4.patch", "type": "file-zip",
"new_exe_sha256": "" "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 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` - manifest`server_manager/build/output/version.json`
- 完整安装包:`server_manager/build/output/ServerManager_Setup.exe` - 完整安装包:`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**:打包完成后执行 2. **生成增量 manifest 项**:在 PyInstaller 成功后、Inno Setup 前执行:
`python scripts/package/release.py --post-build --old-exe <上一版 main.exe 路径>`
或手动执行:
```python ```bat
import bsdiff4 python scripts/package/release.py 1.0.4 --with-delta --notes "说明"
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 项
``` ```
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/ServerManager_Setup.exe`
- `build/output/version.json` - `build/output/version.json`
- `build/output/patches/*.patch`(若使用增量更新) - `build/output/patches/*.smdelta.zip`(若使用增量更新)
用户通过 `update_url` 拉取 `version.json`,客户端更新服务会后台静默准备增量或全量,就绪后弹「重启以更新」。 用户通过 `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 config
{center_server, [ {game_server, [
{server_type, center_server}, {server_type, center_server},
{server_id, ${server_id}}, {server_id, ${server_id}},
{login_node, '${login_node}'}, {login_node, '${login_node}'},
@@ -27,7 +27,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 2000, max_size => 2000,
@@ -52,7 +52,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 10000, max_size => 10000,
@@ -144,7 +144,7 @@
{logger_level, ${logger_level}}, {logger_level, ${logger_level}},
{logger, [ {logger, [
{filters, stop, [ {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]}}} {http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]}, ]},
{handler, default, logger_std_h, #{ {handler, default, logger_std_h, #{
+3 -3
View File
@@ -27,7 +27,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 2000, max_size => 2000,
@@ -52,7 +52,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 10000, max_size => 10000,
@@ -71,7 +71,7 @@
{logger_level, ${logger_level}}, {logger_level, ${logger_level}},
{logger, [ {logger, [
{filters, stop, [ {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, #{ {handler, default, logger_std_h, #{
level => ${logger_level}, level => ${logger_level},
+4 -4
View File
@@ -1,6 +1,6 @@
[ [
%% Cross server config %% Cross server config
{cross_server, [ {game_server, [
{server_type, cross_server}, {server_type, cross_server},
{server_id, ${server_id}}, {server_id, ${server_id}},
{center_node, '${center_node}'}, {center_node, '${center_node}'},
@@ -27,7 +27,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 2000, max_size => 2000,
@@ -52,7 +52,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 10000, max_size => 10000,
@@ -144,7 +144,7 @@
{logger_level, ${logger_level}}, {logger_level, ${logger_level}},
{logger, [ {logger, [
{filters, stop, [ {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]}}} {http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]}, ]},
{handler, default, logger_std_h, #{ {handler, default, logger_std_h, #{
+3 -3
View File
@@ -40,7 +40,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 2000, max_size => 2000,
@@ -65,7 +65,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 10000, max_size => 10000,
@@ -157,7 +157,7 @@
{logger_level, ${logger_level}}, {logger_level, ${logger_level}},
{logger, [ {logger, [
{filters, stop, [ {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]}}} {http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]}, ]},
{handler, default, logger_std_h, #{ {handler, default, logger_std_h, #{
+3 -3
View File
@@ -21,7 +21,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 2000, max_size => 2000,
@@ -46,7 +46,7 @@
}, },
filter_default => stop, filter_default => stop,
filters => [ filters => [
{domain, {fun logger_filters:domain/2, {log, equal, [ms_base]}}} {no_domain, {fun logger_filters:domain/2, {log, undefined, []}}}
], ],
formatter => {logger_formatter, #{ formatter => {logger_formatter, #{
max_size => 10000, max_size => 10000,
@@ -118,7 +118,7 @@
{logger_level, ${logger_level}}, {logger_level, ${logger_level}},
{logger, [ {logger, [
{filters, stop, [ {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]}}} {http_domain, {fun logger_filters:domain/2, {log, equal, [ms_http]}}}
]}, ]},
{handler, default, logger_std_h, #{ {handler, default, logger_std_h, #{
+7 -6
View File
@@ -1,8 +1,9 @@
{ {
"version": "1.1.3", "version": "1.1.8",
"update_url": "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json", "update_url": "https://www.sumi.work/api/software/server-manager/version.json",
"release_notes": "增加启停按钮", "release_notes": "支持文件级增量更新包,减少后续版本更新带宽。",
"download_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe", "download_url": "https://www.sumi.work/api/software/server-manager/update-download",
"full_installer_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe", "full_installer_url": "https://www.sumi.work/api/software/server-manager/update-download",
"delta_updates": {} "delta_updates": {},
"deltaUpdates": {}
} }
+15 -14
View File
@@ -7,6 +7,7 @@ title Server Manager - build
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI" for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
set "SRC=%ROOT%\src" set "SRC=%ROOT%\src"
set "RESOURCE_CONFIG=%ROOT%\resources\config" set "RESOURCE_CONFIG=%ROOT%\resources\config"
set "APP_NAME=ServerManager"
pushd "%SRC%" || ( pushd "%SRC%" || (
echo [ERROR] src directory not found: %SRC% echo [ERROR] src directory not found: %SRC%
exit /b 1 exit /b 1
@@ -55,32 +56,32 @@ echo [3/6] clean dist\build...
if exist "dist" rd /s /q dist if exist "dist" rd /s /q dist
if exist "build" rd /s /q build if exist "build" rd /s /q build
if exist "*.spec" del /q *.spec 2>nul if exist "*.spec" del /q *.spec 2>nul
REM Pre-create work dir for PyInstaller warn-main.txt on some Windows setups REM Pre-create work dir for PyInstaller warn file on some Windows setups
if not exist "build\main" mkdir "build\main" 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) 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 ( if errorlevel 1 (
echo [ERROR] main PyInstaller failed echo [ERROR] %APP_NAME% PyInstaller failed
pause pause
exit /b 1 exit /b 1
) )
echo [4b/6] copy rg.exe... echo [4b/6] copy rg.exe...
if exist "%ROOT%\resources\bin\rg.exe" ( if exist "%ROOT%\resources\bin\rg.exe" (
copy /y "%ROOT%\resources\bin\rg.exe" "dist\main\rg.exe" >nul copy /y "%ROOT%\resources\bin\rg.exe" "dist\%APP_NAME%\rg.exe" >nul
echo copied resources\bin\rg.exe to dist\main\rg.exe echo copied resources\bin\rg.exe to dist\%APP_NAME%\rg.exe
) else ( ) else (
echo [WARN] resources\bin\rg.exe missing, search will not use ripgrep 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 exist "%RESOURCE_CONFIG%" (
if not exist "dist\main\config" mkdir "dist\main\config" if not exist "dist\%APP_NAME%\config" mkdir "dist\%APP_NAME%\config"
xcopy "%RESOURCE_CONFIG%\*" "dist\main\config\" /E /I /Y /Q >nul xcopy "%RESOURCE_CONFIG%\*" "dist\%APP_NAME%\config\" /E /I /Y /Q >nul
echo copied ..\resources\config to dist\main\config echo copied ..\resources\config to dist\%APP_NAME%\config
) else ( ) else (
echo [WARN] ..\resources\config missing, Inno install will fail if resources\config is absent at compile time 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. echo [6/6] done.
if not exist "dist\main\config" mkdir "dist\main\config" if not exist "dist\%APP_NAME%\config" mkdir "dist\%APP_NAME%\config"
if exist "config\tool.config" copy /y "config\tool.config" "dist\main\config\tool.config" >nul 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 2>nul
rd /s /q build_mini 2>nul rd /s /q build_mini 2>nul
@@ -106,7 +107,7 @@ del /q build_mini\*.spec 2>nul
echo. echo.
echo ============================================================ echo ============================================================
echo OK: dist\main\ dist\mini_updater.exe echo OK: dist\%APP_NAME%\ dist\mini_updater.exe
echo ============================================================ echo ============================================================
echo. echo.
+6 -6
View File
@@ -17,8 +17,8 @@
#define MyOutputBase "ServerManager_Setup" #define MyOutputBase "ServerManager_Setup"
#endif #endif
#define MyAppVersion "1.1.3" #define MyAppVersion "1.1.8"
#define MyAppExeName "main.exe" #define MyAppExeName "ServerManager.exe"
#define MyAppPublisher "ServerManager" #define MyAppPublisher "ServerManager"
#define MyAppURL "http://your-server.com/" #define MyAppURL "http://your-server.com/"
@@ -62,13 +62,13 @@ Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription:
Name: "quicklaunchicon"; Description: "创建快速启动栏快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked Name: "quicklaunchicon"; Description: "创建快速启动栏快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
[Files] [Files]
; 主程序目录模式(src\dist\main\ 含 main.exe 与 _internal 等依赖,无 _MEI 解压,避免 DLL 加载失败) ; 主程序目录模式(src\dist\ServerManager\ 含 ServerManager.exe 与 _internal 等依赖,无 _MEI 解压,避免 DLL 加载失败)
Source: "..\..\src\dist\main\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "..\..\src\dist\ServerManager\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 独立进程外更新器:与 main.exe 同目录 ; 独立进程外更新器:与 ServerManager.exe 同目录
Source: "..\..\src\dist\mini_updater.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\..\src\dist\mini_updater.exe"; DestDir: "{app}"; Flags: ignoreversion
; 版本与更新配置 ; 版本与更新配置
Source: "..\..\resources\version.json"; 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 Source: "..\..\resources\config\*"; DestDir: "{app}\config"; Flags: ignoreversion recursesubdirs createallsubdirs
[Dirs] [Dirs]
+12 -12
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/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 # 用法: 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 set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -34,9 +34,9 @@ fi
echo "[3/5] clean dist/build..." echo "[3/5] clean dist/build..."
rm -rf dist build ./*.spec 2>/dev/null || true rm -rf dist build ./*.spec 2>/dev/null || true
echo "[4/5] PyInstaller main (onedir)..." echo "[4/5] PyInstaller ServerManager (onedir)..."
# add-data 在 Linux 下用冒号分隔 # add-data 在 Linux 下用冒号分隔
python3 -m PyInstaller --noconfirm --onedir --name main \ python3 -m PyInstaller --noconfirm --onedir --name ServerManager \
"${ICON_ARG[@]}" \ "${ICON_ARG[@]}" \
--add-data "$ROOT/resources/version.json:." \ --add-data "$ROOT/resources/version.json:." \
--add-data "$ROOT/resources/config.json:." \ --add-data "$ROOT/resources/config.json:." \
@@ -61,13 +61,13 @@ python3 -m PyInstaller --noconfirm --onedir --name main \
entrypoints/main.py entrypoints/main.py
echo "[5/5] done." echo "[5/5] done."
mkdir -p dist/main/config mkdir -p dist/ServerManager/config
if [[ -f config/tool.config ]]; then if [[ -f config/user.config ]]; then
cp -f config/tool.config dist/main/config/tool.config || true cp -f config/user.config dist/ServerManager/config/user.config || true
fi fi
if [[ -d "$RESOURCE_CONFIG" ]]; then if [[ -d "$RESOURCE_CONFIG" ]]; then
cp -a "$RESOURCE_CONFIG/." dist/main/config/ cp -a "$RESOURCE_CONFIG/." dist/ServerManager/config/
echo " copied resources/config -> dist/main/config" echo " copied resources/config -> dist/ServerManager/config"
else else
echo " [WARN] resources/config missing" echo " [WARN] resources/config missing"
fi fi
@@ -77,9 +77,9 @@ rm -f ./*.spec 2>/dev/null || true
echo echo
echo "============================================================" echo "============================================================"
echo "OK: dist/main/main" echo "OK: dist/ServerManager/ServerManager"
echo "CLI: dist/main/main list" echo "CLI: dist/ServerManager/ServerManager list"
echo "GUI: dist/main/main" echo "GUI: dist/ServerManager/ServerManager"
echo "或设置 SERVER_MANAGER_MAIN 指向该文件,配合 scripts/run/run.sh 使用" echo "或设置 SERVER_MANAGER_MAIN 指向该文件,配合 scripts/run/run.sh 使用"
echo "============================================================" echo "============================================================"
echo echo
+8 -7
View File
@@ -3,6 +3,7 @@ chcp 65001 >nul
title Server Manager - 打包便携版 title Server Manager - 打包便携版
for %%I in ("%~dp0..\..") do set "ROOT=%%~fI" for %%I in ("%~dp0..\..") do set "ROOT=%%~fI"
set "SRC=%ROOT%\src" set "SRC=%ROOT%\src"
set "APP_NAME=ServerManager"
pushd "%SRC%" || ( pushd "%SRC%" || (
echo [错误] 未找到 src 目录: %SRC% echo [错误] 未找到 src 目录: %SRC%
exit /b 1 exit /b 1
@@ -42,11 +43,11 @@ if not exist "%ROOT%\resources\icon\icon.ico" (
echo [3/5] 清理旧的构建文件... echo [3/5] 清理旧的构建文件...
if exist "dist" rd /s /q dist if exist "dist" rd /s /q dist
if exist "build" rd /s /q build 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] 开始打包(单文件版本)... echo [4/5] 开始打包(单文件版本)...
pyinstaller --noconfirm --onefile --windowed ^ pyinstaller --noconfirm --onefile --windowed ^
--name "main" ^ --name "%APP_NAME%" ^
%ICON_PARAM% ^ %ICON_PARAM% ^
--add-data "%ROOT%\resources\icon;icon" ^ --add-data "%ROOT%\resources\icon;icon" ^
--hidden-import "PyQt6.QtWidgets" ^ --hidden-import "PyQt6.QtWidgets" ^
@@ -72,7 +73,7 @@ if errorlevel 1 (
echo [错误] 打包失败,尝试使用目录模式... echo [错误] 打包失败,尝试使用目录模式...
pyinstaller --noconfirm --onedir --windowed ^ pyinstaller --noconfirm --onedir --windowed ^
--name "main" ^ --name "%APP_NAME%" ^
%ICON_PARAM% ^ %ICON_PARAM% ^
--add-data "%ROOT%\resources\icon;icon" ^ --add-data "%ROOT%\resources\icon;icon" ^
--hidden-import "PyQt6.QtWidgets" ^ --hidden-import "PyQt6.QtWidgets" ^
@@ -104,10 +105,10 @@ echo [5/5] 打包完成!
echo. echo.
echo ============================================================ echo ============================================================
if exist "dist\main.exe" ( if exist "dist\%APP_NAME%.exe" (
echo 打包成功!单文件版本: dist\main.exe echo 打包成功!单文件版本: dist\%APP_NAME%.exe
) else if exist "dist\main\main.exe" ( ) else if exist "dist\%APP_NAME%\%APP_NAME%.exe" (
echo 打包成功!目录版本: dist\main\main.exe echo 打包成功!目录版本: dist\%APP_NAME%\%APP_NAME%.exe
) )
echo ============================================================ echo ============================================================
echo. echo.
+2 -2
View File
@@ -16,8 +16,8 @@ echo( EXE + Inno Setup 安装包
echo(============================================================ echo(============================================================
echo( echo(
:: Step 1: PyInstaller 打包 main.exe(在 src 目录执行) :: Step 1: PyInstaller 打包 ServerManager.exe(在 src 目录执行)
echo([Step 1/2] PyInstaller 打包 main.exe ... echo([Step 1/2] PyInstaller 打包 ServerManager.exe ...
echo( echo(
call "%ROOT%\scripts\package\build.bat" nopause call "%ROOT%\scripts\package\build.bat" nopause
if errorlevel 1 ( if errorlevel 1 (
+1 -1
View File
@@ -28,7 +28,7 @@ if "%NEW_VER%"=="" (
) )
echo( echo(
echo([阶段 1/4] PyInstaller 打包 main.exe ... echo([阶段 1/4] PyInstaller 打包 ServerManager.exe ...
call "%ROOT%\scripts\package\build.bat" nopause call "%ROOT%\scripts\package\build.bat" nopause
if errorlevel 1 ( if errorlevel 1 (
echo([失败] PyInstaller 打包未成功 echo([失败] PyInstaller 打包未成功
+226 -58
View File
@@ -4,7 +4,7 @@
发版与版本号:推荐顺序见 scripts/package/release.bat — 先 PyInstaller 打包,再更新版本号并同步到 dist,再 Inno Setup,最后 --post-build。 发版与版本号:推荐顺序见 scripts/package/release.bat — 先 PyInstaller 打包,再更新版本号并同步到 dist,再 Inno Setup,最后 --post-build。
单独用法: 单独用法:
仅改版本号(通常在 build.bat 成功之后): python scripts/package/release.py 1.0.4 [--notes "说明"] 仅改版本号(通常在 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 本地测试包: scripts/package/pack_local_test.bat 或 python scripts/package/pack_local_test.py(见 RELEASE.md
""" """
@@ -15,14 +15,20 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
VERSION_JSON = ROOT / "resources" / "version.json" VERSION_JSON = ROOT / "resources" / "version.json"
ROOT_VERSION_JSON = ROOT / "version.json" ROOT_VERSION_JSON = ROOT / "version.json"
OUTPUT_DIR = ROOT / "build" / "output" OUTPUT_DIR = ROOT / "build" / "output"
PUBLIC_MANIFEST_PATH = "server_manager/build/output/version.json" PUBLIC_MANIFEST_URL = "https://www.sumi.work/api/software/server-manager/version.json"
PUBLIC_INSTALLER_PATH = "server_manager/build/output/ServerManager_Setup.exe" 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: def read_text(p: Path) -> str:
@@ -48,34 +54,119 @@ def get_current_version() -> str:
return read_version_from_json(OUTPUT_DIR / "version.json") return read_version_from_json(OUTPUT_DIR / "version.json")
def _set_url_path(url: str, api_kind: str, public_path: str) -> str: def format_size(bytes_count: int) -> str:
fallback = f"http://172.18.180.94:3000/api/{api_kind}?path={public_path}" return f"{bytes_count / 1024 / 1024:.1f} MB"
if not url:
return fallback
url = url.strip() def sha256_file(path: Path) -> str:
url = url.replace("/api/file?", f"/api/{api_kind}?") h = hashlib.sha256()
url = url.replace("/api/download?", f"/api/{api_kind}?") with path.open("rb") as f:
if "path=" in url: for chunk in iter(lambda: f.read(1024 * 1024), b""):
return re.sub(r"path=[^&]+", lambda _m: f"path={public_path}", url, count=1) h.update(chunk)
return fallback 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: def ensure_compat_manifest(data: dict) -> dict:
"""补齐客户端更新字段,并固定到 build/output 发布路径。""" """补齐客户端更新字段,并固定到 build/output 发布路径。"""
seed = ( data["update_url"] = PUBLIC_MANIFEST_URL
data.get("update_url") data["full_installer_url"] = PUBLIC_INSTALLER_URL
or data.get("full_installer_url") data["download_url"] = PUBLIC_INSTALLER_URL
or data.get("download_url")
or ""
)
data["update_url"] = _set_url_path(seed, "file", PUBLIC_MANIFEST_PATH)
full_url = _set_url_path(
data.get("full_installer_url") or data.get("download_url") or seed,
"download",
PUBLIC_INSTALLER_PATH,
)
data["full_installer_url"] = full_url
data["download_url"] = full_url
return data return data
@@ -86,7 +177,7 @@ def write_version_json(data: dict) -> None:
write_text(ROOT_VERSION_JSON, text) write_text(ROOT_VERSION_JSON, text)
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False) -> str: 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 项。""" """更新版本号并同步 manifest。默认全量更新;显式 --with-delta 才生成 delta 项。"""
vj = VERSION_JSON vj = VERSION_JSON
if not vj.exists(): 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["release_notes"] = data.get("release_notes", "") or f"Server Manager {new_version} 更新。"
data = ensure_compat_manifest(data) data = ensure_compat_manifest(data)
full_url = data.get("full_installer_url", "")
if with_delta: if with_delta:
# delta_updates: 只保留 上一版本 -> 当前版本 一项。 # delta_updates: 只保留 上一版本 -> 当前版本 一项。默认使用文件级增量 zip。
# 目录模式下 delta 只替换 main.exe;只有确认依赖目录无需变化时才启用。 if delta_kind == "exe-patch":
patch_path = f"patches/v{old_version}_to_v{new_version}.patch" patch_url = PUBLIC_DELTA_URL.format(from_version=old_version)
if full_url and "build/output/" in full_url: delta_entry = {
patch_url = re.sub(r"build/output/[^?]+", "build/output/" + patch_path, full_url) "type": "bsdiff-exe",
elif full_url and "output/" in full_url:
patch_url = re.sub(r"output/[^?]+", "build/output/" + patch_path, full_url)
else:
patch_url = full_url.replace("ServerManager_Setup.exe", patch_path) if full_url else ""
data["delta_updates"] = {
old_version: {
"patch_url": patch_url, "patch_url": patch_url,
"new_exe_sha256": "", "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}] 已添加") print(f" [OK] version.json -> version {new_version}, delta_updates[{old_version}] 已添加")
else: else:
data["delta_updates"] = {} data["delta_updates"] = {}
data["deltaUpdates"] = {}
data.pop("delta", None) data.pop("delta", None)
print(f" [OK] version.json -> version {new_version},本次使用全量更新") 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") print(" [OK] 已同步 version.json -> resources/version.json 与根目录 version.json")
# 若已打过 PyInstaller,把新版本同步进 dist,避免安装包内仍是旧 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 dest = ROOT / rel
if dest.parent.is_dir(): if dest.parent.is_dir():
shutil.copy2(vj, dest) 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 return old_version
def set_version_post(old_exe_path: str = None) -> None: def set_version_post(old_exe_path: str = None, old_dist_path: str = None) -> None:
"""打包完成后:若提供 --old-exe(上一版 main.exe 路径)则生成 patch 并填 new_exe_sha256;否则仅复制 version.json。本地不保存 exe。""" """打包完成后:生成文件级增量 zip 或兼容旧 exe patch,并复制 version.json。"""
vj = VERSION_JSON vj = VERSION_JSON
if not vj.exists(): if not vj.exists():
return return
@@ -174,25 +278,83 @@ def set_version_post(old_exe_path: str = None) -> None:
delta_updates = data.get("delta_updates") or {} delta_updates = data.get("delta_updates") or {}
if not isinstance(delta_updates, dict): if not isinstance(delta_updates, dict):
return return
# 找到需要填 new_exe_sha256 的那一项(上一版本) # 找到需要补齐元数据的那一项(上一版本)
old_version = None old_version = None
for k, v in delta_updates.items(): 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() old_version = k.strip()
break break
if not old_version: if not old_version:
print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta") print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta")
write_version_json(data) write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output() _copy_version_to_output()
return 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) if delta_kind != "bsdiff-exe":
new_exe = ROOT / "src" / "dist" / "main" / "main.exe" if not old_dist_path:
if not new_exe.exists(): print(" [i] 未提供上一版 dist 目录(使用 --old-dist 指定可生成文件级增量包),本次仅全量")
new_exe = ROOT / "src" / "dist" / "main.exe" data["delta_updates"] = {}
if not new_exe.exists(): data.pop("deltaUpdates", None)
print(" [i] 未找到 src/dist/main.exe 或 src/dist/main/main.exe,跳过增量包")
write_version_json(data) 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() _copy_version_to_output()
return 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(): if not old_exe_path or not old_exe or not old_exe.is_file():
print(f" [i] 未提供上一版 exe(使用 --old-exe 指定路径可生成增量包),仅全量") print(f" [i] 未提供上一版 exe(使用 --old-exe 指定路径可生成增量包),仅全量")
write_version_json(data) write_version_json(data)
save_dist_snapshot(new_version)
_copy_version_to_output() _copy_version_to_output()
return return
@@ -208,6 +371,7 @@ def set_version_post(old_exe_path: str = None) -> None:
import bsdiff4 import bsdiff4
except ImportError: except ImportError:
print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr) print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr)
save_dist_snapshot(new_version)
_copy_version_to_output() _copy_version_to_output()
return return
@@ -224,9 +388,11 @@ def set_version_post(old_exe_path: str = None) -> None:
new_sha = hashlib.sha256(new_data).hexdigest().lower() new_sha = hashlib.sha256(new_data).hexdigest().lower()
delta_updates[old_version]["new_exe_sha256"] = new_sha delta_updates[old_version]["new_exe_sha256"] = new_sha
data["delta_updates"] = delta_updates data["delta_updates"] = delta_updates
data["deltaUpdates"] = delta_updates
write_version_json(data) write_version_json(data)
print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...") print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...")
save_dist_snapshot(new_version)
_copy_version_to_output() _copy_version_to_output()
print(" 发版准备完成。") print(" 发版准备完成。")
@@ -251,14 +417,16 @@ def clean_generated_artifacts() -> None:
def main(): def main():
parser = argparse.ArgumentParser(description="一键发版:更新版本号 / 打包后生成增量并填 SHA256") parser = argparse.ArgumentParser(description="一键发版:更新版本号 / 打包后生成增量并填 SHA256")
parser.add_argument("version", nargs="?", help="新版本号,如 1.0.4") 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("--post-build", action="store_true", help="打包完成后执行:复制 version.json;若提供 --old-dist 则生成文件级增量包")
parser.add_argument("--old-exe", default=None, help="上一版 main.exe 路径,用于生成增量 patch(与 Cursor 一致,本地不保存 exe)") parser.add_argument("--old-dist", default=None, help="上一版 dist/ServerManager 目录,用于生成文件级增量 zip")
parser.add_argument("--with-delta", action="store_true", help="生成 main.exe 增量项;仅在确认依赖目录无需变化时使用") 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 更新说明") parser.add_argument("--notes", "-n", default=None, help="可选:release_notes 更新说明")
args = parser.parse_args() args = parser.parse_args()
if args.post_build: 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() clean_generated_artifacts()
return return
@@ -266,7 +434,7 @@ def main():
print("用法: python scripts/package/release.py <版本号> 例如: python scripts/package/release.py 1.0.4", file=sys.stderr) print("用法: python scripts/package/release.py <版本号> 例如: python scripts/package/release.py 1.0.4", file=sys.stderr)
sys.exit(1) sys.exit(1)
new_ver = args.version.strip() 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: if args.with_delta:
print(f"\n 当前版本 {old_ver} -> 新版本 {new_ver}。若使用 scripts/package/release.bat,接下来将编译安装包并执行 --post-build。") print(f"\n 当前版本 {old_ver} -> 新版本 {new_ver}。若使用 scripts/package/release.bat,接下来将编译安装包并执行 --post-build。")
else: else:
+3 -3
View File
@@ -14,10 +14,10 @@ rem Run source modules through the new entrypoints package.
cd /d "%ROOT%" cd /d "%ROOT%"
set "PYTHONPATH=%ROOT%\src;%PYTHONPATH%" set "PYTHONPATH=%ROOT%\src;%PYTHONPATH%"
rem Prefer packaged main.exe when present. rem Prefer packaged ServerManager.exe when present.
set "SM_MAIN=" set "SM_MAIN="
if defined SERVER_MANAGER_MAIN if exist "%SERVER_MANAGER_MAIN%" set "SM_MAIN=%SERVER_MANAGER_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. rem No arguments enters interactive CLI mode.
if "%~1"=="" goto :run_interactive if "%~1"=="" goto :run_interactive
@@ -102,7 +102,7 @@ echo.
echo More help: python -m entrypoints.cli --help echo More help: python -m entrypoints.cli --help
if defined SM_MAIN ( if defined SM_MAIN (
echo. echo.
echo Packaged main: %SM_MAIN% echo Packaged program: %SM_MAIN%
) )
goto :end goto :end
+3 -3
View File
@@ -20,10 +20,10 @@ export PYTHONPATH="$ROOT/src${PYTHONPATH:+:$PYTHONPATH}"
# 默认以项目根目录作为服务器根目录;部署到其它项目时可通过 SERVER_MANAGER_ROOT 覆盖。 # 默认以项目根目录作为服务器根目录;部署到其它项目时可通过 SERVER_MANAGER_ROOT 覆盖。
SERVER_ROOT="${SERVER_MANAGER_ROOT:-$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:-}" SM_MAIN="${SERVER_MANAGER_MAIN:-}"
if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/main/main" ]]; then if [[ -z "$SM_MAIN" ]] && [[ -x "$ROOT/src/dist/ServerManager/ServerManager" ]]; then
SM_MAIN="$ROOT/src/dist/main/main" SM_MAIN="$ROOT/src/dist/ServerManager/ServerManager"
fi fi
# 颜色输出 # 颜色输出
+169 -4
View File
@@ -8,10 +8,15 @@
""" """
import argparse import argparse
import hashlib
import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import time import time
import tempfile
import zipfile
# 仅标准库:PID 检测用 ctypesWindows)或 os.killUnix # 仅标准库:PID 检测用 ctypesWindows)或 os.killUnix
if sys.platform == "win32": if sys.platform == "win32":
@@ -63,17 +68,173 @@ def is_process_alive(pid: int) -> bool:
return False 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: def main() -> None:
parser = argparse.ArgumentParser(description="独立更新守护:等主进程退出后替换 exe 并重启") parser = argparse.ArgumentParser(description="独立更新守护:等主进程退出后替换 exe 并重启")
parser.add_argument("--pid", type=int, required=True, help="主程序进程 ID,必须等其完全退出") parser.add_argument("--pid", type=int, required=True, help="主程序进程 ID,必须等其完全退出")
parser.add_argument("--install-dir", type=str, required=True, help="主程序安装根目录") 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("--new-exe-path", type=str, default="", help="已准备好的新版本 ServerManager.exe 的完整路径(通常在 TEMP)")
parser.add_argument("--target-exe-name", type=str, default="main.exe", help="主程序文件名,如 main.exe") 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() args = parser.parse_args()
install_dir = os.path.abspath(args.install_dir) install_dir = os.path.abspath(args.install_dir)
new_exe_path = os.path.abspath(args.new_exe_path) new_exe_path = os.path.abspath(args.new_exe_path) if args.new_exe_path else ""
target_exe_name = args.target_exe_name.strip() or "main.exe" 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) target_exe_full = os.path.join(install_dir, target_exe_name)
old_exe_full = target_exe_full + ".old" old_exe_full = target_exe_full + ".old"
@@ -93,6 +254,10 @@ def main() -> None:
_log_error(install_dir, "切换到安装目录失败", e) _log_error(install_dir, "切换到安装目录失败", e)
sys.exit(1) sys.exit(1)
if delta_zip_path:
if not apply_delta_zip(install_dir, delta_zip_path):
sys.exit(1)
else:
# 若存在上次遗留的 .old,先静默删除 # 若存在上次遗留的 .old,先静默删除
if os.path.isfile(old_exe_full): if os.path.isfile(old_exe_full):
try: try:
+19 -19
View File
@@ -2,10 +2,10 @@
""" """
仿 Cursor / VS Code 的无感增量更新模块(Tkinter 入口用) 仿 Cursor / VS Code 的无感增量更新模块(Tkinter 入口用)
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 main.exe 与 _internal 等依赖。 【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 ServerManager.exe 与 _internal 等依赖。
【增量范围】.patch 仅针对 main.exe:在 TEMP 合成 main_new.exe 并校验 SHA256 【增量范围】.patch 仅针对 ServerManager.exe:在 TEMP 合成 ServerManager_new.exe 并校验 SHA256
替换时只覆盖安装目录下的 main.exe,绝不修改 _internal 或其它文件。 替换时只覆盖安装目录下的 ServerManager.exe,绝不修改 _internal 或其它文件。
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 main_new.exe,不留下垃圾。 【临时清理】替换脚本会清理 TEMP 中的 .patch 与 ServerManager_new.exe,不留下垃圾。
依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。 依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。
远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。 远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。
@@ -91,9 +91,9 @@ def apply_update_and_restart(
if not os.path.isfile(new_exe_path): if not os.path.isfile(new_exe_path):
return return
install_dir = os.path.normpath(os.path.dirname(current_exe_path)) 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")) 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): if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
try: try:
@@ -185,14 +185,14 @@ def _get_delta_map(manifest: dict) -> dict:
class SeamlessUpdater: class SeamlessUpdater:
""" """
隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」, 隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」,
用户确认后极速替换 main.exe(或静默安装完整包)并退出。 用户确认后极速替换 ServerManager.exe(或静默安装完整包)并退出。
""" """
def __init__(self, root: tk.Tk, local_version: str, version_url: str = None): def __init__(self, root: tk.Tk, local_version: str, version_url: str = None):
self.root = root self.root = root
self.local_version = (local_version or "").strip() self.local_version = (local_version or "").strip()
self.version_url = (version_url or VERSION_JSON_URL).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_full_path = None # TEMP 下 Setup.exe 路径(全量就绪)
self._ready_remote_version = None self._ready_remote_version = None
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -242,8 +242,8 @@ class SeamlessUpdater:
def _try_delta_path(self, manifest: dict) -> bool: def _try_delta_path(self, manifest: dict) -> bool:
""" """
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 main_new.exe -> 校验 SHA256。 若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 ServerManager_new.exe -> 校验 SHA256。
仅针对 main.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。 仅针对 ServerManager.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。 成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。
""" """
delta_map = _get_delta_map(manifest) delta_map = _get_delta_map(manifest)
@@ -386,7 +386,7 @@ import tkinter as tk
from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL
# 从 version.json 或常量读取本地版本号 # 从 version.json 或常量读取本地版本号
LOCAL_VERSION = "1.1.3" # 需与 version.json / 安装包一致 LOCAL_VERSION = "1.1.8" # 需与 version.json / 安装包一致
def main(): def main():
root = tk.Tk() root = tk.Tk()
@@ -408,20 +408,20 @@ if __name__ == "__main__":
# 开发者:如何生成 .patch 文件(发版流程) # 开发者:如何生成 .patch 文件(发版流程)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
""" """
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 main.exe,不包含 _internal 【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 ServerManager.exe,不包含 _internal
用户端替换时只覆盖 main.exe,绝不修改 _internal 或其它文件。 用户端替换时只覆盖 ServerManager.exe,绝不修改 _internal 或其它文件。
1. 打包:使用 PyInstaller 文件夹模式生成当前版本: 1. 打包:使用 PyInstaller 文件夹模式生成当前版本:
pyinstaller -D -w your_main.py pyinstaller -D -w -n ServerManager entrypoints/main.py
得到 dist/your_main/main.exe(或 dist/main.exe 得到 dist/ServerManager/ServerManager.exe。
2. 保存上一版本的 main.exe(仅此单文件,无需 _internal): 2. 保存上一版本的 ServerManager.exe(仅此单文件,无需 _internal):
将上一发版时的 main.exe 保存为 build/output/main_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。 将上一发版时的 ServerManager.exe 保存为 build/output/ServerManager_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
3. 生成差分补丁(需安装 bsdiff4pip install bsdiff4): 3. 生成差分补丁(需安装 bsdiff4pip install bsdiff4):
import bsdiff4 import bsdiff4
old = open('build/output/main_1.0.2.exe', 'rb').read() old = open('build/output/ServerManager_1.0.2.exe', 'rb').read()
new = open('dist/your_main/main.exe', 'rb').read() new = open('dist/ServerManager/ServerManager.exe', 'rb').read()
patch = bsdiff4.diff(old, new) patch = bsdiff4.diff(old, new)
open('build/output/patches/v1.0.2_to_v1.0.3.patch', 'wb').write(patch) 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) # 错误信息 error_signal = pyqtSignal(str) # 错误信息
status_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__() super().__init__()
self.server_name = server_name self.server_name = server_name
self.stop_cmd = stop_cmd self.stop_cmd = stop_cmd
@@ -214,6 +229,11 @@ class ClearDatabaseThread(QThread):
self.db_pass = db_pass self.db_pass = db_pass
self.db_game = db_game self.db_game = db_game
self.db_log = db_log 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): def run(self):
import time import time
@@ -227,6 +247,12 @@ class ClearDatabaseThread(QThread):
try: try:
logger.info(f"开始清档服务器: {self.server_name}") 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}") 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. 停止服务器 # 1. 停止服务器
self.status_signal.emit("正在停止服务器...") self.status_signal.emit("正在停止服务器...")
@@ -242,7 +268,31 @@ class ClearDatabaseThread(QThread):
except Exception as e: except Exception as e:
logger.warning(f"停止命令执行失败: {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("正在删除数据库...") self.status_signal.emit("正在删除数据库...")
logger.info(f"连接数据库: {self.db_host}:{self.db_port}") logger.info(f"连接数据库: {self.db_host}:{self.db_port}")
+2 -2
View File
@@ -8,7 +8,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional 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") 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: def config_target_path(cli: Any, server: Optional[str] = None) -> Path:
if server: if server:
return Path(cli.run_dir) / server / "config" / "kv.config" 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, 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 pathlib import Path
from typing import Any 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 ( from .common import (
config_target_path, 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="允许输出敏感字段") cfg_get.add_argument("--include-sensitive", action="store_true", help="允许输出敏感字段")
_add_json_flag(cfg_get) _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("key")
cfg_set.add_argument("value") 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) _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("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) _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: def _handle_config_files(args: argparse.Namespace, cli: Any) -> None:
config_dir = Path(cli.config_path) config_dir = Path(cli.config_path)
files = [] files = []
names = [ files_to_check = [
"default.kv", (DEFAULT_CONFIG_FILE, config_dir / DEFAULT_CONFIG_FILE),
"tool.config", (PROJECT_CONFIG_FILE, Path(cli.server_root) / "config" / PROJECT_CONFIG_FILE),
"sys_game.config.example", (USER_CONFIG_FILE, config_dir / USER_CONFIG_FILE),
"sys_login.config.example", ("sys_game.config.example", config_dir / "sys_game.config.example"),
"sys_center.config.example", ("sys_login.config.example", config_dir / "sys_login.config.example"),
"sys_cross.config.example", ("sys_center.config.example", config_dir / "sys_center.config.example"),
"sys_client.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: for name, path in files_to_check:
path = config_dir / name
files.append({"name": name, "path": str(path), "exists": path.exists()}) files.append({"name": name, "path": str(path), "exists": path.exists()})
data = {"config_dir": str(config_dir), "files": files} data = {"config_dir": str(config_dir), "files": files}
if is_json_output(args): if is_json_output(args):
+2 -1
View File
@@ -12,6 +12,7 @@ from typing import Any, Dict, List
from services.commands import ( from services.commands import (
check_node_status, check_node_status,
check_nodes_status, check_nodes_status,
DEFAULT_CONFIG_FILE,
generate_start_config, generate_start_config,
get_erl_cmd, get_erl_cmd,
get_local_ip, 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("server_root", Path(cli.server_root).is_dir(), cli.server_root),
_check("run_dir", Path(cli.run_dir).is_dir(), cli.run_dir), _check("run_dir", Path(cli.run_dir).is_dir(), cli.run_dir),
_check("config_dir", config_dir.is_dir(), str(config_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("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 "")), _check("rg", find_rg_executable() is not None, str(find_rg_executable() or "")),
] ]
+25 -19
View File
@@ -13,13 +13,13 @@ class ServerManagerCLI:
server_root: 服务器根目录(命令行指定) server_root: 服务器根目录(命令行指定)
config_path: 配置文件路径 config_path: 配置文件路径
""" """
# 先自动检测找到 tool.config 所在目录 # 先自动检测找到项目配置所在目录
detected_root = self._detect_server_root() detected_root = self._detect_server_root()
# 尝试从 tool.config 读取配置的 server_root # 尝试从 user.config 读取配置的 server_root
configured_root = self._read_configured_server_root(detected_root) configured_root = self._read_configured_server_root(detected_root)
# 优先级:命令行参数 > tool.config配置 > 自动检测 # 优先级:命令行参数 > user.config 配置 > 自动检测
if server_root: if server_root:
self.server_root = str(Path(server_root).resolve()) self.server_root = str(Path(server_root).resolve())
elif configured_root: elif configured_root:
@@ -27,7 +27,7 @@ class ServerManagerCLI:
else: else:
self.server_root = detected_root self.server_root = detected_root
# 从 tool.config 读取 run_dir,如果没有则使用默认值 # 从 user.config 读取 run_dir,如果没有则使用默认值
configured_run_dir = self._read_configured_run_dir(self.server_root) configured_run_dir = self._read_configured_run_dir(self.server_root)
if configured_run_dir: if configured_run_dir:
run_path = Path(configured_run_dir) run_path = Path(configured_run_dir)
@@ -61,25 +61,31 @@ class ServerManagerCLI:
logger.error(f"执行启动迁移失败: {e}", exc_info=True) logger.error(f"执行启动迁移失败: {e}", exc_info=True)
def _read_tool_config_value(self, detected_root: str, key: str) -> str: def _read_tool_config_value(self, detected_root: str, key: str) -> str:
"""tool.config 读取指定配置项 """user.config 读取指定配置项,兼容旧项目的历史用户配置文件。
Args: Args:
detected_root: 自动检测的根目录(用于定位 tool.config detected_root: 自动检测的根目录(用于定位项目配置
key: 配置项名称 key: 配置项名称
Returns: Returns:
配置值,如果没有配置返回 None 配置值,如果没有配置返回 None
""" """
candidates = ( candidates = (
get_server_manager_config_dir(detected_root) / 'tool.config', get_server_manager_config_dir(detected_root) / USER_CONFIG_FILE,
Path(detected_root) / 'config' / 'tool.config', 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) for config_path in candidates:
if tool_config_path is None: if not config_path.exists():
return None continue
try: try:
with open(tool_config_path, 'r', encoding='utf-8') as f: with open(config_path, 'r', encoding='utf-8') as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if line.startswith(f'{key}='): if line.startswith(f'{key}='):
@@ -92,11 +98,11 @@ class ServerManagerCLI:
return None return None
def _read_configured_server_root(self, detected_root: str) -> str: def _read_configured_server_root(self, detected_root: str) -> str:
"""tool.config 读取配置的服务器根目录""" """user.config 读取配置的服务器根目录"""
return self._read_tool_config_value(detected_root, 'server_root') return self._read_tool_config_value(detected_root, 'server_root')
def _read_configured_run_dir(self, detected_root: str) -> str: def _read_configured_run_dir(self, detected_root: str) -> str:
"""tool.config 读取配置的运行目录""" """user.config 读取配置的运行目录"""
return self._read_tool_config_value(detected_root, 'run_dir') return self._read_tool_config_value(detected_root, 'run_dir')
def _detect_server_root(self) -> str: def _detect_server_root(self) -> str:
@@ -129,7 +135,7 @@ class ServerManagerCLI:
def _load_config(self, config_path: str = None): 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(): if config_path and Path(config_path).exists():
@@ -141,14 +147,14 @@ class ServerManagerCLI:
if cfg is None: if cfg is None:
if not self.quiet: if not self.quiet:
print("警告: 找不到有效的服务器根目录(缺少 config/ 或 .server_manager/config/default.kv") print(f"警告: 找不到有效的服务器根目录(缺少 config/ 或 .server_manager/config/{DEFAULT_CONFIG_FILE}")
print(f" 服务器根目录: {server_root}") print(f" 服务器根目录: {server_root}")
if err: if err:
print(f" 原因: {err}") print(f" 原因: {err}")
self.config = {} self.config = {}
return return
# 合并配置:default.kv < tool.config # 合并配置:default.config < project.config < user.config
self.config = read_merged_config(str(server_root), run_dir=self.run_dir) 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') self.cookie = self.config.get('cookie', 'ddxq2-node')
def _apply_terminal_launch_config(self): def _apply_terminal_launch_config(self):
"""让 CLI 后台启动也使用 tool.config 中的窗口设置。""" """让 CLI 后台启动也使用 user.config 中的窗口设置。"""
mode = (self.config.get('terminal_launch_mode') or '').strip().lower() mode = (self.config.get('terminal_launch_mode') or '').strip().lower()
terminal_exe = ( terminal_exe = (
self.config.get('terminal_exe_path') 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)) sys.path.insert(0, str(SRC_DIR))
def _help_example_prog(): def _help_example_prog():
"""帮助「示例」中的命令前缀:打包为 main.exe 时与源码 python 调用区分。""" """帮助「示例」中的命令前缀:打包与源码 python 调用区分。"""
if getattr(sys, "frozen", False): 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" return "python -m entrypoints.cli"
@@ -58,7 +58,12 @@ logger = setup_logging()
from services.commands import ( from services.commands import (
get_local_ip, get_local_ip,
ensure_and_get_server_manager_config_dir, 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_server_manager_config_dir,
get_user_config_path,
build_start_command, build_start_command,
build_start_command_by_config, build_start_command_by_config,
build_stop_command, build_stop_command,
@@ -75,6 +80,11 @@ from services.commands import (
IS_WINDOWS, IS_WINDOWS,
IS_LINUX, IS_LINUX,
REBAR3_CMD, REBAR3_CMD,
DEFAULT_CONFIG_FILE,
LEGACY_DEFAULT_CONFIG_FILE,
USER_CONFIG_FILE,
LEGACY_USER_CONFIG_FILE,
PROJECT_CONFIG_FILE,
) )
from services.server_creator import ( from services.server_creator import (
+1 -1
View File
@@ -7,7 +7,7 @@ import tkinter as tk
from tkinter import ttk from tkinter import ttk
# 本地版本号,需与安装包/version.json 中的版本约定一致 # 本地版本号,需与安装包/version.json 中的版本约定一致
LOCAL_VERSION = "1.1.3" LOCAL_VERSION = "1.1.8"
# 延迟导入,避免在未安装更新依赖时影响启动 # 延迟导入,避免在未安装更新依赖时影响启动
try: 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 import json
+54 -70
View File
@@ -24,8 +24,8 @@ def _empty_config_data():
class Config: class Config:
"""项目配置管理类:读写当前打开项目下的 .server_manager/config/tool.config(与项目一起走)。 """项目配置管理类:读写当前打开项目下的 .server_manager/config/user.config(与项目一起走)。
打开项目时会将软件目录下受管模板同步到 .server_manager/config,并保留项目自己的 tool.config。 打开项目时会将软件目录下受管模板同步到 .server_manager/config,并保留项目自己的 user.config。
支持“无项目”启动:project_root=None 时仅显示欢迎页;通过 open_project(path) 打开项目后加载该项目配置。 支持“无项目”启动:project_root=None 时仅显示欢迎页;通过 open_project(path) 打开项目后加载该项目配置。
""" """
@@ -38,6 +38,7 @@ class Config:
self._exe_dir = self._script_dir.parent.parent self._exe_dir = self._script_dir.parent.parent
self.tool_dir = None self.tool_dir = None
self.user_config_path = None
self.tool_config_path = None self.tool_config_path = None
self.config_error = None self.config_error = None
self.data = _empty_config_data() self.data = _empty_config_data()
@@ -61,7 +62,8 @@ class Config:
self.config_error = err self.config_error = err
return False return False
self.tool_dir = project_root 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.config_error = None
self.data = self._load_config() self.data = self._load_config()
return True return True
@@ -81,21 +83,31 @@ class Config:
] ]
for path in candidates: for path in candidates:
if (get_server_manager_config_dir(path) / "default.kv").exists(): if find_existing_default_config_path(path):
return 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 path
return None return None
def _load_tool_config(self) -> dict: def _load_tool_config(self) -> dict:
"""加载 tool.config 文件""" """加载 user.config 文件,兼容旧项目的历史用户配置文件。"""
if self.tool_config_path is None or not self.tool_config_path.exists(): if self.user_config_path is None:
return {} 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 = {} config = {}
try: 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: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith('#'): if not line or line.startswith('#'):
@@ -108,7 +120,7 @@ class Config:
return config return config
def _save_tool_config(self, config: dict): def _save_tool_config(self, config: dict):
"""保存 tool.config 文件""" """保存 user.config 文件"""
lines = [ lines = [
"# Server Manager config", "# Server Manager config",
"# 此文件配置工具运行所需的环境路径", "# 此文件配置工具运行所需的环境路径",
@@ -148,45 +160,44 @@ class Config:
f"cookie={config.get('cookie', 'ddxq2-node')}", f"cookie={config.get('cookie', 'ddxq2-node')}",
] ]
self.tool_config_path.parent.mkdir(parents=True, exist_ok=True) config_path = self.user_config_path or self.tool_config_path
with open(self.tool_config_path, 'w', encoding='utf-8') as f: 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)) f.write('\n'.join(lines))
def _load_default_kv(self, server_root: str) -> dict: def _load_default_kv(self, server_root: str) -> dict:
"""从 default.kv 读取默认配置""" """从 default.config 和 project.config 读取默认/项目配置"""
if not server_root: if not server_root:
return {} return {}
default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv'
if not default_kv_path.exists():
return {}
config = {} config = {}
try: default_config_path = find_existing_default_config_path(server_root)
with open(default_kv_path, 'r', encoding='utf-8') as f: if default_config_path:
for line in f: config.update(read_config_file(str(default_config_path)))
line = line.strip()
if not line or line.startswith('#'): project_config_path = get_project_config_path(server_root)
continue if project_config_path.exists():
if '=' in line: config.update(read_config_file(str(project_config_path)))
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
except Exception:
return {}
return config return config
def _load_config(self): def _load_config(self):
"""加载配置,从 tool.config 读取,空值从 default.kv 补充。 """加载配置,从 user.config 读取,空值从 project.config/default.config 补充。
以当前已打开的项目目录(self.tool_dir)为 server_root 默认值,tool.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 return self.data
tool_cfg = self._load_tool_config() 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 可覆盖 # 以当前项目目录为默认,user.config 可覆盖;project.config/default.config 可补充。
server_root = tool_cfg.get('server_root', '') or str(self.tool_dir) default_kv = self._load_default_kv(str(self.tool_dir))
run_dir = tool_cfg.get('run_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: if not run_dir and server_root:
run_dir = str(Path(server_root) / "run") run_dir = str(Path(server_root) / "run")
@@ -199,10 +210,7 @@ class Config:
except Exception: except Exception:
pass pass
# 如果有 server_root,尝试从 default.kv 读取默认 # 辅助函数:优先使用 user.config 值,为空则使用 project.config/default.config
default_kv = self._load_default_kv(server_root)
# 辅助函数:优先使用 tool.config 值,为空则使用 default.kv 值
def get_value(tool_key, default_key, fallback=''): def get_value(tool_key, default_key, fallback=''):
val = tool_cfg.get(tool_key, '') val = tool_cfg.get(tool_key, '')
if not val and default_kv: 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_user = get_value('login_db_user', 'login_db_user', '')
login_db_pass = get_value('login_db_pass', 'login_db_pass', '') login_db_pass = get_value('login_db_pass', 'login_db_pass', '')
r25_path = get_value('r25_path', 'r25_path', '') r25_path = get_value('r25_path', 'r25_path', '')
terminal_launch_mode = (tool_cfg.get('terminal_launch_mode', '') or 'auto').strip().lower() terminal_launch_mode = (get_value('terminal_launch_mode', 'terminal_launch_mode', 'auto') or 'auto').strip().lower()
terminal_exe_path = tool_cfg.get('terminal_exe_path', '') terminal_exe_path = get_value('terminal_exe_path', 'terminal_exe_path', '')
if not terminal_exe_path: if not terminal_exe_path:
legacy_exe = tool_cfg.get('terminal_custom_command', '') legacy_exe = tool_cfg.get('terminal_custom_command', '') or default_kv.get('terminal_custom_command', '')
legacy_cwd = tool_cfg.get('terminal_custom_cwd', '') 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 ('/', '\\')): 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 legacy_path = Path(os.path.expandvars(os.path.expanduser(legacy_cwd))) / legacy_exe
if legacy_path.is_file(): 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 return config_data
def has_default_kv(self) -> bool: def has_default_kv(self) -> bool:
"""检查 default.kv 是否存在""" """检查 default.config 是否存在,兼容旧项目的历史默认配置文件。"""
server_root = self.get('workspace', 'server_root', default='') server_root = self.get('workspace', 'server_root', default='')
if not server_root: if not server_root:
return False return False
default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv' return find_existing_default_config_path(server_root) is not None
return default_kv_path.exists()
def is_config_complete(self) -> bool: def is_config_complete(self) -> bool:
"""检查工具配置是否完整""" """检查工具配置是否完整"""
@@ -333,8 +317,8 @@ class Config:
return bool(db_host and db_user and prefix) return bool(db_host and db_user and prefix)
def save(self): def save(self):
"""保存配置到 tool.config""" """保存配置到 user.config"""
if self.tool_config_path is None: if self.user_config_path is None:
return return
local_ip = get_local_ip() local_ip = get_local_ip()
self.set(local_ip, 'server', 'ip') self.set(local_ip, 'server', 'ip')
+2
View File
@@ -4,6 +4,7 @@
from . import ( from . import (
command_builders as _command_builders, command_builders as _command_builders,
config_files as _config_files, config_files as _config_files,
database as _database,
discovery as _discovery, discovery as _discovery,
erlang as _erlang, erlang as _erlang,
migrations as _migrations, migrations as _migrations,
@@ -23,6 +24,7 @@ _modules = (
_terminal, _terminal,
_window_registry, _window_registry,
_command_builders, _command_builders,
_database,
_config_files, _config_files,
_migrations, _migrations,
_discovery, _discovery,
+9 -4
View File
@@ -24,6 +24,11 @@ def get_rebar_profile(server_type: str) -> tuple:
return ('game_server_dev', 'game_server') 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], def build_start_command_by_config(server_root: str, merged_config: Dict[str, str],
cookie: str, use_rebar: bool = True, cookie: str, use_rebar: bool = True,
erl_path: str = None) -> str: 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: if use_rebar:
rebar3 = get_rebar3_cmd(server_root, erl_path) 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 = ( cmd = (
f'{rebar3} as {profile} shell ' f'{rebar3} as {profile} shell '
f'--eval -1,"{eval_expr}" ' f'--eval "{eval_expr}" '
f'--setcookie {cookie} ' f'--setcookie {cookie} '
f'--name {node_name} ' f'--name {node_name} '
f'--config "{config_file}"' f'--config "{config_file}"'
@@ -127,10 +132,10 @@ def build_start_command(server_root: str, server_name: str, cookie: str,
if use_rebar: if use_rebar:
rebar3 = get_rebar3_cmd(server_root, erl_path) 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 = ( cmd = (
f'{rebar3} as {profile} shell ' f'{rebar3} as {profile} shell '
f'--eval -1,"{eval_expr}" ' f'--eval "{eval_expr}" '
f'--setcookie {cookie} ' f'--setcookie {cookie} '
f'--name {server_name}@{ip} ' f'--name {server_name}@{ip} '
f'--config "{config_file}"' f'--config "{config_file}"'
+38 -24
View File
@@ -3,7 +3,11 @@
from .shared import * from .shared import *
from .erlang import get_local_ip 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( def read_merged_config(
server_root: str, server_root: str,
@@ -12,14 +16,14 @@ def read_merged_config(
) -> Dict[str, str]: ) -> 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: Args:
server_root: 服务器根目录 server_root: 服务器根目录
server_dir: 服务器目录名(可选,如 ddxq_game_s1)。 server_dir: 服务器目录名(可选,如 ddxq_game_s1)。
若提供,则读取 运行目录/{server_dir}/config/kv.config 作为最高优先级。 若提供,则读取 运行目录/{server_dir}/config/kv.config 作为最高优先级。
run_dir: 运行目录(可选)。若提供,则从运行目录下读取配置: 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 同时提供时) - run_dir/server_dir/config/kv.config(与 server_dir 同时提供时)
用于命令行等场景下“用运行目录的配置”获取 IP 等。 用于命令行等场景下“用运行目录的配置”获取 IP 等。
@@ -27,28 +31,38 @@ def read_merged_config(
合并后的配置字典 合并后的配置字典
""" """
config_path = get_server_manager_config_dir(server_root) config_path = get_server_manager_config_dir(server_root)
# 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 = {} merged = {}
# 2. tool.config.server_manager/config 下) # Priority, low to high: default.config < config/project.config < user.config.
tool_config_file = config_path / 'tool.config' default_config_file = _first_existing_config((
if tool_config_file.exists(): config_path / DEFAULT_CONFIG_FILE,
merged.update(read_config_file(str(tool_config_file))) config_path / LEGACY_DEFAULT_CONFIG_FILE,
))
if default_config_file:
merged.update(read_config_file(str(default_config_file)))
# 3. 运行目录下的配置(run_dir):优先用运行目录的 tool.config,便于命令行从运行目录取 IP 等 project_config_file = get_project_config_path(server_root)
if project_config_file.exists():
merged.update(read_config_file(str(project_config_file)))
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: if run_dir:
run_path = Path(run_dir) run_path = Path(run_dir)
for tool_in_run in (run_path / 'config' / 'tool.config', run_path / 'tool.config'): for user_in_run in (
if tool_in_run.exists(): run_path / 'config' / USER_CONFIG_FILE,
merged.update(read_config_file(str(tool_in_run))) 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 break
# 4. kv.config(最高优先级):在运行目录或 server_root/run 下 # 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]]: 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: Args:
server_root: 服务器根目录 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(): if not kv_config_file.exists():
return None 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) 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() 命令) # 不再依赖 file:set_cwd 改变运行时工作目录(为了兼容 Erlang shell 的 c() 命令)
def _normalize_run_log_path(raw: str, default_tail: str) -> str: def _normalize_run_log_path(raw: str, default_tail: str) -> str:
"""把配置里的日志路径统一成 run/<server_dir>/<tail>。 """把配置里的日志路径统一成 run/<server_dir>/<tail>。
形如 run/xxx/log 时只取第 3 段及之后,避免 default.kv 中的模板值 形如 run/xxx/log 时只取第 3 段及之后,避免 default.config 中的模板值
run/server/log 污染具体服务器的实际路径。 run/server/log 污染具体服务器的实际路径。
""" """
raw = (raw or '').strip('"').strip("'").strip() 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} 占位符,需要为所有已有服务器重新生成 # 2026-04 版本:模板新增 ${role_log} 占位符,需要为所有已有服务器重新生成
# sys.config,让 role_log 替换为 run/<server_dir>/log。 # sys.config,让 role_log 替换为 run/<server_dir>/log。
'sys_config_role_log_v1', '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_game.config.example 新增 ${role_log} 占位符,游戏服旧版
sys.config 里还没有 role_log 字段;generate_start_config 会根据当前 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) 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 表示迁移执行(即使个别服务器失败也算已执行)。 # 迁移 id -> 执行函数。函数返回 True 表示迁移执行(即使个别服务器失败也算已执行)。
_MIGRATION_RUNNERS = { _MIGRATION_RUNNERS = {
'sys_config_log_dir_v2': _migrate_sys_config_log_dir_v2, 'sys_config_log_dir_v2': _migrate_sys_config_log_dir_v2,
'sys_config_role_log_v1': _migrate_sys_config_role_log_v1, '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 * from .shared import *
def get_server_manager_config_dir(server_root: Union[str, Path]) -> Path: 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' return Path(server_root).resolve() / '.server_manager' / 'config'
def get_bundled_tool_config_template_dir() -> Path: def get_bundled_tool_config_template_dir() -> Path:
"""软件自带的 config 模板目录(安装后与 main.exe 同级下的 config/)。 """Bundled config template directory."""
源码运行时使用仓库根目录的 resources/config(与安装包内容一致)。
"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
return Path(sys.executable).resolve().parent / 'config' return Path(sys.executable).resolve().parent / 'config'
return Path(__file__).resolve().parents[3] / 'resources' / '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]: 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() project_root = Path(project_root).resolve()
src = get_bundled_tool_config_template_dir() src = get_bundled_tool_config_template_dir()
dst = get_server_manager_config_dir(project_root) dst = get_server_manager_config_dir(project_root)
if not src.is_dir(): if not src.is_dir():
return False, ( return False, (
f'找不到软件自带的配置模板目录: {src}\n' f'Cannot find bundled config template directory: {src}\n'
'请确认安装目录下存在 config 文件夹,或开发环境已准备 resources/config' '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, ( return False, (
f'软件配置模板不完整(缺少 default.kv): {src}' f'Bundled config templates are incomplete; missing {DEFAULT_CONFIG_FILE}: {src}'
) )
try: try:
dst.mkdir(parents=True, exist_ok=True) dst.mkdir(parents=True, exist_ok=True)
_migrate_legacy_config_names(project_root)
for file_name in MANAGED_CONFIG_TEMPLATE_FILES: 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(): if not src_file.exists():
return False, f'软件配置模板不完整(缺少 {file_name}: {src}' return False, f'Bundled config templates are incomplete; missing {file_name}: {src}'
shutil.copy2(src_file, dst / file_name) 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: except Exception as e:
return False, f'同步软件配置模板到项目失败: {e}' return False, f'Failed to sync config templates into project: {e}'
return True, "" return True, ""
def ensure_server_manager_config(project_root: Path) -> Tuple[bool, str]: 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() project_root = Path(project_root).resolve()
sm = get_server_manager_config_dir(project_root) 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(): if not sm.is_dir():
(project_root / '.server_manager').mkdir(parents=True, exist_ok=True) (project_root / '.server_manager').mkdir(parents=True, exist_ok=True)
except Exception as e: 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) return sync_server_manager_config_templates(project_root)
def ensure_and_get_server_manager_config_dir(project_root: Path) -> Tuple[Optional[Path], str]: 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) ok, err = ensure_server_manager_config(project_root)
if not ok: if not ok:
return None, err return None, err
cfg = get_server_manager_config_dir(project_root) cfg = get_server_manager_config_dir(project_root)
if not (cfg / 'default.kv').exists(): if not (cfg / DEFAULT_CONFIG_FILE).exists():
return None, ( return None, (
'项目配置不完整:.server_manager/config 下缺少 default.kv' f'Project config is incomplete: .server_manager/config is missing {DEFAULT_CONFIG_FILE}'
'(请检查软件安装目录下 config 模板是否完整)'
) )
return cfg, "" return cfg, ""
def project_has_default_kv_for_manager(project_root: Union[str, Path]) -> bool: 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) p = Path(project_root)
return ( return (
(get_server_manager_config_dir(p) / 'default.kv').exists() (get_server_manager_config_dir(p) / DEFAULT_CONFIG_FILE).exists()
or (p / 'config' / 'default.kv').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) 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( def rpc_role_gs_trace_network(
server_name: str, server_name: str,
cookie: str, cookie: str,
+7 -1
View File
@@ -25,8 +25,14 @@ IS_LINUX = platform.system() == 'Linux'
# rebar3 命令:Linux 使用当前目录下的 ./rebar3Windows 使用 rebar3.cmd # rebar3 命令:Linux 使用当前目录下的 ./rebar3Windows 使用 rebar3.cmd
REBAR3_CMD = 'rebar3.cmd' if IS_WINDOWS else './rebar3' 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 = ( MANAGED_CONFIG_TEMPLATE_FILES = (
'default.kv', DEFAULT_CONFIG_FILE,
'sys_center.config.example', 'sys_center.config.example',
'sys_client.config.example', 'sys_client.config.example',
'sys_cross.config.example', 'sys_cross.config.example',
+159 -8
View File
@@ -22,9 +22,11 @@
import hashlib import hashlib
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
@@ -48,7 +50,7 @@ def _version_json_path() -> Path:
未打包:从项目根目录或运行目录读取。 未打包:从项目根目录或运行目录读取。
""" """
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
# 安装包会将 version.json 展开到 {app},与 main.exe 同目录 # 安装包会将 version.json 展开到 {app},与 ServerManager.exe 同目录
install_dir = Path(sys.executable).resolve().parent install_dir = Path(sys.executable).resolve().parent
external = install_dir / "version.json" external = install_dir / "version.json"
if external.exists(): if external.exists():
@@ -138,16 +140,61 @@ def fetch_update_manifest(update_url: str, timeout: int = 15) -> Optional[Dict[s
return None 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]]: def get_delta_for_current(manifest: Dict[str, Any], current_version: str) -> Optional[Dict[str, Any]]:
""" """
从 manifest 的 delta_updates 中取当前版本对应的增量信息。 从 manifest 的 delta_updates 中取当前版本对应的增量信息。
返回 { "patch_url": "...", "new_exe_sha256": "..." } 或 None 支持新的文件级增量 zip,也兼容旧的 ServerManager.exe bsdiff patch
""" """
delta_updates = manifest.get("delta_updates") or {} d = _manifest_delta_map(manifest).get(current_version.strip())
if not isinstance(delta_updates, dict): if not isinstance(d, dict):
return None return None
d = delta_updates.get(current_version.strip()) if get_delta_kind(d) and get_delta_download_url(d):
if isinstance(d, dict) and d.get("patch_url") and d.get("new_exe_sha256"):
return d return d
return None return None
@@ -256,9 +303,9 @@ def apply_update_and_restart(
if not os.path.isfile(new_exe_path): if not os.path.isfile(new_exe_path):
return return
install_dir = os.path.normpath(os.path.dirname(current_exe_path)) 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")) 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): if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
try: try:
@@ -316,6 +363,110 @@ def apply_update_and_restart(
os._exit(0) 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]: def apply_delta_patch(patch_path: Path, expected_new_sha256: str) -> Tuple[bool, str]:
""" """
使用 bsdiff4 应用增量补丁:当前 exe + patch -> 新 exe,校验 sha256 后在本进程内 使用 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]: def _read_default_kv(server_root: str) -> Dict[str, str]:
"""读取合并配置(tool.config > default.kv """读取合并配置(user.config > project.config > default.config
Args: Args:
server_root: 服务器根目录 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]]: def get_required_keys_from_kv(server_root: str, server_type: str) -> Optional[Set[str]]:
"""从 default.kv 读取必须配置项定义 """从 default.config 读取必须配置项定义
Args: Args:
server_root: 服务器根目录 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]]: def get_config_keys_from_kv(server_root: str, server_type: str) -> Optional[Dict[str, str]]:
"""从 default.kv 读取可编辑配置项定义 """从 default.config 读取可编辑配置项定义
Args: Args:
server_root: 服务器根目录 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]: def get_editable_config_from_default_kv(server_root: str) -> Dict[str, str]:
"""从 default.kv 读取编辑对话框可选配置项定义 """从 default.config 读取编辑对话框可选配置项定义
Args: Args:
server_root: 服务器根目录 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]: def get_allowed_config_keys(server_type: str, server_root: str = '') -> Dict[str, str]:
"""获取指定服务器类型允许编辑的配置项 """获取指定服务器类型允许编辑的配置项
优先从 default.kv 读取 config_keys_* 定义,如果没有则使用代码中的默认定义 优先从 default.config 读取 config_keys_* 定义,如果没有则使用代码中的默认定义
Args: Args:
server_type: 服务器类型 (game/login/client/center/cross) server_type: 服务器类型 (game/login/client/center/cross)
server_root: 服务器根目录(用于读取 default.kv server_root: 服务器根目录(用于读取 default.config
Returns: Returns:
配置项字典 {key: 中文说明} 配置项字典 {key: 中文说明}
""" """
# 优先从 default.kv 读取 # 优先从 default.config 读取
if server_root: if server_root:
config_from_kv = get_config_keys_from_kv(server_root, server_type) config_from_kv = get_config_keys_from_kv(server_root, server_type)
if config_from_kv: if config_from_kv:
@@ -256,12 +256,12 @@ def get_all_valid_keys(server_root: str = '') -> Set[str]:
"""获取所有有效的配置键 """获取所有有效的配置键
Args: Args:
server_root: 服务器根目录(用于读取 default.kv server_root: 服务器根目录(用于读取 default.config
Returns: Returns:
所有有效配置键的集合 所有有效配置键的集合
""" """
# 优先从 default.kv 读取 # 优先从 default.config 读取
if server_root: if server_root:
kv_config = _read_default_kv(server_root) kv_config = _read_default_kv(server_root)
if kv_config: 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]: def get_required_keys(server_type: str, server_root: str = '') -> Set[str]:
"""获取指定服务器类型必须的配置项(不可删除) """获取指定服务器类型必须的配置项(不可删除)
优先从 default.kv 读取 required_keys_* 定义,如果没有则使用代码中的默认定义 优先从 default.config 读取 required_keys_* 定义,如果没有则使用代码中的默认定义
Args: Args:
server_type: 服务器类型 (game/login/client/center/cross) server_type: 服务器类型 (game/login/client/center/cross)
server_root: 服务器根目录(用于读取 default.kv server_root: 服务器根目录(用于读取 default.config
Returns: Returns:
必须的配置项集合 必须的配置项集合
""" """
# 优先从 default.kv 读取 # 优先从 default.config 读取
if server_root: if server_root:
required_from_kv = get_required_keys_from_kv(server_root, server_type) required_from_kv = get_required_keys_from_kv(server_root, server_type)
if required_from_kv: 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_* 定义(编辑对话框专用) 1. default.config 中的 editable_config_* 定义(编辑对话框专用)
2. default.kv 中的 config_keys_* 定义(完整配置项) 2. default.config 中的 config_keys_* 定义(完整配置项)
3. 从模板文件提取 3. 从模板文件提取
4. 代码中的默认定义 4. 代码中的默认定义
@@ -390,7 +390,7 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
Returns: Returns:
配置项字典 {key: 中文说明} 配置项字典 {key: 中文说明}
""" """
# 优先从 default.kv 读取 editable_config_* (编辑对话框专用) # 优先从 default.config 读取 editable_config_* (编辑对话框专用)
editable_from_kv = get_editable_config_from_default_kv(server_root) editable_from_kv = get_editable_config_from_default_kv(server_root)
config_from_kv = get_config_keys_from_kv(server_root, server_type) config_from_kv = get_config_keys_from_kv(server_root, server_type)
if editable_from_kv: 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', '自动热更') merged['auto_reload'] = label or BASE_CONFIG_KEYS.get('auto_reload', '自动热更')
return merged return merged
# 其次从 default.kv 读取 config_keys_* (完整配置项定义) # 其次从 default.config 读取 config_keys_* (完整配置项定义)
if config_from_kv: if config_from_kv:
return 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 ( from services.commands import (
IS_WINDOWS, IS_WINDOWS,
ensure_and_get_server_manager_config_dir, 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_local_ip,
get_project_config_path,
get_server_manager_config_dir, get_server_manager_config_dir,
get_user_config_path,
get_ebin_paths, get_ebin_paths,
get_erl_cmd, get_erl_cmd,
get_escript_cmd, get_escript_cmd,
@@ -45,11 +50,18 @@ from services.commands import (
get_config_list, get_config_list,
get_server_list, get_server_list,
query_remote_servers_from_login_rpc, query_remote_servers_from_login_rpc,
rpc_clear_server_on_center_and_login,
get_login_db_name, get_login_db_name,
get_node_status_cache, get_node_status_cache,
check_nodes_status, check_nodes_status,
check_node_status, check_node_status,
test_mysql_connection,
REBAR3_CMD, REBAR3_CMD,
DEFAULT_CONFIG_FILE,
LEGACY_DEFAULT_CONFIG_FILE,
USER_CONFIG_FILE,
LEGACY_USER_CONFIG_FILE,
PROJECT_CONFIG_FILE,
) )
from services.server_creator import ( from services.server_creator import (
@@ -73,9 +85,13 @@ from services.hot_update import (
fetch_update_manifest, fetch_update_manifest,
version_less, version_less,
get_delta_for_current, get_delta_for_current,
get_delta_kind,
get_delta_download_url,
get_delta_expected_sha256,
download_file as updater_download_file, download_file as updater_download_file,
run_installer_and_exit, run_installer_and_exit,
apply_delta_patch, apply_delta_patch,
apply_file_delta_package,
clean_up_old_version, clean_up_old_version,
) )
+38 -6
View File
@@ -104,8 +104,9 @@ class ClearDatabaseDialog(QDialog):
f"即将清档: {server_name}\n\n" f"即将清档: {server_name}\n\n"
"操作步骤:\n" "操作步骤:\n"
"1. 关闭服务器\n" "1. 关闭服务器\n"
"2. 删除游戏数据库\n" "2. 通知中心服和登录服清档\n"
"3. 删除日志数据库\n\n" "3. 删除游戏数据库\n"
"4. 删除日志数据库\n\n"
"是否继续?", "是否继续?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No QMessageBox.StandardButton.No
@@ -135,8 +136,11 @@ class ClearDatabaseDialog(QDialog):
QMessageBox.critical(self, "错误", f"找不到配置: {kv_config_file}") QMessageBox.critical(self, "错误", f"找不到配置: {kv_config_file}")
return return
# 合并配置(kv.config > tool.config > default.kv # 合并配置(kv.config > user.config > project.config > default.config
merged_cfg = read_merged_config(server_root, server_name) if server_root else read_config_file(str(kv_config_file)) 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_host = merged_cfg.get('db_host', '127.0.0.1')
db_port = int(merged_cfg.get('db_port', '3306')) db_port = int(merged_cfg.get('db_port', '3306'))
db_user = merged_cfg.get('db_user', 'root') db_user = merged_cfg.get('db_user', 'root')
@@ -144,11 +148,34 @@ class ClearDatabaseDialog(QDialog):
db_game = merged_cfg.get('db_game_name', '') db_game = merged_cfg.get('db_game_name', '')
db_log = merged_cfg.get('db_log_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: if not db_game:
QMessageBox.critical(self, "错误", "无法获取游戏数据库名") QMessageBox.critical(self, "错误", "无法获取游戏数据库名")
return 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 erl_path = self.config.get('erlang', 'r25_path', default='') or None
stop_cmd = build_stop_command(server_name, cookie, erl_path=erl_path) stop_cmd = build_stop_command(server_name, cookie, erl_path=erl_path)
@@ -161,7 +188,12 @@ class ClearDatabaseDialog(QDialog):
db_user=db_user, db_user=db_user,
db_pass=db_pass, db_pass=db_pass,
db_game=db_game, 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.finished_signal.connect(self._on_clear_finished)
self.clear_thread.error_signal.connect(self._on_clear_error) self.clear_thread.error_signal.connect(self._on_clear_error)
+1 -1
View File
@@ -296,7 +296,7 @@ class KvConfigEditDialog(QDialog):
pass pass
return '18001' return '18001'
# 其他配置项从合并配置读取(kv.config > tool.config > default.kv # 其他配置项从合并配置读取(kv.config > user.config > project.config > default.config
merged = read_merged_config(self.server_root) merged = read_merged_config(self.server_root)
return merged.get(key, '') return merged.get(key, '')
+1 -1
View File
@@ -66,7 +66,7 @@ class RemoteConnectDialog(QDialog):
self.ip_input.setText(get_local_ip()) self.ip_input.setText(get_local_ip())
form_layout.addRow("目标 IP:", self.ip_input) 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() self.cookie_input = QLineEdit()
default_cookie = self.config.get('server', 'cookie', default='') default_cookie = self.config.get('server', 'cookie', default='')
if not default_cookie: if not default_cookie:
+1 -1
View File
@@ -123,7 +123,7 @@ class ViewAccountsDialog(QDialog):
self.status_label.setText("错误: 需要安装 pymysql") self.status_label.setText("错误: 需要安装 pymysql")
return return
# 从合并配置获取数据库信息(kv.config > tool.config > default.kv # 从合并配置获取数据库信息(kv.config > user.config > project.config > default.config
server_root = self.config.get('workspace', 'server_root', default='') server_root = self.config.get('workspace', 'server_root', default='')
run_dir = self.config.get('workspace', 'run_dir', default='') run_dir = self.config.get('workspace', 'run_dir', default='')
kv_config_file = Path(run_dir) / server_name / 'config' / 'kv.config' kv_config_file = Path(run_dir) / server_name / 'config' / 'kv.config'
+63 -17
View File
@@ -14,6 +14,7 @@ class MainWindow(QMainWindow):
"""主窗口。支持“无项目”启动:先显示欢迎页,打开项目后加载并显示功能页。""" """主窗口。支持“无项目”启动:先显示欢迎页,打开项目后加载并显示功能页。"""
CONSOLE_PANEL_HEIGHT = 345 CONSOLE_PANEL_HEIGHT = 345
AUTO_UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
def __init__(self, config: Optional[Config] = None): def __init__(self, config: Optional[Config] = None):
super().__init__() super().__init__()
@@ -21,6 +22,9 @@ class MainWindow(QMainWindow):
self._project_content_widget = None # 项目内容容器,首次打开项目时创建 self._project_content_widget = None # 项目内容容器,首次打开项目时创建
self._cache_init_worker = None self._cache_init_worker = None
self._startup_update_check_started = False self._startup_update_check_started = False
self._update_check_worker = None
self._update_prompt_active = False
self._last_prompted_update_version = ""
self.init_ui() self.init_ui()
def init_ui(self): def init_ui(self):
@@ -255,6 +259,9 @@ class MainWindow(QMainWindow):
self._status_timer.timeout.connect(self._update_status_time) self._status_timer.timeout.connect(self._update_status_time)
self._status_timer.start(1000) self._status_timer.start(1000)
self._update_status_time() 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() menubar = self.menuBar()
@@ -306,34 +313,55 @@ class MainWindow(QMainWindow):
def showEvent(self, event): def showEvent(self, event):
super().showEvent(event) super().showEvent(event)
# 首次打开后延迟检查远端版本清单,版本不一致则弹窗询问是否更新 # 首次打开后延迟检查一次,随后每 5 分钟在后台检查远端版本清单。
if not self._startup_update_check_started: if not self._startup_update_check_started:
self._startup_update_check_started = True self._startup_update_check_started = True
self._auto_update_timer.start()
QTimer.singleShot(1500, self._start_startup_update_check) QTimer.singleShot(1500, self._start_startup_update_check)
def _start_startup_update_check(self): def _start_startup_update_check(self):
"""启动时在后台检查远端版本清单(仅当配置了 update_url 时)""" """在后台检查远端版本清单(仅当配置了 update_url 时)"""
info = get_version_info() info = get_version_info()
update_url = (info.get("update_url") or "").strip() update_url = (info.get("update_url") or "").strip()
if not update_url: if not update_url:
return return
self._startup_check_worker = UpdateCheckWorker(update_url, self) if self._update_check_worker and self._update_check_worker.isRunning():
self._startup_check_worker.finished_signal.connect(self._on_startup_update_checked) return
self._startup_check_worker.start() 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): def _on_startup_update_checked(self, manifest):
"""启动检查结果:若本地版本与远端版本不一致则弹窗询问是否更新""" """后台检查结果:若远端版本更高则弹窗询问是否更新"""
if not manifest: if not manifest:
return return
current = get_current_version() current = get_current_version()
remote_version = (manifest.get("version") or "").strip() 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 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() release_notes = (manifest.get("release_notes") or "").strip()
msg = f"远程版本 {remote_version} 与当前版本 {current} 不一致" msg = f"发现新版本 {remote_version}(当前 {current}"
if release_notes: if release_notes:
msg += f"\n\n{release_notes}" msg += f"\n\n{release_notes}"
msg += "\n\n是否立即更新?" msg += "\n\n是否立即更新?"
try:
ret = QMessageBox.question( ret = QMessageBox.question(
self, self,
"检查更新", "检查更新",
@@ -341,14 +369,21 @@ class MainWindow(QMainWindow):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.Yes,
) )
finally:
self._update_prompt_active = False
if ret != QMessageBox.StandardButton.Yes: if ret != QMessageBox.StandardButton.Yes:
return return
delta = get_delta_for_current(manifest, current) 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() full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip()
self._update_manifest = manifest # 保存 manifest,增量失败时可用来发起全量更新 self._update_manifest = manifest # 保存 manifest,增量失败时可用来发起全量更新
if has_delta: 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: elif full_url:
self._start_update_download(full_url, use_delta=False) self._start_update_download(full_url, use_delta=False)
else: else:
@@ -411,6 +446,7 @@ class MainWindow(QMainWindow):
def _on_close_project(self): def _on_close_project(self):
self.config.tool_dir = None self.config.tool_dir = None
self.config.user_config_path = None
self.config.tool_config_path = None self.config.tool_config_path = None
self.config.config_error = "未打开项目" self.config.config_error = "未打开项目"
self.config.data = _empty_config_data() self.config.data = _empty_config_data()
@@ -465,7 +501,7 @@ class MainWindow(QMainWindow):
release_notes = manifest.get("release_notes", "") release_notes = manifest.get("release_notes", "")
full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip() full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip()
delta = get_delta_for_current(manifest, current) 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: if not full_url and not has_delta:
QMessageBox.warning( QMessageBox.warning(
self, self,
@@ -492,9 +528,10 @@ class MainWindow(QMainWindow):
self._update_manifest = manifest self._update_manifest = manifest
if clicked == btn_delta and has_delta: if clicked == btn_delta and has_delta:
self._start_update_download( self._start_update_download(
delta["patch_url"], get_delta_download_url(delta),
use_delta=True, use_delta=True,
expected_sha256=delta.get("new_exe_sha256"), expected_sha256=get_delta_expected_sha256(delta),
delta_kind=get_delta_kind(delta),
) )
else: else:
self._start_update_download(full_url, use_delta=False) self._start_update_download(full_url, use_delta=False)
@@ -504,12 +541,17 @@ class MainWindow(QMainWindow):
download_url: str, download_url: str,
use_delta: bool = False, use_delta: bool = False,
expected_sha256: Optional[str] = None, expected_sha256: Optional[str] = None,
delta_kind: str = "",
): ):
"""下载更新包(全量 exe 或增量 patch),完成后全量则启动安装,增量则 bsdiff 打补丁并替换""" """下载更新包(全量 exe 或增量 patch),完成后全量则启动安装,增量则 bsdiff 打补丁并替换"""
dest = Path(tempfile.gettempdir()) / "ServerManager_Update" dest = Path(tempfile.gettempdir()) / "ServerManager_Update"
dest.mkdir(parents=True, exist_ok=True) 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}") 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_download_path = dest_file
self._update_progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self) self._update_progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self)
self._update_progress.setWindowTitle("检查更新") self._update_progress.setWindowTitle("检查更新")
@@ -542,13 +584,17 @@ class MainWindow(QMainWindow):
if not path or not Path(path).exists(): if not path or not Path(path).exists():
QMessageBox.warning(self, "检查更新", "下载失败,请稍后重试或手动下载。") QMessageBox.warning(self, "检查更新", "下载失败,请稍后重试或手动下载。")
return return
if is_delta and expected_sha256: if is_delta:
QMessageBox.information( QMessageBox.information(
self, 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: if ok:
pass # 成功时进程会退出 pass # 成功时进程会退出
else: else:
+73 -20
View File
@@ -43,7 +43,7 @@ class ConfigTab(QWidget):
self.init_ui() self.init_ui()
def _load_default_kv(self): def _load_default_kv(self):
"""读取合并配置(tool.config > default.kv""" """读取合并配置(user.config > project.config > default.config"""
server_root = self.config.get('workspace', 'server_root', default='') server_root = self.config.get('workspace', 'server_root', default='')
if not server_root: if not server_root:
return return
@@ -59,17 +59,17 @@ class ConfigTab(QWidget):
return value return value
def _get_config_value(self, config_keys: tuple, default_kv_key: str, fallback=''): def _get_config_value(self, config_keys: tuple, default_kv_key: str, fallback=''):
"""获取配置值:优先从 tool.config 读取,没有值则从 default.kv 读取 """获取配置值:优先从 user.config 读取,没有值则从 project.config/default.config 读取
Args: Args:
config_keys: tool.config 的键路径,如 ('database', 'host') config_keys: user.config 的键路径,如 ('database', 'host')
default_kv_key: default.kv 中的键名,如 'db_host' default_kv_key: default.config 中的键名,如 'db_host'
fallback: 最终的默认值 fallback: 最终的默认值
""" """
# 先从 tool.config 读取 # 先从 user.config 读取
value = self.config.get(*config_keys, default=None) value = self.config.get(*config_keys, default=None)
# 如果为空,从 default.kv 读取 # 如果为空,从 project.config/default.config 读取
if value is None or value == '' or value == 0: if value is None or value == '' or value == 0:
value = self._get_default(default_kv_key, str(fallback) if fallback else '') value = self._get_default(default_kv_key, str(fallback) if fallback else '')
@@ -125,6 +125,12 @@ class ConfigTab(QWidget):
form.addRow(label, row) form.addRow(label, row)
return btn 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]: def make_form_group(title: str, icon_kind: str = "settings") -> tuple[QFrame, QFormLayout]:
group = QFrame() group = QFrame()
group.setObjectName("settingsFormCard") group.setObjectName("settingsFormCard")
@@ -174,8 +180,8 @@ class ConfigTab(QWidget):
project_group, project_layout = make_form_group("项目目录配置", "file") project_group, project_layout = make_form_group("项目目录配置", "file")
self.server_root = QLineEdit(self.config.get('workspace', 'server_root', default='')) self.server_root = QLineEdit(self.config.get('workspace', 'server_root', default=''))
self.run_dir = QLineEdit(self.config.get('workspace', 'run_dir', default='')) self.run_dir = QLineEdit(self.config.get('workspace', 'run_dir', default=''))
add_browse_row(project_layout, "服务器根目录:", self.server_root) add_readonly_row(project_layout, "服务器根目录:", self.server_root)
add_browse_row(project_layout, "运行目录:", self.run_dir) add_readonly_row(project_layout, "运行目录:", self.run_dir)
content_layout.addWidget(project_group) content_layout.addWidget(project_group)
# 启动窗口配置 # 启动窗口配置
@@ -215,6 +221,15 @@ class ConfigTab(QWidget):
db_layout.addRow("数据库端口:", self.db_port) db_layout.addRow("数据库端口:", self.db_port)
db_layout.addRow("数据库用户:", self.db_user) db_layout.addRow("数据库用户:", self.db_user)
db_layout.addRow("数据库密码:", self.db_pass) 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) content_layout.addWidget(db_group)
# 服务器默认配置 # 服务器默认配置
@@ -557,24 +572,65 @@ class ConfigTab(QWidget):
"请核对节点名、Cookie、网络与防火墙,并确认上方 Erlang 路径可用。" "请核对节点名、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): def _fill_default_values(self):
"""从 default.kv 填充默认值到表单""" """从 default.config 填充默认值到表单"""
server_root = self.server_root.text() server_root = self.server_root.text()
if not server_root: if not server_root:
QMessageBox.warning(self, "警告", "请先设置服务器根目录") QMessageBox.warning(self, "警告", "请先设置服务器根目录")
return return
default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv' default_kv_path = find_existing_default_config_path(server_root)
if not default_kv_path.exists(): if not default_kv_path:
expected_path = get_default_config_path(server_root)
QMessageBox.warning(self, "警告", QMessageBox.warning(self, "警告",
f"找不到默认配置文件:\n{default_kv_path}\n\n请检查工作路径是否正确") f"找不到默认配置文件:\n{expected_path}\n\n请检查工作路径是否正确")
return return
# 重新加载 default.kv # 重新加载 default.config
self.default_kv = read_config_file(str(default_kv_path)) self.default_kv = read_config_file(str(default_kv_path))
if not self.default_kv: if not self.default_kv:
QMessageBox.warning(self, "警告", "default.kv 文件为空或格式错误") QMessageBox.warning(self, "警告", "default.config 文件为空或格式错误")
return return
# 填充数据库配置 # 填充数据库配置
@@ -612,10 +668,10 @@ class ConfigTab(QWidget):
if 'cookie' in self.default_kv: if 'cookie' in self.default_kv:
self.cookie.setText(self.default_kv['cookie']) 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): def save_config(self):
"""保存配置到 .server_manager/config/tool.config""" """保存配置到 .server_manager/config/user.config"""
# 保存前记录当前 Erlang 路径,用于判断版本是否变更 # 保存前记录当前 Erlang 路径,用于判断版本是否变更
old_r25_path = (self.config.get('erlang', 'r25_path', default='') or '').strip() old_r25_path = (self.config.get('erlang', 'r25_path', default='') or '').strip()
new_r25_path = self.r25_path.text().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}") QMessageBox.warning(self, "警告", f"未在 PATH 中找到运行 EXE:\n{terminal_exe_path}")
return 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_host.text(), 'database', 'host')
self.config.set(self.db_user.text(), 'database', 'user') self.config.set(self.db_user.text(), 'database', 'user')
self.config.set(self.db_pass.text(), 'database', 'password') self.config.set(self.db_pass.text(), 'database', 'password')
@@ -694,4 +747,4 @@ class ConfigTab(QWidget):
# 发出配置保存信号,通知其他页签刷新 # 发出配置保存信号,通知其他页签刷新
self.config_saved.emit() 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 # 初始化完成 self._initializing = False # 初始化完成
def _load_default_kv(self): def _load_default_kv(self):
"""读取合并配置(tool.config > default.kv""" """读取合并配置(user.config > project.config > default.config"""
server_root = self.config.get('workspace', 'server_root', default='') server_root = self.config.get('workspace', 'server_root', default='')
if not server_root: if not server_root:
return return
@@ -97,7 +97,7 @@ class CreateServerTab(QWidget):
type_layout.addRow(self.server_id_label, self.server_id) 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='') default_server_name = self.config.get('server', 'server_name', default='')
if not default_server_name: if not default_server_name:
default_server_name = self.default_kv.get('server_name', '未命名服_1') 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) self.use_default_db.stateChanged.connect(self._toggle_db_fields)
db_layout.addRow("", self.use_default_db) 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_host = self.config.get('database', 'host', default='')
default_db_user = self.config.get('database', 'user', default='') default_db_user = self.config.get('database', 'user', default='')
default_db_pass = self.config.get('database', 'password', 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) self.use_default_nodes.stateChanged.connect(self._toggle_node_fields)
node_layout.addRow("", self.use_default_nodes) 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='') default_login_node = self.config.get('server', 'login_server_node', default='')
if not default_login_node: if not default_login_node:
default_login_node = self.default_kv.get('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.setMinimumHeight(30)
self.login_node_label = QLabel("登录服节点:") 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='') default_center_node = self.config.get('server', 'center_server_node', default='')
if not default_center_node: if not default_center_node:
default_center_node = self.default_kv.get('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() server_type = self.server_type_combo.currentData()
# 根据服务器类型设置ID范围(从 default.kv 读取) # 根据服务器类型设置ID范围(从 default.config 读取)
# 格式: {type}_id_min, {type}_id_max, {type}_id_default # 格式: {type}_id_min, {type}_id_max, {type}_id_default
default_ranges = { default_ranges = {
'game': (100, 9999, 100), 'game': (100, 9999, 100),
@@ -609,7 +609,7 @@ class CreateServerTab(QWidget):
return return
# 获取节点配置 - 直接使用输入框中显示的值 # 获取节点配置 - 直接使用输入框中显示的值
# 输入框的值已经在初始化时从 tool.config 或 default.kv 读取 # 输入框的值已经在初始化时从 user.config 或 project.config/default.config 读取
login_node = self.login_node.text().strip() login_node = self.login_node.text().strip()
center_node = self.center_node.text().strip() center_node = self.center_node.text().strip()
@@ -780,10 +780,10 @@ class CreateServerTab(QWidget):
def refresh_config(self): def refresh_config(self):
"""刷新配置(当工具设置保存后调用)""" """刷新配置(当工具设置保存后调用)"""
# 重新加载 tool.config # 重新加载 user.config
self.config.data = self.config._load_config() self.config.data = self.config._load_config()
# 重新加载 default.kv # 重新加载 default.config/project.config
self._load_default_kv() self._load_default_kv()
# 更新数据库配置输入框 # 更新数据库配置输入框
+8 -7
View File
@@ -29,7 +29,7 @@ from views.dialogs import (
) )
class DefaultKvTab(QWidget): class DefaultKvTab(QWidget):
"""默认配置编辑页面 - 编辑 .server_manager/config/default.kv""" """默认配置编辑页面 - 编辑 .server_manager/config/default.config"""
def __init__(self, config: Config, console: OutputConsole): def __init__(self, config: Config, console: OutputConsole):
super().__init__() super().__init__()
@@ -45,7 +45,7 @@ class DefaultKvTab(QWidget):
layout.setSpacing(10) 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;") title.setStyleSheet("font-size: 16px; font-weight: bold; color: #25c889;")
layout.addWidget(title) layout.addWidget(title)
@@ -123,14 +123,15 @@ class DefaultKvTab(QWidget):
layout.addLayout(btn_layout) layout.addLayout(btn_layout)
def load_default_kv(self): def load_default_kv(self):
"""加载 default.kv 配置""" """加载 default.config 配置"""
server_root = self.config.get('workspace', 'server_root', default='') server_root = self.config.get('workspace', 'server_root', default='')
if not server_root: if not server_root:
self.console.append_output("[警告] 请先配置服务器根目录\n") self.console.append_output("[警告] 请先配置服务器根目录\n")
return return
self.default_kv_path = get_server_manager_config_dir(server_root) / 'default.kv' load_path = find_existing_default_config_path(server_root)
if not self.default_kv_path.exists(): self.default_kv_path = get_default_config_path(server_root)
if not load_path:
self.console.append_output(f"[警告] 默认配置文件不存在: {self.default_kv_path}\n") self.console.append_output(f"[警告] 默认配置文件不存在: {self.default_kv_path}\n")
return return
@@ -139,7 +140,7 @@ class DefaultKvTab(QWidget):
self.categories = {} self.categories = {}
current_category = "其他" 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: for line in f:
line = line.strip() line = line.strip()
if line.startswith('# ===') and line.endswith('==='): if line.startswith('# ===') and line.endswith('==='):
@@ -259,7 +260,7 @@ class DefaultKvTab(QWidget):
self.console.append_output(f"[信息] 已删除配置项: {key}\n") self.console.append_output(f"[信息] 已删除配置项: {key}\n")
def save_default_kv(self): def save_default_kv(self):
"""保存配置到 default.kv""" """保存配置到 default.config"""
if not self.default_kv_path: if not self.default_kv_path:
QMessageBox.warning(self, "警告", "请先加载配置文件") QMessageBox.warning(self, "警告", "请先加载配置文件")
return return
+1 -1
View File
@@ -344,7 +344,7 @@ class ServerManageRemoteMixin:
def _connect_to_remote_node(self, server_node: str): 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='') server_root = self.config.get('workspace', 'server_root', default='')
cookie = self.config.get('server', 'cookie', default='') cookie = self.config.get('server', 'cookie', default='')
if not cookie and server_root: if not cookie and server_root:
+7 -6
View File
@@ -1,8 +1,9 @@
{ {
"version": "1.1.3", "version": "1.1.8",
"update_url": "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json", "update_url": "https://www.sumi.work/api/software/server-manager/version.json",
"release_notes": "增加启停按钮", "release_notes": "支持文件级增量更新包,减少后续版本更新带宽。",
"download_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe", "download_url": "https://www.sumi.work/api/software/server-manager/update-download",
"full_installer_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe", "full_installer_url": "https://www.sumi.work/api/software/server-manager/update-download",
"delta_updates": {} "delta_updates": {},
"deltaUpdates": {}
} }