kotones-auto-assistant/kotonebot/client/host/mumu12_host.py

221 lines
8.3 KiB
Python

import os
import json
import subprocess
from functools import lru_cache
from typing import Any, Literal
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import Device
from kotonebot.client.device import AndroidDevice
from kotonebot.client.implements.adb import AdbImpl
from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb
if os.name == 'nt':
from ...interop.win.reg import read_reg
else:
def read_reg(key, subkey, name, *, default=None, **kwargs):
"""Stub for read_reg on non-Windows platforms."""
return default
logger = logging.getLogger(__name__)
MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._args = args
self.index: int | None = None
self.is_android_started: bool = False
@override
def refresh(self):
ins = Mumu12Host.query(id=self.id)
assert isinstance(ins, Mumu12Instance), f'Expected Mumu12Instance, got {type(ins)}'
if ins is not None:
self.adb_port = ins.adb_port
self.adb_ip = ins.adb_ip
self.adb_name = ins.adb_name
self.is_android_started = ins.is_android_started
logger.debug('Refreshed MuMu12 instance: %s', repr(ins))
@override
def start(self):
if self.running():
logger.warning('Instance is already running.')
return
logger.info('Starting MuMu12 instance %s', self)
Mumu12Host._invoke_manager(['control', '-v', self.id, 'launch'])
self.refresh()
@override
def stop(self):
if not self.running():
logger.warning('Instance is not running.')
return
logger.info('Stopping MuMu12 instance id=%s name=%s...', self.id, self.name)
Mumu12Host._invoke_manager(['control', '-v', self.id, 'shutdown'])
self.refresh()
@override
def wait_available(self, timeout: float = 180):
cd = Countdown(timeout)
it = Interval(5)
while not cd.expired() and not self.running():
it.wait()
self.refresh()
if not self.running():
raise TimeoutError(f'MuMu12 instance "{self.name}" is not available.')
@override
def running(self) -> bool:
return self.is_android_started
@override
def create_device(self, impl: MuMu12Recipes, host_config: AdbHostConfig) -> Device:
"""为MuMu12模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
if impl == 'nemu_ipc':
# NemuImpl
nemu_path = Mumu12Host._read_install_path()
if not nemu_path:
raise RuntimeError("无法找到 MuMu12 的安装路径。")
nemu_config = NemuIpcImplConfig(nemu_folder=nemu_path, instance_id=int(self.id))
nemu_impl = NemuIpcImpl(nemu_config)
# AdbImpl
adb_impl = AdbImpl(connect_adb(
self.adb_ip,
self.adb_port,
timeout=host_config.timeout,
device_serial=self.adb_name
))
device = AndroidDevice()
device._screenshot = nemu_impl
device._touch = nemu_impl
device.commands = adb_impl
return device
else:
return super().create_device(impl, host_config)
class Mumu12Host(HostProtocol[MuMu12Recipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
r"""
从注册表中读取 MuMu Player 12 的安装路径。
返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
:return: 若找到,则返回安装路径;否则返回 None。
"""
if os.name != 'nt':
return None
uninstall_subkeys = [
r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer-12.0',
# TODO: 支持国际版 MuMu
# r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayerGlobal-12.0'
]
for subkey in uninstall_subkeys:
icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
if icon_path and isinstance(icon_path, str):
icon_path = icon_path.replace('"', '')
path = os.path.dirname(icon_path)
logger.debug('MuMu Player 12 installation path: %s', path)
# 返回根目录(去掉 shell 子目录)
if os.path.basename(path).lower() == 'shell':
path = os.path.dirname(path)
return path
return None
@staticmethod
def _invoke_manager(args: list[str]) -> str:
"""
调用 MuMuManager.exe。
:param args: 命令行参数列表。
:return: 命令执行的输出。
"""
install_path = Mumu12Host._read_install_path()
if install_path is None:
raise RuntimeError('MuMu Player 12 is not installed.')
manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
logger.debug('MuMuManager execute: %s', repr(args))
output = subprocess.run(
[manager_path] + args,
capture_output=True,
text=True,
encoding='utf-8',
# https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
)
if output.returncode != 0:
# raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
return output.stdout
@staticmethod
def installed() -> bool:
return Mumu12Host._read_install_path() is not None
@staticmethod
def list() -> list[Instance]:
output = Mumu12Host._invoke_manager(['info', '-v', 'all'])
logger.debug('MuMuManager.exe output: %s', output)
try:
data: dict[str, dict[str, Any]] = json.loads(output)
if 'name' in data.keys():
# 这里有个坑:
# 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
data = { '0': data }
instances = []
for index, instance_data in data.items():
instance = Mumu12Instance(
id=index,
name=instance_data['name'],
adb_port=instance_data.get('adb_port'),
adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
adb_name=None
)
instance.index = int(index)
instance.is_android_started = instance_data.get('is_android_started', False)
logger.debug('Mumu12 instance: %s', repr(instance))
instances.append(instance)
return instances
except json.JSONDecodeError as e:
raise RuntimeError(f'Failed to parse output: {e}')
@staticmethod
def query(*, id: str) -> Instance | None:
instances = Mumu12Host.list()
for instance in instances:
if instance.id == id:
return instance
return None
@staticmethod
def recipes() -> 'list[MuMu12Recipes]':
return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
print(Mumu12Host._read_install_path())
print(Mumu12Host.installed())
print(Mumu12Host.list())
print(ins:=Mumu12Host.query(id='2'))
assert isinstance(ins, Mumu12Instance)
ins.start()
ins.wait_available()
print('status', ins.running(), ins.adb_port, ins.adb_ip)
ins.stop()
print('status', ins.running(), ins.adb_port, ins.adb_ip)