416 lines
17 KiB
Python
416 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
MuMu模拟器管理模块
|
||
"""
|
||
import subprocess
|
||
import os
|
||
import time
|
||
import configparser
|
||
import shutil
|
||
import re
|
||
import requests
|
||
import logging
|
||
import json
|
||
import sys
|
||
|
||
# 注意:日志配置现在由统一的 logging_config 模块管理
|
||
# 配置日志
|
||
# logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class MuMuManager:
|
||
def __init__(self, config, vm_index=None):
|
||
self.config = config
|
||
# 优先从 mumu 子配置读取,兼容旧的扁平键名
|
||
mumu = config.get('mumu', {}) if isinstance(config, dict) else {}
|
||
self.vm_index = (
|
||
vm_index
|
||
or mumu.get('default_vm_index')
|
||
or config.get('DEFAULT_VM_INDEX', 2)
|
||
)
|
||
self.mumu_manager_path = (
|
||
mumu.get('manager_path')
|
||
or config.get('MUMU_MANAGER_PATH')
|
||
)
|
||
self.clean_backup_path = (
|
||
mumu.get('clean_backup')
|
||
or config.get('CLEAN_BACKUP_PATH')
|
||
or config.get('shared_paths', {}).get('clean_backup')
|
||
)
|
||
self.local_import_dir = (
|
||
mumu.get('local_import_dir')
|
||
or config.get('LOCAL_IMPORT_DIR')
|
||
)
|
||
self.max_vm_index = (
|
||
mumu.get('max_vm_index')
|
||
or config.get('MAX_VM_INDEX', 10)
|
||
)
|
||
self.net_bridge_card = (
|
||
mumu.get('bridge_card')
|
||
or config.get('NET_BRIDGE_CARD', 'Realtek PCIe GbE Family Controller')
|
||
)
|
||
# 从 MuMuManager.exe 路径推导工作目录,回退到 C:\
|
||
if self.mumu_manager_path and os.path.exists(os.path.dirname(self.mumu_manager_path)):
|
||
self._cwd = os.path.dirname(self.mumu_manager_path)
|
||
else:
|
||
self._cwd = 'C:\\'
|
||
|
||
@property
|
||
def device_type(self) -> str:
|
||
return 'emulator'
|
||
|
||
def get_active_network_adapter(self):
|
||
"""获取第一个有网的网卡名称"""
|
||
try:
|
||
output = subprocess.check_output(
|
||
['wmic', 'nic', 'where', 'NetConnectionStatus=2', 'get', 'Name', '/value'],
|
||
encoding='utf-8', errors='ignore', timeout=10
|
||
)
|
||
for line in output.strip().split('\n'):
|
||
if line.startswith('Name='):
|
||
adapter_name = line.split('=', 1)[1].strip()
|
||
if adapter_name:
|
||
logger.info(f"检测到活动网卡: {adapter_name}")
|
||
return adapter_name
|
||
except Exception as e:
|
||
logger.warning(f"获取网卡信息失败: {e}")
|
||
|
||
# 如果获取失败,返回默认值
|
||
default_adapter = self.config.get('NET_BRIDGE_CARD', 'Realtek PCIe GbE Family Controller')
|
||
logger.warning(f"无法获取活动网卡,使用默认值: {default_adapter}")
|
||
return default_adapter
|
||
|
||
def run_cmd(self, command, use_index=True):
|
||
"""执行 MuMuManager 命令"""
|
||
# 路径需要用引号包裹,防止Windows路径中的空格导致命令解析错误
|
||
cmd = f'"{self.mumu_manager_path}" {command}' + (f" -v {self.vm_index}" if use_index else "")
|
||
logger.info(f"执行: {cmd}")
|
||
# 使用 utf-8 编码并忽略无法解码的字符,防止在 Windows GBK 环境下崩溃
|
||
res = subprocess.run(cmd, shell=True, capture_output=True, encoding='utf-8', errors='ignore', cwd=self._cwd, timeout=60)
|
||
if res.returncode != 0: logger.error(f"命令失败: {res.stdout.strip()}\n {res.stderr.strip()}")
|
||
return res.returncode == 0
|
||
|
||
def force_cleanup(self):
|
||
"""清理残留进程"""
|
||
logger.info("清理 MuMu 进程...")
|
||
for proc in ['MuMuPlayer.exe', 'MuMuNxDevice.exe', 'MuMuNxMain.exe']:
|
||
# 统一使用 encoding 和 errors 参数
|
||
subprocess.run(f'taskkill /f /im {proc}', shell=True, capture_output=True,
|
||
encoding='utf-8', errors='ignore')
|
||
time.sleep(10)
|
||
|
||
def get_player_state(self) -> str:
|
||
"""
|
||
获取模拟器播放器状态
|
||
:return: 状态字符串
|
||
- 'starting_rom': 模拟器正在启动
|
||
- 'start_finished': 模拟器运行正常
|
||
- '': 空字符串表示模拟器卡死或未响应
|
||
- 'FROZEN': 检测到错误输出(如 result=-3),表示卡死
|
||
- None: 无法获取状态(命令执行失败或超时)
|
||
"""
|
||
try:
|
||
cmd = f'"{self.mumu_manager_path}" api player_state -v {self.vm_index}'
|
||
logger.info(f"检查模拟器状态: {cmd}")
|
||
result = subprocess.run(
|
||
cmd, shell=True, capture_output=True,
|
||
encoding='utf-8', errors='ignore',
|
||
cwd=self._cwd, timeout=30
|
||
)
|
||
|
||
output = result.stdout.strip()
|
||
logger.debug(f"模拟器状态输出: {output}")
|
||
|
||
# 检测错误输出,如 "check player state fail: result=-3"
|
||
if 'fail' in output.lower() or 'result=-' in output:
|
||
logger.warning(f"模拟器状态检测失败: {output}")
|
||
return 'FROZEN' # 返回特殊标记表示卡死
|
||
|
||
# 解析 state=xxx 格式的输出
|
||
# 例如: "check player state: state=start_finished"
|
||
match = re.search(r'state=([\w]*)', output)
|
||
if match:
|
||
state = match.group(1)
|
||
logger.info(f"模拟器状态: {state if state else '(空/卡死)'}")
|
||
return state
|
||
|
||
logger.warning(f"无法解析模拟器状态输出: {output}")
|
||
return None
|
||
except subprocess.TimeoutExpired:
|
||
logger.error("获取模拟器状态超时,视为卡死")
|
||
return 'FROZEN' # 超时也视为卡死
|
||
except Exception as e:
|
||
logger.error(f"获取模拟器状态失败: {e}")
|
||
return None
|
||
|
||
def is_player_frozen(self) -> bool:
|
||
"""
|
||
检查模拟器是否处于卡死状态
|
||
:return: True 表示卡死,False 表示正常或正在启动
|
||
|
||
以下情况判定为卡死:
|
||
- state='' (空字符串)
|
||
- state='FROZEN' (检测到错误输出或超时)
|
||
- state=None (命令执行失败)
|
||
"""
|
||
state = self.get_player_state()
|
||
# 空字符串、FROZEN 标记、None 都视为卡死
|
||
frozen = state in ('', 'FROZEN', None)
|
||
if frozen:
|
||
logger.warning(f"判定模拟器卡死 (state={repr(state)})")
|
||
return frozen
|
||
|
||
def launch_player(self) -> bool:
|
||
"""
|
||
启动模拟器播放器(不执行 shutdown)
|
||
:return: 成功返回 True,失败返回 False
|
||
"""
|
||
logger.info("启动模拟器...")
|
||
cmd = f'"{self.mumu_manager_path}" control -v {self.vm_index} launch'
|
||
logger.info(f"执行: {cmd}")
|
||
result = subprocess.run(
|
||
cmd,
|
||
shell=True,
|
||
capture_output=True,
|
||
encoding='utf-8',
|
||
errors='ignore',
|
||
cwd=self._cwd,
|
||
timeout=60,
|
||
)
|
||
if result.returncode == 0:
|
||
# 等待启动完成并获取 IP
|
||
for _ in range(10):
|
||
time.sleep(15)
|
||
ip = self.get_adb_ip()
|
||
if ip:
|
||
logger.info(f"获取到模拟器 IP: {ip},尝试 ADB 连接...")
|
||
# 执行 adb connect 连接设备
|
||
try:
|
||
connect_result = subprocess.run(
|
||
['adb', 'connect', ip],
|
||
capture_output=True,
|
||
encoding='utf-8',
|
||
errors='ignore',
|
||
timeout=10
|
||
)
|
||
logger.info(f"ADB connect 输出: {connect_result.stdout.strip()}")
|
||
|
||
# 验证连接是否成功
|
||
verify_result = subprocess.run(
|
||
['adb', '-s', ip, 'shell', 'getprop', 'ro.build.version.sdk'],
|
||
capture_output=True,
|
||
encoding='utf-8',
|
||
errors='ignore',
|
||
timeout=10
|
||
)
|
||
|
||
if verify_result.returncode == 0 and verify_result.stdout.strip():
|
||
logger.info(f"模拟器启动成功,ADB 连接验证通过: {ip} (SDK: {verify_result.stdout.strip()})")
|
||
return True
|
||
else:
|
||
logger.warning(f"ADB 连接验证失败,继续等待...")
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(f"ADB 连接超时,继续等待...")
|
||
except Exception as e:
|
||
logger.warning(f"ADB 连接异常: {e},继续等待...")
|
||
else:
|
||
logger.error(f"命令失败: {result.stdout.strip()}\n {result.stderr.strip()}")
|
||
logger.error("模拟器启动失败")
|
||
return False
|
||
|
||
def recover_from_frozen(self) -> bool:
|
||
"""
|
||
从卡死状态恢复模拟器
|
||
使用 force_cleanup() 强杀进程后重新启动
|
||
:return: 成功返回 True,失败返回 False
|
||
"""
|
||
logger.warning("检测到模拟器卡死,执行强制恢复...")
|
||
self.force_cleanup()
|
||
return self.launch_player()
|
||
|
||
def get_adb_ip(self):
|
||
"""获取 ADB IP"""
|
||
# 明确指定编码为 utf-8 并忽略错误,解决 Windows 下的 UnicodeDecodeError
|
||
output = subprocess.check_output([self.mumu_manager_path, "info", "-v", str(self.vm_index)],
|
||
encoding='utf-8', errors='ignore', timeout=30)
|
||
match = re.search(r'"adb_host_ip":\s*"([^"]+)"', output)
|
||
return match.group(1) if match else None
|
||
|
||
def prepare_import_file(self):
|
||
"""同步备份文件到本地目录"""
|
||
if not self.local_import_dir:
|
||
# 如果没配置,回退到桌面
|
||
self.local_import_dir = os.path.join(os.path.expanduser("~"), "Desktop")
|
||
|
||
if not os.path.exists(self.local_import_dir):
|
||
os.makedirs(self.local_import_dir, exist_ok=True)
|
||
|
||
target = os.path.join(self.local_import_dir, os.path.basename(self.clean_backup_path))
|
||
|
||
# 检查源文件是否存在
|
||
if not os.path.exists(self.clean_backup_path):
|
||
error_msg = f"找不到源备份文件: {self.clean_backup_path}"
|
||
logger.error(error_msg)
|
||
raise FileNotFoundError(error_msg)
|
||
|
||
if os.path.exists(target) and os.path.getsize(target) == os.path.getsize(self.clean_backup_path):
|
||
logger.info(f"备份文件已存在且大小一致,跳过拷贝: {target}")
|
||
return target
|
||
|
||
logger.info(f"开始拷贝备份文件: {self.clean_backup_path} -> {target}")
|
||
try:
|
||
shutil.copy2(self.clean_backup_path, target)
|
||
except Exception as e:
|
||
error_msg = f"拷贝备份文件失败: {e}"
|
||
logger.error(error_msg)
|
||
raise
|
||
return target
|
||
|
||
def check_status(self) -> bool:
|
||
"""
|
||
检查模拟器是否正常运行,并验证 ADB 连接是否可用
|
||
:return: 如果模拟器运行正常且 ADB 连接可用返回True,否则返回False
|
||
"""
|
||
try:
|
||
# 1. 检查模拟器进程是否存在
|
||
ip = self.get_adb_ip()
|
||
if not ip:
|
||
logger.warning("无法获取模拟器ADB IP,模拟器可能未运行")
|
||
return False
|
||
|
||
# 2. 验证 ADB 实际连接(关键修复)
|
||
logger.debug(f"检测到模拟器 IP: {ip},验证 ADB 连接...")
|
||
try:
|
||
# 尝试连接设备
|
||
result = subprocess.run(
|
||
['adb', 'connect', ip],
|
||
capture_output=True,
|
||
encoding='utf-8',
|
||
errors='ignore',
|
||
timeout=10
|
||
)
|
||
|
||
# 执行测试命令验证设备确实可用
|
||
result = subprocess.run(
|
||
['adb', '-s', ip, 'shell', 'getprop', 'ro.build.version.sdk'],
|
||
capture_output=True,
|
||
encoding='utf-8',
|
||
errors='ignore',
|
||
timeout=5
|
||
)
|
||
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
logger.debug(f"模拟器 ADB 连接验证成功: {ip} (SDK: {result.stdout.strip()})")
|
||
return True
|
||
else:
|
||
logger.warning(f"ADB 连接验证失败: {ip},设备未响应")
|
||
return False
|
||
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(f"ADB 连接验证超时: {ip}")
|
||
return False
|
||
except Exception as adb_err:
|
||
logger.warning(f"ADB 连接验证异常: {ip} - {adb_err}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.warning(f"检查模拟器状态失败: {e}")
|
||
return False
|
||
|
||
def ensure_running(self) -> tuple:
|
||
"""
|
||
确保模拟器正在运行,如果未运行则尝试启动
|
||
:return: (success, restarted) - success表示模拟器是否正常运行,restarted表示是否执行了重启
|
||
"""
|
||
if self.check_status():
|
||
return (True, False) # 正常运行,未重启
|
||
|
||
logger.warning("模拟器未运行或 ADB 连接失败,检查模拟器状态...")
|
||
|
||
# 检测是否卡死,选择恢复策略
|
||
if self.is_player_frozen():
|
||
# 模拟器卡死:使用 force_cleanup + launch_player
|
||
logger.warning("检测到模拟器卡死状态 (state=空),执行强制恢复...")
|
||
success = self.recover_from_frozen()
|
||
else:
|
||
# 模拟器未卡死:使用常规重启
|
||
logger.info("模拟器状态正常,执行常规重启...")
|
||
success = self.restart()
|
||
|
||
return (success, True) # 返回重启结果和重启标志
|
||
|
||
def restart(self):
|
||
"""重启模拟器"""
|
||
logger.info("正在重启模拟器...")
|
||
cmd = f'"{self.mumu_manager_path}" control --vmindex {self.vm_index} restart'
|
||
logger.info(f"执行: {cmd}")
|
||
result = subprocess.run(
|
||
cmd,
|
||
shell=True,
|
||
capture_output=True,
|
||
encoding='utf-8',
|
||
errors='ignore',
|
||
cwd=self._cwd,
|
||
timeout=120,
|
||
)
|
||
if result.returncode != 0:
|
||
logger.error(f"模拟器重启命令失败: {result.stdout.strip()}\n {result.stderr.strip()}")
|
||
return False
|
||
|
||
for _ in range(18):
|
||
time.sleep(10)
|
||
if self.check_status():
|
||
ip = self.get_adb_ip()
|
||
logger.info(f"模拟器重启成功,ADB IP: {ip}")
|
||
return True
|
||
|
||
logger.error("模拟器重启失败:等待超时")
|
||
return False
|
||
|
||
def recover(self):
|
||
"""执行恢复流程"""
|
||
import_path = self.prepare_import_file()
|
||
for attempt in range(1, 11):
|
||
logger.info(f"尝试恢复 #{attempt}")
|
||
# self.force_cleanup() # 注释强杀,避免文件系统损坏
|
||
|
||
# 1. 重置环境
|
||
self.run_cmd('api shutdown_player')
|
||
time.sleep(15)
|
||
self.run_cmd('delete')
|
||
time.sleep(15)
|
||
for i in range(2, self.max_vm_index + 1):
|
||
self.run_cmd(f'delete -v {i}')
|
||
time.sleep(15)
|
||
|
||
# 2. 导入与配置
|
||
if not self.run_cmd(f'import -p "{import_path}"'): continue
|
||
time.sleep(15)
|
||
|
||
net_adapter = self.get_active_network_adapter()
|
||
self.run_cmd(f'setting -k "net_bridge_card" -val "{net_adapter}"')
|
||
time.sleep(15)
|
||
|
||
# 3. 启动并校验
|
||
if self.run_cmd('api launch_player'):
|
||
for _ in range(10):
|
||
time.sleep(15)
|
||
ip = self.get_adb_ip()
|
||
if ip:
|
||
logger.info("恢复成功!")
|
||
self.run_cmd('api shutdown_player')
|
||
return True
|
||
logger.warning(f"第 {attempt} 次失败")
|
||
|
||
return False
|
||
|
||
if __name__ == '__main__':
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||
from config_loader import load_config
|
||
|
||
config = load_config()
|
||
mumu_controller = MuMuManager(config)
|
||
net_adapter = mumu_controller.get_active_network_adapter()
|
||
print(net_adapter)
|