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.
@@ -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)