换位置
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}"'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Database connectivity helpers."""
|
||||
|
||||
from .shared import Any, Dict
|
||||
|
||||
|
||||
def test_mysql_connection(
|
||||
db_host: str,
|
||||
db_port: int,
|
||||
db_user: str,
|
||||
db_pass: str,
|
||||
timeout: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Open a real MySQL connection and run a lightweight probe query."""
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError as exc:
|
||||
raise ImportError("需要安装 pymysql: pip install pymysql") from exc
|
||||
|
||||
conn = None
|
||||
cursor = None
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=str(db_host).strip(),
|
||||
port=int(db_port),
|
||||
user=str(db_user).strip(),
|
||||
password=db_pass or "",
|
||||
charset="utf8mb4",
|
||||
connect_timeout=timeout,
|
||||
read_timeout=timeout,
|
||||
write_timeout=timeout,
|
||||
autocommit=True,
|
||||
)
|
||||
conn.ping(reconnect=False)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1, VERSION(), CURRENT_USER()")
|
||||
row = cursor.fetchone() or ()
|
||||
return {
|
||||
"ok": True,
|
||||
"server_version": str(row[1]) if len(row) > 1 and row[1] is not None else "",
|
||||
"current_user": str(row[2]) if len(row) > 2 and row[2] is not None else "",
|
||||
}
|
||||
finally:
|
||||
if cursor is not None:
|
||||
cursor.close()
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -13,6 +13,7 @@ _MIGRATIONS = [
|
||||
# 2026-04 版本:模板新增 ${role_log} 占位符,需要为所有已有服务器重新生成
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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: <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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
+159
-8
@@ -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 后在本进程内
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user