换位置
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 客户端配置示例
|
## 客户端配置示例
|
||||||
|
|||||||
@@ -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 GUI;Windows/Linux 下提供 CLI。
|
2. Windows 下提供 PyQt6 GUI;Windows/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.py:Tkinter 示例/遗留入口,LOCAL_VERSION = "1.0.46",演示 updater 自动更新集成;真实桌面工具入口仍以 src/server_manager_gui.py 为准。
|
- main.py:Tkinter 示例/遗留入口,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.iss:Inno Setup 脚本,正式版 AppName=ServerManager,测试版 AppName=ServerManager (本地测试),安装 src/dist/main/*、src/dist/mini_updater.exe、version.json、output/config/*。
|
- build_installer.iss:Inno 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.example:Erlang sys.config 模板,使用 ${key} 占位符。
|
- sys_game.config.example、sys_login.config.example、sys_center.config.example、sys_cross.config.example、sys_client.config.example:Erlang 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 图标:
|
|||||||
- center:auto_reload、open_time、login_node
|
- center:auto_reload、open_time、login_node
|
||||||
- cross:auto_reload、open_time、center_node
|
- cross:auto_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_config:tool.config 为空时从 default.kv 补齐;首次运行自动创建 run_dir;需要保存补齐后的 tool.config。
|
- _load_config:user.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:工具设置。
|
||||||
- DefaultKvTab:default.kv 编辑。
|
- DefaultKvTab:default.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 必须:
|
||||||
|
|
||||||
- 找新 exe:src/dist/main/main.exe,找不到再找 src/dist/main.exe。
|
- 找新 exe:src/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)`,颜色 #25c889,16px,加粗。
|
- 标题:`默认配置管理 (.server_manager/config/default.config)`,颜色 #25c889,16px,加粗。
|
||||||
- 描述:`此文件定义创建服务器时的默认值,支持 ${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-8,strip 空白,跳过空行和 `#` 注释。
|
- 逐行读取 UTF-8,strip 空白,跳过空行和 `#` 注释。
|
||||||
- 只解析第一处 `=`,左右 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 支持全量/增量更新流程。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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`,客户端更新服务会后台静默准备增量或全量,就绪后弹「重启以更新」。
|
||||||
|
|
||||||
|
|||||||
@@ -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` 中已有同名配置,会覆盖上层配置。
|
||||||
@@ -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, #{
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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, #{
|
||||||
|
|||||||
@@ -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, #{
|
||||||
|
|||||||
@@ -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, #{
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 打包未成功
|
||||||
|
|||||||
+227
-59
@@ -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)
|
||||||
|
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)
|
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
@@ -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
@@ -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 打包的 main(Linux):环境变量 SERVER_MANAGER_MAIN,或常见构建路径 src/dist/main/main
|
# 优先使用 PyInstaller 打包的 ServerManager(Linux):环境变量 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
|
||||||
|
|
||||||
# 颜色输出
|
# 颜色输出
|
||||||
|
|||||||
+195
-30
@@ -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 检测用 ctypes(Windows)或 os.kill(Unix)
|
# 仅标准库:PID 检测用 ctypes(Windows)或 os.kill(Unix)
|
||||||
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,36 +254,40 @@ def main() -> None:
|
|||||||
_log_error(install_dir, "切换到安装目录失败", e)
|
_log_error(install_dir, "切换到安装目录失败", e)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 若存在上次遗留的 .old,先静默删除
|
if delta_zip_path:
|
||||||
if os.path.isfile(old_exe_full):
|
if not apply_delta_zip(install_dir, delta_zip_path):
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
# 若存在上次遗留的 .old,先静默删除
|
||||||
|
if os.path.isfile(old_exe_full):
|
||||||
|
try:
|
||||||
|
os.remove(old_exe_full)
|
||||||
|
except Exception as e:
|
||||||
|
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
|
||||||
|
|
||||||
|
# 将当前主程序重命名为 .old,腾出位置
|
||||||
|
if not os.path.isfile(target_exe_full):
|
||||||
|
_log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
|
||||||
|
sys.exit(1)
|
||||||
try:
|
try:
|
||||||
os.remove(old_exe_full)
|
os.rename(target_exe_full, old_exe_full)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
|
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# 将当前主程序重命名为 .old,腾出位置
|
# 将新 exe 移入安装目录并命名为 target-exe-name
|
||||||
if not os.path.isfile(target_exe_full):
|
if not os.path.isfile(new_exe_path):
|
||||||
_log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
|
_log_error(install_dir, "新 exe 不存在: %s" % new_exe_path)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
try:
|
|
||||||
os.rename(target_exe_full, old_exe_full)
|
|
||||||
except Exception as e:
|
|
||||||
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 将新 exe 移入安装目录并命名为 target-exe-name
|
|
||||||
if not os.path.isfile(new_exe_path):
|
|
||||||
_log_error(install_dir, "新 exe 不存在: %s" % new_exe_path)
|
|
||||||
sys.exit(1)
|
|
||||||
try:
|
|
||||||
shutil.move(new_exe_path, target_exe_full)
|
|
||||||
except Exception as e:
|
|
||||||
_log_error(install_dir, "移动新 exe 到安装目录失败", e)
|
|
||||||
try:
|
try:
|
||||||
os.rename(old_exe_full, target_exe_full)
|
shutil.move(new_exe_path, target_exe_full)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
_log_error(install_dir, "移动新 exe 到安装目录失败", e)
|
||||||
sys.exit(1)
|
try:
|
||||||
|
os.rename(old_exe_full, target_exe_full)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS
|
# 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS
|
||||||
|
|||||||
@@ -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. 生成差分补丁(需安装 bsdiff4:pip install bsdiff4):
|
3. 生成差分补丁(需安装 bsdiff4:pip 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 "")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,42 +61,48 @@ 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(config_path, 'r', encoding='utf-8') as f:
|
||||||
with open(tool_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}='):
|
value = line.split('=', 1)[1].strip()
|
||||||
value = line.split('=', 1)[1].strip()
|
if value:
|
||||||
if value:
|
return value
|
||||||
return value
|
except Exception:
|
||||||
except Exception:
|
pass
|
||||||
pass
|
|
||||||
|
|
||||||
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')
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
merged = {}
|
||||||
# 1. default.kv(最低优先级)
|
|
||||||
default_kv_file = config_path / 'default.kv'
|
# Priority, low to high: default.config < config/project.config < user.config.
|
||||||
default_config_file = config_path / 'default.config'
|
default_config_file = _first_existing_config((
|
||||||
if default_kv_file.exists():
|
config_path / DEFAULT_CONFIG_FILE,
|
||||||
merged = read_config_file(str(default_kv_file))
|
config_path / LEGACY_DEFAULT_CONFIG_FILE,
|
||||||
elif default_config_file.exists():
|
))
|
||||||
merged = read_config_file(str(default_config_file))
|
if default_config_file:
|
||||||
else:
|
merged.update(read_config_file(str(default_config_file)))
|
||||||
merged = {}
|
|
||||||
|
project_config_file = get_project_config_path(server_root)
|
||||||
# 2. tool.config(.server_manager/config 下)
|
if project_config_file.exists():
|
||||||
tool_config_file = config_path / 'tool.config'
|
merged.update(read_config_file(str(project_config_file)))
|
||||||
if tool_config_file.exists():
|
|
||||||
merged.update(read_config_file(str(tool_config_file)))
|
user_config_file = _first_existing_config((
|
||||||
|
config_path / USER_CONFIG_FILE,
|
||||||
# 3. 运行目录下的配置(run_dir):优先用运行目录的 tool.config,便于命令行从运行目录取 IP 等
|
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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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/config(default.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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ IS_LINUX = platform.system() == 'Linux'
|
|||||||
# rebar3 命令:Linux 使用当前目录下的 ./rebar3,Windows 使用 rebar3.cmd
|
# rebar3 命令:Linux 使用当前目录下的 ./rebar3,Windows 使用 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
@@ -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 后在本进程内
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +136,46 @@ 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')
|
||||||
db_pass = merged_cfg.get('db_pass', '')
|
db_pass = merged_cfg.get('db_pass', '')
|
||||||
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)
|
||||||
|
|||||||
@@ -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, '')
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+70
-24
@@ -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,49 +313,77 @@ 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是否立即更新?"
|
||||||
ret = QMessageBox.question(
|
try:
|
||||||
self,
|
ret = QMessageBox.question(
|
||||||
"检查更新",
|
self,
|
||||||
msg,
|
"检查更新",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
msg,
|
||||||
QMessageBox.StandardButton.Yes,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# 服务器默认配置
|
# 服务器默认配置
|
||||||
@@ -556,25 +571,66 @@ class ConfigTab(QWidget):
|
|||||||
f"无法连通{kind_label}节点:\n{node}\n\n"
|
f"无法连通{kind_label}节点:\n{node}\n\n"
|
||||||
"请核对节点名、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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
# 更新数据库配置输入框
|
# 更新数据库配置输入框
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ class ServerManageRemoteMixin:
|
|||||||
|
|
||||||
def _connect_to_remote_node(self, server_node: str):
|
def _connect_to_remote_node(self, server_node: str):
|
||||||
"""连接到远程节点"""
|
"""连接到远程节点"""
|
||||||
# 读取 cookie(kv.config > tool.config > default.kv)
|
# 读取 cookie(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='')
|
||||||
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
@@ -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": {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user