675 lines
24 KiB
Python
675 lines
24 KiB
Python
"""
|
||
Windows Device Implementation
|
||
Concrete implementation of AbstractDevice for Windows desktop applications.
|
||
Uses CV mode for UI element detection.
|
||
"""
|
||
import logging
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from typing import Optional, Dict, Any, List, Tuple
|
||
|
||
from ...core.abstract_device import AbstractDevice
|
||
|
||
class WindowsApp:
|
||
"""
|
||
Windows 应用封装类
|
||
用于兼容 DroidBot 的 App 接口
|
||
"""
|
||
def __init__(self, window_title):
|
||
self._window_title = window_title
|
||
self.main_activity = None
|
||
self.activities = []
|
||
|
||
def get_package_name(self):
|
||
return self._window_title
|
||
|
||
|
||
class WindowsDevice(AbstractDevice):
|
||
"""
|
||
Windows 设备的具体实现(CV 模式)
|
||
|
||
使用 OmniParser 进行 UI 元素检测,pyautogui 进行输入模拟。
|
||
"""
|
||
|
||
def __init__(self,
|
||
window_title: str = None,
|
||
exe_path: str = None,
|
||
output_dir: str = None,
|
||
cv_mode: bool = True,
|
||
steam_game_id: str = None, # Steam 游戏 ID
|
||
|
||
**kwargs):
|
||
"""
|
||
初始化 Windows 设备连接
|
||
|
||
:param window_title: 目标窗口标题(支持部分匹配)
|
||
:param exe_path: 可执行文件路径(用于启动应用)
|
||
:param output_dir: 输出目录
|
||
:param cv_mode: 是否使用 CV 模式(默认 True)
|
||
:param steam_game_id: Steam 游戏 ID,例如 '730' (CS2)
|
||
:param app_path: (兼容参数)
|
||
:param device_serial: (兼容参数)
|
||
:param kwargs: 忽略的其他参数
|
||
"""
|
||
super().__init__(output_dir)
|
||
|
||
self._window_title = window_title
|
||
self._exe_path = exe_path
|
||
self._steam_game_id = steam_game_id
|
||
self.cv_mode = cv_mode
|
||
|
||
|
||
# 兼容性:初始化 _app 对象
|
||
self._app = WindowsApp(self._window_title)
|
||
|
||
# 窗口句柄相关
|
||
self._hwnd = None
|
||
self._window_rect = None
|
||
|
||
# 显示信息缓存
|
||
self._display_info = None
|
||
|
||
# 截图计数器
|
||
self._screenshot_count = 0
|
||
|
||
# 控制标志
|
||
self.pause_sending_event = False
|
||
|
||
self.logger = logging.getLogger(self.__class__.__name__)
|
||
|
||
def get_platform_name(self) -> str:
|
||
"""获取平台名称"""
|
||
return "windows"
|
||
|
||
# ==================== 连接管理 ====================
|
||
|
||
def set_up(self) -> None:
|
||
"""设置设备连接前的准备工作"""
|
||
# 检查依赖库
|
||
try:
|
||
import pyautogui
|
||
import mss
|
||
import win32gui
|
||
import win32con
|
||
except ImportError as e:
|
||
raise ImportError(
|
||
f"Missing required library: {e}. "
|
||
"Please install: pip install pyautogui mss pywin32"
|
||
)
|
||
|
||
# 禁用 pyautogui 的安全暂停
|
||
pyautogui.PAUSE = 0.1
|
||
pyautogui.FAILSAFE = True
|
||
|
||
# 预加载必要的 win32 API
|
||
try:
|
||
import win32process
|
||
import win32api
|
||
except ImportError:
|
||
pass
|
||
|
||
def connect(self) -> bool:
|
||
"""连接到目标窗口"""
|
||
self.set_up()
|
||
|
||
# 查找目标窗口
|
||
self._hwnd = self._find_window()
|
||
|
||
if self._hwnd is None:
|
||
self.logger.warning(f"Window not found: {self._window_title}")
|
||
# 如果提供了 exe_path,尝试启动应用
|
||
if self._exe_path and os.path.exists(self._exe_path):
|
||
self.logger.info(f"Attempting to start app: {self._exe_path}")
|
||
if self.start_app():
|
||
time.sleep(2) # 等待应用启动
|
||
self._hwnd = self._find_window()
|
||
|
||
if self._hwnd:
|
||
self.connected = True
|
||
self._update_window_rect()
|
||
self.logger.info(f"Connected to window: {self._get_window_title()}")
|
||
return True
|
||
else:
|
||
self.connected = False
|
||
self.logger.error("Failed to connect to window")
|
||
return False
|
||
|
||
|
||
|
||
def run_initial_setup(self) -> bool:
|
||
from DroidBot.guiagent_bridge import GuiAgentBridge
|
||
|
||
bridge = GuiAgentBridge(device=self, app=self._app, app_name=self._window_title)
|
||
current_state = self.get_current_state()
|
||
return bridge.handle_with_guiagent("game_initial", {"state": current_state})
|
||
|
||
def disconnect(self) -> None:
|
||
"""断开设备连接"""
|
||
self._hwnd = None
|
||
self._window_rect = None
|
||
self.connected = False
|
||
self.logger.info("Disconnected from window")
|
||
|
||
def tear_down(self) -> None:
|
||
"""清理设备资源"""
|
||
self.disconnect()
|
||
|
||
def check_connectivity(self) -> bool:
|
||
"""检查窗口是否仍然存在 (包含句柄自愈机制)"""
|
||
import win32gui
|
||
|
||
# 1. 检查现有句柄是否有效
|
||
if self._hwnd and win32gui.IsWindow(self._hwnd):
|
||
return True
|
||
|
||
# 2. 句柄失效,尝试自愈 (重新查找窗口)
|
||
# 这种情况常见于游戏崩溃重启、更新重启、或从 Launcher 切换到 Game 主窗口
|
||
self.logger.warning(f"Window handle {self._hwnd} invalid, attempting to reconnect to '{self._window_title}'...")
|
||
new_hwnd = self._find_window()
|
||
|
||
if new_hwnd:
|
||
self._hwnd = new_hwnd
|
||
self.logger.info(f"Reconnected to window: {self._hwnd}")
|
||
return True
|
||
|
||
return False
|
||
|
||
# ==================== 窗口查找 ====================
|
||
|
||
def _find_window(self) -> Optional[int]:
|
||
"""查找目标窗口句柄 (优先完全匹配)"""
|
||
import win32gui
|
||
|
||
if self._window_title is None:
|
||
return None
|
||
|
||
exact_matches = []
|
||
partial_matches = []
|
||
|
||
def enum_callback(hwnd, _):
|
||
if win32gui.IsWindowVisible(hwnd):
|
||
title = win32gui.GetWindowText(hwnd)
|
||
if not title:
|
||
return True
|
||
|
||
# 优先完全匹配 (忽略大小写)
|
||
if self._window_title.lower() == title.lower():
|
||
exact_matches.append((hwnd, title))
|
||
# 其次部分匹配
|
||
elif self._window_title.lower() in title.lower():
|
||
partial_matches.append((hwnd, title))
|
||
return True
|
||
|
||
try:
|
||
win32gui.EnumWindows(enum_callback, None)
|
||
except Exception as e:
|
||
self.logger.error(f"EnumWindows failed: {e}")
|
||
return None
|
||
|
||
# 1. 优先返回完全匹配
|
||
if exact_matches:
|
||
self.logger.info(f"Found exact match window: {exact_matches[0][1]} ({exact_matches[0][0]})")
|
||
return exact_matches[0][0]
|
||
|
||
# 2. 其次返回部分匹配
|
||
if partial_matches:
|
||
self.logger.info(f"Found partial match window: {partial_matches[0][1]} ({partial_matches[0][0]})")
|
||
return partial_matches[0][0]
|
||
|
||
return None
|
||
|
||
def _get_window_title(self) -> str:
|
||
"""获取当前窗口标题"""
|
||
if self._hwnd is None:
|
||
return ""
|
||
|
||
import win32gui
|
||
try:
|
||
return win32gui.GetWindowText(self._hwnd)
|
||
except Exception:
|
||
return ""
|
||
|
||
def _update_window_rect(self) -> None:
|
||
"""更新窗口位置和大小"""
|
||
if self._hwnd is None:
|
||
return
|
||
|
||
import win32gui
|
||
try:
|
||
rect = win32gui.GetWindowRect(self._hwnd)
|
||
self._window_rect = {
|
||
'left': rect[0],
|
||
'top': rect[1],
|
||
'right': rect[2],
|
||
'bottom': rect[3],
|
||
'width': rect[2] - rect[0],
|
||
'height': rect[3] - rect[1],
|
||
}
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to get window rect: {e}")
|
||
|
||
# ==================== 状态获取 ====================
|
||
|
||
def check_network(self, host: str = "8.8.8.8") -> bool:
|
||
return True
|
||
|
||
def get_current_state(self):
|
||
"""获取当前设备状态"""
|
||
from .windows_device_state import WindowsDeviceState
|
||
from ...cv import cv
|
||
|
||
# 更新窗口位置
|
||
self._update_window_rect()
|
||
|
||
# 截图
|
||
screenshot_path = self.take_screenshot()
|
||
|
||
# CV 检测
|
||
cv_views = []
|
||
if screenshot_path and os.path.exists(screenshot_path):
|
||
try:
|
||
img = cv.load_image_from_path(screenshot_path)
|
||
if img is not None:
|
||
cv_views = cv.find_views(img)
|
||
self.logger.info(f"CV detected {len(cv_views)} views")
|
||
except Exception as e:
|
||
self.logger.warning(f"CV detection failed: {e}")
|
||
|
||
# 创建状态对象
|
||
state = WindowsDeviceState(
|
||
device=self,
|
||
cv_views=cv_views,
|
||
window_title=self._get_window_title(),
|
||
screenshot_path=screenshot_path
|
||
)
|
||
|
||
return state
|
||
|
||
def get_display_info(self, refresh: bool = False) -> Dict[str, Any]:
|
||
"""获取显示信息"""
|
||
if self._display_info is None or refresh:
|
||
self._update_window_rect()
|
||
|
||
if self._window_rect:
|
||
self._display_info = {
|
||
'width': self._window_rect['width'],
|
||
'height': self._window_rect['height'],
|
||
'left': self._window_rect['left'],
|
||
'top': self._window_rect['top'],
|
||
}
|
||
else:
|
||
# 默认值
|
||
self._display_info = {
|
||
'width': 1920,
|
||
'height': 1080,
|
||
'left': 0,
|
||
'top': 0,
|
||
}
|
||
|
||
return self._display_info
|
||
|
||
# ==================== 屏幕操作 ====================
|
||
|
||
def take_screenshot(self, path: str = None) -> Optional[str]:
|
||
"""截取窗口屏幕"""
|
||
import mss
|
||
|
||
if path is None:
|
||
if self.output_dir:
|
||
self._screenshot_count += 1
|
||
path = os.path.join(
|
||
self.output_dir,
|
||
f"screen_{self._screenshot_count}.png"
|
||
)
|
||
else:
|
||
path = f"screen_{int(time.time())}.png"
|
||
|
||
# 确保目录存在
|
||
os.makedirs(os.path.dirname(path) if os.path.dirname(path) else '.', exist_ok=True)
|
||
|
||
self._update_window_rect()
|
||
|
||
try:
|
||
with mss.mss() as sct:
|
||
if self._window_rect:
|
||
# 截取窗口区域
|
||
monitor = {
|
||
'left': self._window_rect['left'],
|
||
'top': self._window_rect['top'],
|
||
'width': self._window_rect['width'],
|
||
'height': self._window_rect['height'],
|
||
}
|
||
else:
|
||
# 截取整个屏幕
|
||
monitor = sct.monitors[1]
|
||
|
||
screenshot = sct.grab(monitor)
|
||
|
||
# 保存截图
|
||
from PIL import Image
|
||
img = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||
img.save(path)
|
||
|
||
self.logger.debug(f"Screenshot saved to: {path}")
|
||
return path
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to take screenshot: {e}")
|
||
return None
|
||
|
||
def unlock(self) -> None:
|
||
"""解锁屏幕(Windows 上无需实现)"""
|
||
pass
|
||
|
||
# ==================== 输入操作 ====================
|
||
|
||
def send_event(self, event) -> bool:
|
||
"""发送输入事件"""
|
||
return event.send(self)
|
||
|
||
def view_touch(self, x: int, y: int, button: str = 'left') -> None:
|
||
"""点击指定坐标"""
|
||
import pyautogui
|
||
|
||
# 如果是相对于窗口的坐标,转换为屏幕坐标
|
||
if self._window_rect:
|
||
screen_x = self._window_rect['left'] + x
|
||
screen_y = self._window_rect['top'] + y
|
||
else:
|
||
screen_x, screen_y = x, y
|
||
|
||
pyautogui.click(screen_x, screen_y, button=button)
|
||
self.logger.debug(f"Touch ({button}) at ({screen_x}, {screen_y})")
|
||
|
||
def view_long_touch(self, x: int, y: int, duration: int = 2000) -> None:
|
||
"""长按指定坐标"""
|
||
import pyautogui
|
||
|
||
if self._window_rect:
|
||
screen_x = self._window_rect['left'] + x
|
||
screen_y = self._window_rect['top'] + y
|
||
else:
|
||
screen_x, screen_y = x, y
|
||
|
||
# 移动到位置,按下,等待,松开
|
||
pyautogui.moveTo(screen_x, screen_y)
|
||
pyautogui.mouseDown()
|
||
time.sleep(duration / 1000.0)
|
||
pyautogui.mouseUp()
|
||
|
||
self.logger.debug(f"Long touch at ({screen_x}, {screen_y}) for {duration}ms")
|
||
|
||
def view_drag(self, start_xy: Tuple[int, int], end_xy: Tuple[int, int],
|
||
duration: int = 500) -> None:
|
||
"""拖拽操作"""
|
||
import pyautogui
|
||
|
||
start_x, start_y = start_xy
|
||
end_x, end_y = end_xy
|
||
|
||
if self._window_rect:
|
||
start_x += self._window_rect['left']
|
||
start_y += self._window_rect['top']
|
||
end_x += self._window_rect['left']
|
||
end_y += self._window_rect['top']
|
||
|
||
pyautogui.moveTo(start_x, start_y)
|
||
pyautogui.drag(
|
||
end_x - start_x,
|
||
end_y - start_y,
|
||
duration=duration / 1000.0
|
||
)
|
||
|
||
self.logger.debug(f"Drag from ({start_x}, {start_y}) to ({end_x}, {end_y})")
|
||
|
||
def view_set_text(self, text: str) -> None:
|
||
"""输入文本"""
|
||
import pyautogui
|
||
|
||
# 对于中文等非ASCII字符,使用 pyperclip 和 Ctrl+V
|
||
try:
|
||
# 检查是否包含非ASCII字符
|
||
text.encode('ascii')
|
||
# 纯ASCII字符,使用 typewrite
|
||
pyautogui.typewrite(text, interval=0.05)
|
||
except UnicodeEncodeError:
|
||
# 包含非ASCII字符,使用剪贴板
|
||
import pyperclip
|
||
pyperclip.copy(text)
|
||
pyautogui.hotkey('ctrl', 'v')
|
||
|
||
self.logger.debug(f"Set text: {text[:20]}...")
|
||
|
||
def key_press(self, key_code: str) -> None:
|
||
"""按键操作"""
|
||
import pyautogui
|
||
pyautogui.press(key_code)
|
||
self.logger.debug(f"Key press: {key_code}")
|
||
|
||
# ==================== 应用管理 ====================
|
||
|
||
@property
|
||
def app_identifier(self) -> str:
|
||
"""获取应用标识符"""
|
||
if self._window_title:
|
||
return self._window_title
|
||
if self._exe_path:
|
||
return os.path.basename(self._exe_path)
|
||
return "unknown"
|
||
|
||
def is_foreground(self) -> bool:
|
||
"""检查目标窗口是否在前台"""
|
||
if self._hwnd is None:
|
||
return False
|
||
|
||
import win32gui
|
||
try:
|
||
foreground_hwnd = win32gui.GetForegroundWindow()
|
||
return foreground_hwnd == self._hwnd
|
||
except Exception:
|
||
return False
|
||
|
||
def pull_back_to_app(self) -> bool:
|
||
"""
|
||
将目标窗口拉到前台 (多策略尝试)
|
||
"""
|
||
if self._hwnd is None:
|
||
# 尝试重新查找窗口
|
||
self._hwnd = self._find_window()
|
||
if self._hwnd is None:
|
||
self.logger.warning("Cannot find window to bring to foreground")
|
||
return False
|
||
|
||
import win32gui
|
||
import win32con
|
||
import win32process
|
||
import win32api
|
||
import pyautogui
|
||
|
||
# 先检查窗口句柄是否仍然有效
|
||
if not win32gui.IsWindow(self._hwnd):
|
||
self.logger.warning(f"Window handle {self._hwnd} is no longer valid")
|
||
self._hwnd = self._find_window()
|
||
if self._hwnd is None:
|
||
return False
|
||
|
||
# 检查当前是否已经是前台窗口
|
||
try:
|
||
current_foreground = win32gui.GetForegroundWindow()
|
||
if current_foreground == self._hwnd:
|
||
self.logger.debug("Window is already in foreground")
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
self.logger.info(f"Attempting to bring window {self._hwnd} to foreground...")
|
||
|
||
# === Method 1: AttachThreadInput + SetForegroundWindow ===
|
||
try:
|
||
current_thread_id = win32api.GetCurrentThreadId()
|
||
target_thread_id, target_process_id = win32process.GetWindowThreadProcessId(self._hwnd)
|
||
foreground_hwnd = win32gui.GetForegroundWindow()
|
||
foreground_thread_id, _ = win32process.GetWindowThreadProcessId(foreground_hwnd)
|
||
|
||
attached_to_foreground = False
|
||
attached_to_target = False
|
||
|
||
try:
|
||
# 先依附到当前前台窗口的线程(获取输入权限)
|
||
if current_thread_id != foreground_thread_id:
|
||
win32process.AttachThreadInput(current_thread_id, foreground_thread_id, True)
|
||
attached_to_foreground = True
|
||
|
||
# 再依附到目标窗口的线程
|
||
if current_thread_id != target_thread_id:
|
||
win32process.AttachThreadInput(current_thread_id, target_thread_id, True)
|
||
attached_to_target = True
|
||
|
||
# 如果最小化了,先还原
|
||
if win32gui.IsIconic(self._hwnd):
|
||
win32gui.ShowWindow(self._hwnd, win32con.SW_RESTORE)
|
||
|
||
# 显示窗口
|
||
win32gui.ShowWindow(self._hwnd, win32con.SW_SHOW)
|
||
|
||
# 尝试多种置顶方法
|
||
win32gui.BringWindowToTop(self._hwnd)
|
||
win32gui.SetForegroundWindow(self._hwnd)
|
||
|
||
finally:
|
||
# 务必解除依附
|
||
if attached_to_target:
|
||
try:
|
||
win32process.AttachThreadInput(current_thread_id, target_thread_id, False)
|
||
except Exception:
|
||
pass
|
||
if attached_to_foreground:
|
||
try:
|
||
win32process.AttachThreadInput(current_thread_id, foreground_thread_id, False)
|
||
except Exception:
|
||
pass
|
||
|
||
time.sleep(0.3)
|
||
if win32gui.GetForegroundWindow() == self._hwnd:
|
||
self.logger.info("Method 1 (AttachThreadInput) succeeded")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f"Method 1 (AttachThreadInput) failed: {e}")
|
||
|
||
# === Method 2: Alt Key Trick ===
|
||
try:
|
||
self.logger.debug("Trying Alt-Key trick...")
|
||
# 模拟 Alt 键按下释放,欺骗 Windows 认为有用户输入
|
||
pyautogui.keyDown('alt')
|
||
time.sleep(0.02)
|
||
pyautogui.keyUp('alt')
|
||
time.sleep(0.02)
|
||
|
||
if win32gui.IsIconic(self._hwnd):
|
||
win32gui.ShowWindow(self._hwnd, win32con.SW_RESTORE)
|
||
win32gui.SetForegroundWindow(self._hwnd)
|
||
|
||
time.sleep(0.3)
|
||
if win32gui.GetForegroundWindow() == self._hwnd:
|
||
self.logger.info("Method 2 (Alt-Key) succeeded")
|
||
return True
|
||
except Exception as e:
|
||
self.logger.warning(f"Method 2 (Alt-Key) failed: {e}")
|
||
|
||
# === Method 3: Minimize then Restore ===
|
||
try:
|
||
self.logger.debug("Trying Minimize-Restore trick...")
|
||
win32gui.ShowWindow(self._hwnd, win32con.SW_MINIMIZE)
|
||
time.sleep(0.1)
|
||
win32gui.ShowWindow(self._hwnd, win32con.SW_RESTORE)
|
||
win32gui.SetForegroundWindow(self._hwnd)
|
||
|
||
time.sleep(0.3)
|
||
if win32gui.GetForegroundWindow() == self._hwnd:
|
||
self.logger.info("Method 3 (Minimize-Restore) succeeded")
|
||
return True
|
||
except Exception as e:
|
||
self.logger.warning(f"Method 3 (Minimize-Restore) failed: {e}")
|
||
|
||
# === Method 4: Click on Window ===
|
||
try:
|
||
self.logger.debug("Trying direct click on window...")
|
||
self._update_window_rect()
|
||
if self._window_rect:
|
||
# 点击窗口中心
|
||
center_x = self._window_rect['left'] + self._window_rect['width'] // 2
|
||
center_y = self._window_rect['top'] + self._window_rect['height'] // 2
|
||
pyautogui.click(center_x, center_y)
|
||
time.sleep(0.3)
|
||
if win32gui.GetForegroundWindow() == self._hwnd:
|
||
self.logger.info("Method 4 (Direct Click) succeeded")
|
||
return True
|
||
except Exception as e:
|
||
self.logger.warning(f"Method 4 (Direct Click) failed: {e}")
|
||
|
||
self.logger.error("All methods to bring window to foreground failed.")
|
||
return False
|
||
|
||
def start_app(self) -> bool:
|
||
"""启动应用 (支持 Steam 游戏和普通应用)"""
|
||
# 1. 首先尝试查找并连接现有窗口
|
||
if self._find_window():
|
||
self._hwnd = self._find_window()
|
||
if self.pull_back_to_app():
|
||
self.logger.info("App is already running, brought to foreground.")
|
||
return True
|
||
|
||
# 2. 确定启动方式
|
||
launch_command = None
|
||
|
||
# 优先使用 Steam 协议启动
|
||
if self._steam_game_id:
|
||
launch_command = f"start steam://rungameid/{self._steam_game_id}"
|
||
self.logger.info(f"Launching via Steam protocol: {launch_command}")
|
||
elif self._exe_path:
|
||
if not os.path.exists(self._exe_path):
|
||
self.logger.error(f"Executable not found: {self._exe_path}")
|
||
return False
|
||
launch_command = self._exe_path
|
||
self.logger.info(f"Launching via executable: {launch_command}")
|
||
else:
|
||
self.logger.warning("No steam_game_id or exe_path specified, cannot start app")
|
||
return False
|
||
|
||
try:
|
||
# 3. 启动进程
|
||
if self._steam_game_id:
|
||
# Steam 协议需要使用 shell=True
|
||
subprocess.Popen(launch_command, shell=True)
|
||
else:
|
||
subprocess.Popen(
|
||
[self._exe_path],
|
||
cwd=os.path.dirname(self._exe_path),
|
||
shell=True
|
||
)
|
||
self.logger.info(f"Started app process")
|
||
|
||
# 4. 轮询等待窗口出现 (Timeout: 120s for Steam games which may need loading)
|
||
self.logger.info(f"Waiting for window '{self._window_title}' to appear...")
|
||
max_retries = 120 # Steam 游戏启动可能比较慢
|
||
for i in range(max_retries):
|
||
hwnd = self._find_window()
|
||
if hwnd:
|
||
self._hwnd = hwnd
|
||
self.logger.info(f"Window appeared after {i} seconds.")
|
||
time.sleep(3) # Steam 游戏窗口出现后稍等一下,等待完全初始化
|
||
return True
|
||
time.sleep(1)
|
||
|
||
self.logger.error("Timed out waiting for window to appear.")
|
||
return False
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to start app: {e}")
|
||
return False
|
||
|