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