use-computer 0.0.1__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.
- mmini/__init__.py +48 -0
- mmini/ax_transpile.py +504 -0
- mmini/client.py +362 -0
- mmini/display.py +39 -0
- mmini/errors.py +68 -0
- mmini/ios/__init__.py +14 -0
- mmini/ios/apps.py +57 -0
- mmini/ios/environment.py +49 -0
- mmini/ios/input.py +118 -0
- mmini/macos/__init__.py +4 -0
- mmini/macos/keyboard.py +59 -0
- mmini/macos/mouse.py +105 -0
- mmini/models.py +79 -0
- mmini/parsers.py +308 -0
- mmini/py.typed +1 -0
- mmini/recording.py +88 -0
- mmini/retry.py +135 -0
- mmini/sandbox.py +419 -0
- mmini/screenshot.py +63 -0
- mmini/tasks/__init__.py +307 -0
- mmini/tasks/templates/pre_command.sh +2 -0
- mmini/tasks/templates/task.toml +18 -0
- mmini/tasks/templates/test_ios.sh +20 -0
- mmini/tasks/templates/test_ios_nograder.sh +4 -0
- mmini/tasks/templates/test_macos.sh +10 -0
- mmini/tasks/templates/test_macos_check.sh +10 -0
- mmini/tasks/templates/test_macos_nograder.sh +7 -0
- use_computer/__init__.py +15 -0
- use_computer/py.typed +1 -0
- use_computer-0.0.1.dist-info/METADATA +133 -0
- use_computer-0.0.1.dist-info/RECORD +32 -0
- use_computer-0.0.1.dist-info/WHEEL +4 -0
mmini/ios/input.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from mmini.models import ActionResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Button(str, Enum):
|
|
11
|
+
"""Hardware buttons available on iOS simulators."""
|
|
12
|
+
|
|
13
|
+
HOME = "home"
|
|
14
|
+
LOCK = "lock"
|
|
15
|
+
SIRI = "siri"
|
|
16
|
+
SIDE_BUTTON = "side-button"
|
|
17
|
+
APPLE_PAY = "apple-pay"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Key(int, Enum):
|
|
21
|
+
"""Common HID keycodes for iOS simulator key presses."""
|
|
22
|
+
|
|
23
|
+
RETURN = 40
|
|
24
|
+
BACKSPACE = 42
|
|
25
|
+
TAB = 43
|
|
26
|
+
SPACE = 44
|
|
27
|
+
ESCAPE = 41
|
|
28
|
+
DELETE = 76
|
|
29
|
+
UP = 82
|
|
30
|
+
DOWN = 81
|
|
31
|
+
LEFT = 80
|
|
32
|
+
RIGHT = 79
|
|
33
|
+
F1 = 58
|
|
34
|
+
F2 = 59
|
|
35
|
+
F3 = 60
|
|
36
|
+
F4 = 61
|
|
37
|
+
F5 = 62
|
|
38
|
+
F6 = 63
|
|
39
|
+
F7 = 64
|
|
40
|
+
F8 = 65
|
|
41
|
+
F9 = 66
|
|
42
|
+
F10 = 67
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Input:
|
|
46
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
47
|
+
self._http = http
|
|
48
|
+
self._prefix = prefix
|
|
49
|
+
|
|
50
|
+
def tap(self, x: float, y: float) -> ActionResult:
|
|
51
|
+
resp = self._http.post(f"{self._prefix}/tap", json={"x": x, "y": y})
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
return ActionResult.from_dict(resp.json())
|
|
54
|
+
|
|
55
|
+
def swipe(self, from_x: float, from_y: float, to_x: float, to_y: float) -> ActionResult:
|
|
56
|
+
resp = self._http.post(
|
|
57
|
+
f"{self._prefix}/swipe",
|
|
58
|
+
json={"fromX": from_x, "fromY": from_y, "toX": to_x, "toY": to_y},
|
|
59
|
+
)
|
|
60
|
+
resp.raise_for_status()
|
|
61
|
+
return ActionResult.from_dict(resp.json())
|
|
62
|
+
|
|
63
|
+
def type_text(self, text: str) -> ActionResult:
|
|
64
|
+
resp = self._http.post(f"{self._prefix}/type", json={"text": text})
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
return ActionResult.from_dict(resp.json())
|
|
67
|
+
|
|
68
|
+
def press_button(self, button: Button | str) -> ActionResult:
|
|
69
|
+
"""Press a hardware button. Use Button enum for type safety."""
|
|
70
|
+
val = button.value if isinstance(button, Button) else button
|
|
71
|
+
resp = self._http.post(f"{self._prefix}/button", json={"button": val})
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
return ActionResult.from_dict(resp.json())
|
|
74
|
+
|
|
75
|
+
def press_key(self, keycode: Key | int) -> ActionResult:
|
|
76
|
+
"""Press a key by HID keycode. Use Key enum for common keys."""
|
|
77
|
+
val = keycode.value if isinstance(keycode, Key) else keycode
|
|
78
|
+
resp = self._http.post(f"{self._prefix}/key", json={"keycode": val})
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
return ActionResult.from_dict(resp.json())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AsyncInput:
|
|
84
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
85
|
+
self._http = http
|
|
86
|
+
self._prefix = prefix
|
|
87
|
+
|
|
88
|
+
async def tap(self, x: float, y: float) -> ActionResult:
|
|
89
|
+
resp = await self._http.post(f"{self._prefix}/tap", json={"x": x, "y": y})
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
return ActionResult.from_dict(resp.json())
|
|
92
|
+
|
|
93
|
+
async def swipe(self, from_x: float, from_y: float, to_x: float, to_y: float) -> ActionResult:
|
|
94
|
+
resp = await self._http.post(
|
|
95
|
+
f"{self._prefix}/swipe",
|
|
96
|
+
json={"fromX": from_x, "fromY": from_y, "toX": to_x, "toY": to_y},
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
return ActionResult.from_dict(resp.json())
|
|
100
|
+
|
|
101
|
+
async def type_text(self, text: str) -> ActionResult:
|
|
102
|
+
resp = await self._http.post(f"{self._prefix}/type", json={"text": text})
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
return ActionResult.from_dict(resp.json())
|
|
105
|
+
|
|
106
|
+
async def press_button(self, button: Button | str) -> ActionResult:
|
|
107
|
+
"""Press a hardware button. Use Button enum for type safety."""
|
|
108
|
+
val = button.value if isinstance(button, Button) else button
|
|
109
|
+
resp = await self._http.post(f"{self._prefix}/button", json={"button": val})
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
return ActionResult.from_dict(resp.json())
|
|
112
|
+
|
|
113
|
+
async def press_key(self, keycode: Key | int) -> ActionResult:
|
|
114
|
+
"""Press a key by HID keycode. Use Key enum for common keys."""
|
|
115
|
+
val = keycode.value if isinstance(keycode, Key) else keycode
|
|
116
|
+
resp = await self._http.post(f"{self._prefix}/key", json={"keycode": val})
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
return ActionResult.from_dict(resp.json())
|
mmini/macos/__init__.py
ADDED
mmini/macos/keyboard.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from mmini.models import ActionResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Keyboard:
|
|
9
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
10
|
+
self._http = http
|
|
11
|
+
self._prefix = prefix
|
|
12
|
+
|
|
13
|
+
def type(self, text: str, delay: int | None = None) -> ActionResult:
|
|
14
|
+
payload: dict = {"text": text}
|
|
15
|
+
if delay is not None:
|
|
16
|
+
payload["delay"] = delay
|
|
17
|
+
resp = self._http.post(f"{self._prefix}/keyboard/type", json=payload)
|
|
18
|
+
resp.raise_for_status()
|
|
19
|
+
return ActionResult.from_dict(resp.json())
|
|
20
|
+
|
|
21
|
+
def press(self, key: str, modifiers: list[str] | None = None) -> ActionResult:
|
|
22
|
+
payload: dict = {"key": key}
|
|
23
|
+
if modifiers:
|
|
24
|
+
payload["modifiers"] = modifiers
|
|
25
|
+
resp = self._http.post(f"{self._prefix}/keyboard/press", json=payload)
|
|
26
|
+
resp.raise_for_status()
|
|
27
|
+
return ActionResult.from_dict(resp.json())
|
|
28
|
+
|
|
29
|
+
def hotkey(self, keys: str) -> ActionResult:
|
|
30
|
+
resp = self._http.post(f"{self._prefix}/keyboard/hotkey", json={"keys": keys})
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
return ActionResult.from_dict(resp.json())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncKeyboard:
|
|
36
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
37
|
+
self._http = http
|
|
38
|
+
self._prefix = prefix
|
|
39
|
+
|
|
40
|
+
async def type(self, text: str, delay: int | None = None) -> ActionResult:
|
|
41
|
+
payload: dict = {"text": text}
|
|
42
|
+
if delay is not None:
|
|
43
|
+
payload["delay"] = delay
|
|
44
|
+
resp = await self._http.post(f"{self._prefix}/keyboard/type", json=payload)
|
|
45
|
+
resp.raise_for_status()
|
|
46
|
+
return ActionResult.from_dict(resp.json())
|
|
47
|
+
|
|
48
|
+
async def press(self, key: str, modifiers: list[str] | None = None) -> ActionResult:
|
|
49
|
+
payload: dict = {"key": key}
|
|
50
|
+
if modifiers:
|
|
51
|
+
payload["modifiers"] = modifiers
|
|
52
|
+
resp = await self._http.post(f"{self._prefix}/keyboard/press", json=payload)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
return ActionResult.from_dict(resp.json())
|
|
55
|
+
|
|
56
|
+
async def hotkey(self, keys: str) -> ActionResult:
|
|
57
|
+
resp = await self._http.post(f"{self._prefix}/keyboard/hotkey", json={"keys": keys})
|
|
58
|
+
resp.raise_for_status()
|
|
59
|
+
return ActionResult.from_dict(resp.json())
|
mmini/macos/mouse.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from mmini.models import ActionResult, CursorPosition
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Mouse:
|
|
9
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
10
|
+
self._http = http
|
|
11
|
+
self._prefix = prefix
|
|
12
|
+
|
|
13
|
+
def click(self, x: int, y: int, button: str = "left", double: bool = False) -> ActionResult:
|
|
14
|
+
resp = self._http.post(
|
|
15
|
+
f"{self._prefix}/mouse/click",
|
|
16
|
+
json={"x": x, "y": y, "button": button, "double": double},
|
|
17
|
+
)
|
|
18
|
+
resp.raise_for_status()
|
|
19
|
+
return ActionResult.from_dict(resp.json())
|
|
20
|
+
|
|
21
|
+
def move(self, x: int, y: int) -> ActionResult:
|
|
22
|
+
resp = self._http.post(f"{self._prefix}/mouse/move", json={"x": x, "y": y})
|
|
23
|
+
resp.raise_for_status()
|
|
24
|
+
return ActionResult.from_dict(resp.json())
|
|
25
|
+
|
|
26
|
+
def drag(
|
|
27
|
+
self, start_x: int, start_y: int, end_x: int, end_y: int, button: str = "left"
|
|
28
|
+
) -> ActionResult:
|
|
29
|
+
resp = self._http.post(
|
|
30
|
+
f"{self._prefix}/mouse/drag",
|
|
31
|
+
json={
|
|
32
|
+
"startX": start_x,
|
|
33
|
+
"startY": start_y,
|
|
34
|
+
"endX": end_x,
|
|
35
|
+
"endY": end_y,
|
|
36
|
+
"button": button,
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return ActionResult.from_dict(resp.json())
|
|
41
|
+
|
|
42
|
+
def scroll(self, x: int, y: int, direction: str = "down", amount: int = 3) -> ActionResult:
|
|
43
|
+
resp = self._http.post(
|
|
44
|
+
f"{self._prefix}/mouse/scroll",
|
|
45
|
+
json={"x": x, "y": y, "direction": direction, "amount": amount},
|
|
46
|
+
)
|
|
47
|
+
resp.raise_for_status()
|
|
48
|
+
return ActionResult.from_dict(resp.json())
|
|
49
|
+
|
|
50
|
+
def get_position(self) -> CursorPosition:
|
|
51
|
+
resp = self._http.get(f"{self._prefix}/mouse/position")
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
return CursorPosition.from_dict(resp.json())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AsyncMouse:
|
|
57
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
58
|
+
self._http = http
|
|
59
|
+
self._prefix = prefix
|
|
60
|
+
|
|
61
|
+
async def click(
|
|
62
|
+
self, x: int, y: int, button: str = "left", double: bool = False
|
|
63
|
+
) -> ActionResult:
|
|
64
|
+
resp = await self._http.post(
|
|
65
|
+
f"{self._prefix}/mouse/click",
|
|
66
|
+
json={"x": x, "y": y, "button": button, "double": double},
|
|
67
|
+
)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
return ActionResult.from_dict(resp.json())
|
|
70
|
+
|
|
71
|
+
async def move(self, x: int, y: int) -> ActionResult:
|
|
72
|
+
resp = await self._http.post(f"{self._prefix}/mouse/move", json={"x": x, "y": y})
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return ActionResult.from_dict(resp.json())
|
|
75
|
+
|
|
76
|
+
async def drag(
|
|
77
|
+
self, start_x: int, start_y: int, end_x: int, end_y: int, button: str = "left"
|
|
78
|
+
) -> ActionResult:
|
|
79
|
+
resp = await self._http.post(
|
|
80
|
+
f"{self._prefix}/mouse/drag",
|
|
81
|
+
json={
|
|
82
|
+
"startX": start_x,
|
|
83
|
+
"startY": start_y,
|
|
84
|
+
"endX": end_x,
|
|
85
|
+
"endY": end_y,
|
|
86
|
+
"button": button,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
return ActionResult.from_dict(resp.json())
|
|
91
|
+
|
|
92
|
+
async def scroll(
|
|
93
|
+
self, x: int, y: int, direction: str = "down", amount: int = 3
|
|
94
|
+
) -> ActionResult:
|
|
95
|
+
resp = await self._http.post(
|
|
96
|
+
f"{self._prefix}/mouse/scroll",
|
|
97
|
+
json={"x": x, "y": y, "direction": direction, "amount": amount},
|
|
98
|
+
)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
return ActionResult.from_dict(resp.json())
|
|
101
|
+
|
|
102
|
+
async def get_position(self) -> CursorPosition:
|
|
103
|
+
resp = await self._http.get(f"{self._prefix}/mouse/position")
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
return CursorPosition.from_dict(resp.json())
|
mmini/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ActionResult:
|
|
8
|
+
"""Result from a mouse/keyboard/tap/swipe action."""
|
|
9
|
+
|
|
10
|
+
status: str = "ok"
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_dict(cls, d: dict) -> ActionResult:
|
|
14
|
+
return cls(status=d.get("status", "ok"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CursorPosition:
|
|
19
|
+
x: int
|
|
20
|
+
y: int
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, d: dict) -> CursorPosition:
|
|
24
|
+
return cls(x=d.get("x", 0), y=d.get("y", 0))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DisplayInfo:
|
|
29
|
+
width: int
|
|
30
|
+
height: int
|
|
31
|
+
scale: float = 1.0
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, d: dict) -> DisplayInfo:
|
|
35
|
+
return cls(
|
|
36
|
+
width=d.get("width", 0),
|
|
37
|
+
height=d.get("height", 0),
|
|
38
|
+
scale=d.get("scale", 1.0),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RecordingInfo:
|
|
44
|
+
id: str
|
|
45
|
+
status: str = ""
|
|
46
|
+
name: str | None = None
|
|
47
|
+
filename: str = ""
|
|
48
|
+
file_size: int = 0
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, d: dict) -> RecordingInfo:
|
|
52
|
+
return cls(
|
|
53
|
+
id=d.get("id") or d.get("recording_id", ""),
|
|
54
|
+
status=d.get("status", ""),
|
|
55
|
+
name=d.get("name"),
|
|
56
|
+
filename=d.get("filename", ""),
|
|
57
|
+
file_size=d.get("file_size", 0),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ExecResult:
|
|
63
|
+
return_code: int
|
|
64
|
+
stdout: str
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, d: dict) -> ExecResult:
|
|
68
|
+
return cls(
|
|
69
|
+
return_code=d.get("return_code", 0),
|
|
70
|
+
stdout=d.get("stdout", ""),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ActResult:
|
|
76
|
+
"""Result from a compound act() call."""
|
|
77
|
+
|
|
78
|
+
screenshot: bytes | None = None
|
|
79
|
+
data: dict = field(default_factory=dict)
|
mmini/parsers.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Parse pyautogui and xdotool commands into mmini Sandbox calls.
|
|
2
|
+
|
|
3
|
+
Models like Claude and GPT sometimes output raw pyautogui or xdotool commands.
|
|
4
|
+
These parsers translate them into Sandbox method calls.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from mmini.parsers import parse_pyautogui, parse_xdotool
|
|
8
|
+
|
|
9
|
+
for action in parse_pyautogui("pyautogui.click(100, 200)"):
|
|
10
|
+
action.execute(sandbox)
|
|
11
|
+
|
|
12
|
+
for action in parse_xdotool("xdotool mousemove 100 200 click 1"):
|
|
13
|
+
action.execute(sandbox)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from mmini.sandbox import Sandbox
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Action:
|
|
28
|
+
"""A parsed action that can be executed on a Sandbox."""
|
|
29
|
+
|
|
30
|
+
method: str
|
|
31
|
+
args: list = field(default_factory=list)
|
|
32
|
+
kwargs: dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def execute(self, sandbox: Sandbox) -> dict | bytes | None:
|
|
35
|
+
parts = self.method.split(".")
|
|
36
|
+
obj: Any = sandbox
|
|
37
|
+
for part in parts:
|
|
38
|
+
obj = getattr(obj, part)
|
|
39
|
+
return obj(*self.args, **self.kwargs)
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
arg_str = ", ".join(
|
|
43
|
+
[repr(a) for a in self.args] + [f"{k}={v!r}" for k, v in self.kwargs.items()]
|
|
44
|
+
)
|
|
45
|
+
return f"Action({self.method}({arg_str}))"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# pyautogui parser
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
# Map pyautogui function names to (method, arg_parser)
|
|
53
|
+
_PYAUTOGUI_MAP = {
|
|
54
|
+
"click": "mouse.click",
|
|
55
|
+
"rightClick": "mouse.click",
|
|
56
|
+
"doubleClick": "mouse.click",
|
|
57
|
+
"moveTo": "mouse.move",
|
|
58
|
+
"moveRel": "mouse.move",
|
|
59
|
+
"drag": "mouse.drag",
|
|
60
|
+
"dragTo": "mouse.drag",
|
|
61
|
+
"scroll": "mouse.scroll",
|
|
62
|
+
"write": "keyboard.type",
|
|
63
|
+
"typewrite": "keyboard.type",
|
|
64
|
+
"press": "keyboard.press",
|
|
65
|
+
"hotkey": "keyboard.hotkey",
|
|
66
|
+
"screenshot": "screenshot.take_full_screen",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_PYAUTOGUI_RE = re.compile(r"pyautogui\.(\w+)\(([^)]*)\)")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_py_args(raw: str) -> tuple[list, dict]:
|
|
73
|
+
"""Parse Python-style function arguments."""
|
|
74
|
+
args = []
|
|
75
|
+
kwargs = {}
|
|
76
|
+
if not raw.strip():
|
|
77
|
+
return args, kwargs
|
|
78
|
+
|
|
79
|
+
for part in _split_args(raw):
|
|
80
|
+
part = part.strip()
|
|
81
|
+
if "=" in part and not part.startswith(("'", '"')):
|
|
82
|
+
key, val = part.split("=", 1)
|
|
83
|
+
kwargs[key.strip()] = _coerce(val.strip())
|
|
84
|
+
else:
|
|
85
|
+
args.append(_coerce(part))
|
|
86
|
+
return args, kwargs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _split_args(raw: str) -> list[str]:
|
|
90
|
+
"""Split arguments respecting strings and nested parens."""
|
|
91
|
+
parts = []
|
|
92
|
+
depth = 0
|
|
93
|
+
current = ""
|
|
94
|
+
in_str = None
|
|
95
|
+
for c in raw:
|
|
96
|
+
if c in ("'", '"') and in_str is None:
|
|
97
|
+
in_str = c
|
|
98
|
+
elif c == in_str:
|
|
99
|
+
in_str = None
|
|
100
|
+
elif in_str is None:
|
|
101
|
+
if c == "(":
|
|
102
|
+
depth += 1
|
|
103
|
+
elif c == ")":
|
|
104
|
+
depth -= 1
|
|
105
|
+
elif c == "," and depth == 0:
|
|
106
|
+
parts.append(current)
|
|
107
|
+
current = ""
|
|
108
|
+
continue
|
|
109
|
+
current += c
|
|
110
|
+
if current.strip():
|
|
111
|
+
parts.append(current)
|
|
112
|
+
return parts
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _coerce(val: str):
|
|
116
|
+
"""Coerce a string value to int, float, bool, or str."""
|
|
117
|
+
val = val.strip().strip("'\"")
|
|
118
|
+
if val.lower() == "true":
|
|
119
|
+
return True
|
|
120
|
+
if val.lower() == "false":
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
return int(val)
|
|
124
|
+
except ValueError:
|
|
125
|
+
pass
|
|
126
|
+
try:
|
|
127
|
+
return float(val)
|
|
128
|
+
except ValueError:
|
|
129
|
+
pass
|
|
130
|
+
return val
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pyautogui_to_action(func: str, args: list, kwargs: dict) -> Action | None:
|
|
134
|
+
if func == "click":
|
|
135
|
+
x = args[0] if len(args) > 0 else kwargs.get("x", 0)
|
|
136
|
+
y = args[1] if len(args) > 1 else kwargs.get("y", 0)
|
|
137
|
+
return Action("mouse.click", [x, y])
|
|
138
|
+
|
|
139
|
+
if func == "rightClick":
|
|
140
|
+
x = args[0] if len(args) > 0 else kwargs.get("x", 0)
|
|
141
|
+
y = args[1] if len(args) > 1 else kwargs.get("y", 0)
|
|
142
|
+
return Action("mouse.click", [x, y, "right"])
|
|
143
|
+
|
|
144
|
+
if func == "doubleClick":
|
|
145
|
+
x = args[0] if len(args) > 0 else kwargs.get("x", 0)
|
|
146
|
+
y = args[1] if len(args) > 1 else kwargs.get("y", 0)
|
|
147
|
+
return Action("mouse.click", [x, y], {"double": True})
|
|
148
|
+
|
|
149
|
+
if func in ("moveTo", "moveRel"):
|
|
150
|
+
x = args[0] if len(args) > 0 else kwargs.get("x", 0)
|
|
151
|
+
y = args[1] if len(args) > 1 else kwargs.get("y", 0)
|
|
152
|
+
return Action("mouse.move", [x, y])
|
|
153
|
+
|
|
154
|
+
if func in ("drag", "dragTo"):
|
|
155
|
+
# pyautogui.drag(xOffset, yOffset) is relative
|
|
156
|
+
# We need current position for relative, but just map the args
|
|
157
|
+
x = args[0] if len(args) > 0 else kwargs.get("x", 0)
|
|
158
|
+
y = args[1] if len(args) > 1 else kwargs.get("y", 0)
|
|
159
|
+
return Action("mouse.drag", [0, 0, x, y])
|
|
160
|
+
|
|
161
|
+
if func == "scroll":
|
|
162
|
+
amount = args[0] if len(args) > 0 else kwargs.get("clicks", 3)
|
|
163
|
+
x = args[1] if len(args) > 1 else kwargs.get("x", 0)
|
|
164
|
+
y = args[2] if len(args) > 2 else kwargs.get("y", 0)
|
|
165
|
+
direction = "up" if amount > 0 else "down"
|
|
166
|
+
return Action("mouse.scroll", [x, y, direction, abs(amount)])
|
|
167
|
+
|
|
168
|
+
if func in ("write", "typewrite"):
|
|
169
|
+
text = args[0] if len(args) > 0 else kwargs.get("message", "")
|
|
170
|
+
kw = {}
|
|
171
|
+
interval = kwargs.get("interval")
|
|
172
|
+
if interval:
|
|
173
|
+
kw["delay"] = int(interval * 1000)
|
|
174
|
+
return Action("keyboard.type", [text], kw)
|
|
175
|
+
|
|
176
|
+
if func == "press":
|
|
177
|
+
key = args[0] if len(args) > 0 else kwargs.get("key", "")
|
|
178
|
+
return Action("keyboard.press", [key])
|
|
179
|
+
|
|
180
|
+
if func == "hotkey":
|
|
181
|
+
keys = "+".join(str(a) for a in args)
|
|
182
|
+
return Action("keyboard.hotkey", [keys])
|
|
183
|
+
|
|
184
|
+
if func == "screenshot":
|
|
185
|
+
return Action("screenshot.take_full_screen")
|
|
186
|
+
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_pyautogui(text: str) -> list[Action]:
|
|
191
|
+
"""Parse pyautogui commands from text into Actions."""
|
|
192
|
+
actions = []
|
|
193
|
+
for match in _PYAUTOGUI_RE.finditer(text):
|
|
194
|
+
func = match.group(1)
|
|
195
|
+
raw_args = match.group(2)
|
|
196
|
+
args, kwargs = _parse_py_args(raw_args)
|
|
197
|
+
action = _pyautogui_to_action(func, args, kwargs)
|
|
198
|
+
if action:
|
|
199
|
+
actions.append(action)
|
|
200
|
+
return actions
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# xdotool parser
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
_XDOTOOL_KEY_MAP = {
|
|
208
|
+
"Return": "enter",
|
|
209
|
+
"Escape": "escape",
|
|
210
|
+
"BackSpace": "backspace",
|
|
211
|
+
"Tab": "tab",
|
|
212
|
+
"space": "space",
|
|
213
|
+
"Delete": "delete",
|
|
214
|
+
"Home": "home",
|
|
215
|
+
"End": "end",
|
|
216
|
+
"Page_Up": "pageup",
|
|
217
|
+
"Page_Down": "pagedown",
|
|
218
|
+
"Up": "up",
|
|
219
|
+
"Down": "down",
|
|
220
|
+
"Left": "left",
|
|
221
|
+
"Right": "right",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_XDOTOOL_BUTTON_MAP = {
|
|
225
|
+
"1": "left",
|
|
226
|
+
"2": "middle",
|
|
227
|
+
"3": "right",
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_xdotool(text: str) -> list[Action]:
|
|
232
|
+
"""Parse xdotool commands from text into Actions.
|
|
233
|
+
|
|
234
|
+
Handles:
|
|
235
|
+
xdotool mousemove X Y
|
|
236
|
+
xdotool click BUTTON
|
|
237
|
+
xdotool key KEY [KEY...]
|
|
238
|
+
xdotool type TEXT
|
|
239
|
+
xdotool mousemove X Y click BUTTON (chained)
|
|
240
|
+
"""
|
|
241
|
+
actions = []
|
|
242
|
+
|
|
243
|
+
for line in text.strip().splitlines():
|
|
244
|
+
line = line.strip()
|
|
245
|
+
if not line.startswith("xdotool"):
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
tokens = line.split()[1:] # drop "xdotool"
|
|
249
|
+
actions.extend(_parse_xdotool_tokens(tokens))
|
|
250
|
+
|
|
251
|
+
return actions
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _parse_xdotool_tokens(tokens: list[str]) -> list[Action]:
|
|
255
|
+
"""Parse xdotool token stream (supports chained commands)."""
|
|
256
|
+
actions = []
|
|
257
|
+
i = 0
|
|
258
|
+
|
|
259
|
+
while i < len(tokens):
|
|
260
|
+
cmd = tokens[i]
|
|
261
|
+
i += 1
|
|
262
|
+
|
|
263
|
+
if cmd == "mousemove" and i + 1 < len(tokens):
|
|
264
|
+
x, y = int(tokens[i]), int(tokens[i + 1])
|
|
265
|
+
i += 2
|
|
266
|
+
actions.append(Action("mouse.move", [x, y]))
|
|
267
|
+
|
|
268
|
+
elif cmd == "click" and i < len(tokens):
|
|
269
|
+
button = _XDOTOOL_BUTTON_MAP.get(tokens[i], "left")
|
|
270
|
+
i += 1
|
|
271
|
+
actions.append(Action("mouse.click", [0, 0, button]))
|
|
272
|
+
|
|
273
|
+
elif cmd == "mousedown" and i < len(tokens):
|
|
274
|
+
i += 1 # skip button, no direct mapping
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
elif cmd == "mouseup" and i < len(tokens):
|
|
278
|
+
i += 1
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
elif cmd == "key" and i < len(tokens):
|
|
282
|
+
key_combo = tokens[i]
|
|
283
|
+
i += 1
|
|
284
|
+
# xdotool uses "super+a", "ctrl+c", etc.
|
|
285
|
+
if "+" in key_combo:
|
|
286
|
+
combo = key_combo.replace("super", "command").replace("ctrl", "control")
|
|
287
|
+
actions.append(Action("keyboard.hotkey", [combo]))
|
|
288
|
+
else:
|
|
289
|
+
mapped = _XDOTOOL_KEY_MAP.get(key_combo, key_combo.lower())
|
|
290
|
+
actions.append(Action("keyboard.press", [mapped]))
|
|
291
|
+
|
|
292
|
+
elif cmd == "type" and i < len(tokens):
|
|
293
|
+
# Collect remaining tokens as the text
|
|
294
|
+
text = " ".join(tokens[i:])
|
|
295
|
+
# Strip surrounding quotes if present
|
|
296
|
+
if len(text) >= 2 and text[0] in ("'", '"') and text[-1] == text[0]:
|
|
297
|
+
text = text[1:-1]
|
|
298
|
+
actions.append(Action("keyboard.type", [text]))
|
|
299
|
+
break # type consumes everything
|
|
300
|
+
|
|
301
|
+
elif cmd == "--delay" and i < len(tokens):
|
|
302
|
+
i += 1 # skip delay value, not mapped
|
|
303
|
+
elif cmd == "--clearmodifiers":
|
|
304
|
+
pass # skip
|
|
305
|
+
elif cmd.startswith("--"):
|
|
306
|
+
i += 1 # skip flag + value
|
|
307
|
+
|
|
308
|
+
return actions
|
mmini/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|