noracu 0.1.0 → 0.1.1
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.
- package/CHANGELOG.md +36 -2
- package/README.md +13 -9
- package/README.zh-CN.md +13 -9
- package/docs/architecture.md +6 -5
- package/docs/tools.md +15 -15
- package/docs/usage.md +35 -5
- package/imctl/LICENSE +21 -0
- package/imctl/README.md +140 -0
- package/imctl/pyproject.toml +37 -0
- package/imctl/src/imctl/__init__.py +31 -0
- package/imctl/src/imctl/cli.py +236 -0
- package/imctl/src/imctl/gestures.py +151 -0
- package/imctl/src/imctl/input.py +246 -0
- package/imctl/src/imctl/verify.py +77 -0
- package/imctl/src/imctl/vision.py +138 -0
- package/imctl/src/imctl/window.py +248 -0
- package/package.json +9 -3
- package/src/server.mjs +54 -32
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""imctl CLI — control iPhone Mirroring from the command line.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
imctl status # session + window info
|
|
5
|
+
imctl tap 0.5 0.5 # tap center
|
|
6
|
+
imctl longpress 0.5 0.5 1.0 # long-press center for 1s
|
|
7
|
+
imctl swipe 0.5 0.8 0.5 0.2 # swipe up
|
|
8
|
+
imctl home # home gesture
|
|
9
|
+
imctl app-switcher # app switcher
|
|
10
|
+
imctl scroll down 0.5 # scroll down half a screen
|
|
11
|
+
imctl page left # next home page
|
|
12
|
+
imctl type "hello world" # type text (clipboard-based)
|
|
13
|
+
imctl key return # press a key
|
|
14
|
+
imctl ocr # OCR the screen
|
|
15
|
+
imctl find "Settings" # find text on screen
|
|
16
|
+
imctl open "Photos" # open app via Spotlight
|
|
17
|
+
imctl verify home # run a gesture with verification
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from . import gestures, input, verify, vision
|
|
27
|
+
from .window import WindowError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cmd_status(_args) -> int:
|
|
31
|
+
print(json.dumps(gestures.status(), indent=2))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cmd_tap(args) -> int:
|
|
36
|
+
input.tap(args.x, args.y)
|
|
37
|
+
print(f"tapped ({args.x}, {args.y})")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cmd_longpress(args) -> int:
|
|
42
|
+
input.long_press(args.x, args.y, args.duration)
|
|
43
|
+
print(f"long-pressed ({args.x}, {args.y}) for {args.duration}s")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cmd_swipe(args) -> int:
|
|
48
|
+
input.swipe(args.fx, args.fy, args.tx, args.ty, args.ms)
|
|
49
|
+
print(f"swiped ({args.fx},{args.fy}) -> ({args.tx},{args.ty})")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_home(_args) -> int:
|
|
54
|
+
gestures.home()
|
|
55
|
+
print("home gesture sent")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _cmd_app_switcher(_args) -> int:
|
|
60
|
+
gestures.app_switcher()
|
|
61
|
+
print("app switcher opened")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cmd_scroll(args) -> int:
|
|
66
|
+
if args.direction == "down":
|
|
67
|
+
gestures.scroll_down(args.distance)
|
|
68
|
+
else:
|
|
69
|
+
gestures.scroll_up(args.distance)
|
|
70
|
+
print(f"scrolled {args.direction} {args.distance}")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_page(args) -> int:
|
|
75
|
+
if args.direction == "left":
|
|
76
|
+
gestures.page_left()
|
|
77
|
+
else:
|
|
78
|
+
gestures.page_right()
|
|
79
|
+
print(f"paged {args.direction}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _cmd_type(args) -> int:
|
|
84
|
+
input.type_text(args.text)
|
|
85
|
+
print(f"typed {args.text!r}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cmd_key(args) -> int:
|
|
90
|
+
input.press_key(args.key)
|
|
91
|
+
print(f"pressed {args.key}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cmd_ocr(_args) -> int:
|
|
96
|
+
hits = gestures.read_text()
|
|
97
|
+
for h in hits:
|
|
98
|
+
print(h)
|
|
99
|
+
print(f"-- {len(hits)} hits")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cmd_find(args) -> int:
|
|
104
|
+
hit = vision.find(args.text, exact=args.exact)
|
|
105
|
+
if hit is None:
|
|
106
|
+
print(f"not found: {args.text!r}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
print(hit)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_open(args) -> int:
|
|
113
|
+
ok = gestures.open_app(args.app)
|
|
114
|
+
print(f"opened {args.app!r}" if ok else f"app not found: {args.app!r}")
|
|
115
|
+
return 0 if ok else 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_verify(args) -> int:
|
|
119
|
+
actions = {
|
|
120
|
+
"home": gestures.home,
|
|
121
|
+
"app-switcher": gestures.app_switcher,
|
|
122
|
+
"page-left": gestures.page_left,
|
|
123
|
+
"page-right": gestures.page_right,
|
|
124
|
+
}
|
|
125
|
+
if args.action not in actions:
|
|
126
|
+
print(f"unknown action: {args.action}", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
ok = verify.verify_action(actions[args.action])
|
|
129
|
+
print("screen changed: yes" if ok else "screen changed: NO (action may have been dropped)")
|
|
130
|
+
return 0 if ok else 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cmd_launch(_args) -> int:
|
|
134
|
+
gestures.launch()
|
|
135
|
+
print("iPhone Mirroring ready")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cmd_screenshot(args) -> int:
|
|
140
|
+
path = vision.capture_window(args.path)
|
|
141
|
+
print(path)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_resume(_args) -> int:
|
|
146
|
+
ok = gestures.resume()
|
|
147
|
+
print("resumed" if ok else "still paused (unlock the iPhone, then retry)")
|
|
148
|
+
return 0 if ok else 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
152
|
+
p = argparse.ArgumentParser(
|
|
153
|
+
prog="imctl",
|
|
154
|
+
description="Control iPhone Mirroring on macOS from the CLI",
|
|
155
|
+
)
|
|
156
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
157
|
+
|
|
158
|
+
sub.add_parser("status", help="show session status").set_defaults(func=_cmd_status)
|
|
159
|
+
sp = sub.add_parser("screenshot", help="capture the mirroring window to PNG (by window id, works when covered)")
|
|
160
|
+
sp.add_argument("path", nargs="?", default=None)
|
|
161
|
+
sp.set_defaults(func=_cmd_screenshot)
|
|
162
|
+
sub.add_parser("resume", help="click Resume when the session shows Connection Paused").set_defaults(func=_cmd_resume)
|
|
163
|
+
sub.add_parser("launch", help="ensure mirroring is running and frontmost").set_defaults(func=_cmd_launch)
|
|
164
|
+
|
|
165
|
+
sp = sub.add_parser("tap", help="tap at normalized coords")
|
|
166
|
+
sp.add_argument("x", type=float)
|
|
167
|
+
sp.add_argument("y", type=float)
|
|
168
|
+
sp.set_defaults(func=_cmd_tap)
|
|
169
|
+
|
|
170
|
+
sp = sub.add_parser("longpress", help="long-press at normalized coords")
|
|
171
|
+
sp.add_argument("x", type=float)
|
|
172
|
+
sp.add_argument("y", type=float)
|
|
173
|
+
sp.add_argument("--duration", type=float, default=0.8)
|
|
174
|
+
sp.set_defaults(func=_cmd_longpress)
|
|
175
|
+
|
|
176
|
+
sp = sub.add_parser("swipe", help="swipe between two normalized points")
|
|
177
|
+
sp.add_argument("fx", type=float)
|
|
178
|
+
sp.add_argument("fy", type=float)
|
|
179
|
+
sp.add_argument("tx", type=float)
|
|
180
|
+
sp.add_argument("ty", type=float)
|
|
181
|
+
sp.add_argument("--ms", type=int, default=300)
|
|
182
|
+
sp.set_defaults(func=_cmd_swipe)
|
|
183
|
+
|
|
184
|
+
sub.add_parser("home", help="home gesture").set_defaults(func=_cmd_home)
|
|
185
|
+
sub.add_parser("app-switcher", help="open app switcher").set_defaults(func=_cmd_app_switcher)
|
|
186
|
+
|
|
187
|
+
sp = sub.add_parser("scroll", help="scroll up/down")
|
|
188
|
+
sp.add_argument("direction", choices=["up", "down"])
|
|
189
|
+
sp.add_argument("distance", type=float, nargs="?", default=0.5)
|
|
190
|
+
sp.set_defaults(func=_cmd_scroll)
|
|
191
|
+
|
|
192
|
+
sp = sub.add_parser("page", help="page left/right on home screen")
|
|
193
|
+
sp.add_argument("direction", choices=["left", "right"])
|
|
194
|
+
sp.set_defaults(func=_cmd_page)
|
|
195
|
+
|
|
196
|
+
sp = sub.add_parser("type", help="type text (clipboard-based, IME-safe)")
|
|
197
|
+
sp.add_argument("text")
|
|
198
|
+
sp.set_defaults(func=_cmd_type)
|
|
199
|
+
|
|
200
|
+
sp = sub.add_parser("key", help="press a key (return/delete/up/down/left/right)")
|
|
201
|
+
sp.add_argument("key")
|
|
202
|
+
sp.set_defaults(func=_cmd_key)
|
|
203
|
+
|
|
204
|
+
sub.add_parser("ocr", help="OCR the screen").set_defaults(func=_cmd_ocr)
|
|
205
|
+
|
|
206
|
+
sp = sub.add_parser("find", help="find text on screen")
|
|
207
|
+
sp.add_argument("text")
|
|
208
|
+
sp.add_argument("--exact", action="store_true")
|
|
209
|
+
sp.set_defaults(func=_cmd_find)
|
|
210
|
+
|
|
211
|
+
sp = sub.add_parser("open", help="open an app via Spotlight")
|
|
212
|
+
sp.add_argument("app")
|
|
213
|
+
sp.set_defaults(func=_cmd_open)
|
|
214
|
+
|
|
215
|
+
sp = sub.add_parser("verify", help="run a gesture and verify the screen changed")
|
|
216
|
+
sp.add_argument("action", choices=["home", "app-switcher", "page-left", "page-right"])
|
|
217
|
+
sp.set_defaults(func=_cmd_verify)
|
|
218
|
+
|
|
219
|
+
return p
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main(argv: list[str] | None = None) -> int:
|
|
223
|
+
parser = build_parser()
|
|
224
|
+
args = parser.parse_args(argv)
|
|
225
|
+
try:
|
|
226
|
+
return args.func(args)
|
|
227
|
+
except WindowError as exc:
|
|
228
|
+
print(f"window error: {exc}", file=sys.stderr)
|
|
229
|
+
return 3
|
|
230
|
+
except input.InputError as exc:
|
|
231
|
+
print(f"input error: {exc}", file=sys.stderr)
|
|
232
|
+
return 4
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
sys.exit(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""High-level gestures composed from the input engine.
|
|
2
|
+
|
|
3
|
+
These map to standard iOS interactions. Coordinates are normalized
|
|
4
|
+
(0-1) within the mirroring window. Gesture start points were chosen to
|
|
5
|
+
match iOS conventions (e.g. home swipe starts at the very bottom edge).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import input as inp
|
|
11
|
+
from . import vision
|
|
12
|
+
from .window import ensure_running, get_bounds
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GestureError(RuntimeError):
|
|
16
|
+
"""Raised when a composed gesture cannot complete."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def home() -> None:
|
|
20
|
+
"""Swipe up from near the bottom edge: go to the home screen.
|
|
21
|
+
|
|
22
|
+
Starts at 0.90 height: the bottom ~7% of the mirroring window is
|
|
23
|
+
macOS window chrome (rounded corners / resize area), not the video
|
|
24
|
+
surface — events there never reach the phone. 0.90 is the lowest
|
|
25
|
+
point that reliably lands on the iOS home-indicator zone.
|
|
26
|
+
"""
|
|
27
|
+
inp.swipe(0.5, 0.90, 0.5, 0.10, duration_ms=300)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def app_switcher() -> None:
|
|
31
|
+
"""Swipe up and hold: open the app switcher."""
|
|
32
|
+
b = get_bounds()
|
|
33
|
+
from Quartz import (
|
|
34
|
+
CGEventCreateMouseEvent, CGEventPost, CGEventSourceCreate,
|
|
35
|
+
kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGEventLeftMouseDragged,
|
|
36
|
+
kCGEventSourceStateHIDSystemState, kCGHIDEventTap, kCGMouseButtonLeft,
|
|
37
|
+
)
|
|
38
|
+
import time
|
|
39
|
+
|
|
40
|
+
x0, y0 = b.denormalize(0.5, 0.90)
|
|
41
|
+
x1, y1 = b.denormalize(0.5, 0.45)
|
|
42
|
+
src = CGEventSourceCreate(kCGEventSourceStateHIDSystemState)
|
|
43
|
+
|
|
44
|
+
def ev(kind, x, y):
|
|
45
|
+
CGEventPost(kCGHIDEventTap,
|
|
46
|
+
CGEventCreateMouseEvent(src, kind, (x, y), kCGMouseButtonLeft))
|
|
47
|
+
|
|
48
|
+
ev(kCGEventLeftMouseDown, x0, y0)
|
|
49
|
+
time.sleep(0.02)
|
|
50
|
+
# Eased drag to mid-screen.
|
|
51
|
+
steps = 15
|
|
52
|
+
for i in range(1, steps + 1):
|
|
53
|
+
t = i / steps
|
|
54
|
+
e = t * t * (3 - 2 * t) # smoothstep
|
|
55
|
+
ev(kCGEventLeftMouseDragged, x0 + (x1 - x0) * e, y0 + (y1 - y0) * e)
|
|
56
|
+
time.sleep(0.016)
|
|
57
|
+
time.sleep(0.55) # hold to trigger the switcher
|
|
58
|
+
ev(kCGEventLeftMouseUp, x1, y1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scroll_down(distance: float = 0.5) -> None:
|
|
62
|
+
"""Scroll content down (swipe up) by a normalized distance."""
|
|
63
|
+
start = min(0.9, 0.4 + distance / 2)
|
|
64
|
+
inp.swipe(0.5, start, 0.5, start - distance, duration_ms=300)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def scroll_up(distance: float = 0.5) -> None:
|
|
68
|
+
"""Scroll content up (swipe down) by a normalized distance."""
|
|
69
|
+
start = max(0.1, 0.6 - distance / 2)
|
|
70
|
+
inp.swipe(0.5, start, 0.5, start + distance, duration_ms=300)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def page_left() -> None:
|
|
74
|
+
"""Swipe right-to-left: go to the next home-screen page."""
|
|
75
|
+
inp.swipe(0.8, 0.5, 0.2, 0.5, duration_ms=280)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def page_right() -> None:
|
|
79
|
+
"""Swipe left-to-right: go to the previous home-screen page."""
|
|
80
|
+
inp.swipe(0.2, 0.5, 0.8, 0.5, duration_ms=280)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pull_down_search() -> None:
|
|
84
|
+
"""Swipe down from mid-screen: open Spotlight search."""
|
|
85
|
+
inp.swipe(0.5, 0.25, 0.5, 0.75, duration_ms=280)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def open_app(name: str) -> bool:
|
|
89
|
+
"""Open an app by name via Spotlight + OCR + tap.
|
|
90
|
+
|
|
91
|
+
Returns True if the app name was found on screen after typing.
|
|
92
|
+
"""
|
|
93
|
+
pull_down_search()
|
|
94
|
+
import time
|
|
95
|
+
|
|
96
|
+
time.sleep(1.0)
|
|
97
|
+
inp.type_text(name)
|
|
98
|
+
time.sleep(1.5) # wait for Spotlight results
|
|
99
|
+
|
|
100
|
+
hit = vision.find(name)
|
|
101
|
+
if hit is None:
|
|
102
|
+
return False
|
|
103
|
+
inp.tap(hit.nx, hit.ny)
|
|
104
|
+
time.sleep(1.0)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_text() -> list:
|
|
109
|
+
"""OCR the current screen; returns vision.OCRHit list."""
|
|
110
|
+
return vision.read_screen()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def status() -> dict:
|
|
114
|
+
"""Return a status dict for the mirroring session.
|
|
115
|
+
|
|
116
|
+
Keys: running; and when running: window {x,y,w,h} (AX bounds), window_id, onscreen, frontmost,
|
|
117
|
+
paused (True/False, or null when the "Connection Paused" text cannot be read).
|
|
118
|
+
"""
|
|
119
|
+
from .window import get_window_info, is_frontmost, is_paused, is_running
|
|
120
|
+
|
|
121
|
+
running = is_running()
|
|
122
|
+
info = {"running": running}
|
|
123
|
+
if running:
|
|
124
|
+
try:
|
|
125
|
+
b = get_bounds()
|
|
126
|
+
info["window"] = {"x": b.x, "y": b.y, "w": b.width, "h": b.height}
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
info["window_error"] = str(exc)
|
|
129
|
+
wi = get_window_info()
|
|
130
|
+
if wi:
|
|
131
|
+
info["window_id"] = wi["id"]
|
|
132
|
+
info["onscreen"] = wi["onscreen"]
|
|
133
|
+
info["frontmost"] = is_frontmost()
|
|
134
|
+
info["paused"] = is_paused()
|
|
135
|
+
return info
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resume() -> bool:
|
|
139
|
+
"""Resume a paused mirroring session (clicks Resume). Returns True when not paused afterwards."""
|
|
140
|
+
from .window import resume as _resume
|
|
141
|
+
|
|
142
|
+
return _resume()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def launch() -> None:
|
|
146
|
+
"""Ensure iPhone Mirroring is running, frontmost, and not paused."""
|
|
147
|
+
ensure_running()
|
|
148
|
+
from .window import ensure_frontmost, resume as _resume
|
|
149
|
+
|
|
150
|
+
ensure_frontmost()
|
|
151
|
+
_resume()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""CGEvent input engine for iPhone Mirroring.
|
|
2
|
+
|
|
3
|
+
All input is delivered as synthesized CoreGraphics events posted to the
|
|
4
|
+
HID event tap. The iPhone Mirroring app translates these into touch
|
|
5
|
+
events on the phone.
|
|
6
|
+
|
|
7
|
+
Empirically-validated parameters (see project README for the test
|
|
8
|
+
methodology):
|
|
9
|
+
|
|
10
|
+
- TAP_INTERVAL_S = 0.05 — minimum down/up interval that the mirroring
|
|
11
|
+
gesture recognizer registers reliably. Faster taps are sometimes
|
|
12
|
+
dropped.
|
|
13
|
+
- SWIPE_STEPS = 20 — number of interpolated drag points. A single
|
|
14
|
+
dd/dm/du drag (e.g. via cliclick) is NOT recognized as a swipe by
|
|
15
|
+
iPhone Mirroring; stepped drags are.
|
|
16
|
+
- SWIPE_NUDGE_PX = 3 — an initial small movement right after
|
|
17
|
+
mouse-down. Without it, the recognizer sometimes classifies the
|
|
18
|
+
gesture as an icon long-press-drag instead of a scroll swipe.
|
|
19
|
+
- Ease-in-out curve — quadratic ease matches how a human finger
|
|
20
|
+
decelerates, which keeps the gesture inside the recognizer's
|
|
21
|
+
velocity window.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from Quartz import (
|
|
29
|
+
CGEventCreateMouseEvent,
|
|
30
|
+
CGEventPost,
|
|
31
|
+
CGEventSetIntegerValueField,
|
|
32
|
+
CGEventSourceCreate,
|
|
33
|
+
kCGEventLeftMouseDown,
|
|
34
|
+
kCGEventLeftMouseUp,
|
|
35
|
+
kCGEventLeftMouseDragged,
|
|
36
|
+
kCGEventSourceStateHIDSystemState,
|
|
37
|
+
kCGHIDEventTap,
|
|
38
|
+
kCGMouseButtonLeft,
|
|
39
|
+
kCGEventFlagMaskCommand,
|
|
40
|
+
CGEventCreateKeyboardEvent,
|
|
41
|
+
CGEventSetFlags,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from .window import WindowBounds, ensure_frontmost, get_bounds
|
|
45
|
+
|
|
46
|
+
TAP_INTERVAL_S = 0.05
|
|
47
|
+
LONG_PRESS_S = 0.8
|
|
48
|
+
SWIPE_STEPS = 20
|
|
49
|
+
SWIPE_NUDGE_PX = 3
|
|
50
|
+
SWIPE_DEFAULT_MS = 300
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InputError(RuntimeError):
|
|
54
|
+
"""Raised when a synthesized input event cannot be delivered."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _source():
|
|
58
|
+
return CGEventSourceCreate(kCGEventSourceStateHIDSystemState)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _mouse_event(kind: int, x: float, y: float, source) -> None:
|
|
62
|
+
event = CGEventCreateMouseEvent(
|
|
63
|
+
source, kind, (x, y), kCGMouseButtonLeft
|
|
64
|
+
)
|
|
65
|
+
if event is None:
|
|
66
|
+
raise InputError(f"CGEventCreateMouseEvent returned None for {kind}")
|
|
67
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _key_event(keycode: int, down: bool, source) -> None:
|
|
71
|
+
event = CGEventCreateKeyboardEvent(source, keycode, down)
|
|
72
|
+
if event is None:
|
|
73
|
+
raise InputError(f"CGEventCreateKeyboardEvent returned None for {keycode}")
|
|
74
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- Pointer primitives ---------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tap(nx: float = 0.5, ny: float = 0.5, bounds: WindowBounds | None = None) -> None:
|
|
81
|
+
"""Tap at normalized coordinates (0-1) within the mirroring window."""
|
|
82
|
+
ensure_frontmost()
|
|
83
|
+
b = bounds or get_bounds()
|
|
84
|
+
x, y = b.denormalize(nx, ny)
|
|
85
|
+
src = _source()
|
|
86
|
+
_mouse_event(kCGEventLeftMouseDown, x, y, src)
|
|
87
|
+
time.sleep(TAP_INTERVAL_S)
|
|
88
|
+
_mouse_event(kCGEventLeftMouseUp, x, y, src)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def long_press(nx: float = 0.5, ny: float = 0.5, duration_s: float = LONG_PRESS_S,
|
|
92
|
+
bounds: WindowBounds | None = None) -> None:
|
|
93
|
+
"""Press and hold at normalized coordinates."""
|
|
94
|
+
ensure_frontmost()
|
|
95
|
+
b = bounds or get_bounds()
|
|
96
|
+
x, y = b.denormalize(nx, ny)
|
|
97
|
+
src = _source()
|
|
98
|
+
_mouse_event(kCGEventLeftMouseDown, x, y, src)
|
|
99
|
+
time.sleep(duration_s)
|
|
100
|
+
_mouse_event(kCGEventLeftMouseUp, x, y, src)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ease_in_out(t: float) -> float:
|
|
104
|
+
"""Quadratic ease-in-out: slow start, fast middle, slow end."""
|
|
105
|
+
if t < 0.5:
|
|
106
|
+
return 2.0 * t * t
|
|
107
|
+
return 1.0 - ((-2.0 * t + 2.0) ** 2) / 2.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def swipe(from_x: float, from_y: float, to_x: float, to_y: float,
|
|
111
|
+
duration_ms: int = SWIPE_DEFAULT_MS,
|
|
112
|
+
bounds: WindowBounds | None = None) -> None:
|
|
113
|
+
"""Swipe between two normalized points with eased interpolation.
|
|
114
|
+
|
|
115
|
+
The gesture is: mouse-down, 3px nudge, 20 eased drag steps,
|
|
116
|
+
mouse-up. This is the shape iPhone Mirroring's recognizer accepts.
|
|
117
|
+
"""
|
|
118
|
+
ensure_frontmost()
|
|
119
|
+
b = bounds or get_bounds()
|
|
120
|
+
x0, y0 = b.denormalize(from_x, from_y)
|
|
121
|
+
x1, y1 = b.denormalize(to_x, to_y)
|
|
122
|
+
|
|
123
|
+
src = _source()
|
|
124
|
+
step_delay = (duration_ms / 1000.0) / SWIPE_STEPS
|
|
125
|
+
|
|
126
|
+
_mouse_event(kCGEventLeftMouseDown, x0, y0, src)
|
|
127
|
+
time.sleep(0.02)
|
|
128
|
+
|
|
129
|
+
# Initial nudge: disambiguates swipe from long-press-drag.
|
|
130
|
+
dx = 1 if x1 >= x0 else -1
|
|
131
|
+
dy = 1 if y1 >= y0 else -1
|
|
132
|
+
_mouse_event(kCGEventLeftMouseDragged, x0 + dx * SWIPE_NUDGE_PX,
|
|
133
|
+
y0 + dy * SWIPE_NUDGE_PX, src)
|
|
134
|
+
time.sleep(0.01)
|
|
135
|
+
|
|
136
|
+
for i in range(1, SWIPE_STEPS + 1):
|
|
137
|
+
t = i / SWIPE_STEPS
|
|
138
|
+
e = _ease_in_out(t)
|
|
139
|
+
cx = x0 + (x1 - x0) * e
|
|
140
|
+
cy = y0 + (y1 - y0) * e
|
|
141
|
+
_mouse_event(kCGEventLeftMouseDragged, cx, cy, src)
|
|
142
|
+
time.sleep(step_delay)
|
|
143
|
+
|
|
144
|
+
_mouse_event(kCGEventLeftMouseUp, x1, y1, src)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def scroll(dy_pixels: int, nx: float = 0.5, ny: float = 0.5,
|
|
148
|
+
bounds: WindowBounds | None = None) -> None:
|
|
149
|
+
"""Scroll vertically at a position. Positive dy scrolls up."""
|
|
150
|
+
from Quartz import CGEventCreateScrollWheelEvent
|
|
151
|
+
|
|
152
|
+
ensure_frontmost()
|
|
153
|
+
b = bounds or get_bounds()
|
|
154
|
+
x, y = b.denormalize(nx, ny)
|
|
155
|
+
src = _source()
|
|
156
|
+
# Axis 1 = vertical; pixel units; negative = scroll down (content up).
|
|
157
|
+
event = CGEventCreateScrollWheelEvent(
|
|
158
|
+
src, kCGScrollEventUnitPixel, 1, dy_pixels
|
|
159
|
+
)
|
|
160
|
+
if event is None:
|
|
161
|
+
raise InputError("CGEventCreateScrollWheelEvent returned None")
|
|
162
|
+
CGEventPost(kCGHIDEventTap, event)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- Keyboard primitives ----------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# macOS virtual keycodes used by this module.
|
|
168
|
+
_VK_RETURN = 36
|
|
169
|
+
_VK_DELETE = 51
|
|
170
|
+
_VK_ARROW_KEYS = {"up": 126, "down": 125, "left": 123, "right": 124}
|
|
171
|
+
_VK_V = 9
|
|
172
|
+
_VK_A = 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def press_key(key: str) -> None:
|
|
176
|
+
"""Press a single key: 'return', 'delete', 'up', 'down', 'left', 'right'."""
|
|
177
|
+
ensure_frontmost()
|
|
178
|
+
keymap = {"return": _VK_RETURN, "delete": _VK_DELETE, **_VK_ARROW_KEYS}
|
|
179
|
+
if key not in keymap:
|
|
180
|
+
raise InputError(f"unsupported key: {key!r} (supported: {sorted(keymap)})")
|
|
181
|
+
src = _source()
|
|
182
|
+
_key_event(keymap[key], True, src)
|
|
183
|
+
time.sleep(0.02)
|
|
184
|
+
_key_event(keymap[key], False, src)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def type_text(text: str) -> None:
|
|
188
|
+
"""Type text via clipboard paste (IME-safe).
|
|
189
|
+
|
|
190
|
+
Direct CGEvent keystroke synthesis breaks under CJK IMEs and is
|
|
191
|
+
slow for long strings. Instead we:
|
|
192
|
+
1. save the user's pasteboard,
|
|
193
|
+
2. write the text,
|
|
194
|
+
3. Cmd+V into the focused field,
|
|
195
|
+
4. restore the pasteboard.
|
|
196
|
+
|
|
197
|
+
Requires the calling terminal to have Accessibility permission
|
|
198
|
+
(same as every other imctl input).
|
|
199
|
+
"""
|
|
200
|
+
from AppKit import NSPasteboard
|
|
201
|
+
|
|
202
|
+
ensure_frontmost()
|
|
203
|
+
pb = NSPasteboard.generalPasteboard()
|
|
204
|
+
saved = pb.dataForType_("public.utf8-plain-text")
|
|
205
|
+
saved_change = pb.changeCount()
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
pb.clearContents()
|
|
209
|
+
pb.setString_forType_(text, "public.utf8-plain-text")
|
|
210
|
+
time.sleep(0.05)
|
|
211
|
+
|
|
212
|
+
src = _source()
|
|
213
|
+
v_down = CGEventCreateKeyboardEvent(src, _VK_V, True)
|
|
214
|
+
CGEventSetFlags(v_down, kCGEventFlagMaskCommand)
|
|
215
|
+
CGEventPost(kCGHIDEventTap, v_down)
|
|
216
|
+
time.sleep(0.05)
|
|
217
|
+
v_up = CGEventCreateKeyboardEvent(src, _VK_V, False)
|
|
218
|
+
CGEventSetFlags(v_up, kCGEventFlagMaskCommand)
|
|
219
|
+
CGEventPost(kCGHIDEventTap, v_up)
|
|
220
|
+
time.sleep(0.15) # let the paste land before restoring
|
|
221
|
+
finally:
|
|
222
|
+
# Restore the user's clipboard only if nothing else changed it.
|
|
223
|
+
if pb.changeCount() == saved_change + 1:
|
|
224
|
+
pb.clearContents()
|
|
225
|
+
if saved is not None:
|
|
226
|
+
pb.setData_forType_(saved, "public.utf8-plain-text")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def clear_text() -> None:
|
|
230
|
+
"""Select-all and delete in the focused field."""
|
|
231
|
+
ensure_frontmost()
|
|
232
|
+
src = _source()
|
|
233
|
+
for keycode, flags in (
|
|
234
|
+
(_VK_A, kCGEventFlagMaskCommand), # Cmd+A
|
|
235
|
+
):
|
|
236
|
+
e = CGEventCreateKeyboardEvent(src, keycode, True)
|
|
237
|
+
CGEventSetFlags(e, flags)
|
|
238
|
+
CGEventPost(kCGHIDEventTap, e)
|
|
239
|
+
time.sleep(0.03)
|
|
240
|
+
e = CGEventCreateKeyboardEvent(src, keycode, False)
|
|
241
|
+
CGEventSetFlags(e, flags)
|
|
242
|
+
CGEventPost(kCGHIDEventTap, e)
|
|
243
|
+
time.sleep(0.05)
|
|
244
|
+
_key_event(_VK_DELETE, True, src)
|
|
245
|
+
time.sleep(0.02)
|
|
246
|
+
_key_event(_VK_DELETE, False, src)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Pixel-level action verification.
|
|
2
|
+
|
|
3
|
+
Each gesture is followed by a screenshot hash comparison to confirm the
|
|
4
|
+
screen actually changed. This closes the loop: an action that reports
|
|
5
|
+
success but leaves the screen identical is a dropped event, and callers
|
|
6
|
+
should retry or re-observe — the same observe-act-re-observe discipline
|
|
7
|
+
that OpenAI's Computer Use enforces with its revision mechanism.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .window import get_bounds
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ScreenState:
|
|
22
|
+
"""A fingerprint of the mirroring window content."""
|
|
23
|
+
|
|
24
|
+
md5: str
|
|
25
|
+
width: int
|
|
26
|
+
height: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def capture_hash() -> ScreenState:
|
|
30
|
+
"""Capture the window and return a content fingerprint.
|
|
31
|
+
|
|
32
|
+
The hash is computed over a downsampled copy: full-resolution
|
|
33
|
+
hashes are sensitive to video-stream noise (compression flicker)
|
|
34
|
+
even when nothing meaningful changed.
|
|
35
|
+
"""
|
|
36
|
+
b = get_bounds()
|
|
37
|
+
raw = tempfile.mktemp(prefix="imctl_verify_", suffix=".png")
|
|
38
|
+
small = tempfile.mktemp(prefix="imctl_verify_s_", suffix=".png")
|
|
39
|
+
try:
|
|
40
|
+
subprocess.run(
|
|
41
|
+
["screencapture", "-x", f"-R{b.x},{b.y},{b.width},{b.height}", raw],
|
|
42
|
+
check=True,
|
|
43
|
+
timeout=10,
|
|
44
|
+
)
|
|
45
|
+
# Downsample to ~163x360 (half window) to suppress stream noise.
|
|
46
|
+
subprocess.run(
|
|
47
|
+
["sips", "-z", "360", "163", raw, "--out", small],
|
|
48
|
+
check=True,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
timeout=10,
|
|
51
|
+
)
|
|
52
|
+
with open(small, "rb") as f:
|
|
53
|
+
digest = hashlib.md5(f.read()).hexdigest()
|
|
54
|
+
return ScreenState(md5=digest, width=b.width, height=b.height)
|
|
55
|
+
finally:
|
|
56
|
+
subprocess.run(["rm", "-f", raw, small], capture_output=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def changed(before: ScreenState, after: ScreenState) -> bool:
|
|
60
|
+
"""True if the screen content differs between two states."""
|
|
61
|
+
return before.md5 != after.md5
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_action(action, *args, settle_s: float = 1.5, **kwargs) -> bool:
|
|
65
|
+
"""Run an action and report whether the screen changed.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
ok = verify_action(gestures.home)
|
|
69
|
+
Returns True if the screen content changed after the action.
|
|
70
|
+
"""
|
|
71
|
+
before = capture_hash()
|
|
72
|
+
action(*args, **kwargs)
|
|
73
|
+
import time
|
|
74
|
+
|
|
75
|
+
time.sleep(settle_s)
|
|
76
|
+
after = capture_hash()
|
|
77
|
+
return changed(before, after)
|