refactor(core): 将 Commandable 分离为 WindowsCommandable 与 AndroidCommandable

This commit is contained in:
XcantloadX 2025-06-11 08:49:57 +08:00
parent b8b5ba8a98
commit 16a267de79
7 changed files with 86 additions and 52 deletions

View File

@ -747,13 +747,13 @@ class ContextDevice(Device):
return img return img
def __getattribute__(self, name: str) -> Any: def __getattribute__(self, name: str) -> Any:
if name in ['_device', 'screenshot']: if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
return object.__getattribute__(self, name) return object.__getattribute__(self, name)
else: else:
return getattr(self._device, name) return getattr(self._device, name)
def __setattr__(self, name: str, value: Any): def __setattr__(self, name: str, value: Any):
if name in ['_device', 'screenshot']: if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
return object.__setattr__(self, name, value) return object.__setattr__(self, name, value)
else: else:
return setattr(self._device, name, value) return setattr(self._device, name, value)

View File

@ -11,7 +11,7 @@ from adbutils._device import AdbDevice as AdbUtilsDevice
from ..backend.debug import result from ..backend.debug import result
from kotonebot.backend.core import HintBox from kotonebot.backend.core import HintBox
from kotonebot.primitives import Rect, Point, is_point from kotonebot.primitives import Rect, Point, is_point
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable, AndroidCommandable, WindowsCommandable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -71,7 +71,6 @@ class Device:
横屏时为 'landscape'竖屏时为 'portrait' 横屏时为 'landscape'竖屏时为 'portrait'
""" """
self._command: Commandable
self._touch: Touchable self._touch: Touchable
self._screenshot: Screenshotable self._screenshot: Screenshotable
@ -90,12 +89,6 @@ class Device:
def adb(self, value: AdbUtilsDevice) -> None: def adb(self, value: AdbUtilsDevice) -> None:
self._adb = value self._adb = value
def launch_app(self, package_name: str) -> None:
"""
根据包名启动 app
"""
self._command.launch_app(package_name)
@overload @overload
def click(self) -> None: def click(self) -> None:
""" """
@ -306,17 +299,6 @@ class Device:
""" """
return self._screenshot.screen_size return self._screenshot.screen_size
def current_package(self) -> str | None:
"""
获取前台 APP 的包名
:return: 前台 APP 的包名如果获取失败则返回 None
:exception: 如果设备不支持此功能则抛出 NotImplementedError
"""
ret = self._command.current_package()
logger.debug("current_package: %s", ret)
return ret
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None: def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
""" """
检测当前设备方向并设置 `self.orientation` 属性 检测当前设备方向并设置 `self.orientation` 属性
@ -330,10 +312,30 @@ class AndroidDevice(Device):
def __init__(self, adb_connection: AdbUtilsDevice | None = None) -> None: def __init__(self, adb_connection: AdbUtilsDevice | None = None) -> None:
super().__init__('android') super().__init__('android')
self._adb: AdbUtilsDevice | None = adb_connection self._adb: AdbUtilsDevice | None = adb_connection
self.commands: AndroidCommandable
def current_package(self) -> str | None:
"""
获取前台 APP 的包名
:return: 前台 APP 的包名如果获取失败则返回 None
:exception: 如果设备不支持此功能则抛出 NotImplementedError
"""
ret = self.commands.current_package()
logger.debug("current_package: %s", ret)
return ret
def launch_app(self, package_name: str) -> None:
"""
根据包名启动 app
"""
self.commands.launch_app(package_name)
class WindowsDevice(Device): class WindowsDevice(Device):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__('windows') super().__init__('windows')
self.commands: WindowsCommandable
if __name__ == "__main__": if __name__ == "__main__":
@ -346,10 +348,10 @@ if __name__ == "__main__":
d = adb.device_list()[-1] d = adb.device_list()[-1]
d.shell("dumpsys activity top | grep ACTIVITY | tail -n 1") d.shell("dumpsys activity top | grep ACTIVITY | tail -n 1")
dd = AndroidDevice(d) dd = AndroidDevice(d)
adb_imp = AdbRawImpl(dd) adb_imp = AdbRawImpl(d)
dd._command = adb_imp
dd._touch = adb_imp dd._touch = adb_imp
dd._screenshot = adb_imp dd._screenshot = adb_imp
dd.commands = adb_imp
# dd._screenshot = MinicapScreenshotImpl(dd) # dd._screenshot = MinicapScreenshotImpl(dd)
# dd._screenshot = UiAutomator2Impl(dd) # dd._screenshot = UiAutomator2Impl(dd)

View File

@ -5,9 +5,10 @@ from typing_extensions import override
import cv2 import cv2
import numpy as np import numpy as np
from cv2.typing import MatLike from cv2.typing import MatLike
from adbutils._device import AdbDevice as AdbUtilsDevice
from ..device import Device, AndroidDevice from ..device import AndroidDevice
from ..protocol import Commandable, Touchable, Screenshotable from ..protocol import AndroidCommandable, Touchable, Screenshotable
from ..registration import register_impl, ImplConfig from ..registration import register_impl, ImplConfig
from dataclasses import dataclass from dataclasses import dataclass
@ -22,10 +23,9 @@ class AdbImplConfig(ImplConfig):
device_serial: str | None = None device_serial: str | None = None
timeout: float = 180 timeout: float = 180
class AdbImpl(Commandable, Touchable, Screenshotable): class AdbImpl(AndroidCommandable, Touchable, Screenshotable):
def __init__(self, device: Device): def __init__(self, adb_connection: AdbUtilsDevice):
self.device = device self.adb = adb_connection
self.adb = device.adb
@override @override
def launch_app(self, package_name: str) -> None: def launch_app(self, package_name: str) -> None:
@ -47,6 +47,10 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
package = activity.split('/')[0] package = activity.split('/')[0]
return package return package
def adb_shell(self, cmd: str) -> str:
"""执行 ADB shell 命令"""
return cast(str, self.adb.shell(cmd))
@override @override
def detect_orientation(self): def detect_orientation(self):
# 判断方向https://stackoverflow.com/questions/10040624/check-if-device-is-landscape-via-adb # 判断方向https://stackoverflow.com/questions/10040624/check-if-device-is-landscape-via-adb
@ -61,7 +65,9 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
def screen_size(self) -> tuple[int, int]: def screen_size(self) -> tuple[int, int]:
ret = cast(str, self.adb.shell("wm size")).strip('Physical size: ') ret = cast(str, self.adb.shell("wm size")).strip('Physical size: ')
spiltted = tuple(map(int, ret.split("x"))) spiltted = tuple(map(int, ret.split("x")))
landscape = self.device.orientation == 'landscape' # 检测当前方向
orientation = self.detect_orientation()
landscape = orientation == 'landscape'
spiltted = tuple(sorted(spiltted, reverse=landscape)) spiltted = tuple(sorted(spiltted, reverse=landscape))
if len(spiltted) != 2: if len(spiltted) != 2:
raise ValueError(f"Invalid screen size: {ret}") raise ValueError(f"Invalid screen size: {ret}")
@ -79,13 +85,13 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
self.adb.shell(f"input touchscreen swipe {x1} {y1} {x2} {y2}") self.adb.shell(f"input touchscreen swipe {x1} {y1} {x2} {y2}")
def _create_adb_device_base(config: AdbImplConfig, impl_factory: type) -> Device: def _create_adb_device_base(config: AdbImplConfig, impl_class: type) -> AndroidDevice:
""" """
通用的 ADB 设备创建工厂函数 通用的 ADB 设备创建工厂函数
其他任意基于 ADB Impl 可以直接复用这个函数 其他任意基于 ADB Impl 可以直接复用这个函数
:param config: ADB 实现配置 :param config: ADB 实现配置
:param impl_factory: 实现类工厂函数接收 device 参数并返回实现实例 :param impl_class: 实现类或工厂函数构造函数接收 adb_connection 参数
""" """
from adbutils import adb from adbutils import adb
@ -106,15 +112,17 @@ def _create_adb_device_base(config: AdbImplConfig, impl_factory: type) -> Device
if len(d) == 0: if len(d) == 0:
raise ValueError(f"Device {config.addr} not found") raise ValueError(f"Device {config.addr} not found")
d = d[0] d = d[0]
device = AndroidDevice(d) device = AndroidDevice(d)
impl = impl_factory(device) impl = impl_class(d)
device._command = impl
device._touch = impl device._touch = impl
device._screenshot = impl device._screenshot = impl
device.commands = impl
return device return device
@register_impl('adb', config_model=AdbImplConfig) @register_impl('adb', config_model=AdbImplConfig)
def create_adb_device(config: AdbImplConfig) -> Device: def create_adb_device(config: AdbImplConfig) -> AndroidDevice:
"""AdbImpl 工厂函数""" """AdbImpl 工厂函数"""
return _create_adb_device_base(config, AdbImpl) return _create_adb_device_base(config, AdbImpl)

View File

@ -12,7 +12,7 @@ from cv2.typing import MatLike
from adbutils._utils import adb_path from adbutils._utils import adb_path
from .adb import AdbImpl, AdbImplConfig, _create_adb_device_base from .adb import AdbImpl, AdbImplConfig, _create_adb_device_base
from ..device import Device from adbutils._device import AdbDevice as AdbUtilsDevice
from ..registration import register_impl from ..registration import register_impl
from kotonebot import logging from kotonebot import logging
@ -28,8 +28,8 @@ done
""" """
class AdbRawImpl(AdbImpl): class AdbRawImpl(AdbImpl):
def __init__(self, device: Device): def __init__(self, adb_connection: AdbUtilsDevice):
super().__init__(device) super().__init__(adb_connection)
self.__worker: Thread | None = None self.__worker: Thread | None = None
self.__process: subprocess.Popen | None = None self.__process: subprocess.Popen | None = None
self.__data: MatLike | None = None self.__data: MatLike | None = None
@ -161,6 +161,6 @@ class AdbRawImpl(AdbImpl):
@register_impl('adb_raw', config_model=AdbImplConfig) @register_impl('adb_raw', config_model=AdbImplConfig)
def create_adb_raw_device(config: AdbImplConfig) -> Device: def create_adb_raw_device(config: AdbImplConfig):
"""AdbRawImpl 工厂函数""" """AdbRawImpl 工厂函数"""
return _create_adb_device_base(config, AdbRawImpl) return _create_adb_device_base(config, AdbRawImpl)

View File

@ -28,6 +28,19 @@ class Commandable(Protocol):
def launch_app(self, package_name: str) -> None: ... def launch_app(self, package_name: str) -> None: ...
def current_package(self) -> str | None: ... def current_package(self) -> str | None: ...
@runtime_checkable
class AndroidCommandable(Protocol):
"""定义 Android 平台的特定命令"""
def launch_app(self, package_name: str) -> None: ...
def current_package(self) -> str | None: ...
def adb_shell(self, cmd: str) -> str: ...
@runtime_checkable
class WindowsCommandable(Protocol):
"""定义 Windows 平台的特定命令"""
def get_foreground_window(self) -> tuple[int, str]: ...
def exec_command(self, command: str) -> tuple[int, str, str]: ...
@runtime_checkable @runtime_checkable
class Screenshotable(Protocol): class Screenshotable(Protocol):
def __init__(self, device: 'Device'): ... def __init__(self, device: 'Device'): ...

View File

@ -24,6 +24,7 @@ def run_script(script_path: str) -> None:
config_path = './config.json' config_path = './config.json'
kaa_instance = Kaa(config_path) kaa_instance = Kaa(config_path)
init_context(config_type=BaseConfig, target_device=kaa_instance._on_create_device()) init_context(config_type=BaseConfig, target_device=kaa_instance._on_create_device())
kaa_instance._on_after_init_context()
manual_context().begin() manual_context().begin()
runpy.run_module(module_name, run_name="__main__") runpy.run_module(module_name, run_name="__main__")

View File

@ -297,19 +297,27 @@ class DeviceMirrorFrame(wx.Frame):
self.log("请输入包名") self.log("请输入包名")
return return
try: try:
self.device.launch_app(package_name) # 使用新的 API 通过 commands 属性访问平台特定方法
self.log(f"启动APP: {package_name}") if hasattr(self.device, 'commands') and hasattr(self.device.commands, 'launch_app'):
self.device.commands.launch_app(package_name)
self.log(f"启动APP: {package_name}")
else:
self.log("当前设备不支持启动APP功能")
except Exception as e: except Exception as e:
self.log(f"启动APP失败: {e}") self.log(f"启动APP失败: {e}")
def on_get_current_app(self, event): def on_get_current_app(self, event):
"""获取前台APP""" """获取前台APP"""
try: try:
package = self.device.current_package() # 使用新的 API 通过 commands 属性访问平台特定方法
if package: if hasattr(self.device, 'commands') and hasattr(self.device.commands, 'current_package'):
self.log(f"前台APP: {package}") package = self.device.commands.current_package()
if package:
self.log(f"前台APP: {package}")
else:
self.log("未获取到前台APP")
else: else:
self.log("获取前台APP失败") self.log("当前设备不支持获取前台APP功能")
except Exception as e: except Exception as e:
self.log(f"获取前台APP失败: {e}") self.log(f"获取前台APP失败: {e}")
@ -326,6 +334,7 @@ def show_device_mirror(device: Device):
if __name__ == "__main__": if __name__ == "__main__":
# 测试代码 # 测试代码
from kotonebot.client.device import AndroidDevice
from kotonebot.client.implements.adb import AdbImpl from kotonebot.client.implements.adb import AdbImpl
from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl
from adbutils import adb from adbutils import adb
@ -335,10 +344,11 @@ if __name__ == "__main__":
print("devices:", adb.device_list()) print("devices:", adb.device_list())
d = adb.device_list()[-1] d = adb.device_list()[-1]
dd = Device(d) # 使用新的 API
adb_imp = AdbImpl(dd) dd = AndroidDevice(d)
dd._command = adb_imp adb_imp = AdbImpl(d) # 直接传入 adb 连接
dd._touch = adb_imp dd._touch = adb_imp
dd._screenshot = UiAutomator2Impl(dd) dd._screenshot = UiAutomator2Impl(dd) # UiAutomator2Impl 可能还需要 device 对象
dd.commands = adb_imp # 设置 Android 特定命令
show_device_mirror(dd) show_device_mirror(dd)