kotonebot 0.6.0__py3-none-any.whl → 0.7.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/client/host/windows_common.py +16 -1
- kotonebot/client/implements/windows/__init__.py +1 -0
- kotonebot/client/implements/windows/print_window.py +133 -0
- kotonebot/client/implements/windows/send_message.py +324 -0
- kotonebot/client/implements/{windows.py → windows/windows.py} +4 -5
- kotonebot/config/base_config.py +8 -1
- kotonebot/interop/win/__init__.py +2 -0
- kotonebot/interop/win/window.py +89 -0
- {kotonebot-0.6.0.dist-info → kotonebot-0.7.0.dist-info}/METADATA +1 -1
- {kotonebot-0.6.0.dist-info → kotonebot-0.7.0.dist-info}/RECORD +14 -10
- {kotonebot-0.6.0.dist-info → kotonebot-0.7.0.dist-info}/WHEEL +1 -1
- {kotonebot-0.6.0.dist-info → kotonebot-0.7.0.dist-info}/entry_points.txt +0 -0
- {kotonebot-0.6.0.dist-info → kotonebot-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {kotonebot-0.6.0.dist-info → kotonebot-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -8,7 +8,7 @@ from kotonebot.util import require_windows
|
|
|
8
8
|
from .protocol import Device, WindowsHostConfig, RemoteWindowsHostConfig
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
|
-
WindowsRecipes = Literal['windows', 'remote_windows']
|
|
11
|
+
WindowsRecipes = Literal['windows', 'remote_windows', 'windows_background']
|
|
12
12
|
|
|
13
13
|
# Windows 相关的配置类型联合
|
|
14
14
|
WindowsHostConfigs = WindowsHostConfig | RemoteWindowsHostConfig
|
|
@@ -41,6 +41,21 @@ class CommonWindowsCreateDeviceMixin(ABC):
|
|
|
41
41
|
d._screenshot = impl
|
|
42
42
|
d._touch = impl
|
|
43
43
|
return d
|
|
44
|
+
case 'windows_background':
|
|
45
|
+
if not isinstance(config, WindowsHostConfig):
|
|
46
|
+
raise ValueError(f"Expected WindowsHostConfig for 'windows' recipe, got {type(config)}")
|
|
47
|
+
from kotonebot.client.implements.windows import WindowsImpl
|
|
48
|
+
d = WindowsDevice()
|
|
49
|
+
impl = WindowsImpl(
|
|
50
|
+
device=d,
|
|
51
|
+
window_title=config.window_title,
|
|
52
|
+
ahk_exe_path=config.ahk_exe_path
|
|
53
|
+
)
|
|
54
|
+
from kotonebot.client.implements.windows.send_message import SendMessageImpl
|
|
55
|
+
from kotonebot.client.implements.windows.print_window import PrintWindowImpl
|
|
56
|
+
d._screenshot = PrintWindowImpl(d, config.window_title)
|
|
57
|
+
d._touch = SendMessageImpl(d, config.window_title)
|
|
58
|
+
return d
|
|
44
59
|
case 'remote_windows':
|
|
45
60
|
if not isinstance(config, RemoteWindowsHostConfig):
|
|
46
61
|
raise ValueError(f"Expected RemoteWindowsHostConfig for 'remote_windows' recipe, got {type(config)}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .windows import *
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# ruff: noqa: E402
|
|
2
|
+
from kotonebot.util import require_windows
|
|
3
|
+
require_windows('"WindowsImpl" implementation')
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import ctypes.wintypes as wt
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
from cv2.typing import MatLike
|
|
11
|
+
import numpy as np
|
|
12
|
+
import win32ui
|
|
13
|
+
import win32con
|
|
14
|
+
import win32gui
|
|
15
|
+
|
|
16
|
+
from ...protocol import Screenshotable
|
|
17
|
+
from kotonebot.interop.win.window import Win32Window
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ...device import Device
|
|
20
|
+
|
|
21
|
+
PW_CLIENTONLY = 0x1
|
|
22
|
+
PW_RENDERFULLCONTENT = 0x2
|
|
23
|
+
|
|
24
|
+
# TODO: 目前每次截图都会完整创建和销毁 GDI 对象,性能较差,后续可以考虑缓存这些对象以提升性能
|
|
25
|
+
# TODO: 需要先支持 Impl 的生命周期管理
|
|
26
|
+
def capture_printwindow(hwnd: int) -> MatLike:
|
|
27
|
+
# client rect size
|
|
28
|
+
left, top, right, bottom = win32gui.GetClientRect(hwnd)
|
|
29
|
+
width, height = right - left, bottom - top
|
|
30
|
+
if width <= 0 or height <= 0:
|
|
31
|
+
raise RuntimeError("invalid client size")
|
|
32
|
+
|
|
33
|
+
hdc = win32gui.GetDC(hwnd)
|
|
34
|
+
mfc_dc = win32ui.CreateDCFromHandle(hdc)
|
|
35
|
+
mem_dc = mfc_dc.CreateCompatibleDC()
|
|
36
|
+
bmp = win32ui.CreateBitmap()
|
|
37
|
+
bmp.CreateCompatibleBitmap(mfc_dc, width, height)
|
|
38
|
+
mem_dc.SelectObject(bmp)
|
|
39
|
+
|
|
40
|
+
flags = PW_CLIENTONLY | PW_RENDERFULLCONTENT
|
|
41
|
+
res = ctypes.windll.user32.PrintWindow(hwnd, mem_dc.GetSafeHdc(), flags)
|
|
42
|
+
if res != 1:
|
|
43
|
+
raise RuntimeError("PrintWindow failed")
|
|
44
|
+
|
|
45
|
+
# extract BGRA bits via GetDIBits (pywin32 does not expose BITMAPINFO)
|
|
46
|
+
class BITMAPINFOHEADER(ctypes.Structure):
|
|
47
|
+
_fields_ = [
|
|
48
|
+
("biSize", wt.DWORD),
|
|
49
|
+
("biWidth", wt.LONG),
|
|
50
|
+
("biHeight", wt.LONG),
|
|
51
|
+
("biPlanes", wt.WORD),
|
|
52
|
+
("biBitCount", wt.WORD),
|
|
53
|
+
("biCompression", wt.DWORD),
|
|
54
|
+
("biSizeImage", wt.DWORD),
|
|
55
|
+
("biXPelsPerMeter", wt.LONG),
|
|
56
|
+
("biYPelsPerMeter", wt.LONG),
|
|
57
|
+
("biClrUsed", wt.DWORD),
|
|
58
|
+
("biClrImportant", wt.DWORD),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
class BITMAPINFO(ctypes.Structure):
|
|
62
|
+
_fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wt.DWORD * 3)]
|
|
63
|
+
|
|
64
|
+
bmi = BITMAPINFO()
|
|
65
|
+
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
|
|
66
|
+
bmi.bmiHeader.biWidth = width
|
|
67
|
+
bmi.bmiHeader.biHeight = -height # top-down
|
|
68
|
+
bmi.bmiHeader.biPlanes = 1
|
|
69
|
+
bmi.bmiHeader.biBitCount = 32
|
|
70
|
+
bmi.bmiHeader.biCompression = win32con.BI_RGB
|
|
71
|
+
|
|
72
|
+
buf = bytearray(width * height * 4)
|
|
73
|
+
bits_ok = ctypes.windll.gdi32.GetDIBits(
|
|
74
|
+
mem_dc.GetSafeHdc(),
|
|
75
|
+
bmp.GetHandle(),
|
|
76
|
+
0,
|
|
77
|
+
height,
|
|
78
|
+
ctypes.byref((ctypes.c_ubyte * len(buf)).from_buffer(buf)),
|
|
79
|
+
ctypes.byref(bmi),
|
|
80
|
+
win32con.DIB_RGB_COLORS,
|
|
81
|
+
)
|
|
82
|
+
if bits_ok == 0:
|
|
83
|
+
raise RuntimeError("GetDIBits failed")
|
|
84
|
+
|
|
85
|
+
img = np.frombuffer(buf, dtype=np.uint8).reshape((height, width, 4))
|
|
86
|
+
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
|
87
|
+
|
|
88
|
+
# cleanup
|
|
89
|
+
win32gui.DeleteObject(bmp.GetHandle())
|
|
90
|
+
mem_dc.DeleteDC()
|
|
91
|
+
mfc_dc.DeleteDC()
|
|
92
|
+
win32gui.ReleaseDC(hwnd, hdc)
|
|
93
|
+
return img
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PrintWindowImpl(Screenshotable):
|
|
97
|
+
def __init__(self, device: 'Device', window_title: str):
|
|
98
|
+
self.window = Win32Window.require_window('title', window_title)
|
|
99
|
+
ctypes.windll.user32.SetProcessDPIAware()
|
|
100
|
+
|
|
101
|
+
def __client_rect(self) -> tuple[int, int, int, int]:
|
|
102
|
+
"""获取 Client 区域屏幕坐标"""
|
|
103
|
+
hwnd = self.window.hwnd
|
|
104
|
+
client_left, client_top, client_right, client_bottom = win32gui.GetClientRect(hwnd)
|
|
105
|
+
client_left, client_top = win32gui.ClientToScreen(hwnd, (client_left, client_top))
|
|
106
|
+
client_right, client_bottom = win32gui.ClientToScreen(hwnd, (client_right, client_bottom))
|
|
107
|
+
return client_left, client_top, client_right, client_bottom
|
|
108
|
+
|
|
109
|
+
def detect_orientation(self) -> None | Literal['portrait'] | Literal['landscape']:
|
|
110
|
+
rect = self.window.get_rect()
|
|
111
|
+
if rect.w > rect.h:
|
|
112
|
+
return 'landscape'
|
|
113
|
+
else:
|
|
114
|
+
return 'portrait'
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def screen_size(self) -> tuple[int, int]:
|
|
118
|
+
left, top, right, bot = self.__client_rect()
|
|
119
|
+
w = right - left
|
|
120
|
+
h = bot - top
|
|
121
|
+
return w, h
|
|
122
|
+
|
|
123
|
+
def screenshot(self) -> MatLike:
|
|
124
|
+
if self.window.is_minimized():
|
|
125
|
+
self.window.restore()
|
|
126
|
+
return capture_printwindow(self.window.hwnd)
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
impl = PrintWindowImpl(None, "gakumas") # type: ignore
|
|
130
|
+
while True:
|
|
131
|
+
img = impl.screenshot()
|
|
132
|
+
cv2.imshow("screenshot", img)
|
|
133
|
+
cv2.waitKey(1)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# ruff: noqa: E402
|
|
2
|
+
from kotonebot.util import require_windows
|
|
3
|
+
require_windows('"WindowsImpl" implementation')
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from time import sleep
|
|
7
|
+
from typing_extensions import assert_never
|
|
8
|
+
from typing import Optional, Literal, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import win32gui
|
|
11
|
+
import win32con
|
|
12
|
+
|
|
13
|
+
from ...protocol import Touchable
|
|
14
|
+
from kotonebot.interop.win.window import Win32Window
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ...device import Device
|
|
17
|
+
|
|
18
|
+
MouseButton = Literal['left', 'right', 'middle']
|
|
19
|
+
|
|
20
|
+
def _make_lparam(x: int, y: int) -> int:
|
|
21
|
+
"""
|
|
22
|
+
创建 LPARAM 参数,打包 x,y 坐标
|
|
23
|
+
|
|
24
|
+
:param x: X 坐标
|
|
25
|
+
:param y: Y 坐标
|
|
26
|
+
:returns: 打包的 LPARAM 值
|
|
27
|
+
"""
|
|
28
|
+
return (y << 16) | (x & 0xFFFF)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_wparam(button_data: int, wheel_delta: int = 0) -> int:
|
|
32
|
+
"""
|
|
33
|
+
创建 WPARAM 参数,用于滚轮消息
|
|
34
|
+
|
|
35
|
+
:param button_data: 按钮数据
|
|
36
|
+
:param wheel_delta: 滚轮增量
|
|
37
|
+
:returns: 打包的 WPARAM 值
|
|
38
|
+
"""
|
|
39
|
+
return (wheel_delta << 16) | (button_data & 0xFFFF)
|
|
40
|
+
|
|
41
|
+
def _wait_cursor_idle(max_speed: float = 50):
|
|
42
|
+
if max_speed <= 0:
|
|
43
|
+
return
|
|
44
|
+
sample_interval = 0.05
|
|
45
|
+
prev_pos = win32gui.GetCursorPos()
|
|
46
|
+
prev_t = time.monotonic()
|
|
47
|
+
|
|
48
|
+
while True:
|
|
49
|
+
sleep(sample_interval)
|
|
50
|
+
cur_pos = win32gui.GetCursorPos()
|
|
51
|
+
cur_t = time.monotonic()
|
|
52
|
+
|
|
53
|
+
dx = cur_pos[0] - prev_pos[0]
|
|
54
|
+
dy = cur_pos[1] - prev_pos[1]
|
|
55
|
+
dist = (dx * dx + dy * dy) ** 0.5
|
|
56
|
+
dt = cur_t - prev_t
|
|
57
|
+
speed = dist / dt if dt > 0 else float('inf')
|
|
58
|
+
if speed <= max_speed:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
prev_pos = cur_pos
|
|
62
|
+
prev_t = cur_t
|
|
63
|
+
|
|
64
|
+
class SendMessageWrapper:
|
|
65
|
+
def __init__(self, window: Win32Window, wait_cursor_idle: float = -1):
|
|
66
|
+
self.window = window
|
|
67
|
+
self.last_pos = (0, 0)
|
|
68
|
+
self.last_pos_set = False
|
|
69
|
+
if wait_cursor_idle == -1:
|
|
70
|
+
self.wait_cursor_idle_speed = 50 # 默认值
|
|
71
|
+
else:
|
|
72
|
+
self.wait_cursor_idle_speed = wait_cursor_idle
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def hwnd(self) -> int:
|
|
76
|
+
return self.window.hwnd
|
|
77
|
+
|
|
78
|
+
def _send_activate(self):
|
|
79
|
+
self.window.post_message(win32con.WM_ACTIVATE, win32con.WA_ACTIVE, 0)
|
|
80
|
+
|
|
81
|
+
def _align_window(self, target_client_x: int, target_client_y: int) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
移动窗口,使得目标客户区坐标对齐到指定的光标位置。
|
|
84
|
+
如果提供 `cursor_pos` 则使用该屏幕坐标(例如预测值),否则使用真实光标位置。
|
|
85
|
+
"""
|
|
86
|
+
window_rect = self.window.get_rect()
|
|
87
|
+
if not window_rect:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
cursor_x, cursor_y = win32gui.GetCursorPos()
|
|
91
|
+
# 计算客户区偏移
|
|
92
|
+
client_left, client_top, client_right, client_bottom = win32gui.GetClientRect(self.hwnd)
|
|
93
|
+
client_screen_left, client_screen_top = win32gui.ClientToScreen(self.hwnd, (client_left, client_top))
|
|
94
|
+
offset_x = client_screen_left - window_rect.x1
|
|
95
|
+
offset_y = client_screen_top - window_rect.y1
|
|
96
|
+
|
|
97
|
+
new_window_x = cursor_x - target_client_x - offset_x
|
|
98
|
+
new_window_y = cursor_y - target_client_y - offset_y
|
|
99
|
+
|
|
100
|
+
win32gui.SetWindowPos(
|
|
101
|
+
self.hwnd,
|
|
102
|
+
None,
|
|
103
|
+
new_window_x,
|
|
104
|
+
new_window_y,
|
|
105
|
+
window_rect.w,
|
|
106
|
+
window_rect.h,
|
|
107
|
+
win32con.SWP_NOREDRAW | win32con.SWP_NOACTIVATE |
|
|
108
|
+
win32con.SWP_NOZORDER | win32con.SWP_NOCOPYBITS |
|
|
109
|
+
win32con.SWP_NOSENDCHANGING | win32con.SWP_NOOWNERZORDER
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def _send_mouse_button(self, x: int, y: int, button: MouseButton, down: bool) -> bool:
|
|
115
|
+
if down:
|
|
116
|
+
match button:
|
|
117
|
+
case 'left':
|
|
118
|
+
msg = win32con.WM_LBUTTONDOWN
|
|
119
|
+
w_param = win32con.MK_LBUTTON
|
|
120
|
+
case 'right':
|
|
121
|
+
msg = win32con.WM_RBUTTONDOWN
|
|
122
|
+
w_param = win32con.MK_RBUTTON
|
|
123
|
+
case 'middle':
|
|
124
|
+
msg = win32con.WM_MBUTTONDOWN
|
|
125
|
+
w_param = win32con.MK_MBUTTON
|
|
126
|
+
case _:
|
|
127
|
+
assert_never("Unknown mouse button")
|
|
128
|
+
else:
|
|
129
|
+
match button:
|
|
130
|
+
case 'left':
|
|
131
|
+
msg = win32con.WM_LBUTTONUP
|
|
132
|
+
w_param = 0
|
|
133
|
+
case 'right':
|
|
134
|
+
msg = win32con.WM_RBUTTONUP
|
|
135
|
+
w_param = 0
|
|
136
|
+
case 'middle':
|
|
137
|
+
msg = win32con.WM_MBUTTONUP
|
|
138
|
+
w_param = 0
|
|
139
|
+
case _:
|
|
140
|
+
assert_never("Unknown mouse button")
|
|
141
|
+
|
|
142
|
+
l_param = _make_lparam(x, y)
|
|
143
|
+
return self.window.post_message(msg, w_param, l_param)
|
|
144
|
+
|
|
145
|
+
def _send_mouse_move(self, x: int, y: int, button: Optional[MouseButton] = None) -> bool:
|
|
146
|
+
"""
|
|
147
|
+
发送鼠标移动消息,支持在按键按下时携带对应的 wParam 标志(例如拖拽时带 MK_LBUTTON)。
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
if button == 'left':
|
|
151
|
+
w_param = win32con.MK_LBUTTON
|
|
152
|
+
elif button == 'right':
|
|
153
|
+
w_param = win32con.MK_RBUTTON
|
|
154
|
+
elif button == 'middle':
|
|
155
|
+
w_param = win32con.MK_MBUTTON
|
|
156
|
+
else:
|
|
157
|
+
w_param = 0
|
|
158
|
+
|
|
159
|
+
l_param = _make_lparam(x, y)
|
|
160
|
+
return self.window.post_message(win32con.WM_MOUSEMOVE, w_param, l_param)
|
|
161
|
+
|
|
162
|
+
def mouse_down(self, x: int, y: int, button: MouseButton) -> bool:
|
|
163
|
+
"""
|
|
164
|
+
发送鼠标按下消息
|
|
165
|
+
button: 0=左键, 1=右键, 2=中键
|
|
166
|
+
|
|
167
|
+
:param x: X 坐标
|
|
168
|
+
:param y: Y 坐标
|
|
169
|
+
:param button: 按钮类型,0=左键, 1=右键, 2=中键
|
|
170
|
+
:returns: 操作是否成功
|
|
171
|
+
"""
|
|
172
|
+
self._send_activate()
|
|
173
|
+
self._align_window(x, y)
|
|
174
|
+
return self._send_mouse_button(x, y, button, down=True)
|
|
175
|
+
|
|
176
|
+
def mouse_up(self, x: int, y: int, button: MouseButton) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
发送鼠标释放消息
|
|
179
|
+
button: 0=左键, 1=右键, 2=中键
|
|
180
|
+
|
|
181
|
+
:param x: X 坐标
|
|
182
|
+
:param y: Y 坐标
|
|
183
|
+
:param button: 按钮类型,0=左键, 1=右键, 2=中键
|
|
184
|
+
:returns: 操作是否成功
|
|
185
|
+
"""
|
|
186
|
+
self._send_activate()
|
|
187
|
+
self._align_window(x, y)
|
|
188
|
+
return self._send_mouse_button(x, y, button, down=False)
|
|
189
|
+
|
|
190
|
+
def click(self, x: int, y: int, *, button: MouseButton = 'left') -> bool:
|
|
191
|
+
"""
|
|
192
|
+
发送点击事件。
|
|
193
|
+
|
|
194
|
+
:param x: X 坐标
|
|
195
|
+
:param y: Y 坐标
|
|
196
|
+
:param button: 按钮类型,0=左键, 1=右键, 2=中键
|
|
197
|
+
:returns: 操作是否成功
|
|
198
|
+
"""
|
|
199
|
+
# 为避免在一次点击操作中重复激活 -> 先激活一次,然后在 down/up 中禁用额外激活
|
|
200
|
+
self._send_activate()
|
|
201
|
+
_wait_cursor_idle(self.wait_cursor_idle_speed)
|
|
202
|
+
self._align_window(x, y)
|
|
203
|
+
if self._send_mouse_button(x, y, button, down=True):
|
|
204
|
+
return self._send_mouse_button(x, y, button, down=False)
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def keyboard_down(self, key_code: int) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
发送键盘按下消息
|
|
210
|
+
|
|
211
|
+
:param key_code: 虚拟键码,例如 win32con.VK_RETURN 等
|
|
212
|
+
:returns: 操作是否成功
|
|
213
|
+
"""
|
|
214
|
+
# 发送激活消息
|
|
215
|
+
self._send_activate()
|
|
216
|
+
|
|
217
|
+
# 发送 WM_KEYDOWN 消息
|
|
218
|
+
result = self.window.post_message(win32con.WM_KEYDOWN, key_code, 0)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def keyboard_up(self, key_code: int) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
发送键盘释放消息
|
|
224
|
+
|
|
225
|
+
:param key_code: 虚拟键码,例如 win32con.VK_RETURN 等
|
|
226
|
+
:returns: 操作是否成功
|
|
227
|
+
"""
|
|
228
|
+
# 发送激活消息
|
|
229
|
+
self._send_activate()
|
|
230
|
+
return self.window.post_message(win32con.WM_KEYUP, key_code, 0)
|
|
231
|
+
|
|
232
|
+
def drag(
|
|
233
|
+
self,
|
|
234
|
+
x1: int, y1: int,
|
|
235
|
+
x2: int, y2: int,
|
|
236
|
+
*,
|
|
237
|
+
button: MouseButton = 'left',
|
|
238
|
+
duration: float | None = None
|
|
239
|
+
) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
从指定点拖拽到指定点。
|
|
242
|
+
|
|
243
|
+
:param x1: 起始点 X 坐标,相对于客户区。
|
|
244
|
+
:param y1: 起始点 Y 坐标,相对于客户区。
|
|
245
|
+
:param x2: 结束点 X 坐标,相对于客户区。
|
|
246
|
+
:param y2: 结束点 Y 坐标,相对于客户区。
|
|
247
|
+
:param button: 按钮类型,'left'、'right'、'middle'。
|
|
248
|
+
:returns: 操作是否成功
|
|
249
|
+
"""
|
|
250
|
+
if duration is None:
|
|
251
|
+
duration = 0.5
|
|
252
|
+
|
|
253
|
+
self._send_activate()
|
|
254
|
+
# 将窗口对齐到起点,确保起始客户区坐标与当前光标对齐
|
|
255
|
+
self._align_window(x1, y1)
|
|
256
|
+
|
|
257
|
+
# 发送按下
|
|
258
|
+
if not self._send_mouse_button(x1, y1, button, down=True):
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
# 如果 duration 为 0 或负数,直接跳到结束点并释放
|
|
262
|
+
if not duration or duration <= 0:
|
|
263
|
+
# 最后对齐到结束点以保证位置精确
|
|
264
|
+
self._align_window(x2, y2)
|
|
265
|
+
return self._send_mouse_button(x2, y2, button, down=False)
|
|
266
|
+
|
|
267
|
+
# 分段发送移动事件,避免直接跳到结束点
|
|
268
|
+
# 使用 60Hz 作为默认帧率,至少 1 步
|
|
269
|
+
fps = 60
|
|
270
|
+
steps = max(1, int(duration * fps))
|
|
271
|
+
interval = duration / steps - (13 / 1000) # 减去约 13ms 的消息处理时间
|
|
272
|
+
|
|
273
|
+
dx = x2 - x1
|
|
274
|
+
dy = y2 - y1
|
|
275
|
+
|
|
276
|
+
# 从 1 到 steps(包含终点)进行插值并发送 WM_MOUSEMOVE
|
|
277
|
+
for i in range(1, steps + 1):
|
|
278
|
+
t = i / steps
|
|
279
|
+
xi = int(x1 + dx * t)
|
|
280
|
+
yi = int(y1 + dy * t)
|
|
281
|
+
# 发送移动事件,保留当前按键状态
|
|
282
|
+
self._send_mouse_move(xi, yi, button=button)
|
|
283
|
+
self._align_window(xi, yi)
|
|
284
|
+
if interval > 0:
|
|
285
|
+
sleep(interval)
|
|
286
|
+
|
|
287
|
+
# 对齐到结束点并发送释放
|
|
288
|
+
self._align_window(x2, y2)
|
|
289
|
+
return self._send_mouse_button(x2, y2, button, down=False)
|
|
290
|
+
|
|
291
|
+
def drag_by(
|
|
292
|
+
self,
|
|
293
|
+
x: int, y: int,
|
|
294
|
+
dx: int, dy: int,
|
|
295
|
+
*,
|
|
296
|
+
button: MouseButton = 'left',
|
|
297
|
+
duration: float | None = None
|
|
298
|
+
) -> bool:
|
|
299
|
+
end_x = x + dx
|
|
300
|
+
end_y = y + dy
|
|
301
|
+
return self.drag(x, y, end_x, end_y, button=button, duration=duration)
|
|
302
|
+
|
|
303
|
+
class SendMessageImpl(Touchable):
|
|
304
|
+
def __init__(self, device: 'Device', window_title: str, *, wait_cursor_idle: float = -1) -> None:
|
|
305
|
+
self.device = device
|
|
306
|
+
window = Win32Window.require_window('title', window_title)
|
|
307
|
+
self.wrapper = SendMessageWrapper(window, wait_cursor_idle)
|
|
308
|
+
|
|
309
|
+
def click(self, x: int, y: int) -> None:
|
|
310
|
+
self.wrapper.click(x, y, button='left')
|
|
311
|
+
|
|
312
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
|
|
313
|
+
ret = self.wrapper.drag(x1, y1, x2, y2, button='left', duration=duration)
|
|
314
|
+
if not ret:
|
|
315
|
+
raise RuntimeError('Swipe operation failed')
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == '__main__':
|
|
319
|
+
# impl = SendMessageImpl(None, window_title='gakumas') # type: ignore
|
|
320
|
+
# impl.click(0, 0)
|
|
321
|
+
|
|
322
|
+
while True:
|
|
323
|
+
_wait_cursor_idle()
|
|
324
|
+
print("Cursor idle detected")
|
|
@@ -5,7 +5,6 @@ require_windows('"WindowsImpl" implementation')
|
|
|
5
5
|
from ctypes import windll
|
|
6
6
|
from typing import Literal
|
|
7
7
|
from importlib import resources
|
|
8
|
-
from functools import cached_property
|
|
9
8
|
from dataclasses import dataclass
|
|
10
9
|
|
|
11
10
|
import cv2
|
|
@@ -19,9 +18,9 @@ except ImportError as _e:
|
|
|
19
18
|
raise MissingDependencyError(_e, 'windows')
|
|
20
19
|
from cv2.typing import MatLike
|
|
21
20
|
|
|
22
|
-
from
|
|
23
|
-
from
|
|
24
|
-
from
|
|
21
|
+
from ...device import Device
|
|
22
|
+
from ...protocol import Touchable, Screenshotable
|
|
23
|
+
from ...registration import ImplConfig
|
|
25
24
|
|
|
26
25
|
# 1. 定义配置模型
|
|
27
26
|
@dataclass
|
|
@@ -160,7 +159,7 @@ class WindowsImpl(Touchable, Screenshotable):
|
|
|
160
159
|
self.ahk.mouse_drag(x2, y2, from_position=(x1, y1), coord_mode='Client', speed=10)
|
|
161
160
|
|
|
162
161
|
if __name__ == '__main__':
|
|
163
|
-
from
|
|
162
|
+
from ...device import Device
|
|
164
163
|
device = Device()
|
|
165
164
|
# 在测试环境中直接使用默认路径
|
|
166
165
|
ahk_path = str(resources.files('kaa.res.bin') / 'AutoHotkey.exe')
|
kotonebot/config/base_config.py
CHANGED
|
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict
|
|
|
6
6
|
|
|
7
7
|
T = TypeVar('T')
|
|
8
8
|
BackendType = Literal['custom', 'mumu12', 'mumu12v5', 'leidian', 'dmm']
|
|
9
|
-
DeviceRecipes = Literal['adb', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
|
|
9
|
+
DeviceRecipes = Literal['adb', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc', 'windows_background']
|
|
10
10
|
|
|
11
11
|
class ConfigBaseModel(BaseModel):
|
|
12
12
|
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
@@ -52,6 +52,13 @@ class BackendConfig(ConfigBaseModel):
|
|
|
52
52
|
"""MuMu12 模拟器后台保活模式"""
|
|
53
53
|
target_screenshot_interval: float | None = None
|
|
54
54
|
"""最小截图间隔,单位为秒。为 None 时不限制截图速度。"""
|
|
55
|
+
cursor_wait_speed: float = -1
|
|
56
|
+
"""
|
|
57
|
+
使用 DMM 版后台挂机功能时,在点击前会尝试等待光标静止,以避免发生点击偏移。
|
|
58
|
+
此项规定了速度小于多少时认为光标静止,单位为像素/秒。
|
|
59
|
+
|
|
60
|
+
-1 表示使用内置默认值,0 表示禁用该功能。
|
|
61
|
+
"""
|
|
55
62
|
|
|
56
63
|
class PushConfig(ConfigBaseModel):
|
|
57
64
|
"""推送配置。"""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from typing_extensions import assert_never
|
|
3
|
+
|
|
4
|
+
import win32gui
|
|
5
|
+
import win32con
|
|
6
|
+
import win32api
|
|
7
|
+
|
|
8
|
+
from kotonebot.primitives import Rect
|
|
9
|
+
|
|
10
|
+
FindWindowMethod = Literal['title']
|
|
11
|
+
|
|
12
|
+
class Win32Window:
|
|
13
|
+
def __init__(self, hwnd: int) -> None:
|
|
14
|
+
self.hwnd = hwnd
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def find_window(method: FindWindowMethod, title: str) -> 'Win32Window | None':
|
|
18
|
+
"""查找窗口。
|
|
19
|
+
|
|
20
|
+
:param method: 查找依据。
|
|
21
|
+
:param title: 窗口标题
|
|
22
|
+
:return: 若找到窗口则返回 Win32Window 实例,否则返回 None。
|
|
23
|
+
"""
|
|
24
|
+
match method:
|
|
25
|
+
case 'title':
|
|
26
|
+
hwnd = win32gui.FindWindow(None, title)
|
|
27
|
+
if hwnd == 0:
|
|
28
|
+
return None
|
|
29
|
+
return Win32Window(hwnd)
|
|
30
|
+
case _:
|
|
31
|
+
assert_never(method)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def require_window(method: FindWindowMethod, title: str) -> 'Win32Window':
|
|
35
|
+
"""查找窗口,未找到则抛出异常。
|
|
36
|
+
|
|
37
|
+
参数同 :ref:`find_window`。
|
|
38
|
+
"""
|
|
39
|
+
window = Win32Window.find_window(method, title)
|
|
40
|
+
if window is None:
|
|
41
|
+
raise RuntimeError(f'Window not found: {title}')
|
|
42
|
+
return window
|
|
43
|
+
|
|
44
|
+
def get_rect(self) -> Rect:
|
|
45
|
+
"""取得窗口范围"""
|
|
46
|
+
left, top, right, bottom = win32gui.GetWindowRect(self.hwnd)
|
|
47
|
+
return Rect(left, top, right - left, bottom - top)
|
|
48
|
+
|
|
49
|
+
def get_client_rect(self) -> Rect:
|
|
50
|
+
"""取得窗口客户区域范围"""
|
|
51
|
+
left, top, right, bottom = win32gui.GetClientRect(self.hwnd)
|
|
52
|
+
return Rect(left, top, right - left, bottom - top)
|
|
53
|
+
|
|
54
|
+
def is_active(self) -> bool:
|
|
55
|
+
"""检查窗口是否为前台窗口"""
|
|
56
|
+
active_hwnd = win32gui.GetForegroundWindow()
|
|
57
|
+
return active_hwnd == self.hwnd
|
|
58
|
+
|
|
59
|
+
def is_minimized(self) -> bool:
|
|
60
|
+
"""检查窗口是否最小化"""
|
|
61
|
+
return win32gui.IsIconic(self.hwnd) != 0
|
|
62
|
+
|
|
63
|
+
def restore(self) -> None:
|
|
64
|
+
"""还原最小化的窗口"""
|
|
65
|
+
win32gui.ShowWindow(self.hwnd, win32con.SW_RESTORE)
|
|
66
|
+
|
|
67
|
+
def set_position(self, x: int, y: int, *, flags: int | None = None) -> None:
|
|
68
|
+
"""
|
|
69
|
+
设置窗口位置。
|
|
70
|
+
|
|
71
|
+
:param flags: SetWindowPos 的 `flags` 参数。默认参数为 SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE。
|
|
72
|
+
"""
|
|
73
|
+
if flags is None:
|
|
74
|
+
flags = win32con.SWP_NOSIZE | win32con.SWP_NOZORDER | win32con.SWP_NOACTIVATE
|
|
75
|
+
win32gui.SetWindowPos(
|
|
76
|
+
self.hwnd, None, x, y, 0, 0,
|
|
77
|
+
flags
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def bring_foreground(self) -> None:
|
|
81
|
+
"""将窗口置于前台"""
|
|
82
|
+
win32gui.SetForegroundWindow(self.hwnd)
|
|
83
|
+
|
|
84
|
+
def send_message(self, msg: int, wparam: int, lparam: int) -> int:
|
|
85
|
+
return win32gui.SendMessage(self.hwnd, msg, wparam, lparam)
|
|
86
|
+
|
|
87
|
+
def post_message(self, msg: int, wparam: int, lparam: int) -> bool:
|
|
88
|
+
win32gui.PostMessage(self.hwnd, msg, wparam, lparam)
|
|
89
|
+
return win32api.GetLastError() == 0
|
|
@@ -31,17 +31,20 @@ kotonebot/client/host/custom.py,sha256=zYI4tZl5xGxdUTvF2li7c315252_hijdwMXwGMCXR
|
|
|
31
31
|
kotonebot/client/host/leidian_host.py,sha256=O8BUx61HDJs3lsdhoJcuYKEYImG9jWM6mqrOec0oSlc,7538
|
|
32
32
|
kotonebot/client/host/mumu12_host.py,sha256=CB-O7z3B-dWGVxiI78eK78hhyqileswa1DYXAftvjU8,14253
|
|
33
33
|
kotonebot/client/host/protocol.py,sha256=fnb9EqlTnSEfPlJ37HMYU6We4_YemX8mQFxcy2gsP8U,8173
|
|
34
|
-
kotonebot/client/host/windows_common.py,sha256=
|
|
34
|
+
kotonebot/client/host/windows_common.py,sha256=RDjn7DtNE-jqB5ih1h7NYTJf7J3CgEeD3rPWRoc3BIc,3331
|
|
35
35
|
kotonebot/client/implements/__init__.py,sha256=OaSvmYTwxasdnYCS0kKrrcmmWPHjfMpYdmZ9siP03m8,2137
|
|
36
36
|
kotonebot/client/implements/adb.py,sha256=0Xs5o014SG9dNDIJYgI2_QyOMcZghuscolfK8eQnA40,3288
|
|
37
37
|
kotonebot/client/implements/remote_windows.py,sha256=SRq97cuXdUVvFIhsazZt4djMeGSLfYS88IUyzmlpaww,6756
|
|
38
38
|
kotonebot/client/implements/uiautomator2.py,sha256=Hal_DDWRCoLjL854xqDRq1X4Gm6O_SCBCUhqM2p1ykw,2722
|
|
39
|
-
kotonebot/client/implements/windows.py,sha256=bnASXFE8xHzY9Ue90r0ppYNnIVezhfMHAA0X9deXTNM,6889
|
|
40
39
|
kotonebot/client/implements/nemu_ipc/__init__.py,sha256=atIZjwnxhKEOndikoHDYhPrkvb7iC3zscBC457MQPz4,321
|
|
41
40
|
kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py,sha256=YsfKf0-qorfAf2YvNuxpLb9af-HJFsu97bnXABshhbA,10643
|
|
42
41
|
kotonebot/client/implements/nemu_ipc/nemu_ipc.py,sha256=LhUUyfB28MDnRg8z2FyGah1hTeOMFiX7w8LZLAAjLF8,12082
|
|
42
|
+
kotonebot/client/implements/windows/__init__.py,sha256=LTV4QpWocfTG1blVDyhRnwcuBlqWl_P3VWpIrXVFLL4,22
|
|
43
|
+
kotonebot/client/implements/windows/print_window.py,sha256=_4wWKgMoAI5OMy5AZ1XaSEd070HjJf4TYWZ7NHYZLU4,4602
|
|
44
|
+
kotonebot/client/implements/windows/send_message.py,sha256=rG0USG-fMCz5fBq2WeDi7sFotEJCmdCTYuIYcoXv1Pw,11309
|
|
45
|
+
kotonebot/client/implements/windows/windows.py,sha256=vlNQDuC2vqPVEJ-o44_tS3_kFyetcLxmSteWH8LYdoo,6826
|
|
43
46
|
kotonebot/config/__init__.py,sha256=-jATUOdrpUrBRT9RiTRQho2-2zeet50qQggsVMVpqNE,35
|
|
44
|
-
kotonebot/config/base_config.py,sha256=
|
|
47
|
+
kotonebot/config/base_config.py,sha256=60sqxyd3XTM2Er2Mvu6SK4pN9z_vsXH77LfIchYdRAM,3770
|
|
45
48
|
kotonebot/config/config.py,sha256=0MeumpADN8PneyeqVGutByL-39jZdJioJWL8DM5xtdU,1951
|
|
46
49
|
kotonebot/config/manager.py,sha256=XBtriAU9eo-wv2iKOwyDqu8tzhbKqFCuy0jsAM9T9uU,1061
|
|
47
50
|
kotonebot/core/__init__.py,sha256=r2d-3TLGKvr3H8q3sfDZRtRaq0xPE9gUN4aHnqa7Ofs,410
|
|
@@ -77,13 +80,14 @@ kotonebot/devtools/web/dist/icons/symbol-method.svg,sha256=KGS0YF4X4jyzU9PB0NHe6
|
|
|
77
80
|
kotonebot/devtools/web/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
78
81
|
kotonebot/devtools/web/server/rest_api.py,sha256=Kk_bc3T3Qz_8okxOWd4url3ImXJh-V5x9t3vir3muKA,8249
|
|
79
82
|
kotonebot/devtools/web/server/server.py,sha256=e4M99fDAR3lWGG7k3FBzWDye2O8yTDjYt6A-tXug5wQ,2694
|
|
80
|
-
kotonebot/interop/win/__init__.py,sha256=
|
|
83
|
+
kotonebot/interop/win/__init__.py,sha256=UaJrj8ZCwPmHOnC1N3y4qbI2H6RKknLJiFkQl1UIaWM,324
|
|
81
84
|
kotonebot/interop/win/_mouse.py,sha256=SdkgVyxRnZQbR47yQWqmWzSQw3lcG9eNN1hITgiSu2c,10441
|
|
82
85
|
kotonebot/interop/win/message_box.py,sha256=R06GSu936Bx_Wg7ddn6LOvazD9_Gt3mhz4_oUuIoYO0,8635
|
|
83
86
|
kotonebot/interop/win/reg.py,sha256=xw35d1xl8ucITT4bOMFgHmMkAUhak7x3lzegR3g3S48,1347
|
|
84
87
|
kotonebot/interop/win/shake_mouse.py,sha256=SdJ94NyScAgF_IDr7E4DPDE1DVpo_vyFLaAf0SQCgG0,7266
|
|
85
88
|
kotonebot/interop/win/shortcut.py,sha256=f1u6IWvpw6Kxt014wnHz5Z94rVK1qf4kLtRsI9bJYnk,1764
|
|
86
89
|
kotonebot/interop/win/task_dialog.py,sha256=Ezi1CsjFSbnKccvYfIRaJoCGHUsJnnIpHb0gtKR603E,20191
|
|
90
|
+
kotonebot/interop/win/window.py,sha256=DC_tZxH6QfqI1YbYrQiExfYosyBc1zKGw0Fx9L60fd8,3104
|
|
87
91
|
kotonebot/logging/__init__.py,sha256=r0q4z59yYy_bQnHTwJYsiPGwOGIgEOrcXH1mNs1h_N0,142
|
|
88
92
|
kotonebot/logging/log.py,sha256=PLb6r_hlW1mvqU_kx6_89zaQ1IpamgHWG3sQ0ZnlrCI,555
|
|
89
93
|
kotonebot/primitives/__init__.py,sha256=Gsuo5NSTo81aDi3HDkAj2gFqSTWtY-k2PSJ4fc8w5FA,392
|
|
@@ -97,9 +101,9 @@ kotonebot/ui/pushkit/__init__.py,sha256=xDUctRUL3euvge-yl8IhFYMlxIxQXsjxcyGN5tUw
|
|
|
97
101
|
kotonebot/ui/pushkit/image_host.py,sha256=4ZEptuowUJJ-b9WwXaEfbVes2b-aJBK-JDI4bP8A9VM,2591
|
|
98
102
|
kotonebot/ui/pushkit/protocol.py,sha256=KVZ-xr0sMdiuri7AiYqugpZRRtefBsosXm6zouScUR4,266
|
|
99
103
|
kotonebot/ui/pushkit/wxpusher.py,sha256=Px-7pUvUMsgJLKzJXRJBVv8rPZMnELTYrTBhhllR8tM,1750
|
|
100
|
-
kotonebot-0.
|
|
101
|
-
kotonebot-0.
|
|
102
|
-
kotonebot-0.
|
|
103
|
-
kotonebot-0.
|
|
104
|
-
kotonebot-0.
|
|
105
|
-
kotonebot-0.
|
|
104
|
+
kotonebot-0.7.0.dist-info/licenses/LICENSE,sha256=gcuuhKKc5-dwvyvHsXjlC9oM6N5gZ6umYbC8ewW1Yvg,35821
|
|
105
|
+
kotonebot-0.7.0.dist-info/METADATA,sha256=Ivfdne2Tyhwl_AEZ1r7SPGF3Ky76Kc-fBcooxiWsS3o,3351
|
|
106
|
+
kotonebot-0.7.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
107
|
+
kotonebot-0.7.0.dist-info/entry_points.txt,sha256=0TRRHk88fKrlrGGuxTMfa80P618wK3kLspjTrRAVoy0,53
|
|
108
|
+
kotonebot-0.7.0.dist-info/top_level.txt,sha256=QUWAZdbBndoojkrs6RcNytLAn7a0ns4YNF4tLx2Nc4s,10
|
|
109
|
+
kotonebot-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|