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,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.0",
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 默认按同级 checkout 解析(…/code/noracu → …/code/imctl),可用 NORACU_IMCTL_DIR 覆盖
31
- const IMCTL_DIR = process.env.NORACU_IMCTL_DIR || join(__dirname, "..", "..", "imctl");
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("/usr/bin/env", ["python3", "-m", "imctl.cli", ...args], {
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
- // imctl 不可用 —— checkout 缺失,或 python3 没有 pyobjc(Quartz)。
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: its python3 is missing ${mod ? `the ${mod} module (pyobjc)` : "pyobjc"} — install pyobjc for that python, or point NORACU_IMCTL_DIR at a checkout whose python has it (see README)` };
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 python3 for imctl at ${IMCTL_DIR} — install imctl or set NORACU_IMCTL_DIR (see README)` };
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
- // Mirroring 窗口截图(imctl status 拿窗口 frame → screencapture -R)
704
- const st = imctl("status");
705
- if (!st.ok) return { error: "imctl status failed: " + st.out.slice(0, 150) };
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: "parse status failed: " + String(e.message).slice(0, 150) };
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. Requires imctl (not yet public). Return the iPhone Mirroring session status as JSON.", args: {}, fn: phoneStatus },
768
- { name: "phone_launch", desc: "EXPERIMENTAL. Requires imctl (not yet public). Ensure iPhone Mirroring is running and brought to the front.", args: {}, fn: phoneLaunch },
769
- { name: "phone_screenshot", desc: "EXPERIMENTAL. Requires imctl (not yet public). Screenshot the iPhone screen (the Mirroring window region).", args: {}, fn: phoneScreenshot },
770
- { name: "phone_ocr", desc: "EXPERIMENTAL. Requires imctl (not yet public). OCR the iPhone screen into text with coordinates.", args: {}, fn: phoneOcr },
771
- { name: "phone_find", desc: "EXPERIMENTAL. Requires imctl (not yet public). Locate text on the iPhone screen.", args: { text: "string" }, fn: phoneFind },
772
- { name: "phone_tap", desc: "EXPERIMENTAL. Requires imctl (not yet public). Tap at {x, y} in normalized 0-1 coordinates.", args: { x: "number", y: "number" }, fn: phoneTap },
773
- { name: "phone_longpress", desc: "EXPERIMENTAL. Requires imctl (not yet public). Long-press at {x, y} for an optional duration in seconds.", args: { x: "number", y: "number", duration: "number?" }, fn: phoneLongpress },
774
- { name: "phone_swipe", desc: "EXPERIMENTAL. Requires imctl (not yet public). Swipe from [x,y] to [x,y] over an optional duration in ms.", args: { from: "array", to: "array", ms: "number?" }, fn: phoneSwipe },
775
- { name: "phone_home", desc: "EXPERIMENTAL. Go to the iPhone Home Screen via the iPhone Mirroring menu (works without imctl when the mirror is connected); falls back to an imctl swipe.", args: {}, fn: phoneHome },
776
- { name: "phone_app_switcher", desc: "EXPERIMENTAL. Open the iPhone App Switcher via the iPhone Mirroring menu (works without imctl when the mirror is connected); falls back to an imctl gesture.", args: {}, fn: phoneAppSwitcher },
777
- { name: "phone_scroll", desc: "EXPERIMENTAL. Requires imctl (not yet public). Scroll up or down by a distance.", args: { direction: "string?", distance: "number?" }, fn: phoneScroll },
778
- { name: "phone_type", desc: "EXPERIMENTAL. Requires imctl (not yet public). Type text via the clipboard (IME-safe).", args: { text: "string" }, fn: phoneType },
779
- { name: "phone_key", desc: "EXPERIMENTAL. Requires imctl (not yet public). Press a key: return, delete, up, down, left, or right.", args: { key: "string" }, fn: phoneKey },
780
- { name: "phone_open", desc: "EXPERIMENTAL. Requires imctl (not yet public). Open an app via Spotlight search plus OCR-guided tap.", args: { app: "string" }, fn: phoneOpen },
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=错误)──