uacc 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.
- uacc/__init__.py +8 -0
- uacc/actions/__init__.py +1 -0
- uacc/actions/artistic_painter.py +390 -0
- uacc/actions/executor.py +292 -0
- uacc/actions/human_mimicry.py +199 -0
- uacc/actions/schema.py +263 -0
- uacc/agent/__init__.py +1 -0
- uacc/agent/controller.py +356 -0
- uacc/agent/memory.py +238 -0
- uacc/agent/specialists.py +165 -0
- uacc/agent/verifier.py +207 -0
- uacc/config.py +84 -0
- uacc/core/__init__.py +1 -0
- uacc/core/accessibility.py +243 -0
- uacc/core/clipboard.py +176 -0
- uacc/core/element_finder.py +313 -0
- uacc/core/grid_encoder.py +244 -0
- uacc/core/ocr_engine.py +169 -0
- uacc/core/screen_capture.py +107 -0
- uacc/core/screen_diff.py +256 -0
- uacc/core/text_map.py +335 -0
- uacc/core/window_manager.py +587 -0
- uacc/models/__init__.py +1 -0
- uacc/models/base_adapter.py +200 -0
- uacc/models/hybrid_adapter.py +172 -0
- uacc/models/text_adapter.py +123 -0
- uacc/models/vision_adapter.py +140 -0
- uacc/web/server.py +280 -0
- uacc-0.1.0.dist-info/METADATA +667 -0
- uacc-0.1.0.dist-info/RECORD +36 -0
- uacc-0.1.0.dist-info/WHEEL +5 -0
- uacc-0.1.0.dist-info/entry_points.txt +3 -0
- uacc-0.1.0.dist-info/top_level.txt +2 -0
- uacc_mcp/__init__.py +8 -0
- uacc_mcp/server.py +1396 -0
- uacc_mcp/utils.py +178 -0
uacc/__init__.py
ADDED
uacc/actions/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""UACC Actions — action schema, execution, and human mimicry."""
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Artistic Painter — turn UACC into an AI painter that can paint images or preset designs
|
|
3
|
+
directly on screen in Microsoft Paint using precise drag-and-drop stroke trajectories.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from PIL import Image, ImageFilter, ImageOps
|
|
14
|
+
import pyautogui
|
|
15
|
+
|
|
16
|
+
from uacc.actions.schema import DragAction, MouseButton, ClickAction, HotkeyAction
|
|
17
|
+
from uacc.actions.executor import ActionExecutor
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ArtisticPainter:
|
|
23
|
+
"""Converts images or geometric presets into vector stroke paths and paints
|
|
24
|
+
them in Microsoft Paint using the UACC ActionExecutor."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, executor: Optional[ActionExecutor] = None):
|
|
27
|
+
self.executor = executor or ActionExecutor(human_mimicry=False, action_delay_ms=5)
|
|
28
|
+
|
|
29
|
+
def draw_preset(self, preset_name: str, canvas_center: Tuple[int, int]) -> Dict[str, Any]:
|
|
30
|
+
"""Paint a built-in masterpiece design by name.
|
|
31
|
+
|
|
32
|
+
Presets: "rose", "galaxy", "peacock", "mountains".
|
|
33
|
+
"""
|
|
34
|
+
preset_name = preset_name.lower().strip()
|
|
35
|
+
cx, cy = canvas_center
|
|
36
|
+
|
|
37
|
+
logger.info("Painting preset art: '%s' around (%d, %d)", preset_name, cx, cy)
|
|
38
|
+
|
|
39
|
+
if preset_name == "rose":
|
|
40
|
+
strokes = self._generate_rose(cx, cy)
|
|
41
|
+
elif preset_name == "galaxy":
|
|
42
|
+
strokes = self._generate_galaxy(cx, cy)
|
|
43
|
+
elif preset_name == "mountains":
|
|
44
|
+
strokes = self._generate_mountains(cx, cy)
|
|
45
|
+
elif preset_name == "peacock":
|
|
46
|
+
return self._draw_peacock_direct(cx, cy)
|
|
47
|
+
else:
|
|
48
|
+
return {"success": False, "message": f"Unknown preset: '{preset_name}'"}
|
|
49
|
+
|
|
50
|
+
return self._execute_strokes(strokes)
|
|
51
|
+
|
|
52
|
+
def draw_image(
|
|
53
|
+
self,
|
|
54
|
+
image_path: str,
|
|
55
|
+
canvas_bounds: Tuple[int, int, int, int], # (left, top, right, bottom)
|
|
56
|
+
max_strokes: int = 150,
|
|
57
|
+
edge_threshold: int = 100,
|
|
58
|
+
) -> Dict[str, Any]:
|
|
59
|
+
"""Load an image, extract its outline contours, and paint it on screen.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
image_path: Path to the image file to paint.
|
|
63
|
+
canvas_bounds: Screen coordinates of Paint's drawing canvas (left, top, right, bottom).
|
|
64
|
+
max_strokes: Cap on number of strokes to avoid infinite execution.
|
|
65
|
+
edge_threshold: Threshold to detect edges (higher = fewer lines, faster).
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
img = Image.open(image_path)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
return {"success": False, "message": f"Failed to load image: {exc}"}
|
|
71
|
+
|
|
72
|
+
# Step 1: Image Processing (Resize to fit canvas and extract edges)
|
|
73
|
+
canvas_w = canvas_bounds[2] - canvas_bounds[0]
|
|
74
|
+
canvas_h = canvas_bounds[3] - canvas_bounds[1]
|
|
75
|
+
|
|
76
|
+
# Preserve aspect ratio
|
|
77
|
+
img.thumbnail((canvas_w - 40, canvas_h - 40))
|
|
78
|
+
img_w, img_h = img.size
|
|
79
|
+
|
|
80
|
+
# Offset to center within the canvas
|
|
81
|
+
offset_x = canvas_bounds[0] + (canvas_w - img_w) // 2
|
|
82
|
+
offset_y = canvas_bounds[1] + (canvas_h - img_h) // 2
|
|
83
|
+
|
|
84
|
+
# Grayscale + find edges
|
|
85
|
+
gray = ImageOps.grayscale(img)
|
|
86
|
+
edges = gray.filter(ImageFilter.FIND_EDGES)
|
|
87
|
+
|
|
88
|
+
# Binary thresholding
|
|
89
|
+
binary_edges = edges.point(lambda p: 255 if p > edge_threshold else 0)
|
|
90
|
+
width, height = binary_edges.size
|
|
91
|
+
pixels = binary_edges.load()
|
|
92
|
+
|
|
93
|
+
# Step 2: Path Tracing (Contiguous DFS tracking of edge pixels)
|
|
94
|
+
visited = set()
|
|
95
|
+
strokes = []
|
|
96
|
+
|
|
97
|
+
def get_neighbors(x, y):
|
|
98
|
+
neighbors = []
|
|
99
|
+
for dx in [-1, 0, 1]:
|
|
100
|
+
for dy in [-1, 0, 1]:
|
|
101
|
+
if dx == 0 and dy == 0:
|
|
102
|
+
continue
|
|
103
|
+
nx, ny = x + dx, y + dy
|
|
104
|
+
if 0 <= nx < width and 0 <= ny < height:
|
|
105
|
+
neighbors.append((nx, ny))
|
|
106
|
+
return neighbors
|
|
107
|
+
|
|
108
|
+
# Traverse and find contiguous lines
|
|
109
|
+
for y in range(0, height, 2): # Step 2 to downsample slightly
|
|
110
|
+
for x in range(0, width, 2):
|
|
111
|
+
if pixels[x, y] == 255 and (x, y) not in visited:
|
|
112
|
+
# Start a new stroke path
|
|
113
|
+
path = []
|
|
114
|
+
curr_x, curr_y = x, y
|
|
115
|
+
path.append((curr_x, curr_y))
|
|
116
|
+
visited.add((curr_x, curr_y))
|
|
117
|
+
|
|
118
|
+
# Follow the line
|
|
119
|
+
while True:
|
|
120
|
+
next_pixel = None
|
|
121
|
+
for nx, ny in get_neighbors(curr_x, curr_y):
|
|
122
|
+
if pixels[nx, ny] == 255 and (nx, ny) not in visited:
|
|
123
|
+
next_pixel = (nx, ny)
|
|
124
|
+
break
|
|
125
|
+
if next_pixel:
|
|
126
|
+
curr_x, curr_y = next_pixel
|
|
127
|
+
path.append((curr_x, curr_y))
|
|
128
|
+
visited.add((curr_x, curr_y))
|
|
129
|
+
if len(path) > 30: # Cap single path length to avoid huge vectors
|
|
130
|
+
break
|
|
131
|
+
else:
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if len(path) > 3: # Filter out short noise dots
|
|
135
|
+
strokes.append(path)
|
|
136
|
+
|
|
137
|
+
# Cap the total strokes
|
|
138
|
+
strokes = strokes[:max_strokes]
|
|
139
|
+
logger.info("Generated %d outline paths from image", len(strokes))
|
|
140
|
+
|
|
141
|
+
# Step 3: Convert paths to DragActions (downsampled for 6x speed-up)
|
|
142
|
+
drag_actions = []
|
|
143
|
+
for path in strokes:
|
|
144
|
+
simplified_path = []
|
|
145
|
+
step = 6 # Take every 6th point along the path
|
|
146
|
+
for idx in range(0, len(path), step):
|
|
147
|
+
simplified_path.append(path[idx])
|
|
148
|
+
if path[-1] not in simplified_path:
|
|
149
|
+
simplified_path.append(path[-1])
|
|
150
|
+
|
|
151
|
+
for i in range(len(simplified_path) - 1):
|
|
152
|
+
x1, y1 = simplified_path[i]
|
|
153
|
+
x2, y2 = simplified_path[i + 1]
|
|
154
|
+
drag_actions.append(
|
|
155
|
+
DragAction(
|
|
156
|
+
start_x=int(x1 + offset_x),
|
|
157
|
+
start_y=int(y1 + offset_y),
|
|
158
|
+
end_x=int(x2 + offset_x),
|
|
159
|
+
end_y=int(y2 + offset_y),
|
|
160
|
+
button=MouseButton.LEFT,
|
|
161
|
+
duration_ms=40, # Fast drawing strokes
|
|
162
|
+
reasoning="Tracing edge outline",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return self._execute_strokes(drag_actions)
|
|
167
|
+
|
|
168
|
+
# ── Masterpiece Preset Generators ─────────────────────────
|
|
169
|
+
|
|
170
|
+
def _generate_rose(self, cx: int, cy: int) -> List[DragAction]:
|
|
171
|
+
"""Generate a mathematical rose curve (Rhodonea curve)."""
|
|
172
|
+
actions = []
|
|
173
|
+
n, d = 5, 1 # 5-lobed rose
|
|
174
|
+
k = n / d
|
|
175
|
+
a = 150 # Radius/size
|
|
176
|
+
steps = 180
|
|
177
|
+
|
|
178
|
+
points = []
|
|
179
|
+
for i in range(steps + 1):
|
|
180
|
+
theta = (2 * math.pi * i) / steps
|
|
181
|
+
r = a * math.cos(k * theta)
|
|
182
|
+
x = cx + r * math.cos(theta)
|
|
183
|
+
y = cy + r * math.sin(theta)
|
|
184
|
+
points.append((int(x), int(y)))
|
|
185
|
+
|
|
186
|
+
for i in range(len(points) - 1):
|
|
187
|
+
actions.append(
|
|
188
|
+
DragAction(
|
|
189
|
+
start_x=points[i][0],
|
|
190
|
+
start_y=points[i][1],
|
|
191
|
+
end_x=points[i+1][0],
|
|
192
|
+
end_y=points[i+1][1],
|
|
193
|
+
button=MouseButton.LEFT,
|
|
194
|
+
duration_ms=50,
|
|
195
|
+
reasoning="Drawing rose petal curve",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return actions
|
|
199
|
+
|
|
200
|
+
def _generate_galaxy(self, cx: int, cy: int) -> List[DragAction]:
|
|
201
|
+
"""Generate a double spiral galaxy pattern."""
|
|
202
|
+
actions = []
|
|
203
|
+
arms = 2
|
|
204
|
+
rotations = 3.5
|
|
205
|
+
max_r = 180
|
|
206
|
+
steps = 150
|
|
207
|
+
|
|
208
|
+
# Arm 1 & Arm 2
|
|
209
|
+
for arm in range(arms):
|
|
210
|
+
points = []
|
|
211
|
+
angle_offset = arm * math.pi
|
|
212
|
+
for i in range(steps):
|
|
213
|
+
t = i / steps
|
|
214
|
+
r = t * max_r
|
|
215
|
+
theta = t * rotations * 2 * math.pi + angle_offset
|
|
216
|
+
x = cx + r * math.cos(theta)
|
|
217
|
+
y = cy + r * math.sin(theta)
|
|
218
|
+
points.append((int(x), int(y)))
|
|
219
|
+
|
|
220
|
+
for i in range(len(points) - 1):
|
|
221
|
+
actions.append(
|
|
222
|
+
DragAction(
|
|
223
|
+
start_x=points[i][0],
|
|
224
|
+
start_y=points[i][1],
|
|
225
|
+
end_x=points[i+1][0],
|
|
226
|
+
end_y=points[i+1][1],
|
|
227
|
+
button=MouseButton.LEFT,
|
|
228
|
+
duration_ms=40,
|
|
229
|
+
reasoning="Drawing galaxy arm spiral",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return actions
|
|
233
|
+
|
|
234
|
+
def _generate_mountains(self, cx: int, cy: int) -> List[DragAction]:
|
|
235
|
+
"""Generate a mountain range silhouette with a rising sun."""
|
|
236
|
+
actions = []
|
|
237
|
+
|
|
238
|
+
# 1. Draw Sun (ellipse)
|
|
239
|
+
sun_r = 50
|
|
240
|
+
sun_cx = cx
|
|
241
|
+
sun_cy = cy - 60
|
|
242
|
+
sun_pts = []
|
|
243
|
+
for i in range(21):
|
|
244
|
+
theta = math.pi * i / 20 # Half circle top
|
|
245
|
+
x = sun_cx + sun_r * math.cos(theta)
|
|
246
|
+
y = sun_cy - sun_r * math.sin(theta)
|
|
247
|
+
sun_pts.append((int(x), int(y)))
|
|
248
|
+
|
|
249
|
+
for i in range(len(sun_pts) - 1):
|
|
250
|
+
actions.append(
|
|
251
|
+
DragAction(
|
|
252
|
+
start_x=sun_pts[i][0],
|
|
253
|
+
start_y=sun_pts[i][1],
|
|
254
|
+
end_x=sun_pts[i+1][0],
|
|
255
|
+
end_y=sun_pts[i+1][1],
|
|
256
|
+
button=MouseButton.LEFT,
|
|
257
|
+
duration_ms=60,
|
|
258
|
+
reasoning="Drawing sun",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# 2. Draw Mountain ridges
|
|
263
|
+
peaks = [
|
|
264
|
+
(cx - 200, cy + 80),
|
|
265
|
+
(cx - 100, cy - 20),
|
|
266
|
+
(cx, cy + 40),
|
|
267
|
+
(cx + 120, cy - 50),
|
|
268
|
+
(cx + 220, cy + 80)
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
for i in range(len(peaks) - 1):
|
|
272
|
+
actions.append(
|
|
273
|
+
DragAction(
|
|
274
|
+
start_x=peaks[i][0],
|
|
275
|
+
start_y=peaks[i][1],
|
|
276
|
+
end_x=peaks[i+1][0],
|
|
277
|
+
end_y=peaks[i+1][1],
|
|
278
|
+
button=MouseButton.LEFT,
|
|
279
|
+
duration_ms=100,
|
|
280
|
+
reasoning="Drawing mountain ridge line",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Draw second background ridge
|
|
285
|
+
peaks2 = [
|
|
286
|
+
(cx - 150, cy + 80),
|
|
287
|
+
(cx - 40, cy + 10),
|
|
288
|
+
(cx + 60, cy - 10),
|
|
289
|
+
(cx + 170, cy + 80)
|
|
290
|
+
]
|
|
291
|
+
for i in range(len(peaks2) - 1):
|
|
292
|
+
actions.append(
|
|
293
|
+
DragAction(
|
|
294
|
+
start_x=peaks2[i][0],
|
|
295
|
+
start_y=peaks2[i][1],
|
|
296
|
+
end_x=peaks2[i+1][0],
|
|
297
|
+
end_y=peaks2[i+1][1],
|
|
298
|
+
button=MouseButton.LEFT,
|
|
299
|
+
duration_ms=100,
|
|
300
|
+
reasoning="Drawing background mountain ridge",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return actions
|
|
305
|
+
|
|
306
|
+
def _draw_peacock_direct(self, cx: int, cy: int) -> Dict[str, Any]:
|
|
307
|
+
"""Robust direct drawing of the famous peacock preset."""
|
|
308
|
+
# Standard utility helpers
|
|
309
|
+
def curve_points(x0, y0, x1, y1, cx, cy, n=8):
|
|
310
|
+
pts = []
|
|
311
|
+
for i in range(n + 1):
|
|
312
|
+
t = i / n
|
|
313
|
+
x = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t**2 * x1
|
|
314
|
+
y = (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t**2 * y1
|
|
315
|
+
pts.append((int(x), int(y)))
|
|
316
|
+
return pts
|
|
317
|
+
|
|
318
|
+
def connect_points(pts):
|
|
319
|
+
acts = []
|
|
320
|
+
for i in range(len(pts) - 1):
|
|
321
|
+
acts.append(
|
|
322
|
+
DragAction(
|
|
323
|
+
start_x=pts[i][0], start_y=pts[i][1],
|
|
324
|
+
end_x=pts[i+1][0], end_y=pts[i+1][1],
|
|
325
|
+
button=MouseButton.LEFT, duration_ms=100,
|
|
326
|
+
reasoning="Peacock drawing path"
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
return acts
|
|
330
|
+
|
|
331
|
+
def ellipse(ecx, ecy, rx, ry, steps=16):
|
|
332
|
+
pts = []
|
|
333
|
+
for i in range(steps + 1):
|
|
334
|
+
theta = 2 * math.pi * i / steps
|
|
335
|
+
pts.append((int(ecx + rx * math.cos(theta)), int(ecy + ry * math.sin(theta))))
|
|
336
|
+
return connect_points(pts)
|
|
337
|
+
|
|
338
|
+
all_actions = []
|
|
339
|
+
tail_origin_x, tail_origin_y = cx - 40, cy + 20
|
|
340
|
+
|
|
341
|
+
# Draw tail feathers
|
|
342
|
+
feather_angles = [(-45, 200), (-25, 170), (25, 170), (45, 200)]
|
|
343
|
+
for angle_deg, length in feather_angles:
|
|
344
|
+
angle = math.radians(angle_deg)
|
|
345
|
+
tx = tail_origin_x + length * math.cos(angle)
|
|
346
|
+
ty = tail_origin_y + length * math.sin(angle)
|
|
347
|
+
|
|
348
|
+
shaft = curve_points(
|
|
349
|
+
tail_origin_x, tail_origin_y, tx, ty,
|
|
350
|
+
tail_origin_x + 30 * math.cos(angle + 0.1),
|
|
351
|
+
tail_origin_y + 30 * math.sin(angle + 0.1),
|
|
352
|
+
n=8
|
|
353
|
+
)
|
|
354
|
+
all_actions.extend(connect_points(shaft))
|
|
355
|
+
all_actions.extend(ellipse(tx, ty, 15, 8, steps=12))
|
|
356
|
+
|
|
357
|
+
# Body
|
|
358
|
+
all_actions.extend(ellipse(cx + 10, cy + 20, 30, 50, steps=20))
|
|
359
|
+
# Neck
|
|
360
|
+
neck = curve_points(cx + 10, cy - 20, cx + 50, cy - 100, cx + 40, cy - 60, n=8)
|
|
361
|
+
all_actions.extend(connect_points(neck))
|
|
362
|
+
# Head
|
|
363
|
+
all_actions.extend(ellipse(cx + 50, cy - 100, 10, 12, steps=12))
|
|
364
|
+
|
|
365
|
+
return self._execute_strokes(all_actions)
|
|
366
|
+
|
|
367
|
+
def _execute_strokes(self, strokes: List[DragAction]) -> Dict[str, Any]:
|
|
368
|
+
"""Execute a list of DragActions sequentially."""
|
|
369
|
+
total = len(strokes)
|
|
370
|
+
if total == 0:
|
|
371
|
+
return {"success": False, "message": "No paths or lines generated."}
|
|
372
|
+
|
|
373
|
+
logger.info("Executing %d drawing strokes...", total)
|
|
374
|
+
success_count = 0
|
|
375
|
+
|
|
376
|
+
# Hold mouse click down safety checks in pyautogui
|
|
377
|
+
for idx, action in enumerate(strokes, 1):
|
|
378
|
+
res = self.executor.execute(action)
|
|
379
|
+
if res.get("success"):
|
|
380
|
+
success_count += 1
|
|
381
|
+
# Add small pause so user can abort if necessary
|
|
382
|
+
time.sleep(0.01)
|
|
383
|
+
|
|
384
|
+
pct = (success_count / total) * 100
|
|
385
|
+
return {
|
|
386
|
+
"success": success_count > 0,
|
|
387
|
+
"message": f"Successfully completed {success_count}/{total} strokes ({pct:.1f}%)",
|
|
388
|
+
"total_strokes": total,
|
|
389
|
+
"success_strokes": success_count,
|
|
390
|
+
}
|
uacc/actions/executor.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Action Executor — translate typed Action objects into real mouse / keyboard events.
|
|
3
|
+
|
|
4
|
+
Uses pyautogui for cross-platform input simulation, with optional
|
|
5
|
+
human-like movement from the mimicry module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import pyautogui
|
|
15
|
+
|
|
16
|
+
from uacc.actions.human_mimicry import move_mouse_human, type_human
|
|
17
|
+
from uacc.actions.schema import (
|
|
18
|
+
Action,
|
|
19
|
+
ClickAction,
|
|
20
|
+
DoneAction,
|
|
21
|
+
DragAction,
|
|
22
|
+
HotkeyAction,
|
|
23
|
+
HoverAction,
|
|
24
|
+
ScrollAction,
|
|
25
|
+
ScreenshotAction,
|
|
26
|
+
TypeAction,
|
|
27
|
+
WaitAction,
|
|
28
|
+
is_potentially_destructive,
|
|
29
|
+
LaunchAction,
|
|
30
|
+
ClipboardAction,
|
|
31
|
+
FocusWindowAction,
|
|
32
|
+
)
|
|
33
|
+
from uacc.config import config
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Safety: disable pyautogui's fail-safe (move mouse to corner to abort)
|
|
38
|
+
pyautogui.FAILSAFE = config.uacc.pyautogui_failsafe
|
|
39
|
+
pyautogui.PAUSE = 0.05 # Small pause between pyautogui calls
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ActionExecutor:
|
|
43
|
+
"""Dispatches Action objects to real input events."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
human_mimicry: Optional[bool] = None,
|
|
48
|
+
action_delay_ms: Optional[int] = None,
|
|
49
|
+
safe_mode: Optional[bool] = None,
|
|
50
|
+
):
|
|
51
|
+
self.human_mimicry = (
|
|
52
|
+
human_mimicry if human_mimicry is not None else config.uacc.human_mimicry
|
|
53
|
+
)
|
|
54
|
+
self.action_delay_ms = (
|
|
55
|
+
action_delay_ms if action_delay_ms is not None else config.uacc.action_delay_ms
|
|
56
|
+
)
|
|
57
|
+
self.safe_mode = safe_mode if safe_mode is not None else config.uacc.safe_mode
|
|
58
|
+
self._last_action_time = 0.0
|
|
59
|
+
|
|
60
|
+
def execute(self, action: Action) -> dict:
|
|
61
|
+
"""Execute a single action and return a result dict.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
{"success": bool, "message": str, "action": str}
|
|
65
|
+
"""
|
|
66
|
+
# Safety check
|
|
67
|
+
if self.safe_mode and is_potentially_destructive(action):
|
|
68
|
+
logger.warning("Destructive action detected: %s", action)
|
|
69
|
+
return {
|
|
70
|
+
"success": False,
|
|
71
|
+
"message": "Action blocked by safe mode (potentially destructive)",
|
|
72
|
+
"action": getattr(action, "action", "unknown"),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Inter-action delay
|
|
76
|
+
elapsed = (time.time() - self._last_action_time) * 1000
|
|
77
|
+
if elapsed < self.action_delay_ms:
|
|
78
|
+
time.sleep((self.action_delay_ms - elapsed) / 1000)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
result = self._dispatch(action)
|
|
82
|
+
self._last_action_time = time.time()
|
|
83
|
+
return result
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.error("Action execution failed: %s", exc)
|
|
86
|
+
return {
|
|
87
|
+
"success": False,
|
|
88
|
+
"message": f"Execution error: {exc}",
|
|
89
|
+
"action": getattr(action, "action", "unknown"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def _dispatch(self, action: Action) -> dict:
|
|
93
|
+
"""Route to the appropriate handler."""
|
|
94
|
+
if isinstance(action, ClickAction):
|
|
95
|
+
return self._click(action)
|
|
96
|
+
elif isinstance(action, DragAction):
|
|
97
|
+
return self._drag(action)
|
|
98
|
+
elif isinstance(action, TypeAction):
|
|
99
|
+
return self._type(action)
|
|
100
|
+
elif isinstance(action, HotkeyAction):
|
|
101
|
+
return self._hotkey(action)
|
|
102
|
+
elif isinstance(action, ScrollAction):
|
|
103
|
+
return self._scroll(action)
|
|
104
|
+
elif isinstance(action, HoverAction):
|
|
105
|
+
return self._hover(action)
|
|
106
|
+
elif isinstance(action, WaitAction):
|
|
107
|
+
return self._wait(action)
|
|
108
|
+
elif isinstance(action, ScreenshotAction):
|
|
109
|
+
return self._screenshot(action)
|
|
110
|
+
elif isinstance(action, DoneAction):
|
|
111
|
+
return self._done(action)
|
|
112
|
+
elif isinstance(action, LaunchAction):
|
|
113
|
+
return self._launch(action)
|
|
114
|
+
elif isinstance(action, ClipboardAction):
|
|
115
|
+
return self._clipboard(action)
|
|
116
|
+
elif isinstance(action, FocusWindowAction):
|
|
117
|
+
return self._focus_window(action)
|
|
118
|
+
else:
|
|
119
|
+
return {"success": False, "message": f"Unknown action: {action}", "action": "unknown"}
|
|
120
|
+
|
|
121
|
+
# ── Handlers ─────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def _click(self, action: ClickAction) -> dict:
|
|
124
|
+
"""Execute a click action."""
|
|
125
|
+
# Move to position
|
|
126
|
+
if self.human_mimicry:
|
|
127
|
+
current = pyautogui.position()
|
|
128
|
+
move_mouse_human(current, (action.x, action.y))
|
|
129
|
+
else:
|
|
130
|
+
pyautogui.moveTo(action.x, action.y, duration=0.1)
|
|
131
|
+
|
|
132
|
+
# Apply modifiers
|
|
133
|
+
for mod in action.modifiers:
|
|
134
|
+
pyautogui.keyDown(mod)
|
|
135
|
+
|
|
136
|
+
# Click
|
|
137
|
+
button = action.button.value
|
|
138
|
+
if action.count == 2:
|
|
139
|
+
pyautogui.doubleClick(action.x, action.y, button=button)
|
|
140
|
+
else:
|
|
141
|
+
for _ in range(action.count):
|
|
142
|
+
pyautogui.click(action.x, action.y, button=button)
|
|
143
|
+
|
|
144
|
+
# Release modifiers
|
|
145
|
+
for mod in reversed(action.modifiers):
|
|
146
|
+
pyautogui.keyUp(mod)
|
|
147
|
+
|
|
148
|
+
logger.info(
|
|
149
|
+
"Click: (%d, %d) button=%s count=%d", action.x, action.y, button, action.count
|
|
150
|
+
)
|
|
151
|
+
return {
|
|
152
|
+
"success": True,
|
|
153
|
+
"message": f"Clicked at ({action.x}, {action.y})",
|
|
154
|
+
"action": "click",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def _drag(self, action: DragAction) -> dict:
|
|
158
|
+
"""Execute a drag action with smooth movement."""
|
|
159
|
+
if self.human_mimicry:
|
|
160
|
+
current = pyautogui.position()
|
|
161
|
+
move_mouse_human(current, (action.start_x, action.start_y))
|
|
162
|
+
|
|
163
|
+
duration = action.duration_ms / 1000
|
|
164
|
+
pyautogui.moveTo(action.start_x, action.start_y, duration=0.1)
|
|
165
|
+
pyautogui.mouseDown(button=action.button.value)
|
|
166
|
+
|
|
167
|
+
if self.human_mimicry:
|
|
168
|
+
move_mouse_human(
|
|
169
|
+
(action.start_x, action.start_y),
|
|
170
|
+
(action.end_x, action.end_y),
|
|
171
|
+
duration_ms=action.duration_ms,
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
pyautogui.moveTo(action.end_x, action.end_y, duration=duration)
|
|
175
|
+
|
|
176
|
+
pyautogui.mouseUp(button=action.button.value)
|
|
177
|
+
|
|
178
|
+
logger.info(
|
|
179
|
+
"Drag: (%d,%d) → (%d,%d)",
|
|
180
|
+
action.start_x, action.start_y, action.end_x, action.end_y,
|
|
181
|
+
)
|
|
182
|
+
return {
|
|
183
|
+
"success": True,
|
|
184
|
+
"message": f"Dragged ({action.start_x},{action.start_y}) → ({action.end_x},{action.end_y})",
|
|
185
|
+
"action": "drag",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def _type(self, action: TypeAction) -> dict:
|
|
189
|
+
"""Type text."""
|
|
190
|
+
if self.human_mimicry and action.delay_ms == 0:
|
|
191
|
+
type_human(action.text)
|
|
192
|
+
elif action.delay_ms > 0:
|
|
193
|
+
pyautogui.typewrite(action.text, interval=action.delay_ms / 1000)
|
|
194
|
+
else:
|
|
195
|
+
# Use write() for fast typing — handles special characters
|
|
196
|
+
pyautogui.write(action.text)
|
|
197
|
+
|
|
198
|
+
logger.info("Typed: %s", action.text[:50])
|
|
199
|
+
return {
|
|
200
|
+
"success": True,
|
|
201
|
+
"message": f"Typed {len(action.text)} characters",
|
|
202
|
+
"action": "type",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def _hotkey(self, action: HotkeyAction) -> dict:
|
|
206
|
+
"""Press a hotkey combination."""
|
|
207
|
+
pyautogui.hotkey(*action.keys)
|
|
208
|
+
combo = "+".join(action.keys)
|
|
209
|
+
logger.info("Hotkey: %s", combo)
|
|
210
|
+
return {"success": True, "message": f"Pressed {combo}", "action": "hotkey"}
|
|
211
|
+
|
|
212
|
+
def _scroll(self, action: ScrollAction) -> dict:
|
|
213
|
+
"""Scroll at a position."""
|
|
214
|
+
pyautogui.moveTo(action.x, action.y, duration=0.05)
|
|
215
|
+
if action.direction.value in ("up", "down"):
|
|
216
|
+
clicks = action.amount if action.direction.value == "up" else -action.amount
|
|
217
|
+
pyautogui.scroll(clicks, action.x, action.y)
|
|
218
|
+
else:
|
|
219
|
+
clicks = action.amount if action.direction.value == "right" else -action.amount
|
|
220
|
+
pyautogui.hscroll(clicks, action.x, action.y)
|
|
221
|
+
|
|
222
|
+
logger.info("Scroll: %s ×%d at (%d,%d)", action.direction.value, action.amount, action.x, action.y)
|
|
223
|
+
return {
|
|
224
|
+
"success": True,
|
|
225
|
+
"message": f"Scrolled {action.direction.value} ×{action.amount}",
|
|
226
|
+
"action": "scroll",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def _hover(self, action: HoverAction) -> dict:
|
|
230
|
+
"""Hover at a position."""
|
|
231
|
+
if self.human_mimicry:
|
|
232
|
+
current = pyautogui.position()
|
|
233
|
+
move_mouse_human(current, (action.x, action.y))
|
|
234
|
+
else:
|
|
235
|
+
pyautogui.moveTo(action.x, action.y, duration=0.15)
|
|
236
|
+
|
|
237
|
+
time.sleep(action.duration_ms / 1000)
|
|
238
|
+
logger.info("Hover: (%d,%d) for %dms", action.x, action.y, action.duration_ms)
|
|
239
|
+
return {
|
|
240
|
+
"success": True,
|
|
241
|
+
"message": f"Hovered at ({action.x}, {action.y}) for {action.duration_ms}ms",
|
|
242
|
+
"action": "hover",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
def _wait(self, action: WaitAction) -> dict:
|
|
246
|
+
"""Wait for a fixed duration."""
|
|
247
|
+
time.sleep(action.duration_ms / 1000)
|
|
248
|
+
logger.info("Waited: %dms", action.duration_ms)
|
|
249
|
+
return {
|
|
250
|
+
"success": True,
|
|
251
|
+
"message": f"Waited {action.duration_ms}ms",
|
|
252
|
+
"action": "wait",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
def _screenshot(self, action: ScreenshotAction) -> dict:
|
|
256
|
+
"""Signal that a screenshot should be taken (handled by controller)."""
|
|
257
|
+
logger.info("Screenshot requested")
|
|
258
|
+
return {
|
|
259
|
+
"success": True,
|
|
260
|
+
"message": "Screenshot requested — will be captured by controller",
|
|
261
|
+
"action": "screenshot",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def _done(self, action: DoneAction) -> dict:
|
|
265
|
+
"""Signal task completion."""
|
|
266
|
+
logger.info("Task done: success=%s result=%s", action.success, action.result)
|
|
267
|
+
return {
|
|
268
|
+
"success": action.success,
|
|
269
|
+
"message": action.result or "Task completed",
|
|
270
|
+
"action": "done",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def _launch(self, action: LaunchAction) -> dict:
|
|
274
|
+
"""Launch an application."""
|
|
275
|
+
from uacc.core.window_manager import launch_application
|
|
276
|
+
res = launch_application(action.name_or_path, action.arguments)
|
|
277
|
+
return {**res, "action": "launch"}
|
|
278
|
+
|
|
279
|
+
def _clipboard(self, action: ClipboardAction) -> dict:
|
|
280
|
+
"""Read from or write to the clipboard."""
|
|
281
|
+
from uacc.core.clipboard import read_clipboard, write_clipboard
|
|
282
|
+
if action.mode == "write":
|
|
283
|
+
res = write_clipboard(action.text)
|
|
284
|
+
else:
|
|
285
|
+
res = read_clipboard()
|
|
286
|
+
return {**res, "action": "clipboard"}
|
|
287
|
+
|
|
288
|
+
def _focus_window(self, action: FocusWindowAction) -> dict:
|
|
289
|
+
"""Focus a window."""
|
|
290
|
+
from uacc.core.window_manager import focus_window
|
|
291
|
+
res = focus_window(action.title)
|
|
292
|
+
return {**res, "action": "focus_window"}
|