kotonebot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kotonebot/__init__.py +40 -0
- kotonebot/backend/__init__.py +0 -0
- kotonebot/backend/bot.py +302 -0
- kotonebot/backend/color.py +525 -0
- kotonebot/backend/context/__init__.py +3 -0
- kotonebot/backend/context/context.py +1001 -0
- kotonebot/backend/context/task_action.py +176 -0
- kotonebot/backend/core.py +126 -0
- kotonebot/backend/debug/__init__.py +1 -0
- kotonebot/backend/debug/entry.py +89 -0
- kotonebot/backend/debug/mock.py +79 -0
- kotonebot/backend/debug/server.py +223 -0
- kotonebot/backend/debug/vars.py +346 -0
- kotonebot/backend/dispatch.py +228 -0
- kotonebot/backend/flow_controller.py +197 -0
- kotonebot/backend/image.py +748 -0
- kotonebot/backend/loop.py +277 -0
- kotonebot/backend/ocr.py +511 -0
- kotonebot/backend/preprocessor.py +103 -0
- kotonebot/client/__init__.py +10 -0
- kotonebot/client/device.py +500 -0
- kotonebot/client/fast_screenshot.py +378 -0
- kotonebot/client/host/__init__.py +12 -0
- kotonebot/client/host/adb_common.py +94 -0
- kotonebot/client/host/custom.py +114 -0
- kotonebot/client/host/leidian_host.py +202 -0
- kotonebot/client/host/mumu12_host.py +245 -0
- kotonebot/client/host/protocol.py +213 -0
- kotonebot/client/host/windows_common.py +55 -0
- kotonebot/client/implements/__init__.py +7 -0
- kotonebot/client/implements/adb.py +85 -0
- kotonebot/client/implements/adb_raw.py +159 -0
- kotonebot/client/implements/nemu_ipc/__init__.py +8 -0
- kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +280 -0
- kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -0
- kotonebot/client/implements/remote_windows.py +193 -0
- kotonebot/client/implements/uiautomator2.py +82 -0
- kotonebot/client/implements/windows.py +168 -0
- kotonebot/client/protocol.py +69 -0
- kotonebot/client/registration.py +24 -0
- kotonebot/config/__init__.py +1 -0
- kotonebot/config/base_config.py +96 -0
- kotonebot/config/manager.py +36 -0
- kotonebot/errors.py +72 -0
- kotonebot/interop/win/__init__.py +0 -0
- kotonebot/interop/win/message_box.py +314 -0
- kotonebot/interop/win/reg.py +37 -0
- kotonebot/interop/win/shortcut.py +43 -0
- kotonebot/interop/win/task_dialog.py +469 -0
- kotonebot/logging/__init__.py +2 -0
- kotonebot/logging/log.py +18 -0
- kotonebot/primitives/__init__.py +17 -0
- kotonebot/primitives/geometry.py +290 -0
- kotonebot/primitives/visual.py +63 -0
- kotonebot/tools/__init__.py +0 -0
- kotonebot/tools/mirror.py +354 -0
- kotonebot/ui/__init__.py +0 -0
- kotonebot/ui/file_host/sensio.py +36 -0
- kotonebot/ui/file_host/tmp_send.py +54 -0
- kotonebot/ui/pushkit/__init__.py +3 -0
- kotonebot/ui/pushkit/image_host.py +87 -0
- kotonebot/ui/pushkit/protocol.py +13 -0
- kotonebot/ui/pushkit/wxpusher.py +53 -0
- kotonebot/ui/user.py +144 -0
- kotonebot/util.py +409 -0
- kotonebot-0.1.0.dist-info/METADATA +204 -0
- kotonebot-0.1.0.dist-info/RECORD +70 -0
- kotonebot-0.1.0.dist-info/WHEEL +5 -0
- kotonebot-0.1.0.dist-info/licenses/LICENSE +674 -0
- kotonebot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing_extensions import override
|
|
6
|
+
|
|
7
|
+
from kotonebot import logging
|
|
8
|
+
from kotonebot.client import Device
|
|
9
|
+
from kotonebot.util import Countdown, Interval
|
|
10
|
+
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
|
|
11
|
+
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
LeidianRecipes = AdbRecipes
|
|
15
|
+
|
|
16
|
+
if os.name == 'nt':
|
|
17
|
+
from ...interop.win.reg import read_reg
|
|
18
|
+
else:
|
|
19
|
+
def read_reg(key, subkey, name, *, default=None, **kwargs):
|
|
20
|
+
"""Stub for read_reg on non-Windows platforms."""
|
|
21
|
+
return default
|
|
22
|
+
|
|
23
|
+
class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
|
|
24
|
+
@copy_type(Instance.__init__)
|
|
25
|
+
def __init__(self, *args, **kwargs):
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
self._args = args
|
|
28
|
+
self.index: int | None = None
|
|
29
|
+
self.is_running: bool = False
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def refresh(self):
|
|
33
|
+
ins = LeidianHost.query(id=self.id)
|
|
34
|
+
assert isinstance(ins, LeidianInstance), f'Expected LeidianInstance, got {type(ins)}'
|
|
35
|
+
if ins is not None:
|
|
36
|
+
self.adb_port = ins.adb_port
|
|
37
|
+
self.adb_ip = ins.adb_ip
|
|
38
|
+
self.adb_name = ins.adb_name
|
|
39
|
+
self.is_running = ins.is_running
|
|
40
|
+
logger.debug('Refreshed Leidian instance: %s', repr(ins))
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
def start(self):
|
|
44
|
+
if self.running():
|
|
45
|
+
logger.warning('Instance is already running.')
|
|
46
|
+
return
|
|
47
|
+
logger.info('Starting Leidian instance %s', self)
|
|
48
|
+
LeidianHost._invoke_manager(['launch', '--index', str(self.index)])
|
|
49
|
+
self.refresh()
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
def stop(self):
|
|
53
|
+
if not self.running():
|
|
54
|
+
logger.warning('Instance is not running.')
|
|
55
|
+
return
|
|
56
|
+
logger.info('Stopping Leidian instance id=%s name=%s...', self.id, self.name)
|
|
57
|
+
LeidianHost._invoke_manager(['quit', '--index', str(self.index)])
|
|
58
|
+
self.refresh()
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
def wait_available(self, timeout: float = 180):
|
|
62
|
+
cd = Countdown(timeout)
|
|
63
|
+
it = Interval(5)
|
|
64
|
+
while not cd.expired() and not self.running():
|
|
65
|
+
it.wait()
|
|
66
|
+
self.refresh()
|
|
67
|
+
if not self.running():
|
|
68
|
+
raise TimeoutError(f'Leidian instance "{self.name}" is not available.')
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def running(self) -> bool:
|
|
72
|
+
return self.is_running
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
def create_device(self, impl: LeidianRecipes, host_config: AdbHostConfig) -> Device:
|
|
76
|
+
"""为雷电模拟器实例创建 Device。"""
|
|
77
|
+
if self.adb_port is None:
|
|
78
|
+
raise ValueError("ADB port is not set and is required.")
|
|
79
|
+
|
|
80
|
+
return super().create_device(impl, host_config)
|
|
81
|
+
|
|
82
|
+
class LeidianHost(HostProtocol[LeidianRecipes]):
|
|
83
|
+
@staticmethod
|
|
84
|
+
@lru_cache(maxsize=1)
|
|
85
|
+
def _read_install_path() -> str | None:
|
|
86
|
+
"""
|
|
87
|
+
从注册表中读取雷电模拟器的安装路径。
|
|
88
|
+
|
|
89
|
+
:return: 安装路径,如果未找到则返回 None。
|
|
90
|
+
"""
|
|
91
|
+
if os.name != 'nt':
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
icon_path = read_reg('HKCU', r'Software\leidian\LDPlayer9', 'DisplayIcon', default=None)
|
|
96
|
+
if icon_path and isinstance(icon_path, str):
|
|
97
|
+
icon_path = icon_path.replace('"', '')
|
|
98
|
+
path = os.path.dirname(icon_path)
|
|
99
|
+
logger.debug('Leidian installation path (from DisplayIcon): %s', path)
|
|
100
|
+
return path
|
|
101
|
+
install_dir = read_reg('HKCU', r'Software\leidian\LDPlayer9', 'InstallDir', default=None)
|
|
102
|
+
if install_dir and isinstance(install_dir, str):
|
|
103
|
+
install_dir = install_dir.replace('"', '')
|
|
104
|
+
logger.debug('Leidian installation path (from InstallDir): %s', install_dir)
|
|
105
|
+
return install_dir
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f'Failed to read Leidian installation path from registry: {e}')
|
|
108
|
+
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _invoke_manager(args: list[str]) -> str:
|
|
113
|
+
"""
|
|
114
|
+
调用 ldconsole.exe。
|
|
115
|
+
|
|
116
|
+
参考文档:https://www.ldmnq.com/forum/30.html,以及命令行帮助。
|
|
117
|
+
另外还有个 ld.exe,封装了 adb.exe,可以直接执行 adb 命令。(https://www.ldmnq.com/forum/9178.html)
|
|
118
|
+
|
|
119
|
+
:param args: 命令行参数列表。
|
|
120
|
+
:return: 命令执行的输出。
|
|
121
|
+
"""
|
|
122
|
+
install_path = LeidianHost._read_install_path()
|
|
123
|
+
if install_path is None:
|
|
124
|
+
raise RuntimeError('Leidian is not installed.')
|
|
125
|
+
manager_path = os.path.join(install_path, 'ldconsole.exe')
|
|
126
|
+
logger.debug('ldconsole execute: %s', repr(args))
|
|
127
|
+
output = subprocess.run(
|
|
128
|
+
[manager_path] + args,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
# encoding='utf-8', # 居然不是 utf-8 编码
|
|
132
|
+
# https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
|
|
133
|
+
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
134
|
+
)
|
|
135
|
+
if output.returncode != 0:
|
|
136
|
+
raise RuntimeError(f'Failed to invoke ldconsole: {output.stderr}')
|
|
137
|
+
return output.stdout
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def installed() -> bool:
|
|
141
|
+
return LeidianHost._read_install_path() is not None
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def list() -> list[Instance]:
|
|
145
|
+
output = LeidianHost._invoke_manager(['list2'])
|
|
146
|
+
instances = []
|
|
147
|
+
|
|
148
|
+
# 解析 list2 命令的输出
|
|
149
|
+
# 格式: 索引,标题,顶层窗口句柄,绑定窗口句柄,是否进入android,进程PID,VBox进程PID
|
|
150
|
+
for line in output.strip().split('\n'):
|
|
151
|
+
if not line:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
parts = line.split(',')
|
|
155
|
+
if len(parts) < 5:
|
|
156
|
+
logger.warning(f'Invalid list2 output line: {line}')
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
index = parts[0]
|
|
160
|
+
name = parts[1]
|
|
161
|
+
is_android_started = parts[4] == '1'
|
|
162
|
+
# 端口号规则 https://help.ldmnq.com/docs/LD9adbserver#a67730c2e7e2e0400d40bcab37d0e0cf
|
|
163
|
+
adb_port = 5554 + (int(index) * 2)
|
|
164
|
+
|
|
165
|
+
instance = LeidianInstance(
|
|
166
|
+
id=index,
|
|
167
|
+
name=name,
|
|
168
|
+
adb_port=adb_port,
|
|
169
|
+
adb_ip='127.0.0.1',
|
|
170
|
+
adb_name=f'emulator-{adb_port}'
|
|
171
|
+
)
|
|
172
|
+
instance.index = int(index)
|
|
173
|
+
instance.is_running = is_android_started
|
|
174
|
+
logger.debug('Leidian instance: %s', repr(instance))
|
|
175
|
+
instances.append(instance)
|
|
176
|
+
|
|
177
|
+
return instances
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def query(*, id: str) -> Instance | None:
|
|
181
|
+
instances = LeidianHost.list()
|
|
182
|
+
for instance in instances:
|
|
183
|
+
if instance.id == id:
|
|
184
|
+
return instance
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def recipes() -> 'list[LeidianRecipes]':
|
|
189
|
+
return ['adb', 'adb_raw', 'uiautomator2']
|
|
190
|
+
|
|
191
|
+
if __name__ == '__main__':
|
|
192
|
+
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
|
193
|
+
print(LeidianHost._read_install_path())
|
|
194
|
+
print(LeidianHost.installed())
|
|
195
|
+
print(LeidianHost.list())
|
|
196
|
+
print(ins:=LeidianHost.query(id='0'))
|
|
197
|
+
assert isinstance(ins, LeidianInstance)
|
|
198
|
+
ins.start()
|
|
199
|
+
ins.wait_available()
|
|
200
|
+
print('status', ins.running(), ins.adb_port, ins.adb_ip)
|
|
201
|
+
# ins.stop()
|
|
202
|
+
# print('status', ins.running(), ins.adb_port, ins.adb_ip)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any, Literal, overload
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from kotonebot import logging
|
|
10
|
+
from kotonebot.client import Device
|
|
11
|
+
from kotonebot.client.device import AndroidDevice
|
|
12
|
+
from kotonebot.client.implements.adb import AdbImpl
|
|
13
|
+
from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
|
|
14
|
+
from kotonebot.util import Countdown, Interval
|
|
15
|
+
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
|
|
16
|
+
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb, is_adb_recipe
|
|
17
|
+
|
|
18
|
+
if os.name == 'nt':
|
|
19
|
+
from ...interop.win.reg import read_reg
|
|
20
|
+
else:
|
|
21
|
+
def read_reg(key, subkey, name, *, default=None, **kwargs):
|
|
22
|
+
"""Stub for read_reg on non-Windows platforms."""
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MuMu12HostConfig(AdbHostConfig):
|
|
30
|
+
"""nemu_ipc 能力的配置模型。"""
|
|
31
|
+
display_id: int | None = 0
|
|
32
|
+
"""目标显示器 ID,默认为 0(主显示器)。若为 None 且设置了 target_package_name,则自动获取对应的 display_id。"""
|
|
33
|
+
target_package_name: str | None = None
|
|
34
|
+
"""目标应用包名,用于自动获取 display_id。"""
|
|
35
|
+
app_index: int = 0
|
|
36
|
+
"""多开应用索引,传给 get_display_id 方法。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
|
|
40
|
+
@copy_type(Instance.__init__)
|
|
41
|
+
def __init__(self, *args, **kwargs):
|
|
42
|
+
super().__init__(*args, **kwargs)
|
|
43
|
+
self._args = args
|
|
44
|
+
self.index: int | None = None
|
|
45
|
+
self.is_android_started: bool = False
|
|
46
|
+
|
|
47
|
+
@override
|
|
48
|
+
def refresh(self):
|
|
49
|
+
ins = Mumu12Host.query(id=self.id)
|
|
50
|
+
assert isinstance(ins, Mumu12Instance), f'Expected Mumu12Instance, got {type(ins)}'
|
|
51
|
+
if ins is not None:
|
|
52
|
+
self.adb_port = ins.adb_port
|
|
53
|
+
self.adb_ip = ins.adb_ip
|
|
54
|
+
self.adb_name = ins.adb_name
|
|
55
|
+
self.is_android_started = ins.is_android_started
|
|
56
|
+
logger.debug('Refreshed MuMu12 instance: %s', repr(ins))
|
|
57
|
+
|
|
58
|
+
@override
|
|
59
|
+
def start(self):
|
|
60
|
+
if self.running():
|
|
61
|
+
logger.warning('Instance is already running.')
|
|
62
|
+
return
|
|
63
|
+
logger.info('Starting MuMu12 instance %s', self)
|
|
64
|
+
Mumu12Host._invoke_manager(['control', '-v', self.id, 'launch'])
|
|
65
|
+
self.refresh()
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
def stop(self):
|
|
69
|
+
if not self.running():
|
|
70
|
+
logger.warning('Instance is not running.')
|
|
71
|
+
return
|
|
72
|
+
logger.info('Stopping MuMu12 instance id=%s name=%s...', self.id, self.name)
|
|
73
|
+
Mumu12Host._invoke_manager(['control', '-v', self.id, 'shutdown'])
|
|
74
|
+
self.refresh()
|
|
75
|
+
|
|
76
|
+
@override
|
|
77
|
+
def wait_available(self, timeout: float = 180):
|
|
78
|
+
cd = Countdown(timeout)
|
|
79
|
+
it = Interval(5)
|
|
80
|
+
while not cd.expired() and not self.running():
|
|
81
|
+
it.wait()
|
|
82
|
+
self.refresh()
|
|
83
|
+
if not self.running():
|
|
84
|
+
raise TimeoutError(f'MuMu12 instance "{self.name}" is not available.')
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
def running(self) -> bool:
|
|
88
|
+
return self.is_android_started
|
|
89
|
+
|
|
90
|
+
@overload
|
|
91
|
+
def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
|
|
92
|
+
@overload
|
|
93
|
+
def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
|
|
97
|
+
"""为MuMu12模拟器实例创建 Device。"""
|
|
98
|
+
if self.adb_port is None:
|
|
99
|
+
raise ValueError("ADB port is not set and is required.")
|
|
100
|
+
|
|
101
|
+
if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
|
|
102
|
+
# NemuImpl
|
|
103
|
+
nemu_path = Mumu12Host._read_install_path()
|
|
104
|
+
if not nemu_path:
|
|
105
|
+
raise RuntimeError("无法找到 MuMu12 的安装路径。")
|
|
106
|
+
nemu_config = NemuIpcImplConfig(
|
|
107
|
+
nemu_folder=nemu_path,
|
|
108
|
+
instance_id=int(self.id),
|
|
109
|
+
display_id=host_config.display_id,
|
|
110
|
+
target_package_name=host_config.target_package_name,
|
|
111
|
+
app_index=host_config.app_index
|
|
112
|
+
)
|
|
113
|
+
nemu_impl = NemuIpcImpl(nemu_config)
|
|
114
|
+
# AdbImpl
|
|
115
|
+
adb_impl = AdbImpl(connect_adb(
|
|
116
|
+
self.adb_ip,
|
|
117
|
+
self.adb_port,
|
|
118
|
+
timeout=host_config.timeout,
|
|
119
|
+
device_serial=self.adb_name
|
|
120
|
+
))
|
|
121
|
+
device = AndroidDevice()
|
|
122
|
+
device._screenshot = nemu_impl
|
|
123
|
+
device._touch = nemu_impl
|
|
124
|
+
device.commands = adb_impl
|
|
125
|
+
|
|
126
|
+
return device
|
|
127
|
+
elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
|
|
128
|
+
return super().create_device(recipe, host_config)
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError(f'Unknown recipe: {recipe}')
|
|
131
|
+
|
|
132
|
+
class Mumu12Host(HostProtocol[MuMu12Recipes]):
|
|
133
|
+
@staticmethod
|
|
134
|
+
@lru_cache(maxsize=1)
|
|
135
|
+
def _read_install_path() -> str | None:
|
|
136
|
+
r"""
|
|
137
|
+
从注册表中读取 MuMu Player 12 的安装路径。
|
|
138
|
+
|
|
139
|
+
返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
|
|
140
|
+
|
|
141
|
+
:return: 若找到,则返回安装路径;否则返回 None。
|
|
142
|
+
"""
|
|
143
|
+
if os.name != 'nt':
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
uninstall_subkeys = [
|
|
147
|
+
r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer-12.0',
|
|
148
|
+
# TODO: 支持国际版 MuMu
|
|
149
|
+
# r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayerGlobal-12.0'
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
for subkey in uninstall_subkeys:
|
|
153
|
+
icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
|
|
154
|
+
if icon_path and isinstance(icon_path, str):
|
|
155
|
+
icon_path = icon_path.replace('"', '')
|
|
156
|
+
path = os.path.dirname(icon_path)
|
|
157
|
+
logger.debug('MuMu Player 12 installation path: %s', path)
|
|
158
|
+
# 返回根目录(去掉 shell 子目录)
|
|
159
|
+
if os.path.basename(path).lower() == 'shell':
|
|
160
|
+
path = os.path.dirname(path)
|
|
161
|
+
return path
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _invoke_manager(args: list[str]) -> str:
|
|
166
|
+
"""
|
|
167
|
+
调用 MuMuManager.exe。
|
|
168
|
+
|
|
169
|
+
:param args: 命令行参数列表。
|
|
170
|
+
:return: 命令执行的输出。
|
|
171
|
+
"""
|
|
172
|
+
install_path = Mumu12Host._read_install_path()
|
|
173
|
+
if install_path is None:
|
|
174
|
+
raise RuntimeError('MuMu Player 12 is not installed.')
|
|
175
|
+
manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
|
|
176
|
+
logger.debug('MuMuManager execute: %s', repr(args))
|
|
177
|
+
output = subprocess.run(
|
|
178
|
+
[manager_path] + args,
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
encoding='utf-8',
|
|
182
|
+
# https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
|
|
183
|
+
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
184
|
+
)
|
|
185
|
+
if output.returncode != 0:
|
|
186
|
+
# raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
|
|
187
|
+
logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
|
|
188
|
+
return output.stdout
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def installed() -> bool:
|
|
192
|
+
return Mumu12Host._read_install_path() is not None
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def list() -> list[Instance]:
|
|
196
|
+
output = Mumu12Host._invoke_manager(['info', '-v', 'all'])
|
|
197
|
+
logger.debug('MuMuManager.exe output: %s', output)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
data: dict[str, dict[str, Any]] = json.loads(output)
|
|
201
|
+
if 'name' in data.keys():
|
|
202
|
+
# 这里有个坑:
|
|
203
|
+
# 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
|
|
204
|
+
data = { '0': data }
|
|
205
|
+
instances = []
|
|
206
|
+
for index, instance_data in data.items():
|
|
207
|
+
instance = Mumu12Instance(
|
|
208
|
+
id=index,
|
|
209
|
+
name=instance_data['name'],
|
|
210
|
+
adb_port=instance_data.get('adb_port'),
|
|
211
|
+
adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
|
|
212
|
+
adb_name=None
|
|
213
|
+
)
|
|
214
|
+
instance.index = int(index)
|
|
215
|
+
instance.is_android_started = instance_data.get('is_android_started', False)
|
|
216
|
+
logger.debug('Mumu12 instance: %s', repr(instance))
|
|
217
|
+
instances.append(instance)
|
|
218
|
+
return instances
|
|
219
|
+
except json.JSONDecodeError as e:
|
|
220
|
+
raise RuntimeError(f'Failed to parse output: {e}')
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def query(*, id: str) -> Instance | None:
|
|
224
|
+
instances = Mumu12Host.list()
|
|
225
|
+
for instance in instances:
|
|
226
|
+
if instance.id == id:
|
|
227
|
+
return instance
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def recipes() -> 'list[MuMu12Recipes]':
|
|
232
|
+
return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
|
|
233
|
+
|
|
234
|
+
if __name__ == '__main__':
|
|
235
|
+
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
|
236
|
+
print(Mumu12Host._read_install_path())
|
|
237
|
+
print(Mumu12Host.installed())
|
|
238
|
+
print(Mumu12Host.list())
|
|
239
|
+
print(ins:=Mumu12Host.query(id='2'))
|
|
240
|
+
assert isinstance(ins, Mumu12Instance)
|
|
241
|
+
ins.start()
|
|
242
|
+
ins.wait_available()
|
|
243
|
+
print('status', ins.running(), ins.adb_port, ins.adb_ip)
|
|
244
|
+
ins.stop()
|
|
245
|
+
print('status', ins.running(), ins.adb_port, ins.adb_ip)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import socket
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Callable, TypeVar, Protocol, Any, Generic
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from adbutils import adb, AdbTimeout, AdbError
|
|
8
|
+
from adbutils._device import AdbDevice
|
|
9
|
+
|
|
10
|
+
from kotonebot import logging
|
|
11
|
+
from kotonebot.client import Device, DeviceImpl
|
|
12
|
+
|
|
13
|
+
from kotonebot.util import Countdown, Interval
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
# https://github.com/python/typing/issues/769#issuecomment-903760354
|
|
17
|
+
_T = TypeVar("_T")
|
|
18
|
+
def copy_type(_: _T) -> Callable[[Any], _T]:
|
|
19
|
+
return lambda x: x
|
|
20
|
+
|
|
21
|
+
# --- 定义专用的 HostConfig 数据类 ---
|
|
22
|
+
@dataclass
|
|
23
|
+
class AdbHostConfig:
|
|
24
|
+
"""由外部为基于 ADB 的主机提供的配置。"""
|
|
25
|
+
timeout: float = 180
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class WindowsHostConfig:
|
|
29
|
+
"""由外部为 Windows 实现提供配置。"""
|
|
30
|
+
window_title: str
|
|
31
|
+
ahk_exe_path: str
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RemoteWindowsHostConfig:
|
|
35
|
+
"""由外部为远程 Windows 实现提供配置。"""
|
|
36
|
+
windows_host_config: WindowsHostConfig
|
|
37
|
+
host: str
|
|
38
|
+
port: int
|
|
39
|
+
|
|
40
|
+
# --- 使用泛型改造 Instance 协议 ---
|
|
41
|
+
T_HostConfig = TypeVar("T_HostConfig")
|
|
42
|
+
|
|
43
|
+
def tcp_ping(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
通过 TCP ping 检查主机和端口是否可达。
|
|
46
|
+
|
|
47
|
+
:param host: 主机名或 IP 地址
|
|
48
|
+
:param port: 端口号
|
|
49
|
+
:param timeout: 超时时间(秒)
|
|
50
|
+
:return: 如果主机和端口可达,则返回 True,否则返回 False
|
|
51
|
+
"""
|
|
52
|
+
logger.debug('TCP ping %s:%d...', host, port)
|
|
53
|
+
try:
|
|
54
|
+
with socket.create_connection((host, port), timeout):
|
|
55
|
+
logger.debug('TCP ping %s:%d success.', host, port)
|
|
56
|
+
return True
|
|
57
|
+
except (socket.timeout, ConnectionRefusedError, OSError):
|
|
58
|
+
logger.debug('TCP ping %s:%d failed.', host, port)
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Instance(Generic[T_HostConfig], ABC):
|
|
63
|
+
"""
|
|
64
|
+
代表一个可运行环境的实例(如一个模拟器)。
|
|
65
|
+
使用泛型来约束 create_device 方法的配置参数类型。
|
|
66
|
+
"""
|
|
67
|
+
def __init__(self,
|
|
68
|
+
id: str,
|
|
69
|
+
name: str,
|
|
70
|
+
adb_port: int | None = None,
|
|
71
|
+
adb_ip: str = '127.0.0.1',
|
|
72
|
+
adb_name: str | None = None
|
|
73
|
+
):
|
|
74
|
+
self.id: str = id
|
|
75
|
+
self.name: str = name
|
|
76
|
+
self.adb_port: int | None = adb_port
|
|
77
|
+
self.adb_ip: str = adb_ip
|
|
78
|
+
self.adb_name: str | None = adb_name
|
|
79
|
+
|
|
80
|
+
def require_adb_port(self) -> int:
|
|
81
|
+
if self.adb_port is None:
|
|
82
|
+
raise ValueError("ADB port is not set and is required.")
|
|
83
|
+
return self.adb_port
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def refresh(self):
|
|
87
|
+
"""
|
|
88
|
+
刷新实例信息,如 ADB 端口号等。
|
|
89
|
+
"""
|
|
90
|
+
raise NotImplementedError()
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def start(self):
|
|
94
|
+
"""
|
|
95
|
+
启动模拟器实例。
|
|
96
|
+
"""
|
|
97
|
+
raise NotImplementedError()
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def stop(self):
|
|
101
|
+
"""
|
|
102
|
+
停止模拟器实例。
|
|
103
|
+
"""
|
|
104
|
+
raise NotImplementedError()
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def running(self) -> bool:
|
|
108
|
+
raise NotImplementedError()
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def create_device(self, impl: DeviceImpl, host_config: T_HostConfig) -> Device:
|
|
112
|
+
"""
|
|
113
|
+
根据实现名称和类型化的主机配置创建设备。
|
|
114
|
+
|
|
115
|
+
:param impl: 设备实现的名称。
|
|
116
|
+
:param host_config: 一个类型化的数据对象,包含创建所需的所有外部配置。
|
|
117
|
+
:return: 配置好的 Device 实例。
|
|
118
|
+
"""
|
|
119
|
+
raise NotImplementedError()
|
|
120
|
+
|
|
121
|
+
def wait_available(self, timeout: float = 180):
|
|
122
|
+
logger.info('Starting to wait for emulator %s(127.0.0.1:%d) to be available...', self.name, self.adb_port)
|
|
123
|
+
state = 0
|
|
124
|
+
port = self.require_adb_port()
|
|
125
|
+
emulator_name = self.adb_name
|
|
126
|
+
cd = Countdown(timeout)
|
|
127
|
+
it = Interval(1)
|
|
128
|
+
d: AdbDevice | None = None
|
|
129
|
+
while True:
|
|
130
|
+
if cd.expired():
|
|
131
|
+
raise TimeoutError(f'Emulator "{self.name}" is not available.')
|
|
132
|
+
it.wait()
|
|
133
|
+
try:
|
|
134
|
+
match state:
|
|
135
|
+
case 0:
|
|
136
|
+
logger.debug('Ping emulator %s(127.0.0.1:%d)...', self.name, port)
|
|
137
|
+
if tcp_ping('127.0.0.1', port):
|
|
138
|
+
logger.debug('Ping emulator %s(127.0.0.1:%d) success.', self.name, port)
|
|
139
|
+
state = 1
|
|
140
|
+
case 1:
|
|
141
|
+
logger.debug('Connecting to emulator %s(127.0.0.1:%d)...', self.name, port)
|
|
142
|
+
if adb.connect(f'127.0.0.1:{port}', timeout=0.5):
|
|
143
|
+
logger.debug('Connect to emulator %s(127.0.0.1:%d) success.', self.name, port)
|
|
144
|
+
state = 2
|
|
145
|
+
case 2:
|
|
146
|
+
logger.debug('Getting device list...')
|
|
147
|
+
if devices := adb.device_list():
|
|
148
|
+
logger.debug('Get device list success. devices=%s', devices)
|
|
149
|
+
# emulator_name 用于适配雷电模拟器
|
|
150
|
+
# 雷电模拟器启动后,在上方的列表中并不会出现 127.0.0.1:5555,而是 emulator-5554
|
|
151
|
+
d = next(
|
|
152
|
+
(d for d in devices if d.serial == f'127.0.0.1:{port}' or d.serial == emulator_name),
|
|
153
|
+
None
|
|
154
|
+
)
|
|
155
|
+
if d:
|
|
156
|
+
logger.debug('Get target device success. d=%s', d)
|
|
157
|
+
state = 3
|
|
158
|
+
case 3:
|
|
159
|
+
if not d:
|
|
160
|
+
logger.warning('Device is None.')
|
|
161
|
+
state = 0
|
|
162
|
+
continue
|
|
163
|
+
logger.debug('Waiting for device state...')
|
|
164
|
+
if d.get_state() == 'device':
|
|
165
|
+
logger.debug('Device state ready. state=%s', d.get_state())
|
|
166
|
+
state = 4
|
|
167
|
+
case 4:
|
|
168
|
+
logger.debug('Waiting for device boot completed...')
|
|
169
|
+
if not d:
|
|
170
|
+
logger.warning('Device is None.')
|
|
171
|
+
state = 0
|
|
172
|
+
continue
|
|
173
|
+
ret = d.shell('getprop sys.boot_completed')
|
|
174
|
+
if isinstance(ret, str) and ret.strip() == '1':
|
|
175
|
+
logger.debug('Device boot completed. ret=%s', ret)
|
|
176
|
+
state = 5
|
|
177
|
+
case 5:
|
|
178
|
+
if not d:
|
|
179
|
+
logger.warning('Device is None.')
|
|
180
|
+
state = 0
|
|
181
|
+
continue
|
|
182
|
+
app = d.app_current()
|
|
183
|
+
logger.debug('Waiting for launcher... (current=%s)', app)
|
|
184
|
+
if app and 'launcher' in app.package:
|
|
185
|
+
logger.info('Emulator %s(127.0.0.1:%d) now is available.', self.name, self.adb_port)
|
|
186
|
+
state = 6
|
|
187
|
+
case 6:
|
|
188
|
+
break
|
|
189
|
+
except (AdbError, AdbTimeout):
|
|
190
|
+
state = 1
|
|
191
|
+
continue
|
|
192
|
+
time.sleep(1)
|
|
193
|
+
logger.info('Emulator %s(127.0.0.1:%d) now is available.', self.name, self.adb_port)
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
return f'{self.__class__.__name__}(name="{self.name}", id="{self.id}", adb="{self.adb_ip}:{self.adb_port}"({self.adb_name}))'
|
|
197
|
+
|
|
198
|
+
Recipe = TypeVar('Recipe', bound=str)
|
|
199
|
+
class HostProtocol(Generic[Recipe], Protocol):
|
|
200
|
+
@staticmethod
|
|
201
|
+
def installed() -> bool: ...
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def list() -> list[Instance]: ...
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def query(*, id: str) -> Instance | None: ...
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def recipes() -> 'list[Recipe]': ...
|
|
211
|
+
|
|
212
|
+
if __name__ == '__main__':
|
|
213
|
+
pass
|