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,138 @@
|
|
|
1
|
+
"""Local OCR via the macOS Vision framework.
|
|
2
|
+
|
|
3
|
+
Runs VNRecognizeTextRequest on a screenshot of the mirroring window
|
|
4
|
+
and returns text with normalized (0-1) coordinates, ready to feed into
|
|
5
|
+
input.tap(). Everything runs locally — no network, no API keys.
|
|
6
|
+
|
|
7
|
+
Coordinate convention: Vision returns bottom-left-origin bounding
|
|
8
|
+
boxes; we convert to top-left-origin (screen convention) and normalize
|
|
9
|
+
to the window size so results map directly onto tap(nx, ny).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from .window import get_bounds, get_window_info
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OCRError(RuntimeError):
|
|
23
|
+
"""Raised when OCR fails."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class OCRHit:
|
|
28
|
+
text: str
|
|
29
|
+
nx: float # normalized x of the text center (0-1)
|
|
30
|
+
ny: float # normalized y of the text center (0-1)
|
|
31
|
+
confidence: float
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return f"({self.nx:.2f},{self.ny:.2f}) {self.text!r} [{self.confidence:.2f}]"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def capture_window(path: str | None = None) -> str:
|
|
38
|
+
"""Screenshot the mirroring window to a PNG file; returns the path.
|
|
39
|
+
|
|
40
|
+
Captures by CGWindow id (``screencapture -l``) so the image is the window's own content even when
|
|
41
|
+
other windows cover it or it is not frontmost. Falls back to a screen-region capture of the AX bounds
|
|
42
|
+
when the window id cannot be resolved.
|
|
43
|
+
"""
|
|
44
|
+
if path is None:
|
|
45
|
+
path = tempfile.mktemp(prefix="imctl_", suffix=".png")
|
|
46
|
+
info = get_window_info()
|
|
47
|
+
if info is not None:
|
|
48
|
+
r = subprocess.run(
|
|
49
|
+
["screencapture", "-x", "-o", "-l", str(info["id"]), path],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
timeout=10,
|
|
52
|
+
)
|
|
53
|
+
if r.returncode == 0 and os.path.exists(path) and os.path.getsize(path) > 0:
|
|
54
|
+
return path
|
|
55
|
+
b = get_bounds()
|
|
56
|
+
subprocess.run(
|
|
57
|
+
["screencapture", "-x", f"-R{b.x},{b.y},{b.width},{b.height}", path],
|
|
58
|
+
check=True,
|
|
59
|
+
timeout=10,
|
|
60
|
+
)
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ocr(image_path: str, languages: tuple[str, ...] = ("en-US", "zh-Hans")) -> list[OCRHit]:
|
|
65
|
+
"""Run Vision OCR on an image; return hits sorted reading-order.
|
|
66
|
+
|
|
67
|
+
Uses the pyobjc Vision bindings (no subprocess Swift compile).
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
import Vision
|
|
71
|
+
from Foundation import NSURL
|
|
72
|
+
from Quartz import (
|
|
73
|
+
CGImageSourceCreateWithURL,
|
|
74
|
+
CGImageSourceCreateImageAtIndex,
|
|
75
|
+
CGImageGetWidth,
|
|
76
|
+
CGImageGetHeight,
|
|
77
|
+
)
|
|
78
|
+
except ImportError as exc: # pragma: no cover
|
|
79
|
+
raise OCRError(f"pyobjc frameworks missing: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
url = NSURL.fileURLWithPath_(image_path)
|
|
82
|
+
src = CGImageSourceCreateWithURL(url, None)
|
|
83
|
+
if src is None:
|
|
84
|
+
raise OCRError(f"cannot open image: {image_path}")
|
|
85
|
+
cg_image = CGImageSourceCreateImageAtIndex(src, 0, None)
|
|
86
|
+
if cg_image is None:
|
|
87
|
+
raise OCRError(f"cannot decode image: {image_path}")
|
|
88
|
+
|
|
89
|
+
request = Vision.VNRecognizeTextRequest.alloc().init()
|
|
90
|
+
request.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)
|
|
91
|
+
if hasattr(request, "setRecognitionLanguages_"):
|
|
92
|
+
request.setRecognitionLanguages_(list(languages))
|
|
93
|
+
request.setUsesLanguageCorrection_(True)
|
|
94
|
+
|
|
95
|
+
handler = Vision.VNImageRequestHandler.alloc().initWithCGImage_options_(
|
|
96
|
+
cg_image, None
|
|
97
|
+
)
|
|
98
|
+
ok = handler.performRequests_error_([request], None)
|
|
99
|
+
if not ok:
|
|
100
|
+
raise OCRError("VNImageRequestHandler.performRequests failed")
|
|
101
|
+
|
|
102
|
+
results = request.results() or []
|
|
103
|
+
hits: list[OCRHit] = []
|
|
104
|
+
for obs in results:
|
|
105
|
+
cand = obs.topCandidates_(1)
|
|
106
|
+
if not cand:
|
|
107
|
+
continue
|
|
108
|
+
cand = cand[0]
|
|
109
|
+
box = obs.boundingBox() # bottom-left origin, normalized
|
|
110
|
+
cx = box.origin.x + box.size.width / 2
|
|
111
|
+
cy_top = 1.0 - (box.origin.y + box.size.height / 2) # flip to top-left
|
|
112
|
+
hits.append(
|
|
113
|
+
OCRHit(
|
|
114
|
+
text=str(cand.string()),
|
|
115
|
+
nx=cx,
|
|
116
|
+
ny=cy_top,
|
|
117
|
+
confidence=float(cand.confidence()),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Reading order: group by row (within 3% height), then left-to-right.
|
|
122
|
+
hits.sort(key=lambda h: (round(h.ny / 0.03), h.nx))
|
|
123
|
+
return hits
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find(text: str, image_path: str | None = None,
|
|
127
|
+
exact: bool = False) -> OCRHit | None:
|
|
128
|
+
"""Find the first OCR hit matching text; returns None if absent."""
|
|
129
|
+
path = image_path or capture_window()
|
|
130
|
+
for hit in ocr(path):
|
|
131
|
+
if (hit.text == text) if exact else (text in hit.text):
|
|
132
|
+
return hit
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_screen() -> list[OCRHit]:
|
|
137
|
+
"""Convenience: capture + OCR the current mirroring window."""
|
|
138
|
+
return ocr(capture_window())
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Window management for the iPhone Mirroring app.
|
|
2
|
+
|
|
3
|
+
Resolves the iPhone Mirroring window bounds dynamically before every
|
|
4
|
+
gesture. The window can be moved, minimized, or closed between actions,
|
|
5
|
+
so callers must never cache bounds across gestures.
|
|
6
|
+
|
|
7
|
+
Uses System Events (AppleScript) rather than the raw AX C API: it is
|
|
8
|
+
the most reliable way to enumerate windows of another process and
|
|
9
|
+
survives permission quirks better than direct AXUIElement calls.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
APP_NAME = "iPhone Mirroring"
|
|
18
|
+
PROCESS_NAME = "iPhone Mirroring"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WindowError(RuntimeError):
|
|
22
|
+
"""Raised when the iPhone Mirroring window cannot be found or used."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class WindowBounds:
|
|
27
|
+
x: int
|
|
28
|
+
y: int
|
|
29
|
+
width: int
|
|
30
|
+
height: int
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def center(self) -> tuple[int, int]:
|
|
34
|
+
return (self.x + self.width // 2, self.y + self.height // 2)
|
|
35
|
+
|
|
36
|
+
def denormalize(self, nx: float, ny: float) -> tuple[int, int]:
|
|
37
|
+
"""Convert normalized (0-1) coordinates to screen coordinates."""
|
|
38
|
+
if not (0.0 <= nx <= 1.0 and 0.0 <= ny <= 1.0):
|
|
39
|
+
raise ValueError(f"normalized coords out of range: ({nx}, {ny})")
|
|
40
|
+
return (
|
|
41
|
+
round(self.x + nx * self.width),
|
|
42
|
+
round(self.y + ny * self.height),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_GET_BOUNDS_SCRIPT = '''
|
|
47
|
+
tell application "System Events"
|
|
48
|
+
tell process "iPhone Mirroring"
|
|
49
|
+
set winCount to count of windows
|
|
50
|
+
if winCount is 0 then return "NO_WINDOW"
|
|
51
|
+
set {x, y} to position of window 1
|
|
52
|
+
set {w, h} to size of window 1
|
|
53
|
+
return (x as text) & "," & (y as text) & "," & (w as text) & "," & (h as text)
|
|
54
|
+
end tell
|
|
55
|
+
end tell
|
|
56
|
+
'''
|
|
57
|
+
|
|
58
|
+
_FRONTMOST_SCRIPT = '''
|
|
59
|
+
tell application "System Events"
|
|
60
|
+
tell process "iPhone Mirroring"
|
|
61
|
+
return frontmost as text
|
|
62
|
+
end tell
|
|
63
|
+
end tell
|
|
64
|
+
'''
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_osascript(script: str) -> str:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["osascript", "-e", script],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=10,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
raise WindowError(f"osascript failed: {result.stderr.strip()}")
|
|
76
|
+
return result.stdout.strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_running() -> bool:
|
|
80
|
+
"""Return True if the iPhone Mirroring process is alive."""
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["pgrep", "-x", PROCESS_NAME], capture_output=True
|
|
83
|
+
)
|
|
84
|
+
return result.returncode == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_running() -> None:
|
|
88
|
+
"""Launch iPhone Mirroring if it is not running."""
|
|
89
|
+
if is_running():
|
|
90
|
+
return
|
|
91
|
+
subprocess.run(
|
|
92
|
+
["open", "-a", APP_NAME], check=True, timeout=15
|
|
93
|
+
)
|
|
94
|
+
import time
|
|
95
|
+
|
|
96
|
+
for _ in range(20): # wait up to ~10s for the process
|
|
97
|
+
if is_running():
|
|
98
|
+
break
|
|
99
|
+
time.sleep(0.5)
|
|
100
|
+
else:
|
|
101
|
+
raise WindowError("iPhone Mirroring did not start within 10s")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ensure_frontmost() -> None:
|
|
105
|
+
"""Bring the iPhone Mirroring window to the front.
|
|
106
|
+
|
|
107
|
+
CGEvents are delivered to the frontmost app; if another window
|
|
108
|
+
covers the mirroring window, taps would land on the wrong target.
|
|
109
|
+
"""
|
|
110
|
+
if _run_osascript(_FRONTMOST_SCRIPT) == "true":
|
|
111
|
+
return
|
|
112
|
+
subprocess.run(
|
|
113
|
+
["osascript", "-e", f'tell application "{APP_NAME}" to activate'],
|
|
114
|
+
capture_output=True,
|
|
115
|
+
timeout=10,
|
|
116
|
+
)
|
|
117
|
+
import time
|
|
118
|
+
|
|
119
|
+
time.sleep(0.4) # let the window server settle
|
|
120
|
+
if _run_osascript(_FRONTMOST_SCRIPT) != "true":
|
|
121
|
+
raise WindowError("could not bring iPhone Mirroring to front")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_bounds() -> WindowBounds:
|
|
125
|
+
"""Return current bounds of the mirroring window.
|
|
126
|
+
|
|
127
|
+
Raises WindowError if the app is not running or has no window
|
|
128
|
+
(e.g. the window was closed but the process lives on).
|
|
129
|
+
"""
|
|
130
|
+
out = _run_osascript(_GET_BOUNDS_SCRIPT)
|
|
131
|
+
if out == "NO_WINDOW":
|
|
132
|
+
# Window closed but process alive: re-activate to restore it.
|
|
133
|
+
subprocess.run(
|
|
134
|
+
["osascript", "-e", f'tell application "{APP_NAME}" to activate'],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
timeout=10,
|
|
137
|
+
)
|
|
138
|
+
import time
|
|
139
|
+
|
|
140
|
+
time.sleep(1.0)
|
|
141
|
+
out = _run_osascript(_GET_BOUNDS_SCRIPT)
|
|
142
|
+
if out == "NO_WINDOW":
|
|
143
|
+
raise WindowError(
|
|
144
|
+
"iPhone Mirroring window not found (app running, no window)"
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
x, y, w, h = (int(v) for v in out.split(","))
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise WindowError(f"unexpected bounds format: {out!r}") from exc
|
|
150
|
+
if w <= 0 or h <= 0:
|
|
151
|
+
raise WindowError(f"degenerate window size: {w}x{h}")
|
|
152
|
+
return WindowBounds(x=x, y=y, width=w, height=h)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_PAUSED_SCRIPT = '''
|
|
156
|
+
tell application "System Events"
|
|
157
|
+
tell process "iPhone Mirroring"
|
|
158
|
+
tell window 1
|
|
159
|
+
return (exists (static text "Connection Paused" of group 1)) as text
|
|
160
|
+
end tell
|
|
161
|
+
end tell
|
|
162
|
+
end tell
|
|
163
|
+
'''
|
|
164
|
+
|
|
165
|
+
_RESUME_SCRIPT = '''
|
|
166
|
+
tell application "System Events"
|
|
167
|
+
tell process "iPhone Mirroring"
|
|
168
|
+
tell window 1
|
|
169
|
+
if exists (static text "Connection Paused" of group 1) then
|
|
170
|
+
click button 1 of group 1
|
|
171
|
+
return "clicked"
|
|
172
|
+
end if
|
|
173
|
+
return "not-paused"
|
|
174
|
+
end tell
|
|
175
|
+
end tell
|
|
176
|
+
end tell
|
|
177
|
+
'''
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_window_info() -> dict | None:
|
|
181
|
+
"""Return the mirroring window's CGWindow record: {"id", "x", "y", "w", "h", "onscreen"}.
|
|
182
|
+
|
|
183
|
+
Uses Quartz (pyobjc). Prefers the window literally named "iPhone Mirroring"; otherwise the largest
|
|
184
|
+
normal-layer window of the process. Returns None when nothing qualifies. The id is what
|
|
185
|
+
``screencapture -l`` needs to capture the window's own pixels even when other windows cover it.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
import Quartz # type: ignore
|
|
189
|
+
except ImportError:
|
|
190
|
+
return None
|
|
191
|
+
opts = Quartz.kCGWindowListOptionAll | Quartz.kCGWindowListExcludeDesktopElements
|
|
192
|
+
best = None
|
|
193
|
+
for w in Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID) or []:
|
|
194
|
+
if w.get("kCGWindowOwnerName") != PROCESS_NAME or w.get("kCGWindowLayer", 0) != 0:
|
|
195
|
+
continue
|
|
196
|
+
b = w.get("kCGWindowBounds") or {}
|
|
197
|
+
rec = {
|
|
198
|
+
"id": int(w["kCGWindowNumber"]),
|
|
199
|
+
"x": int(b.get("X", 0)),
|
|
200
|
+
"y": int(b.get("Y", 0)),
|
|
201
|
+
"w": int(b.get("Width", 0)),
|
|
202
|
+
"h": int(b.get("Height", 0)),
|
|
203
|
+
"onscreen": bool(w.get("kCGWindowIsOnscreen", False)),
|
|
204
|
+
}
|
|
205
|
+
if rec["w"] < 50 or rec["h"] < 50:
|
|
206
|
+
continue
|
|
207
|
+
if w.get("kCGWindowName") == APP_NAME:
|
|
208
|
+
return rec
|
|
209
|
+
if best is None or rec["w"] * rec["h"] > best["w"] * best["h"]:
|
|
210
|
+
best = rec
|
|
211
|
+
return best
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def is_frontmost() -> bool:
|
|
215
|
+
"""True when iPhone Mirroring is the frontmost application."""
|
|
216
|
+
try:
|
|
217
|
+
return _run_osascript(_FRONTMOST_SCRIPT) == "true"
|
|
218
|
+
except WindowError:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_paused() -> bool | None:
|
|
223
|
+
"""True when the window shows "Connection Paused"; None when that cannot be read (e.g. localized UI)."""
|
|
224
|
+
try:
|
|
225
|
+
return _run_osascript(_PAUSED_SCRIPT) == "true"
|
|
226
|
+
except WindowError:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def resume() -> bool:
|
|
231
|
+
"""Click the Resume control when the session is paused.
|
|
232
|
+
|
|
233
|
+
Returns True when the session is not paused afterwards (or was never paused). The phone may still
|
|
234
|
+
ask for Face ID / passcode on its own screen; that part cannot be automated from the Mac.
|
|
235
|
+
"""
|
|
236
|
+
import time
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
out = _run_osascript(_RESUME_SCRIPT)
|
|
240
|
+
except WindowError:
|
|
241
|
+
return False
|
|
242
|
+
if out == "not-paused":
|
|
243
|
+
return True
|
|
244
|
+
for _ in range(10): # up to ~5 s for the stream to come back
|
|
245
|
+
time.sleep(0.5)
|
|
246
|
+
if is_paused() is False:
|
|
247
|
+
return True
|
|
248
|
+
return False
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "noracu",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local, no-cloud MCP server for macOS and Playwright computer-use, plus experimental iPhone. Acts by element index.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.mjs",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"claude",
|
|
29
29
|
"claude-desktop",
|
|
30
30
|
"claude-code",
|
|
31
|
-
"cursor"
|
|
31
|
+
"cursor",
|
|
32
|
+
"iphone",
|
|
33
|
+
"iphone-mirroring"
|
|
32
34
|
],
|
|
33
35
|
"license": "MIT",
|
|
34
36
|
"dependencies": {
|
|
@@ -52,6 +54,10 @@
|
|
|
52
54
|
"README.zh-CN.md",
|
|
53
55
|
"LICENSE",
|
|
54
56
|
"CHANGELOG.md",
|
|
55
|
-
"SECURITY.md"
|
|
57
|
+
"SECURITY.md",
|
|
58
|
+
"imctl/README.md",
|
|
59
|
+
"imctl/LICENSE",
|
|
60
|
+
"imctl/pyproject.toml",
|
|
61
|
+
"imctl/src/imctl/*.py"
|
|
56
62
|
]
|
|
57
63
|
}
|
package/src/server.mjs
CHANGED
|
@@ -27,8 +27,8 @@ import { createRequire } from "node:module";
|
|
|
27
27
|
|
|
28
28
|
// ── 配置(开源友好:环境变量覆盖 + 同级目录解析)──
|
|
29
29
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
30
|
-
// imctl
|
|
31
|
-
const IMCTL_DIR = process.env.NORACU_IMCTL_DIR || join(__dirname, "..", "
|
|
30
|
+
// imctl (the iPhone Mirroring driver, Python) ships inside the package at imctl/; NORACU_IMCTL_DIR overrides it (e.g. a dev checkout)
|
|
31
|
+
const IMCTL_DIR = process.env.NORACU_IMCTL_DIR || join(__dirname, "..", "imctl");
|
|
32
32
|
const CLICLICK = process.env.NORACU_CLICLICK || "/opt/homebrew/bin/cliclick";
|
|
33
33
|
if (!existsSync(IMCTL_DIR)) {
|
|
34
34
|
process.stderr.write(`[noracu] warning: imctl not found at ${IMCTL_DIR} — phone_* tools will fail. Set NORACU_IMCTL_DIR.\n`);
|
|
@@ -47,23 +47,50 @@ function sh(cmd, args, timeoutMs = 15000) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// The iPhone layer runs the bundled imctl (Python) and needs an interpreter that has pyobjc (Quartz, Vision, AppKit).
|
|
51
|
+
// Resolution: NORACU_PYTHON if set, else the first candidate that imports Quartz. Probed once, lazily, on the first
|
|
52
|
+
// phone_* call, so the Mac and browser layers pay nothing. Apple's /usr/bin/python3 usually lacks pyobjc; Homebrew's
|
|
53
|
+
// python3 plus the three pyobjc-framework-* packages is the common working setup.
|
|
54
|
+
const PYTHON_CANDIDATES = ["/opt/homebrew/bin/python3", "/usr/local/bin/python3", "python3", "/usr/bin/python3"];
|
|
55
|
+
const PYOBJC_INSTALL = "python3 -m pip install pyobjc-framework-Quartz pyobjc-framework-Vision pyobjc-framework-AppKit";
|
|
56
|
+
let pythonProbe = null; // { path, tried } once resolved; path === null when nothing qualified
|
|
57
|
+
function pythonArgv(path) { return path === "python3" ? ["/usr/bin/env", ["python3"]] : [path, []]; }
|
|
58
|
+
function resolvePython() {
|
|
59
|
+
if (pythonProbe) return pythonProbe;
|
|
60
|
+
const tried = [];
|
|
61
|
+
for (const cand of (process.env.NORACU_PYTHON ? [process.env.NORACU_PYTHON] : PYTHON_CANDIDATES)) {
|
|
62
|
+
const [cmd, pre] = pythonArgv(cand);
|
|
63
|
+
try {
|
|
64
|
+
execFileSync(cmd, [...pre, "-c", "import Quartz, Vision, AppKit"], { timeout: 20000, stdio: ["ignore", "ignore", "ignore"] });
|
|
65
|
+
pythonProbe = { path: cand, tried };
|
|
66
|
+
return pythonProbe;
|
|
67
|
+
} catch { tried.push(cand); }
|
|
68
|
+
}
|
|
69
|
+
pythonProbe = { path: null, tried };
|
|
70
|
+
return pythonProbe;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
function imctl(...args) {
|
|
74
|
+
const py = resolvePython();
|
|
75
|
+
if (!py.path) {
|
|
76
|
+
return { ok: false, out: `imctl unavailable: no python3 with pyobjc found (tried ${py.tried.join(", ")}) — run \`${PYOBJC_INSTALL}\` for the python3 you want, or set NORACU_PYTHON=/path/to/python3 (see README)` };
|
|
77
|
+
}
|
|
78
|
+
const [cmd, pre] = pythonArgv(py.path);
|
|
51
79
|
try {
|
|
52
|
-
const out = execFileSync(
|
|
80
|
+
const out = execFileSync(cmd, [...pre, "-m", "imctl.cli", ...args], {
|
|
53
81
|
timeout: 30000, encoding: "utf8", maxBuffer: 10 * 1024 * 1024,
|
|
54
|
-
cwd: IMCTL_DIR, env: { ...process.env, PYTHONPATH: join(IMCTL_DIR, "src") },
|
|
82
|
+
cwd: IMCTL_DIR, env: { ...process.env, PYTHONPATH: join(IMCTL_DIR, "src"), PYTHONDONTWRITEBYTECODE: "1" },
|
|
55
83
|
});
|
|
56
84
|
return { ok: true, out: out.trim() };
|
|
57
85
|
} catch (e) {
|
|
58
86
|
const raw = String(e.stderr || e.stdout || e.message);
|
|
59
|
-
//
|
|
60
|
-
// 把 Python traceback / spawn 错误收敛成一行可操作信息,phone_* 因此干净失败、测试可据此跳过(优雅降级)。
|
|
87
|
+
// Collapse Python tracebacks / spawn errors into one actionable line so phone_* fail cleanly and tests can skip (graceful degradation).
|
|
61
88
|
if (/No module named|ModuleNotFoundError|ImportError|Traceback \(most recent/.test(raw)) {
|
|
62
89
|
const mod = (raw.match(/No module named '([^']+)'/) || [])[1];
|
|
63
|
-
return { ok: false, out: `imctl unavailable:
|
|
90
|
+
return { ok: false, out: `imctl unavailable: ${py.path} is missing ${mod ? `the ${mod} module` : "a module"} — run \`${PYOBJC_INSTALL}\` for that python, or set NORACU_PYTHON (see README)` };
|
|
64
91
|
}
|
|
65
92
|
if (/ENOENT|No such file or directory/.test(raw)) {
|
|
66
|
-
return { ok: false, out: `imctl unavailable: cannot run
|
|
93
|
+
return { ok: false, out: `imctl unavailable: cannot run imctl at ${IMCTL_DIR} — the bundled imctl/ directory is missing or NORACU_IMCTL_DIR points to the wrong place (see README)` };
|
|
67
94
|
}
|
|
68
95
|
return { ok: false, out: raw.slice(0, 500) };
|
|
69
96
|
}
|
|
@@ -700,21 +727,16 @@ function phoneOpen(args) {
|
|
|
700
727
|
return imctl("open", args.app);
|
|
701
728
|
}
|
|
702
729
|
function phoneScreenshot() {
|
|
703
|
-
//
|
|
704
|
-
const
|
|
705
|
-
|
|
730
|
+
// Captured by imctl by CGWindow id, so it is the Mirroring window's own pixels even when another window covers it.
|
|
731
|
+
const file = join(tmpdir(), `noracu-phone-${randomUUID().slice(0, 8)}.png`);
|
|
732
|
+
const r = imctl("screenshot", file);
|
|
733
|
+
if (!r.ok) return { error: r.out.slice(0, 300) };
|
|
706
734
|
try {
|
|
707
|
-
const j = JSON.parse(st.out);
|
|
708
|
-
const w = j.window;
|
|
709
|
-
if (!w) return { error: "no mirroring window" };
|
|
710
|
-
const file = join(tmpdir(), `noracu-phone-${randomUUID().slice(0, 8)}.png`);
|
|
711
|
-
const r = sh("/usr/sbin/screencapture", ["-x", "-R", `${w.x},${w.y},${w.w},${w.h}`, file]);
|
|
712
|
-
if (!r.ok) return { error: r.out };
|
|
713
735
|
const data = readFileSync(file).toString("base64");
|
|
714
736
|
unlinkSync(file);
|
|
715
737
|
return { image: data, mime: "image/png" };
|
|
716
738
|
} catch (e) {
|
|
717
|
-
return { error: "
|
|
739
|
+
return { error: "screenshot file missing: " + String(e.message).slice(0, 150) };
|
|
718
740
|
}
|
|
719
741
|
}
|
|
720
742
|
|
|
@@ -764,20 +786,20 @@ const TOOLS = [
|
|
|
764
786
|
{ name: "browser_evaluate", desc: "Evaluate JavaScript in the page and return the result.", args: { js: "string" }, fn: browserEvaluate },
|
|
765
787
|
{ name: "browser_close", desc: "Close the browser.", args: {}, fn: browserClose },
|
|
766
788
|
// iPhone device-use(imctl)
|
|
767
|
-
{ name: "phone_status", desc: "EXPERIMENTAL.
|
|
768
|
-
{ name: "phone_launch", desc: "EXPERIMENTAL.
|
|
769
|
-
{ name: "phone_screenshot", desc: "EXPERIMENTAL.
|
|
770
|
-
{ name: "phone_ocr", desc: "EXPERIMENTAL.
|
|
771
|
-
{ name: "phone_find", desc: "EXPERIMENTAL.
|
|
772
|
-
{ name: "phone_tap", desc: "EXPERIMENTAL.
|
|
773
|
-
{ name: "phone_longpress", desc: "EXPERIMENTAL.
|
|
774
|
-
{ name: "phone_swipe", desc: "EXPERIMENTAL.
|
|
775
|
-
{ name: "phone_home", desc: "EXPERIMENTAL. Go to the iPhone Home Screen via the iPhone Mirroring menu (works without
|
|
776
|
-
{ name: "phone_app_switcher", desc: "EXPERIMENTAL. Open the iPhone App Switcher via the iPhone Mirroring menu (works without
|
|
777
|
-
{ name: "phone_scroll", desc: "EXPERIMENTAL.
|
|
778
|
-
{ name: "phone_type", desc: "EXPERIMENTAL.
|
|
779
|
-
{ name: "phone_key", desc: "EXPERIMENTAL.
|
|
780
|
-
{ name: "phone_open", desc: "EXPERIMENTAL.
|
|
789
|
+
{ name: "phone_status", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Return the iPhone Mirroring session status as JSON (running, window, onscreen, frontmost, paused).", args: {}, fn: phoneStatus },
|
|
790
|
+
{ name: "phone_launch", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Ensure iPhone Mirroring is running, brought to the front, and resumed if the connection is paused.", args: {}, fn: phoneLaunch },
|
|
791
|
+
{ name: "phone_screenshot", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Screenshot the iPhone screen (the Mirroring window, captured by window id so it works even when covered).", args: {}, fn: phoneScreenshot },
|
|
792
|
+
{ name: "phone_ocr", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. OCR the iPhone screen into text with coordinates.", args: {}, fn: phoneOcr },
|
|
793
|
+
{ name: "phone_find", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Locate text on the iPhone screen.", args: { text: "string" }, fn: phoneFind },
|
|
794
|
+
{ name: "phone_tap", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Tap at {x, y} in normalized 0-1 coordinates.", args: { x: "number", y: "number" }, fn: phoneTap },
|
|
795
|
+
{ name: "phone_longpress", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Long-press at {x, y} for an optional duration in seconds.", args: { x: "number", y: "number", duration: "number?" }, fn: phoneLongpress },
|
|
796
|
+
{ name: "phone_swipe", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Swipe from [x,y] to [x,y] over an optional duration in ms.", args: { from: "array", to: "array", ms: "number?" }, fn: phoneSwipe },
|
|
797
|
+
{ name: "phone_home", desc: "EXPERIMENTAL. Go to the iPhone Home Screen via the iPhone Mirroring menu (works without python/pyobjc when the mirror is connected); falls back to an imctl swipe.", args: {}, fn: phoneHome },
|
|
798
|
+
{ name: "phone_app_switcher", desc: "EXPERIMENTAL. Open the iPhone App Switcher via the iPhone Mirroring menu (works without python/pyobjc when the mirror is connected); falls back to an imctl gesture.", args: {}, fn: phoneAppSwitcher },
|
|
799
|
+
{ name: "phone_scroll", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Scroll up or down by a distance.", args: { direction: "string?", distance: "number?" }, fn: phoneScroll },
|
|
800
|
+
{ name: "phone_type", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Type text via the clipboard (IME-safe).", args: { text: "string" }, fn: phoneType },
|
|
801
|
+
{ name: "phone_key", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Press a key: return, delete, up, down, left, or right.", args: { key: "string" }, fn: phoneKey },
|
|
802
|
+
{ name: "phone_open", desc: "EXPERIMENTAL. Needs iPhone Mirroring and a python3 with pyobjc. Open an app via Spotlight search plus OCR-guided tap.", args: { app: "string" }, fn: phoneOpen },
|
|
781
803
|
];
|
|
782
804
|
|
|
783
805
|
// ── 统一参数校验(全工具入口门禁,返回 null=通过 / string=错误)──
|