kotonebot 0.3.1__py3-none-any.whl → 0.5.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.
Files changed (66) hide show
  1. kotonebot/__init__.py +39 -39
  2. kotonebot/backend/bot.py +312 -302
  3. kotonebot/backend/color.py +525 -525
  4. kotonebot/backend/context/__init__.py +3 -3
  5. kotonebot/backend/context/context.py +49 -56
  6. kotonebot/backend/context/task_action.py +183 -175
  7. kotonebot/backend/core.py +129 -126
  8. kotonebot/backend/debug/entry.py +89 -89
  9. kotonebot/backend/debug/mock.py +78 -78
  10. kotonebot/backend/debug/server.py +222 -222
  11. kotonebot/backend/debug/vars.py +351 -351
  12. kotonebot/backend/dispatch.py +227 -227
  13. kotonebot/backend/flow_controller.py +196 -196
  14. kotonebot/backend/loop.py +12 -88
  15. kotonebot/backend/ocr.py +535 -529
  16. kotonebot/backend/preprocessor.py +103 -103
  17. kotonebot/client/__init__.py +9 -9
  18. kotonebot/client/device.py +528 -502
  19. kotonebot/client/fast_screenshot.py +377 -377
  20. kotonebot/client/host/__init__.py +43 -12
  21. kotonebot/client/host/adb_common.py +107 -94
  22. kotonebot/client/host/custom.py +118 -114
  23. kotonebot/client/host/leidian_host.py +196 -201
  24. kotonebot/client/host/mumu12_host.py +353 -358
  25. kotonebot/client/host/protocol.py +214 -213
  26. kotonebot/client/host/windows_common.py +58 -55
  27. kotonebot/client/implements/__init__.py +71 -7
  28. kotonebot/client/implements/adb.py +89 -85
  29. kotonebot/client/implements/adb_raw.py +162 -158
  30. kotonebot/client/implements/nemu_ipc/__init__.py +11 -7
  31. kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +284 -284
  32. kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -327
  33. kotonebot/client/implements/remote_windows.py +188 -192
  34. kotonebot/client/implements/uiautomator2.py +85 -81
  35. kotonebot/client/implements/windows.py +176 -168
  36. kotonebot/client/protocol.py +69 -69
  37. kotonebot/client/registration.py +24 -24
  38. kotonebot/config/base_config.py +96 -96
  39. kotonebot/config/manager.py +36 -36
  40. kotonebot/errors.py +76 -71
  41. kotonebot/interop/win/__init__.py +10 -0
  42. kotonebot/interop/win/_mouse.py +311 -0
  43. kotonebot/interop/win/message_box.py +313 -313
  44. kotonebot/interop/win/reg.py +37 -37
  45. kotonebot/interop/win/shortcut.py +43 -43
  46. kotonebot/interop/win/task_dialog.py +513 -469
  47. kotonebot/logging/__init__.py +2 -2
  48. kotonebot/logging/log.py +17 -17
  49. kotonebot/primitives/__init__.py +17 -17
  50. kotonebot/primitives/geometry.py +862 -290
  51. kotonebot/primitives/visual.py +63 -63
  52. kotonebot/tools/mirror.py +354 -354
  53. kotonebot/ui/file_host/sensio.py +36 -36
  54. kotonebot/ui/file_host/tmp_send.py +54 -54
  55. kotonebot/ui/pushkit/__init__.py +3 -3
  56. kotonebot/ui/pushkit/image_host.py +88 -87
  57. kotonebot/ui/pushkit/protocol.py +13 -13
  58. kotonebot/ui/pushkit/wxpusher.py +54 -53
  59. kotonebot/ui/user.py +148 -143
  60. kotonebot/util.py +436 -409
  61. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/METADATA +82 -76
  62. kotonebot-0.5.0.dist-info/RECORD +71 -0
  63. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/licenses/LICENSE +673 -673
  64. kotonebot-0.3.1.dist-info/RECORD +0 -70
  65. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/WHEEL +0 -0
  66. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/top_level.txt +0 -0
@@ -1,358 +1,353 @@
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, TYPE_CHECKING
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
- # Forward declarations for type hints
26
- if TYPE_CHECKING:
27
- from typing import Type
28
-
29
- logger = logging.getLogger(__name__)
30
- MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
31
-
32
- @dataclass
33
- class MuMu12HostConfig(AdbHostConfig):
34
- """nemu_ipc 能力的配置模型。"""
35
- display_id: int | None = 0
36
- """目标显示器 ID,默认为 0(主显示器)。若为 None 且设置了 target_package_name,则自动获取对应的 display_id。"""
37
- target_package_name: str | None = None
38
- """目标应用包名,用于自动获取 display_id。"""
39
- app_index: int = 0
40
- """多开应用索引,传给 get_display_id 方法。"""
41
-
42
- class Mumu12Host(HostProtocol[MuMu12Recipes]):
43
- InstanceClass: 'Type[Mumu12Instance]'
44
-
45
- @staticmethod
46
- @lru_cache(maxsize=1)
47
- def _read_install_path() -> str | None:
48
- r"""
49
- 从注册表中读取 MuMu Player 12 的安装路径。
50
-
51
- 返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
52
-
53
- :return: 若找到,则返回安装路径;否则返回 None。
54
- """
55
- if os.name != 'nt':
56
- return None
57
-
58
- uninstall_subkeys = [
59
- r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer-12.0',
60
- # TODO: 支持国际版 MuMu
61
- # r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayerGlobal-12.0'
62
- ]
63
-
64
- for subkey in uninstall_subkeys:
65
- icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
66
- if icon_path and isinstance(icon_path, str):
67
- icon_path = icon_path.replace('"', '')
68
- path = os.path.dirname(icon_path)
69
- logger.debug('MuMu Player 12 installation path: %s', path)
70
- # 返回根目录(去掉 shell 子目录)
71
- if os.path.basename(path).lower() == 'shell':
72
- path = os.path.dirname(path)
73
- return path
74
- return None
75
-
76
- @classmethod
77
- def _invoke_manager(cls,args: list[str]) -> str:
78
- """
79
- 调用 MuMuManager.exe。
80
-
81
- :param args: 命令行参数列表。
82
- :return: 命令执行的输出。
83
- """
84
- install_path = cls._read_install_path()
85
- if install_path is None:
86
- raise RuntimeError('MuMu Player 12 is not installed.')
87
- manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
88
- logger.debug('MuMuManager execute: %s', repr(args))
89
- output = subprocess.run(
90
- [manager_path] + args,
91
- capture_output=True,
92
- text=True,
93
- encoding='utf-8',
94
- # https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
95
- creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
96
- )
97
- if output.returncode != 0:
98
- # raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
99
- logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
100
- return output.stdout
101
-
102
- @staticmethod
103
- def installed() -> bool:
104
- return Mumu12Host._read_install_path() is not None
105
-
106
- @classmethod
107
- def list(cls) -> list[Instance]:
108
- nemu_path = cls._read_install_path()
109
- if nemu_path is None:
110
- raise RuntimeError("Nemu path not found.")
111
- output = cls._invoke_manager(['info', '-v', 'all'])
112
- logger.debug('MuMuManager.exe output: %s', output)
113
-
114
- try:
115
- data: dict[str, dict[str, Any]] = json.loads(output)
116
- if 'name' in data.keys():
117
- # 这里有个坑:
118
- # 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
119
- data = { '0': data }
120
- instances = []
121
- for index, instance_data in data.items():
122
- instance = cls.InstanceClass(
123
- id=index,
124
- name=instance_data['name'],
125
- adb_port=instance_data.get('adb_port'),
126
- adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
127
- adb_name=None,
128
- )
129
- instance.nemu_path = nemu_path
130
- instance.index = int(index)
131
- instance.is_android_started = instance_data.get('is_android_started', False)
132
- logger.debug('Mumu12 instance: %s', repr(instance))
133
- instances.append(instance)
134
- return instances
135
- except json.JSONDecodeError as e:
136
- raise RuntimeError(f'Failed to parse output: {e}')
137
-
138
- @classmethod
139
- def query(cls, *, id: str) -> Instance | None:
140
- instances = cls.list()
141
- for instance in instances:
142
- if instance.id == id:
143
- return instance
144
- return None
145
-
146
- @staticmethod
147
- def recipes() -> 'list[MuMu12Recipes]':
148
- return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
149
-
150
- class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
151
- HostClass: 'type[Mumu12Host]' = Mumu12Host
152
-
153
- @copy_type(Instance.__init__)
154
- def __init__(self, *args, **kwargs):
155
- if not hasattr(self.HostClass, 'InstanceClass'):
156
- raise RuntimeError(f"{self.HostClass.__name__}.InstanceClass not initialized")
157
-
158
- super().__init__(*args, **kwargs)
159
- self._args = args
160
- self.index: int | None = None
161
- self.is_android_started: bool = False
162
- self.nemu_path: str | None = None
163
-
164
- @override
165
- def refresh(self):
166
- ins = self.HostClass.query(id=self.id)
167
- if ins is not None and isinstance(ins, self.__class__):
168
- self.adb_port = ins.adb_port
169
- self.adb_ip = ins.adb_ip
170
- self.adb_name = ins.adb_name
171
- self.is_android_started = ins.is_android_started
172
- self.nemu_path = ins.nemu_path
173
- logger.debug('Refreshed MuMu12 instance: %s', repr(ins))
174
-
175
- @override
176
- def start(self):
177
- if self.running():
178
- logger.warning('Instance is already running.')
179
- return
180
- logger.info('Starting MuMu12 instance %s', self)
181
- self.HostClass._invoke_manager(['control', '-v', self.id, 'launch'])
182
- self.refresh()
183
-
184
- @override
185
- def stop(self):
186
- if not self.running():
187
- logger.warning('Instance is not running.')
188
- return
189
- logger.info('Stopping MuMu12 instance id=%s name=%s...', self.id, self.name)
190
- self.HostClass._invoke_manager(['control', '-v', self.id, 'shutdown'])
191
- self.refresh()
192
-
193
- @override
194
- def wait_available(self, timeout: float = 180):
195
- cd = Countdown(timeout)
196
- it = Interval(5)
197
- while not cd.expired() and not self.running():
198
- it.wait()
199
- self.refresh()
200
- if not self.running():
201
- raise TimeoutError(f'MuMu12 instance "{self.name}" is not available.')
202
-
203
- @override
204
- def running(self) -> bool:
205
- return self.is_android_started
206
-
207
- @overload
208
- def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
209
- @overload
210
- def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
211
-
212
- @override
213
- def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
214
- """为MuMu12模拟器实例创建 Device。"""
215
- if self.adb_port is None:
216
- raise ValueError("ADB port is not set and is required.")
217
-
218
- if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
219
- # NemuImpl
220
- if self.nemu_path is None:
221
- raise RuntimeError("Nemu path is not set.")
222
- nemu_config = NemuIpcImplConfig(
223
- nemu_folder=self.nemu_path,
224
- instance_id=int(self.id),
225
- display_id=host_config.display_id,
226
- target_package_name=host_config.target_package_name,
227
- app_index=host_config.app_index
228
- )
229
- nemu_impl = NemuIpcImpl(nemu_config)
230
- # AdbImpl
231
- adb_impl = AdbImpl(connect_adb(
232
- self.adb_ip,
233
- self.adb_port,
234
- timeout=host_config.timeout,
235
- device_serial=self.adb_name
236
- ))
237
- device = AndroidDevice()
238
- device._screenshot = nemu_impl
239
- device._touch = nemu_impl
240
- device.commands = adb_impl
241
-
242
- return device
243
- elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
244
- return super().create_device(recipe, host_config)
245
- else:
246
- raise ValueError(f'Unknown recipe: {recipe}')
247
-
248
- class Mumu12V5Host(Mumu12Host):
249
- InstanceClass: 'Type[Mumu12V5Instance]'
250
-
251
- @classmethod
252
- @lru_cache(maxsize=1)
253
- def _read_install_path(cls) -> str | None:
254
- r"""
255
- 从注册表中读取 MuMu Player 12 v5.x 的安装路径。
256
-
257
- 返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
258
-
259
- :return: 若找到,则返回安装路径;否则返回 None。
260
- """
261
- if os.name != 'nt':
262
- return None
263
-
264
- uninstall_subkeys = [
265
- r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer',
266
- ]
267
-
268
- for subkey in uninstall_subkeys:
269
- icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
270
- if icon_path and isinstance(icon_path, str):
271
- icon_path = icon_path.replace('"', '')
272
- path = os.path.dirname(icon_path)
273
- logger.debug('MuMu Player 12 installation path: %s', path)
274
- # 返回根目录(去掉 shell 子目录)
275
- if os.path.basename(path).lower() == 'nx_main':
276
- path = os.path.dirname(path)
277
- return path
278
- return None
279
-
280
- @classmethod
281
- def _invoke_manager(cls, args: list[str]) -> str:
282
- """
283
- 调用 MuMuManager.exe。
284
-
285
- :param args: 命令行参数列表。
286
- :return: 命令执行的输出。
287
- """
288
- install_path = cls._read_install_path()
289
- if install_path is None:
290
- raise RuntimeError('MuMu Player 12 v5.x is not installed.')
291
- manager_path = os.path.join(install_path, 'nx_main', 'MuMuManager.exe')
292
- logger.debug('MuMuManager execute: %s', repr(args))
293
- output = subprocess.run(
294
- [manager_path] + args,
295
- capture_output=True,
296
- text=True,
297
- encoding='utf-8',
298
- # https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
299
- creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
300
- )
301
- if output.returncode != 0:
302
- # raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
303
- logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
304
- return output.stdout
305
-
306
- @classmethod
307
- def installed(cls) -> bool:
308
- return cls._read_install_path() is not None
309
-
310
- @classmethod
311
- def list(cls) -> list[Instance]:
312
- output = cls._invoke_manager(['info', '-v', 'all'])
313
- logger.debug('MuMuManager.exe output: %s', output)
314
-
315
- try:
316
- data: dict[str, dict[str, Any]] = json.loads(output)
317
- if 'name' in data.keys():
318
- # 这里有个坑:
319
- # 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
320
- data = { '0': data }
321
- instances = []
322
- for index, instance_data in data.items():
323
- instance = cls.InstanceClass(
324
- id=index,
325
- name=instance_data['name'],
326
- adb_port=instance_data.get('adb_port'),
327
- adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
328
- adb_name=None
329
- )
330
- instance.nemu_path = cls._read_install_path()
331
- instance.index = int(index)
332
- instance.is_android_started = instance_data.get('is_android_started', False)
333
- logger.debug('Mumu12 v5.x instance: %s', repr(instance))
334
- instances.append(instance)
335
- return instances
336
- except json.JSONDecodeError as e:
337
- raise RuntimeError(f'Failed to parse output: {e}')
338
-
339
- class Mumu12V5Instance(Mumu12Instance):
340
- HostClass: 'type[Mumu12V5Host]' = Mumu12V5Host
341
-
342
- # 延迟初始化 InstanceClass 变量
343
- Mumu12Host.InstanceClass = Mumu12Instance
344
- Mumu12V5Host.InstanceClass = Mumu12V5Instance
345
-
346
-
347
- if __name__ == '__main__':
348
- logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
349
- print(Mumu12Host._read_install_path())
350
- print(Mumu12Host.installed())
351
- print(Mumu12Host.list())
352
- print(ins:=Mumu12Host.query(id='2'))
353
- assert isinstance(ins, Mumu12Host.InstanceClass)
354
- ins.start()
355
- ins.wait_available()
356
- print('status', ins.running(), ins.adb_port, ins.adb_ip)
357
- ins.stop()
358
- print('status', ins.running(), ins.adb_port, ins.adb_ip)
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, TYPE_CHECKING
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
+ from ...interop.win.reg import read_reg
18
+
19
+
20
+ # Forward declarations for type hints
21
+ if TYPE_CHECKING:
22
+ from typing import Type
23
+
24
+ logger = logging.getLogger(__name__)
25
+ MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
26
+
27
+ @dataclass
28
+ class MuMu12HostConfig(AdbHostConfig):
29
+ """nemu_ipc 能力的配置模型。"""
30
+ display_id: int | None = 0
31
+ """目标显示器 ID,默认为 0(主显示器)。若为 None 且设置了 target_package_name,则自动获取对应的 display_id。"""
32
+ target_package_name: str | None = None
33
+ """目标应用包名,用于自动获取 display_id。"""
34
+ app_index: int = 0
35
+ """多开应用索引,传给 get_display_id 方法。"""
36
+
37
+ class Mumu12Host(HostProtocol[MuMu12Recipes]):
38
+ InstanceClass: 'Type[Mumu12Instance]'
39
+
40
+ @staticmethod
41
+ @lru_cache(maxsize=1)
42
+ def _read_install_path() -> str | None:
43
+ r"""
44
+ 从注册表中读取 MuMu Player 12 的安装路径。
45
+
46
+ 返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
47
+
48
+ :return: 若找到,则返回安装路径;否则返回 None。
49
+ """
50
+ if os.name != 'nt':
51
+ return None
52
+
53
+ uninstall_subkeys = [
54
+ r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer-12.0',
55
+ # TODO: 支持国际版 MuMu
56
+ # r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayerGlobal-12.0'
57
+ ]
58
+
59
+ for subkey in uninstall_subkeys:
60
+ icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
61
+ if icon_path and isinstance(icon_path, str):
62
+ icon_path = icon_path.replace('"', '')
63
+ path = os.path.dirname(icon_path)
64
+ logger.debug('MuMu Player 12 installation path: %s', path)
65
+ # 返回根目录(去掉 shell 子目录)
66
+ if os.path.basename(path).lower() == 'shell':
67
+ path = os.path.dirname(path)
68
+ return path
69
+ return None
70
+
71
+ @classmethod
72
+ def _invoke_manager(cls,args: list[str]) -> str:
73
+ """
74
+ 调用 MuMuManager.exe。
75
+
76
+ :param args: 命令行参数列表。
77
+ :return: 命令执行的输出。
78
+ """
79
+ install_path = cls._read_install_path()
80
+ if install_path is None:
81
+ raise RuntimeError('MuMu Player 12 is not installed.')
82
+ manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
83
+ logger.debug('MuMuManager execute: %s', repr(args))
84
+ output = subprocess.run(
85
+ [manager_path] + args,
86
+ capture_output=True,
87
+ text=True,
88
+ encoding='utf-8',
89
+ # https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
90
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
91
+ )
92
+ if output.returncode != 0:
93
+ # raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
94
+ logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
95
+ return output.stdout
96
+
97
+ @staticmethod
98
+ def installed() -> bool:
99
+ return Mumu12Host._read_install_path() is not None
100
+
101
+ @classmethod
102
+ def list(cls) -> list[Instance]:
103
+ nemu_path = cls._read_install_path()
104
+ if nemu_path is None:
105
+ raise RuntimeError("Nemu path not found.")
106
+ output = cls._invoke_manager(['info', '-v', 'all'])
107
+ logger.debug('MuMuManager.exe output: %s', output)
108
+
109
+ try:
110
+ data: dict[str, dict[str, Any]] = json.loads(output)
111
+ if 'name' in data.keys():
112
+ # 这里有个坑:
113
+ # 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
114
+ data = { '0': data }
115
+ instances = []
116
+ for index, instance_data in data.items():
117
+ instance = cls.InstanceClass(
118
+ id=index,
119
+ name=instance_data['name'],
120
+ adb_port=instance_data.get('adb_port'),
121
+ adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
122
+ adb_name=None,
123
+ )
124
+ instance.nemu_path = nemu_path
125
+ instance.index = int(index)
126
+ instance.is_android_started = instance_data.get('is_android_started', False)
127
+ logger.debug('Mumu12 instance: %s', repr(instance))
128
+ instances.append(instance)
129
+ return instances
130
+ except json.JSONDecodeError as e:
131
+ raise RuntimeError(f'Failed to parse output: {e}')
132
+
133
+ @classmethod
134
+ def query(cls, *, id: str) -> Instance | None:
135
+ instances = cls.list()
136
+ for instance in instances:
137
+ if instance.id == id:
138
+ return instance
139
+ return None
140
+
141
+ @staticmethod
142
+ def recipes() -> 'list[MuMu12Recipes]':
143
+ return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
144
+
145
+ class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
146
+ HostClass: 'type[Mumu12Host]' = Mumu12Host
147
+
148
+ @copy_type(Instance.__init__)
149
+ def __init__(self, *args, **kwargs):
150
+ if not hasattr(self.HostClass, 'InstanceClass'):
151
+ raise RuntimeError(f"{self.HostClass.__name__}.InstanceClass not initialized")
152
+
153
+ super().__init__(*args, **kwargs)
154
+ self._args = args
155
+ self.index: int | None = None
156
+ self.is_android_started: bool = False
157
+ self.nemu_path: str | None = None
158
+
159
+ @override
160
+ def refresh(self):
161
+ ins = self.HostClass.query(id=self.id)
162
+ if ins is not None and isinstance(ins, self.__class__):
163
+ self.adb_port = ins.adb_port
164
+ self.adb_ip = ins.adb_ip
165
+ self.adb_name = ins.adb_name
166
+ self.is_android_started = ins.is_android_started
167
+ self.nemu_path = ins.nemu_path
168
+ logger.debug('Refreshed MuMu12 instance: %s', repr(ins))
169
+
170
+ @override
171
+ def start(self):
172
+ if self.running():
173
+ logger.warning('Instance is already running.')
174
+ return
175
+ logger.info('Starting MuMu12 instance %s', self)
176
+ self.HostClass._invoke_manager(['control', '-v', self.id, 'launch'])
177
+ self.refresh()
178
+
179
+ @override
180
+ def stop(self):
181
+ if not self.running():
182
+ logger.warning('Instance is not running.')
183
+ return
184
+ logger.info('Stopping MuMu12 instance id=%s name=%s...', self.id, self.name)
185
+ self.HostClass._invoke_manager(['control', '-v', self.id, 'shutdown'])
186
+ self.refresh()
187
+
188
+ @override
189
+ def wait_available(self, timeout: float = 180):
190
+ cd = Countdown(timeout)
191
+ it = Interval(5)
192
+ while not cd.expired() and not self.running():
193
+ it.wait()
194
+ self.refresh()
195
+ if not self.running():
196
+ raise TimeoutError(f'MuMu12 instance "{self.name}" is not available.')
197
+
198
+ @override
199
+ def running(self) -> bool:
200
+ return self.is_android_started
201
+
202
+ @overload
203
+ def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
204
+ @overload
205
+ def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
206
+
207
+ @override
208
+ def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
209
+ """为MuMu12模拟器实例创建 Device。"""
210
+ if self.adb_port is None:
211
+ raise ValueError("ADB port is not set and is required.")
212
+
213
+ if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
214
+ # NemuImpl
215
+ if self.nemu_path is None:
216
+ raise RuntimeError("Nemu path is not set.")
217
+ nemu_config = NemuIpcImplConfig(
218
+ nemu_folder=self.nemu_path,
219
+ instance_id=int(self.id),
220
+ display_id=host_config.display_id,
221
+ target_package_name=host_config.target_package_name,
222
+ app_index=host_config.app_index
223
+ )
224
+ nemu_impl = NemuIpcImpl(nemu_config)
225
+ # AdbImpl
226
+ adb_impl = AdbImpl(connect_adb(
227
+ self.adb_ip,
228
+ self.adb_port,
229
+ timeout=host_config.timeout,
230
+ device_serial=self.adb_name
231
+ ))
232
+ device = AndroidDevice()
233
+ device._screenshot = nemu_impl
234
+ device._touch = nemu_impl
235
+ device.commands = adb_impl
236
+
237
+ return device
238
+ elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
239
+ return super().create_device(recipe, host_config)
240
+ else:
241
+ raise ValueError(f'Unknown recipe: {recipe}')
242
+
243
+ class Mumu12V5Host(Mumu12Host):
244
+ InstanceClass: 'Type[Mumu12V5Instance]'
245
+
246
+ @classmethod
247
+ @lru_cache(maxsize=1)
248
+ def _read_install_path(cls) -> str | None:
249
+ r"""
250
+ 从注册表中读取 MuMu Player 12 v5.x 的安装路径。
251
+
252
+ 返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
253
+
254
+ :return: 若找到,则返回安装路径;否则返回 None。
255
+ """
256
+ if os.name != 'nt':
257
+ return None
258
+
259
+ uninstall_subkeys = [
260
+ r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer',
261
+ ]
262
+
263
+ for subkey in uninstall_subkeys:
264
+ icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
265
+ if icon_path and isinstance(icon_path, str):
266
+ icon_path = icon_path.replace('"', '')
267
+ path = os.path.dirname(icon_path)
268
+ logger.debug('MuMu Player 12 installation path: %s', path)
269
+ # 返回根目录(去掉 shell 子目录)
270
+ if os.path.basename(path).lower() == 'nx_main':
271
+ path = os.path.dirname(path)
272
+ return path
273
+ return None
274
+
275
+ @classmethod
276
+ def _invoke_manager(cls, args: list[str]) -> str:
277
+ """
278
+ 调用 MuMuManager.exe。
279
+
280
+ :param args: 命令行参数列表。
281
+ :return: 命令执行的输出。
282
+ """
283
+ install_path = cls._read_install_path()
284
+ if install_path is None:
285
+ raise RuntimeError('MuMu Player 12 v5.x is not installed.')
286
+ manager_path = os.path.join(install_path, 'nx_main', 'MuMuManager.exe')
287
+ logger.debug('MuMuManager execute: %s', repr(args))
288
+ output = subprocess.run(
289
+ [manager_path] + args,
290
+ capture_output=True,
291
+ text=True,
292
+ encoding='utf-8',
293
+ # https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
294
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
295
+ )
296
+ if output.returncode != 0:
297
+ # raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
298
+ logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
299
+ return output.stdout
300
+
301
+ @classmethod
302
+ def installed(cls) -> bool:
303
+ return cls._read_install_path() is not None
304
+
305
+ @classmethod
306
+ def list(cls) -> list[Instance]:
307
+ output = cls._invoke_manager(['info', '-v', 'all'])
308
+ logger.debug('MuMuManager.exe output: %s', output)
309
+
310
+ try:
311
+ data: dict[str, dict[str, Any]] = json.loads(output)
312
+ if 'name' in data.keys():
313
+ # 这里有个坑:
314
+ # 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
315
+ data = { '0': data }
316
+ instances = []
317
+ for index, instance_data in data.items():
318
+ instance = cls.InstanceClass(
319
+ id=index,
320
+ name=instance_data['name'],
321
+ adb_port=instance_data.get('adb_port'),
322
+ adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
323
+ adb_name=None
324
+ )
325
+ instance.nemu_path = cls._read_install_path()
326
+ instance.index = int(index)
327
+ instance.is_android_started = instance_data.get('is_android_started', False)
328
+ logger.debug('Mumu12 v5.x instance: %s', repr(instance))
329
+ instances.append(instance)
330
+ return instances
331
+ except json.JSONDecodeError as e:
332
+ raise RuntimeError(f'Failed to parse output: {e}')
333
+
334
+ class Mumu12V5Instance(Mumu12Instance):
335
+ HostClass: 'type[Mumu12V5Host]' = Mumu12V5Host
336
+
337
+ # 延迟初始化 InstanceClass 变量
338
+ Mumu12Host.InstanceClass = Mumu12Instance
339
+ Mumu12V5Host.InstanceClass = Mumu12V5Instance
340
+
341
+
342
+ if __name__ == '__main__':
343
+ logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
344
+ print(Mumu12Host._read_install_path())
345
+ print(Mumu12Host.installed())
346
+ print(Mumu12Host.list())
347
+ print(ins:=Mumu12Host.query(id='2'))
348
+ assert isinstance(ins, Mumu12Host.InstanceClass)
349
+ ins.start()
350
+ ins.wait_available()
351
+ print('status', ins.running(), ins.adb_port, ins.adb_ip)
352
+ ins.stop()
353
+ print('status', ins.running(), ins.adb_port, ins.adb_ip)