use-computer-cli 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.
- use_computer/__init__.py +93 -0
- use_computer/actions.py +184 -0
- use_computer/backends/__init__.py +31 -0
- use_computer/backends/base.py +66 -0
- use_computer/backends/local.py +214 -0
- use_computer/backends/vnc.py +186 -0
- use_computer/cli.py +676 -0
- use_computer/compare.py +121 -0
- use_computer/config.py +532 -0
- use_computer/coordinates.py +104 -0
- use_computer/errors.py +53 -0
- use_computer/keys.py +209 -0
- use_computer/runner.py +304 -0
- use_computer/skill/SKILL.md +118 -0
- use_computer/skill/__init__.py +185 -0
- use_computer_cli-0.1.0.dist-info/METADATA +150 -0
- use_computer_cli-0.1.0.dist-info/RECORD +20 -0
- use_computer_cli-0.1.0.dist-info/WHEEL +4 -0
- use_computer_cli-0.1.0.dist-info/entry_points.txt +2 -0
- use_computer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
use_computer/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""use-computer: execute input on a screen for computer-use agents.
|
|
2
|
+
|
|
3
|
+
The acting half of a pair. ui-locator answers where; this performs the action there.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from use_computer.actions import (
|
|
11
|
+
Action,
|
|
12
|
+
ClickAction,
|
|
13
|
+
DoubleClickAction,
|
|
14
|
+
DragAction,
|
|
15
|
+
KeyAction,
|
|
16
|
+
MouseButton,
|
|
17
|
+
MoveAction,
|
|
18
|
+
RightClickAction,
|
|
19
|
+
ScreenshotAction,
|
|
20
|
+
ScrollAction,
|
|
21
|
+
ScrollDirection,
|
|
22
|
+
TypeAction,
|
|
23
|
+
)
|
|
24
|
+
from use_computer.backends import Backend, create_backend
|
|
25
|
+
from use_computer.compare import ChangeReport, Screenshot, compare
|
|
26
|
+
from use_computer.config import BackendProfile, ResolvedConfig, Settings
|
|
27
|
+
from use_computer.config import load as load_config
|
|
28
|
+
from use_computer.coordinates import Coordinate, CoordinateSpace, ScreenInfo, convert
|
|
29
|
+
from use_computer.errors import (
|
|
30
|
+
ActionFailedError,
|
|
31
|
+
BackendNotAvailableError,
|
|
32
|
+
ConfigError,
|
|
33
|
+
CoordinateSpaceError,
|
|
34
|
+
KeySyntaxError,
|
|
35
|
+
PermissionDeniedError,
|
|
36
|
+
UseComputerError,
|
|
37
|
+
)
|
|
38
|
+
from use_computer.keys import KeyCombo, parse_combo
|
|
39
|
+
from use_computer.runner import ActionResult, ErrorInfo, RunResult, Session, run_actions
|
|
40
|
+
|
|
41
|
+
#: The distribution name on PyPI, which differs from the import package: `use-computer` was
|
|
42
|
+
#: already taken there. importlib.metadata is keyed by the distribution, so this is the name
|
|
43
|
+
#: that must appear here.
|
|
44
|
+
DISTRIBUTION = "use-computer-cli"
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
__version__ = version(DISTRIBUTION)
|
|
48
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without an install
|
|
49
|
+
__version__ = "0.0.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"DISTRIBUTION",
|
|
53
|
+
"Action",
|
|
54
|
+
"ActionFailedError",
|
|
55
|
+
"ActionResult",
|
|
56
|
+
"Backend",
|
|
57
|
+
"BackendNotAvailableError",
|
|
58
|
+
"BackendProfile",
|
|
59
|
+
"ChangeReport",
|
|
60
|
+
"ClickAction",
|
|
61
|
+
"ConfigError",
|
|
62
|
+
"Coordinate",
|
|
63
|
+
"CoordinateSpace",
|
|
64
|
+
"CoordinateSpaceError",
|
|
65
|
+
"DoubleClickAction",
|
|
66
|
+
"DragAction",
|
|
67
|
+
"ErrorInfo",
|
|
68
|
+
"KeyAction",
|
|
69
|
+
"KeyCombo",
|
|
70
|
+
"KeySyntaxError",
|
|
71
|
+
"MouseButton",
|
|
72
|
+
"MoveAction",
|
|
73
|
+
"PermissionDeniedError",
|
|
74
|
+
"ResolvedConfig",
|
|
75
|
+
"RightClickAction",
|
|
76
|
+
"RunResult",
|
|
77
|
+
"ScreenInfo",
|
|
78
|
+
"Screenshot",
|
|
79
|
+
"ScreenshotAction",
|
|
80
|
+
"ScrollAction",
|
|
81
|
+
"ScrollDirection",
|
|
82
|
+
"Session",
|
|
83
|
+
"Settings",
|
|
84
|
+
"TypeAction",
|
|
85
|
+
"UseComputerError",
|
|
86
|
+
"__version__",
|
|
87
|
+
"compare",
|
|
88
|
+
"convert",
|
|
89
|
+
"create_backend",
|
|
90
|
+
"load_config",
|
|
91
|
+
"parse_combo",
|
|
92
|
+
"run_actions",
|
|
93
|
+
]
|
use_computer/actions.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The action models, and the resolution that happens before a backend sees them.
|
|
2
|
+
|
|
3
|
+
The action set is the whole surface of what use-computer does to a screen. Coordinate scaling
|
|
4
|
+
and key parsing are applied here, so that everything crossing the backend boundary is already
|
|
5
|
+
in actuation units and canonical key names.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, TypeAdapter, field_validator
|
|
15
|
+
|
|
16
|
+
from use_computer.coordinates import Coordinate, CoordinateSpace, ScreenInfo, convert
|
|
17
|
+
from use_computer.keys import KeyCombo, canonical, parse_combo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MouseButton(str, Enum):
|
|
21
|
+
LEFT = "left"
|
|
22
|
+
RIGHT = "right"
|
|
23
|
+
MIDDLE = "middle"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ScrollDirection(str, Enum):
|
|
27
|
+
UP = "up"
|
|
28
|
+
DOWN = "down"
|
|
29
|
+
LEFT = "left"
|
|
30
|
+
RIGHT = "right"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseAction(BaseModel):
|
|
34
|
+
"""Fields every action shares."""
|
|
35
|
+
|
|
36
|
+
delay: float | None = Field(default=None, ge=0, description="Seconds to wait afterwards.")
|
|
37
|
+
verify: bool = Field(default=False, description="Compare the screen before and after.")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def target(self) -> Coordinate | None:
|
|
41
|
+
"""The point this action acts on, or None if it has no coordinate."""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def origin(self) -> Coordinate | None:
|
|
46
|
+
"""The point this action starts from -- only a drag has one."""
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Positioned(BaseAction):
|
|
51
|
+
"""An action that may carry a coordinate. Omitting it acts where the pointer already is."""
|
|
52
|
+
|
|
53
|
+
x: int | None = None
|
|
54
|
+
y: int | None = None
|
|
55
|
+
space: CoordinateSpace | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def target(self) -> Coordinate | None:
|
|
59
|
+
if self.x is None or self.y is None:
|
|
60
|
+
return None
|
|
61
|
+
space = self.space if self.space is not None else CoordinateSpace.SCREENSHOT
|
|
62
|
+
return Coordinate(x=self.x, y=self.y, space=space)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MoveAction(_Positioned):
|
|
66
|
+
action: Literal["move"] = "move"
|
|
67
|
+
x: int
|
|
68
|
+
y: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ClickAction(_Positioned):
|
|
72
|
+
action: Literal["click"] = "click"
|
|
73
|
+
button: MouseButton = MouseButton.LEFT
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DoubleClickAction(_Positioned):
|
|
77
|
+
action: Literal["double_click"] = "double_click"
|
|
78
|
+
button: MouseButton = MouseButton.LEFT
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RightClickAction(_Positioned):
|
|
82
|
+
action: Literal["right_click"] = "right_click"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DragAction(BaseAction):
|
|
86
|
+
action: Literal["drag"] = "drag"
|
|
87
|
+
from_x: int
|
|
88
|
+
from_y: int
|
|
89
|
+
to_x: int
|
|
90
|
+
to_y: int
|
|
91
|
+
space: CoordinateSpace | None = None
|
|
92
|
+
button: MouseButton = MouseButton.LEFT
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def origin(self) -> Coordinate:
|
|
96
|
+
return Coordinate(x=self.from_x, y=self.from_y, space=self._space)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def target(self) -> Coordinate:
|
|
100
|
+
return Coordinate(x=self.to_x, y=self.to_y, space=self._space)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def _space(self) -> CoordinateSpace:
|
|
104
|
+
return self.space if self.space is not None else CoordinateSpace.SCREENSHOT
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ScrollAction(_Positioned):
|
|
108
|
+
action: Literal["scroll"] = "scroll"
|
|
109
|
+
amount: int
|
|
110
|
+
direction: ScrollDirection = ScrollDirection.DOWN
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TypeAction(BaseAction):
|
|
114
|
+
action: Literal["type"] = "type"
|
|
115
|
+
text: str
|
|
116
|
+
rate: float | None = Field(
|
|
117
|
+
default=None,
|
|
118
|
+
ge=0,
|
|
119
|
+
description="Seconds between keystrokes; None uses the configured rate.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class KeyAction(BaseAction):
|
|
124
|
+
action: Literal["key"] = "key"
|
|
125
|
+
combo: str
|
|
126
|
+
|
|
127
|
+
@field_validator("combo")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _canonicalise(cls, value: str) -> str:
|
|
130
|
+
# Parse at construction: an unknown key name must fail before a connection is opened,
|
|
131
|
+
# and the canonical spelling is what appears in results and logs.
|
|
132
|
+
return canonical(value)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def key_combo(self) -> KeyCombo:
|
|
136
|
+
return parse_combo(self.combo)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ScreenshotAction(BaseAction):
|
|
140
|
+
action: Literal["screenshot"] = "screenshot"
|
|
141
|
+
out: Path | None = None
|
|
142
|
+
base64: bool = False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
Action = Annotated[
|
|
146
|
+
MoveAction
|
|
147
|
+
| ClickAction
|
|
148
|
+
| DoubleClickAction
|
|
149
|
+
| RightClickAction
|
|
150
|
+
| DragAction
|
|
151
|
+
| ScrollAction
|
|
152
|
+
| TypeAction
|
|
153
|
+
| KeyAction
|
|
154
|
+
| ScreenshotAction,
|
|
155
|
+
Field(discriminator="action"),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
#: Parses a batch file: a JSON array of action objects, discriminated on `action`.
|
|
159
|
+
ActionListAdapter: TypeAdapter[list[Action]] = TypeAdapter(list[Action])
|
|
160
|
+
|
|
161
|
+
#: Parses a single action object.
|
|
162
|
+
ActionAdapter: TypeAdapter[Action] = TypeAdapter(Action)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def with_default_space(action: Action, space: CoordinateSpace) -> Action:
|
|
166
|
+
"""Fill in the coordinate space an action did not state, from configuration."""
|
|
167
|
+
if getattr(action, "space", "missing") is None:
|
|
168
|
+
return action.model_copy(update={"space": space})
|
|
169
|
+
return action
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def resolve(action: Action, screen: ScreenInfo) -> tuple[Coordinate | None, Coordinate | None]:
|
|
173
|
+
"""Convert an action's coordinates into actuation units.
|
|
174
|
+
|
|
175
|
+
Returns ``(target, origin)``; either may be ``None`` when the action carries no coordinate.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
CoordinateSpaceError: when the scale needed for the conversion is unknown.
|
|
179
|
+
"""
|
|
180
|
+
target = action.target
|
|
181
|
+
origin = action.origin
|
|
182
|
+
resolved_target = convert(target, CoordinateSpace.ACTUATION, screen) if target else None
|
|
183
|
+
resolved_origin = convert(origin, CoordinateSpace.ACTUATION, screen) if origin else None
|
|
184
|
+
return resolved_target, resolved_origin
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Backend selection.
|
|
2
|
+
|
|
3
|
+
Adding a backend is a module here plus a name in the config file -- never a change to the
|
|
4
|
+
action layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from use_computer.backends.base import Backend, BackendNotAvailableError, require
|
|
10
|
+
from use_computer.config import BackendProfile
|
|
11
|
+
from use_computer.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
__all__ = ["Backend", "BackendNotAvailableError", "create_backend", "require"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_backend(profile: BackendProfile) -> Backend:
|
|
17
|
+
"""Construct the backend a profile names. The module is imported only when selected."""
|
|
18
|
+
if profile.backend == "local":
|
|
19
|
+
from use_computer.backends.local import LocalBackend
|
|
20
|
+
|
|
21
|
+
return LocalBackend(allow_local=profile.allow_local, scale=profile.scale)
|
|
22
|
+
if profile.backend == "vnc":
|
|
23
|
+
from use_computer.backends.vnc import VNCBackend
|
|
24
|
+
|
|
25
|
+
return VNCBackend(
|
|
26
|
+
host=profile.host,
|
|
27
|
+
port=profile.port,
|
|
28
|
+
password=profile.password,
|
|
29
|
+
scale=profile.scale,
|
|
30
|
+
)
|
|
31
|
+
raise ConfigError(f"unknown backend {profile.backend!r} in profile {profile.name!r}")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The backend Protocol, and the lazy-import helper every backend constructs itself through.
|
|
2
|
+
|
|
3
|
+
Two interchangeable backends sit behind this interface. Coordinates crossing it are always in
|
|
4
|
+
*actuation* units -- conversion happens above. A backend's third-party dependency is imported
|
|
5
|
+
inside its constructor, never at module import, so the package installs and `--help` works with
|
|
6
|
+
no extras present.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from use_computer.actions import MouseButton, ScrollDirection
|
|
16
|
+
from use_computer.compare import Screenshot
|
|
17
|
+
from use_computer.coordinates import ScreenInfo
|
|
18
|
+
from use_computer.errors import BackendNotAvailableError
|
|
19
|
+
from use_computer.keys import KeyCombo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Backend(Protocol):
|
|
24
|
+
"""What every backend implements. The fake backend used in tests satisfies it too."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
def screen_info(self) -> ScreenInfo:
|
|
29
|
+
"""Report this backend's own coordinate spaces and screen size."""
|
|
30
|
+
|
|
31
|
+
def screenshot(self) -> Screenshot:
|
|
32
|
+
"""Capture the current screen."""
|
|
33
|
+
|
|
34
|
+
def move(self, x: int, y: int) -> None: ...
|
|
35
|
+
|
|
36
|
+
def click(self, x: int | None, y: int | None, button: MouseButton, count: int) -> None: ...
|
|
37
|
+
|
|
38
|
+
def drag(
|
|
39
|
+
self, from_x: int, from_y: int, to_x: int, to_y: int, button: MouseButton
|
|
40
|
+
) -> None: ...
|
|
41
|
+
|
|
42
|
+
def scroll(
|
|
43
|
+
self, amount: int, direction: ScrollDirection, x: int | None, y: int | None
|
|
44
|
+
) -> None: ...
|
|
45
|
+
|
|
46
|
+
def type_text(self, text: str, rate: float) -> None: ...
|
|
47
|
+
|
|
48
|
+
def key(self, combo: KeyCombo) -> None: ...
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""Release whatever the backend holds. Called even when an action failed."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require(module: str, *, backend: str, extra: str) -> ModuleType:
|
|
55
|
+
"""Import an optional dependency, or say exactly what to install.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
BackendNotAvailableError: naming the extra, not the import error.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return import_module(module)
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
raise BackendNotAvailableError(backend=backend, extra=extra, missing=module) from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["Backend", "BackendNotAvailableError", "require"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The local backend: drives the display of the machine use-computer runs on.
|
|
2
|
+
|
|
3
|
+
Input via pynput, capture via mss. pyautogui is deliberately not used -- its last release,
|
|
4
|
+
0.9.54, dates from 2023.
|
|
5
|
+
|
|
6
|
+
This backend types on the user's own keyboard and moves their own pointer, so it refuses to act
|
|
7
|
+
without an explicit opt-in, and it checks the macOS permissions that would otherwise make every
|
|
8
|
+
action silently do nothing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from use_computer.actions import MouseButton, ScrollDirection
|
|
19
|
+
from use_computer.backends.base import require
|
|
20
|
+
from use_computer.compare import Screenshot
|
|
21
|
+
from use_computer.coordinates import CoordinateSpace, ScreenInfo, derive_scale
|
|
22
|
+
from use_computer.errors import ActionFailedError, ConfigError, PermissionDeniedError
|
|
23
|
+
from use_computer.keys import PYNPUT_KEYS, KeyCombo
|
|
24
|
+
|
|
25
|
+
#: Seconds held between press and release, and between the two clicks of a double-click.
|
|
26
|
+
PRESS_HOLD = 0.02
|
|
27
|
+
DOUBLE_CLICK_GAP = 0.08
|
|
28
|
+
#: Steps a drag is interpolated over, so the application sees movement rather than a teleport.
|
|
29
|
+
DRAG_STEPS = 24
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LocalBackend:
|
|
33
|
+
"""Drives this machine's display."""
|
|
34
|
+
|
|
35
|
+
name = "local"
|
|
36
|
+
|
|
37
|
+
def __init__(self, *, allow_local: bool = False, scale: float | None = None) -> None:
|
|
38
|
+
if not allow_local:
|
|
39
|
+
raise ConfigError(
|
|
40
|
+
"the local backend controls this machine's own keyboard and pointer and is "
|
|
41
|
+
"disabled by default. Enable it explicitly with `allow-local = true` in the "
|
|
42
|
+
"profile, or USE_COMPUTER_ALLOW_LOCAL=1."
|
|
43
|
+
)
|
|
44
|
+
self._explicit_scale = scale
|
|
45
|
+
pynput_mouse = require("pynput.mouse", backend=self.name, extra="local")
|
|
46
|
+
pynput_keyboard = require("pynput.keyboard", backend=self.name, extra="local")
|
|
47
|
+
mss = require("mss", backend=self.name, extra="local")
|
|
48
|
+
|
|
49
|
+
_check_macos_permissions()
|
|
50
|
+
|
|
51
|
+
self._mouse_module = pynput_mouse
|
|
52
|
+
self._key_module = pynput_keyboard
|
|
53
|
+
self._mouse = pynput_mouse.Controller()
|
|
54
|
+
self._keyboard = pynput_keyboard.Controller()
|
|
55
|
+
self._sct = mss.mss()
|
|
56
|
+
self._screen: ScreenInfo | None = None
|
|
57
|
+
|
|
58
|
+
# --- reporting ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def screen_info(self) -> ScreenInfo:
|
|
61
|
+
if self._screen is not None:
|
|
62
|
+
return self._screen
|
|
63
|
+
monitor = self._sct.monitors[1]
|
|
64
|
+
width, height = int(monitor["width"]), int(monitor["height"])
|
|
65
|
+
shot = self._sct.grab(monitor)
|
|
66
|
+
# On a HiDPI display mss reports the monitor in points and grabs in pixels. That
|
|
67
|
+
# difference is the whole reason coordinates carry their space.
|
|
68
|
+
scale = self._explicit_scale
|
|
69
|
+
if scale is None:
|
|
70
|
+
scale = derive_scale(width, height, shot.width, shot.height)
|
|
71
|
+
self._screen = ScreenInfo(
|
|
72
|
+
width=width,
|
|
73
|
+
height=height,
|
|
74
|
+
screenshot_width=shot.width,
|
|
75
|
+
screenshot_height=shot.height,
|
|
76
|
+
scale=scale,
|
|
77
|
+
)
|
|
78
|
+
return self._screen
|
|
79
|
+
|
|
80
|
+
def screenshot(self) -> Screenshot:
|
|
81
|
+
monitor = self._sct.monitors[1]
|
|
82
|
+
shot = self._sct.grab(monitor)
|
|
83
|
+
image = _to_png(shot)
|
|
84
|
+
return Screenshot(
|
|
85
|
+
data=image,
|
|
86
|
+
width=shot.width,
|
|
87
|
+
height=shot.height,
|
|
88
|
+
space=CoordinateSpace.SCREENSHOT,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# --- acting ------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def move(self, x: int, y: int) -> None:
|
|
94
|
+
self._mouse.position = (x, y)
|
|
95
|
+
|
|
96
|
+
def click(self, x: int | None, y: int | None, button: MouseButton, count: int) -> None:
|
|
97
|
+
if x is not None and y is not None:
|
|
98
|
+
self.move(x, y)
|
|
99
|
+
pynput_button = self._button(button)
|
|
100
|
+
for index in range(count):
|
|
101
|
+
if index:
|
|
102
|
+
time.sleep(DOUBLE_CLICK_GAP)
|
|
103
|
+
self._mouse.press(pynput_button)
|
|
104
|
+
time.sleep(PRESS_HOLD)
|
|
105
|
+
self._mouse.release(pynput_button)
|
|
106
|
+
|
|
107
|
+
def drag(self, from_x: int, from_y: int, to_x: int, to_y: int, button: MouseButton) -> None:
|
|
108
|
+
pynput_button = self._button(button)
|
|
109
|
+
self.move(from_x, from_y)
|
|
110
|
+
self._mouse.press(pynput_button)
|
|
111
|
+
try:
|
|
112
|
+
for step in range(1, DRAG_STEPS + 1):
|
|
113
|
+
ratio = step / DRAG_STEPS
|
|
114
|
+
self.move(
|
|
115
|
+
round(from_x + (to_x - from_x) * ratio),
|
|
116
|
+
round(from_y + (to_y - from_y) * ratio),
|
|
117
|
+
)
|
|
118
|
+
time.sleep(PRESS_HOLD / 2)
|
|
119
|
+
finally:
|
|
120
|
+
self._mouse.release(pynput_button)
|
|
121
|
+
|
|
122
|
+
def scroll(
|
|
123
|
+
self, amount: int, direction: ScrollDirection, x: int | None, y: int | None
|
|
124
|
+
) -> None:
|
|
125
|
+
if x is not None and y is not None:
|
|
126
|
+
self.move(x, y)
|
|
127
|
+
dx, dy = _scroll_vector(amount, direction)
|
|
128
|
+
self._mouse.scroll(dx, dy)
|
|
129
|
+
|
|
130
|
+
def type_text(self, text: str, rate: float) -> None:
|
|
131
|
+
# Typing as fast as the API allows loses characters in real applications.
|
|
132
|
+
for char in text:
|
|
133
|
+
self._keyboard.type(char)
|
|
134
|
+
if rate:
|
|
135
|
+
time.sleep(rate)
|
|
136
|
+
|
|
137
|
+
def key(self, combo: KeyCombo) -> None:
|
|
138
|
+
modifiers = [self._key(name) for name in combo.modifiers]
|
|
139
|
+
key = self._key(combo.key)
|
|
140
|
+
for modifier in modifiers:
|
|
141
|
+
self._keyboard.press(modifier)
|
|
142
|
+
try:
|
|
143
|
+
self._keyboard.press(key)
|
|
144
|
+
time.sleep(PRESS_HOLD)
|
|
145
|
+
self._keyboard.release(key)
|
|
146
|
+
finally:
|
|
147
|
+
for modifier in reversed(modifiers):
|
|
148
|
+
self._keyboard.release(modifier)
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
self._sct.close()
|
|
152
|
+
|
|
153
|
+
# --- mapping -----------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def _button(self, button: MouseButton) -> Any:
|
|
156
|
+
return getattr(self._mouse_module.Button, button.value)
|
|
157
|
+
|
|
158
|
+
def _key(self, name: str) -> Any:
|
|
159
|
+
"""Canonical key name -> pynput key. Single characters are themselves."""
|
|
160
|
+
attribute = PYNPUT_KEYS.get(name)
|
|
161
|
+
if attribute is None:
|
|
162
|
+
if len(name) == 1:
|
|
163
|
+
return name
|
|
164
|
+
raise ActionFailedError(f"the local backend has no mapping for key {name!r}")
|
|
165
|
+
return getattr(self._key_module.Key, attribute)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _scroll_vector(amount: int, direction: ScrollDirection) -> tuple[int, int]:
|
|
169
|
+
if direction is ScrollDirection.UP:
|
|
170
|
+
return 0, amount
|
|
171
|
+
if direction is ScrollDirection.DOWN:
|
|
172
|
+
return 0, -amount
|
|
173
|
+
if direction is ScrollDirection.LEFT:
|
|
174
|
+
return -amount, 0
|
|
175
|
+
return amount, 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _to_png(shot: Any) -> bytes:
|
|
179
|
+
from PIL import Image
|
|
180
|
+
|
|
181
|
+
image = Image.frombytes("RGB", (shot.width, shot.height), shot.rgb)
|
|
182
|
+
buffer = io.BytesIO()
|
|
183
|
+
image.save(buffer, format="PNG")
|
|
184
|
+
return buffer.getvalue()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_macos_permissions() -> None:
|
|
188
|
+
"""Fail loudly when macOS has denied what pynput and mss need.
|
|
189
|
+
|
|
190
|
+
Without the permission the libraries typically do nothing at all -- a click that never
|
|
191
|
+
happens and never errors, which is the worst possible failure for a calling agent.
|
|
192
|
+
"""
|
|
193
|
+
if sys.platform != "darwin": # pragma: no cover - platform specific
|
|
194
|
+
return
|
|
195
|
+
try: # pragma: no cover - requires macOS
|
|
196
|
+
from ApplicationServices import AXIsProcessTrusted
|
|
197
|
+
except ImportError: # pragma: no cover - pyobjc absent; let the action fail instead
|
|
198
|
+
return
|
|
199
|
+
if not AXIsProcessTrusted(): # pragma: no cover - requires macOS
|
|
200
|
+
raise PermissionDeniedError(
|
|
201
|
+
"Accessibility",
|
|
202
|
+
"Grant it in System Settings > Privacy & Security > Accessibility for the "
|
|
203
|
+
"terminal or application running use-computer, then start it again.",
|
|
204
|
+
)
|
|
205
|
+
try: # pragma: no cover - requires macOS
|
|
206
|
+
from Quartz import CGPreflightScreenCaptureAccess
|
|
207
|
+
except ImportError: # pragma: no cover
|
|
208
|
+
return
|
|
209
|
+
if not CGPreflightScreenCaptureAccess(): # pragma: no cover - requires macOS
|
|
210
|
+
raise PermissionDeniedError(
|
|
211
|
+
"Screen Recording",
|
|
212
|
+
"Grant it in System Settings > Privacy & Security > Screen Recording, then start "
|
|
213
|
+
"use-computer again.",
|
|
214
|
+
)
|