换位置

This commit is contained in:
lwt
2026-06-09 14:01:20 +08:00
parent 6d86678e11
commit 0b34f2e3d0
53 changed files with 1762 additions and 603 deletions
+2
View File
@@ -4,6 +4,7 @@
from . import (
command_builders as _command_builders,
config_files as _config_files,
database as _database,
discovery as _discovery,
erlang as _erlang,
migrations as _migrations,
@@ -23,6 +24,7 @@ _modules = (
_terminal,
_window_registry,
_command_builders,
_database,
_config_files,
_migrations,
_discovery,
+9 -4
View File
@@ -24,6 +24,11 @@ def get_rebar_profile(server_type: str) -> tuple:
return ('game_server_dev', 'game_server')
def _build_rebar_eval_expr(app_name: str) -> str:
# Keep the leading dummy expression; rebar3/getopt can split --eval values on commas.
return f'-1,application:ensure_all_started({app_name}).'
def build_start_command_by_config(server_root: str, merged_config: Dict[str, str],
cookie: str, use_rebar: bool = True,
erl_path: str = None) -> str:
@@ -58,10 +63,10 @@ def build_start_command_by_config(server_root: str, merged_config: Dict[str, str
if use_rebar:
rebar3 = get_rebar3_cmd(server_root, erl_path)
eval_expr = f'application:ensure_all_started({app_name}).'
eval_expr = _build_rebar_eval_expr(app_name)
cmd = (
f'{rebar3} as {profile} shell '
f'--eval -1,"{eval_expr}" '
f'--eval "{eval_expr}" '
f'--setcookie {cookie} '
f'--name {node_name} '
f'--config "{config_file}"'
@@ -127,10 +132,10 @@ def build_start_command(server_root: str, server_name: str, cookie: str,
if use_rebar:
rebar3 = get_rebar3_cmd(server_root, erl_path)
eval_expr = f'application:ensure_all_started({app_name}).'
eval_expr = _build_rebar_eval_expr(app_name)
cmd = (
f'{rebar3} as {profile} shell '
f'--eval -1,"{eval_expr}" '
f'--eval "{eval_expr}" '
f'--setcookie {cookie} '
f'--name {server_name}@{ip} '
f'--config "{config_file}"'
+41 -27
View File
@@ -3,7 +3,11 @@
from .shared import *
from .erlang import get_local_ip
from .project_config import get_server_manager_config_dir
from .project_config import get_project_config_path, get_server_manager_config_dir
def _first_existing_config(paths: Tuple[Path, ...]) -> Optional[Path]:
return next((path for path in paths if path.exists()), None)
def read_merged_config(
server_root: str,
@@ -12,14 +16,14 @@ def read_merged_config(
) -> Dict[str, str]:
"""读取合并后的配置(优先级从低到高)
优先级:default.kv < tool.config(项目根/.server_manager/config) < 运行目录下配置(run_dir) < kv.config
优先级:default.config < config/project.config < user.config(项目根/.server_manager/config) < 运行目录下配置(run_dir) < kv.config
Args:
server_root: 服务器根目录
server_dir: 服务器目录名(可选,如 ddxq_game_s1)。
若提供,则读取 运行目录/{server_dir}/config/kv.config 作为最高优先级。
run_dir: 运行目录(可选)。若提供,则从运行目录下读取配置:
- run_dir/config/tool.config 或 run_dir/tool.config
- run_dir/config/user.config 或 run_dir/user.config(兼容旧用户配置文件)
- run_dir/server_dir/config/kv.config(与 server_dir 同时提供时)
用于命令行等场景下“用运行目录的配置”获取 IP 等。
@@ -27,28 +31,38 @@ def read_merged_config(
合并后的配置字典
"""
config_path = get_server_manager_config_dir(server_root)
# 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/<server_dir>/<tail>。
形如 run/xxx/log 时只取第 3 段及之后,避免 default.kv 中的模板值
形如 run/xxx/log 时只取第 3 段及之后,避免 default.config 中的模板值
run/server/log 污染具体服务器的实际路径。
"""
raw = (raw or '').strip('"').strip("'").strip()
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Database connectivity helpers."""
from .shared import Any, Dict
def test_mysql_connection(
db_host: str,
db_port: int,
db_user: str,
db_pass: str,
timeout: int = 5,
) -> Dict[str, Any]:
"""Open a real MySQL connection and run a lightweight probe query."""
try:
import pymysql
except ImportError as exc:
raise ImportError("需要安装 pymysql: pip install pymysql") from exc
conn = None
cursor = None
try:
conn = pymysql.connect(
host=str(db_host).strip(),
port=int(db_port),
user=str(db_user).strip(),
password=db_pass or "",
charset="utf8mb4",
connect_timeout=timeout,
read_timeout=timeout,
write_timeout=timeout,
autocommit=True,
)
conn.ping(reconnect=False)
cursor = conn.cursor()
cursor.execute("SELECT 1, VERSION(), CURRENT_USER()")
row = cursor.fetchone() or ()
return {
"ok": True,
"server_version": str(row[1]) if len(row) > 1 and row[1] is not None else "",
"current_user": str(row[2]) if len(row) > 2 and row[2] is not None else "",
}
finally:
if cursor is not None:
cursor.close()
if conn is not None:
conn.close()
+8 -1
View File
@@ -13,6 +13,7 @@ _MIGRATIONS = [
# 2026-04 版本:模板新增 ${role_log} 占位符,需要为所有已有服务器重新生成
# sys.config,让 role_log 替换为 run/<server_dir>/log。
'sys_config_role_log_v1',
'sys_config_logger_no_domain_v1',
]
@@ -91,15 +92,21 @@ def _migrate_sys_config_role_log_v1(server_root) -> Tuple[int, int, List[str]]:
对应改动:sys_game.config.example 新增 ${role_log} 占位符,游戏服旧版
sys.config 里还没有 role_log 字段;generate_start_config 会根据当前
default.kv 里的 role_log 自动计算出 run/<server_dir>/log 并替换。
default.config 里的 role_log 自动计算出 run/<server_dir>/log 并替换。
"""
return _regenerate_all_sys_configs(server_root)
def _migrate_sys_config_logger_no_domain_v1(server_root) -> Tuple[int, int, List[str]]:
"""Regenerate sys.config so existing servers use no_domain logger filters."""
return _regenerate_all_sys_configs(server_root)
# 迁移 id -> 执行函数。函数返回 True 表示迁移执行(即使个别服务器失败也算已执行)。
_MIGRATION_RUNNERS = {
'sys_config_log_dir_v2': _migrate_sys_config_log_dir_v2,
'sys_config_role_log_v1': _migrate_sys_config_role_log_v1,
'sys_config_logger_no_domain_v1': _migrate_sys_config_logger_no_domain_v1,
}
+84 -22
View File
@@ -3,51 +3,111 @@
from .shared import *
def get_server_manager_config_dir(server_root: Union[str, Path]) -> Path:
"""工具托管的项目配置目录:项目根/.server_manager/configdefault.kv、tool.config、模板等)。"""
"""Tool-managed project config directory: <project>/.server_manager/config."""
return Path(server_root).resolve() / '.server_manager' / 'config'
def get_bundled_tool_config_template_dir() -> Path:
"""软件自带的 config 模板目录(安装后与 main.exe 同级下的 config/)。
源码运行时使用仓库根目录的 resources/config(与安装包内容一致)。
"""
"""Bundled config template directory."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).resolve().parent / 'config'
return Path(__file__).resolve().parents[3] / 'resources' / 'config'
def get_default_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / DEFAULT_CONFIG_FILE
def get_legacy_default_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / LEGACY_DEFAULT_CONFIG_FILE
def get_user_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / USER_CONFIG_FILE
def get_legacy_user_config_path(project_root: Union[str, Path]) -> Path:
return get_server_manager_config_dir(project_root) / LEGACY_USER_CONFIG_FILE
def get_project_config_path(project_root: Union[str, Path]) -> Path:
return Path(project_root).resolve() / 'config' / PROJECT_CONFIG_FILE
def _first_existing(paths: Tuple[Path, ...]) -> Optional[Path]:
return next((path for path in paths if path.exists()), None)
def find_existing_default_config_path(project_root: Union[str, Path]) -> Optional[Path]:
return _first_existing((
get_default_config_path(project_root),
get_legacy_default_config_path(project_root),
))
def find_existing_user_config_path(project_root: Union[str, Path]) -> Optional[Path]:
return _first_existing((
get_user_config_path(project_root),
get_legacy_user_config_path(project_root),
))
def _migrate_legacy_config_names(project_root: Path) -> None:
"""Move legacy project config names to the current names when possible."""
cfg = get_server_manager_config_dir(project_root)
renames = (
(cfg / LEGACY_USER_CONFIG_FILE, cfg / USER_CONFIG_FILE),
(cfg / LEGACY_DEFAULT_CONFIG_FILE, cfg / DEFAULT_CONFIG_FILE),
)
for old_path, new_path in renames:
if old_path.exists() and not new_path.exists():
try:
shutil.move(str(old_path), str(new_path))
except Exception:
pass
def sync_server_manager_config_templates(project_root: Path) -> Tuple[bool, str]:
"""将软件自带的受管模板文件同步到项目 .server_manager/config"""
"""Sync bundled managed template files into <project>/.server_manager/config."""
project_root = Path(project_root).resolve()
src = get_bundled_tool_config_template_dir()
dst = get_server_manager_config_dir(project_root)
if not src.is_dir():
return False, (
f'找不到软件自带的配置模板目录: {src}\n'
'请确认安装目录下存在 config 文件夹,或开发环境已准备 resources/config'
f'Cannot find bundled config template directory: {src}\n'
'Please make sure the install directory or resources/config is complete.'
)
if not (src / 'default.kv').exists():
default_src = src / DEFAULT_CONFIG_FILE
if not default_src.exists():
legacy_default_src = src / LEGACY_DEFAULT_CONFIG_FILE
default_src = legacy_default_src if legacy_default_src.exists() else default_src
if not default_src.exists():
return False, (
f'软件配置模板不完整(缺少 default.kv): {src}'
f'Bundled config templates are incomplete; missing {DEFAULT_CONFIG_FILE}: {src}'
)
try:
dst.mkdir(parents=True, exist_ok=True)
_migrate_legacy_config_names(project_root)
for file_name in MANAGED_CONFIG_TEMPLATE_FILES:
src_file = src / file_name
src_file = default_src if file_name == DEFAULT_CONFIG_FILE else src / file_name
if not src_file.exists():
return False, f'软件配置模板不完整(缺少 {file_name}: {src}'
shutil.copy2(src_file, dst / file_name)
return False, f'Bundled config templates are incomplete; missing {file_name}: {src}'
dst_file = dst / file_name
if file_name == DEFAULT_CONFIG_FILE and dst_file.exists():
continue
shutil.copy2(src_file, dst_file)
except Exception as e:
return False, f'同步软件配置模板到项目失败: {e}'
return False, f'Failed to sync config templates into project: {e}'
return True, ""
def ensure_server_manager_config(project_root: Path) -> Tuple[bool, str]:
"""确保项目存在 .server_manager/config,并同步受管模板文件。"""
"""Ensure .server_manager/config exists and bundled templates have been synced."""
project_root = Path(project_root).resolve()
sm = get_server_manager_config_dir(project_root)
@@ -55,29 +115,31 @@ def ensure_server_manager_config(project_root: Path) -> Tuple[bool, str]:
if not sm.is_dir():
(project_root / '.server_manager').mkdir(parents=True, exist_ok=True)
except Exception as e:
return False, f'创建项目配置目录失败: {e}'
return False, f'Failed to create project config directory: {e}'
return sync_server_manager_config_templates(project_root)
def ensure_and_get_server_manager_config_dir(project_root: Path) -> Tuple[Optional[Path], str]:
"""确保存在工具配置目录且含 default.kv;成功返回该目录 Path。"""
"""Ensure the tool config directory exists and contains default.config."""
ok, err = ensure_server_manager_config(project_root)
if not ok:
return None, err
cfg = get_server_manager_config_dir(project_root)
if not (cfg / 'default.kv').exists():
if not (cfg / DEFAULT_CONFIG_FILE).exists():
return None, (
'项目配置不完整:.server_manager/config 下缺少 default.kv'
'(请检查软件安装目录下 config 模板是否完整)'
f'Project config is incomplete: .server_manager/config is missing {DEFAULT_CONFIG_FILE}'
)
return cfg, ""
def project_has_default_kv_for_manager(project_root: Union[str, Path]) -> bool:
"""用于最近项目列表等:是否存在可识别的 default.kv(含尚未迁移的 config/)。"""
"""Backward-compatible project validity check used by recent-project lists."""
p = Path(project_root)
return (
(get_server_manager_config_dir(p) / 'default.kv').exists()
or (p / 'config' / 'default.kv').exists()
(get_server_manager_config_dir(p) / DEFAULT_CONFIG_FILE).exists()
or (get_server_manager_config_dir(p) / LEGACY_DEFAULT_CONFIG_FILE).exists()
or (p / 'config' / PROJECT_CONFIG_FILE).exists()
or (p / 'config' / DEFAULT_CONFIG_FILE).exists()
or (p / 'config' / LEGACY_DEFAULT_CONFIG_FILE).exists()
)
+74
View File
@@ -340,6 +340,80 @@ def _argv_to_shell_line(args: List[str]) -> str:
return shlex.join(args)
def rpc_clear_server_on_center_and_login(
center_node: str,
login_node: str,
server_id: Union[int, str],
cookie: str,
erl_path: Optional[str] = None,
timeout: int = 45,
) -> Tuple[int, str, str, str]:
"""Notify center/login nodes to clear a game server.
Returns:
(returncode, stdout, stderr, full_command_line)
"""
clean_center_node = str(center_node or "").strip().strip("'\"")
clean_login_node = str(login_node or "").strip().strip("'\"")
if not clean_center_node:
raise ValueError("center_node is empty")
if not clean_login_node:
raise ValueError("login_node is empty")
try:
sid = int(str(server_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"invalid server_id: {server_id!r}") from exc
cookie_arg = (cookie or "").strip() or "ddxq2-node"
if any(c in cookie_arg for c in " \t\r\n'\""):
raise ValueError("cookie cannot contain whitespace or quotes")
erl = get_erl_cmd(erl_path)
local_ip = get_local_ip()
ping_node = f"clear_{os.getpid()}_{time.time_ns() % 100000000}"
center_atom = _erl_quoted_atom(clean_center_node)
login_atom = _erl_quoted_atom(clean_login_node)
eval_code = (
f"CenterNode = {center_atom}, "
f"LoginNode = {login_atom}, "
f"ServerId = {sid}, "
"CenterResult = rpc:call(CenterNode, center_node_gs_lib, clear_server, [ServerId]), "
"LoginResult = rpc:call(LoginNode, login_server_app, clear_server, [ServerId]), "
"io:format(\"CENTER: ~p~nLOGIN: ~p~n\", [CenterResult, LoginResult]), "
"case {CenterResult, LoginResult} of "
"{{badrpc, _}, _} -> erlang:halt(2, [{flush, true}]); "
"{_, {badrpc, _}} -> erlang:halt(3, [{flush, true}]); "
"_ -> erlang:halt(0, [{flush, true}]) "
"end."
)
args = [
erl,
"-noshell",
"-name", f"{ping_node}@{local_ip}",
"-setcookie", cookie_arg,
"-eval", eval_code,
]
cmd_line = _argv_to_shell_line(args)
try:
_ensure_epmd_daemon(erl_path)
result = subprocess.run(
args,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
)
return result.returncode, (result.stdout or ""), (result.stderr or ""), cmd_line
except subprocess.TimeoutExpired:
return -1, "", f"RPC timeout after {timeout}s", cmd_line
except Exception as e:
return -1, "", str(e), cmd_line
def rpc_role_gs_trace_network(
server_name: str,
cookie: str,
+7 -1
View File
@@ -25,8 +25,14 @@ IS_LINUX = platform.system() == 'Linux'
# rebar3 命令:Linux 使用当前目录下的 ./rebar3Windows 使用 rebar3.cmd
REBAR3_CMD = 'rebar3.cmd' if IS_WINDOWS else './rebar3'
DEFAULT_CONFIG_FILE = 'default.config'
LEGACY_DEFAULT_CONFIG_FILE = 'default.kv'
USER_CONFIG_FILE = 'user.config'
LEGACY_USER_CONFIG_FILE = 'tool.config'
PROJECT_CONFIG_FILE = 'project.config'
MANAGED_CONFIG_TEMPLATE_FILES = (
'default.kv',
DEFAULT_CONFIG_FILE,
'sys_center.config.example',
'sys_client.config.example',
'sys_cross.config.example',
+159 -8
View File
@@ -22,9 +22,11 @@
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
@@ -48,7 +50,7 @@ def _version_json_path() -> Path:
未打包:从项目根目录或运行目录读取。
"""
if getattr(sys, "frozen", False):
# 安装包会将 version.json 展开到 {app},与 main.exe 同目录
# 安装包会将 version.json 展开到 {app},与 ServerManager.exe 同目录
install_dir = Path(sys.executable).resolve().parent
external = install_dir / "version.json"
if external.exists():
@@ -138,16 +140,61 @@ def fetch_update_manifest(update_url: str, timeout: int = 15) -> Optional[Dict[s
return None
def _manifest_delta_map(manifest: Dict[str, Any]) -> Dict[str, Any]:
delta_updates = manifest.get("delta_updates") or manifest.get("deltaUpdates") or manifest.get("delta") or {}
return delta_updates if isinstance(delta_updates, dict) else {}
def get_delta_kind(delta: Dict[str, Any]) -> str:
"""Return 'file_zip', 'bsdiff_exe', or '' for unsupported delta entries."""
if not isinstance(delta, dict):
return ""
kind = str(delta.get("type") or delta.get("kind") or "").strip().lower().replace("_", "-")
url = get_delta_download_url(delta)
if kind in {"file-zip", "file-delta", "archive", "zip"}:
return "file_zip"
if kind in {"bsdiff", "bsdiff-exe", "exe-patch"}:
return "bsdiff_exe"
if url.lower().endswith((".zip", ".smdelta")) or delta.get("format") == "server-manager-file-delta-v1":
return "file_zip"
if delta.get("new_exe_sha256") and (delta.get("patch_url") or delta.get("patchUrl")):
return "bsdiff_exe"
return ""
def get_delta_download_url(delta: Dict[str, Any]) -> str:
"""Return the URL field used by both new file-level deltas and legacy exe patches."""
if not isinstance(delta, dict):
return ""
return (
delta.get("package_url")
or delta.get("packageUrl")
or delta.get("delta_url")
or delta.get("deltaUrl")
or delta.get("patch_url")
or delta.get("patchUrl")
or ""
).strip()
def get_delta_expected_sha256(delta: Dict[str, Any]) -> str:
kind = get_delta_kind(delta)
if kind == "file_zip":
return str(delta.get("sha256") or delta.get("package_sha256") or delta.get("packageSha256") or "").strip().lower()
if kind == "bsdiff_exe":
return str(delta.get("new_exe_sha256") or "").strip().lower()
return ""
def get_delta_for_current(manifest: Dict[str, Any], current_version: str) -> Optional[Dict[str, Any]]:
"""
从 manifest 的 delta_updates 中取当前版本对应的增量信息。
返回 { "patch_url": "...", "new_exe_sha256": "..." } 或 None
支持新的文件级增量 zip,也兼容旧的 ServerManager.exe bsdiff patch
"""
delta_updates = manifest.get("delta_updates") or {}
if not isinstance(delta_updates, dict):
d = _manifest_delta_map(manifest).get(current_version.strip())
if not isinstance(d, dict):
return None
d = delta_updates.get(current_version.strip())
if isinstance(d, dict) and d.get("patch_url") and d.get("new_exe_sha256"):
if get_delta_kind(d) and get_delta_download_url(d):
return d
return None
@@ -256,9 +303,9 @@ def apply_update_and_restart(
if not os.path.isfile(new_exe_path):
return
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
target_name = (target_exe_name or os.path.basename(current_exe_path) or "main.exe").strip() or "main.exe"
target_name = (target_exe_name or os.path.basename(current_exe_path) or "ServerManager.exe").strip() or "ServerManager.exe"
# 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径
# 优先使用安装目录下的 mini_updater.exe(与 ServerManager.exe 同目录),必须用绝对路径
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
try:
@@ -316,6 +363,110 @@ def apply_update_and_restart(
os._exit(0)
DELTA_PACKAGE_FORMAT = "server-manager-file-delta-v1"
def _sha256_path(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def _safe_delta_rel(value: str) -> str:
rel = str(value or "").replace("\\", "/").strip("/")
parts = [part for part in rel.split("/") if part]
if not parts or any(part in (".", "..") for part in parts):
raise ValueError(f"unsafe path: {value}")
return "/".join(parts)
def _safe_install_path(install_dir: Path, rel_path: str) -> Path:
rel = _safe_delta_rel(rel_path)
root = install_dir.resolve()
target = (root / Path(*rel.split("/"))).resolve()
if target != root and root not in target.parents:
raise ValueError(f"path escapes install dir: {rel_path}")
return target
def _validate_delta_package(delta_zip_path: Path, expected_sha256: str = "") -> Tuple[bool, str]:
if not delta_zip_path.is_file():
return False, "增量包文件不存在"
expected_sha = (expected_sha256 or "").strip().lower()
if expected_sha and _sha256_path(delta_zip_path) != expected_sha:
return False, "增量包 SHA256 校验失败,请使用全量更新"
try:
with zipfile.ZipFile(delta_zip_path, "r") as zf:
with zf.open("delta_manifest.json") as f:
manifest = json.loads(f.read().decode("utf-8-sig"))
if not isinstance(manifest, dict) or manifest.get("format") != DELTA_PACKAGE_FORMAT:
return False, "增量包格式不受支持,请使用全量更新"
if not isinstance(manifest.get("files"), list):
return False, "增量包文件清单无效,请使用全量更新"
if getattr(sys, "frozen", False):
install_dir = Path(sys.executable).resolve().parent
for entry in manifest.get("files", []):
rel = _safe_delta_rel(entry.get("path"))
old_sha = str(entry.get("old_sha256") or "").strip().lower()
if old_sha:
target = _safe_install_path(install_dir, rel)
if not target.is_file() or _sha256_path(target) != old_sha:
return False, f"当前安装文件与增量包来源版本不一致: {rel}"
for entry in manifest.get("delete", []) if isinstance(manifest.get("delete"), list) else []:
rel = _safe_delta_rel(entry.get("path"))
old_sha = str(entry.get("old_sha256") or "").strip().lower()
target = _safe_install_path(install_dir, rel)
if old_sha and target.is_file() and _sha256_path(target) != old_sha:
return False, f"当前安装文件与增量包来源版本不一致: {rel}"
except Exception as e:
return False, f"增量包解析失败: {e!s}"
return True, ""
def apply_file_delta_package(delta_zip_path: Path, expected_sha256: str = "") -> Tuple[bool, str]:
"""
应用文件级增量包:主进程验证 zip 后,复制 mini_updater 到 TEMP 并在退出后替换安装目录文件。
成功时本函数不会返回;失败时返回 (False, reason) 供 UI 回退全量更新。
"""
if not getattr(sys, "frozen", False):
return False, "当前未以打包方式运行"
delta_zip_path = Path(delta_zip_path)
ok, reason = _validate_delta_package(delta_zip_path, expected_sha256)
if not ok:
return False, reason
current_exe_path = os.path.abspath(sys.executable)
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
if sys.platform != "win32" or not os.path.isfile(mini_updater_exe):
return False, "未找到 mini_updater.exe,请使用全量更新"
try:
temp_updater = os.path.join(tempfile.gettempdir(), f"ServerManager_mini_updater_{os.getpid()}.exe")
shutil.copy2(mini_updater_exe, temp_updater)
pid = os.getpid()
subprocess.Popen(
[
temp_updater,
"--pid", str(pid),
"--install-dir", install_dir,
"--delta-zip-path", str(delta_zip_path.resolve()),
"--target-exe-name", os.path.basename(current_exe_path) or "ServerManager.exe",
],
cwd=install_dir,
close_fds=True,
creationflags=0x00000008,
shell=False,
)
_write_update_log(install_dir, "已拉起文件级增量更新器 (pid=%s)" % pid)
os._exit(0)
except Exception as e:
_write_update_log(install_dir, "拉起文件级增量更新器失败: %s" % e)
return False, f"启动增量更新器失败: {e!s}"
def apply_delta_patch(patch_path: Path, expected_new_sha256: str) -> Tuple[bool, str]:
"""
使用 bsdiff4 应用增量补丁:当前 exe + patch -> 新 exe,校验 sha256 后在本进程内
+16 -16
View File
@@ -126,7 +126,7 @@ SERVER_TYPE_SPECIFIC_KEYS = {
def _read_default_kv(server_root: str) -> Dict[str, str]:
"""读取合并配置(tool.config > default.kv
"""读取合并配置(user.config > project.config > default.config
Args:
server_root: 服务器根目录
@@ -141,7 +141,7 @@ def _read_default_kv(server_root: str) -> Dict[str, str]:
def get_required_keys_from_kv(server_root: str, server_type: str) -> Optional[Set[str]]:
"""从 default.kv 读取必须配置项定义
"""从 default.config 读取必须配置项定义
Args:
server_root: 服务器根目录
@@ -170,7 +170,7 @@ def get_required_keys_from_kv(server_root: str, server_type: str) -> Optional[Se
def get_config_keys_from_kv(server_root: str, server_type: str) -> Optional[Dict[str, str]]:
"""从 default.kv 读取可编辑配置项定义
"""从 default.config 读取可编辑配置项定义
Args:
server_root: 服务器根目录
@@ -205,7 +205,7 @@ def get_config_keys_from_kv(server_root: str, server_type: str) -> Optional[Dict
def get_editable_config_from_default_kv(server_root: str) -> Dict[str, str]:
"""从 default.kv 读取编辑对话框可选配置项定义
"""从 default.config 读取编辑对话框可选配置项定义
Args:
server_root: 服务器根目录
@@ -230,16 +230,16 @@ def get_editable_config_from_default_kv(server_root: str) -> Dict[str, str]:
def get_allowed_config_keys(server_type: str, server_root: str = '') -> Dict[str, str]:
"""获取指定服务器类型允许编辑的配置项
优先从 default.kv 读取 config_keys_* 定义,如果没有则使用代码中的默认定义
优先从 default.config 读取 config_keys_* 定义,如果没有则使用代码中的默认定义
Args:
server_type: 服务器类型 (game/login/client/center/cross)
server_root: 服务器根目录(用于读取 default.kv
server_root: 服务器根目录(用于读取 default.config
Returns:
配置项字典 {key: 中文说明}
"""
# 优先从 default.kv 读取
# 优先从 default.config 读取
if server_root:
config_from_kv = get_config_keys_from_kv(server_root, server_type)
if config_from_kv:
@@ -256,12 +256,12 @@ def get_all_valid_keys(server_root: str = '') -> Set[str]:
"""获取所有有效的配置键
Args:
server_root: 服务器根目录(用于读取 default.kv
server_root: 服务器根目录(用于读取 default.config
Returns:
所有有效配置键的集合
"""
# 优先从 default.kv 读取
# 优先从 default.config 读取
if server_root:
kv_config = _read_default_kv(server_root)
if kv_config:
@@ -287,16 +287,16 @@ def get_all_valid_keys(server_root: str = '') -> Set[str]:
def get_required_keys(server_type: str, server_root: str = '') -> Set[str]:
"""获取指定服务器类型必须的配置项(不可删除)
优先从 default.kv 读取 required_keys_* 定义,如果没有则使用代码中的默认定义
优先从 default.config 读取 required_keys_* 定义,如果没有则使用代码中的默认定义
Args:
server_type: 服务器类型 (game/login/client/center/cross)
server_root: 服务器根目录(用于读取 default.kv
server_root: 服务器根目录(用于读取 default.config
Returns:
必须的配置项集合
"""
# 优先从 default.kv 读取
# 优先从 default.config 读取
if server_root:
required_from_kv = get_required_keys_from_kv(server_root, server_type)
if required_from_kv:
@@ -378,8 +378,8 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
"""根据模板文件获取可配置的参数列表
优先级:
1. default.kv 中的 editable_config_* 定义(编辑对话框专用)
2. default.kv 中的 config_keys_* 定义(完整配置项)
1. default.config 中的 editable_config_* 定义(编辑对话框专用)
2. default.config 中的 config_keys_* 定义(完整配置项)
3. 从模板文件提取
4. 代码中的默认定义
@@ -390,7 +390,7 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
Returns:
配置项字典 {key: 中文说明}
"""
# 优先从 default.kv 读取 editable_config_* (编辑对话框专用)
# 优先从 default.config 读取 editable_config_* (编辑对话框专用)
editable_from_kv = get_editable_config_from_default_kv(server_root)
config_from_kv = get_config_keys_from_kv(server_root, server_type)
if editable_from_kv:
@@ -401,7 +401,7 @@ def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, st
merged['auto_reload'] = label or BASE_CONFIG_KEYS.get('auto_reload', '自动热更')
return merged
# 其次从 default.kv 读取 config_keys_* (完整配置项定义)
# 其次从 default.config 读取 config_keys_* (完整配置项定义)
if config_from_kv:
return config_from_kv