bingocode 1.1.62 → 1.1.64
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/package.json +2 -1
- package/runtime/mac_helper.py +775 -0
- package/runtime/requirements-win.txt +7 -0
- package/runtime/requirements.txt +6 -0
- package/runtime/test_helpers.py +322 -0
- package/runtime/win_helper.py +723 -0
- package/src/manager/CliMenuManager.tsx +0 -1
- package/src/server/ensureSingletonLocalServer.ts +160 -256
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import base64
|
|
6
|
+
import ctypes
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import mss
|
|
17
|
+
from AppKit import NSWorkspace, NSPasteboard, NSPasteboardTypeString, NSURL
|
|
18
|
+
from PIL import Image
|
|
19
|
+
from Quartz import (
|
|
20
|
+
CGDisplayBounds,
|
|
21
|
+
CGDisplayIsMain,
|
|
22
|
+
CGDisplayModeGetPixelHeight,
|
|
23
|
+
CGDisplayModeGetPixelWidth,
|
|
24
|
+
CGDisplayPixelsHigh,
|
|
25
|
+
CGDisplayPixelsWide,
|
|
26
|
+
CGGetActiveDisplayList,
|
|
27
|
+
CGMainDisplayID,
|
|
28
|
+
CGWindowListCopyWindowInfo,
|
|
29
|
+
CGRectContainsPoint,
|
|
30
|
+
CGRectIntersection,
|
|
31
|
+
CGPointMake,
|
|
32
|
+
CGPreflightScreenCaptureAccess,
|
|
33
|
+
kCGNullWindowID,
|
|
34
|
+
kCGWindowBounds,
|
|
35
|
+
kCGWindowIsOnscreen,
|
|
36
|
+
kCGWindowLayer,
|
|
37
|
+
kCGWindowListExcludeDesktopElements,
|
|
38
|
+
kCGWindowListOptionOnScreenOnly,
|
|
39
|
+
kCGWindowName,
|
|
40
|
+
kCGWindowOwnerName,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1")
|
|
44
|
+
os.environ.setdefault("PYAUTOGUI_HIDE_SUPPORT_PROMPT", "1")
|
|
45
|
+
|
|
46
|
+
import pyautogui # noqa: E402
|
|
47
|
+
|
|
48
|
+
pyautogui.FAILSAFE = False
|
|
49
|
+
pyautogui.PAUSE = 0
|
|
50
|
+
|
|
51
|
+
KEY_MAP = {
|
|
52
|
+
"a": "a",
|
|
53
|
+
"b": "b",
|
|
54
|
+
"c": "c",
|
|
55
|
+
"d": "d",
|
|
56
|
+
"e": "e",
|
|
57
|
+
"f": "f",
|
|
58
|
+
"g": "g",
|
|
59
|
+
"h": "h",
|
|
60
|
+
"i": "i",
|
|
61
|
+
"j": "j",
|
|
62
|
+
"k": "k",
|
|
63
|
+
"l": "l",
|
|
64
|
+
"m": "m",
|
|
65
|
+
"n": "n",
|
|
66
|
+
"o": "o",
|
|
67
|
+
"p": "p",
|
|
68
|
+
"q": "q",
|
|
69
|
+
"r": "r",
|
|
70
|
+
"s": "s",
|
|
71
|
+
"t": "t",
|
|
72
|
+
"u": "u",
|
|
73
|
+
"v": "v",
|
|
74
|
+
"w": "w",
|
|
75
|
+
"x": "x",
|
|
76
|
+
"y": "y",
|
|
77
|
+
"z": "z",
|
|
78
|
+
"0": "0",
|
|
79
|
+
"1": "1",
|
|
80
|
+
"2": "2",
|
|
81
|
+
"3": "3",
|
|
82
|
+
"4": "4",
|
|
83
|
+
"5": "5",
|
|
84
|
+
"6": "6",
|
|
85
|
+
"7": "7",
|
|
86
|
+
"8": "8",
|
|
87
|
+
"9": "9",
|
|
88
|
+
"cmd": "command",
|
|
89
|
+
"command": "command",
|
|
90
|
+
"meta": "command",
|
|
91
|
+
"super": "command",
|
|
92
|
+
"ctrl": "ctrl",
|
|
93
|
+
"control": "ctrl",
|
|
94
|
+
"shift": "shift",
|
|
95
|
+
"alt": "option",
|
|
96
|
+
"option": "option",
|
|
97
|
+
"opt": "option",
|
|
98
|
+
"fn": "fn",
|
|
99
|
+
"escape": "esc",
|
|
100
|
+
"esc": "esc",
|
|
101
|
+
"enter": "enter",
|
|
102
|
+
"return": "enter",
|
|
103
|
+
"tab": "tab",
|
|
104
|
+
"space": "space",
|
|
105
|
+
"backspace": "backspace",
|
|
106
|
+
"delete": "delete",
|
|
107
|
+
"forwarddelete": "delete",
|
|
108
|
+
"up": "up",
|
|
109
|
+
"down": "down",
|
|
110
|
+
"left": "left",
|
|
111
|
+
"right": "right",
|
|
112
|
+
"home": "home",
|
|
113
|
+
"end": "end",
|
|
114
|
+
"pageup": "pageup",
|
|
115
|
+
"pagedown": "pagedown",
|
|
116
|
+
"capslock": "capslock",
|
|
117
|
+
"f1": "f1",
|
|
118
|
+
"f2": "f2",
|
|
119
|
+
"f3": "f3",
|
|
120
|
+
"f4": "f4",
|
|
121
|
+
"f5": "f5",
|
|
122
|
+
"f6": "f6",
|
|
123
|
+
"f7": "f7",
|
|
124
|
+
"f8": "f8",
|
|
125
|
+
"f9": "f9",
|
|
126
|
+
"f10": "f10",
|
|
127
|
+
"f11": "f11",
|
|
128
|
+
"f12": "f12",
|
|
129
|
+
"-": "minus",
|
|
130
|
+
"=": "equals",
|
|
131
|
+
"[": "[",
|
|
132
|
+
"]": "]",
|
|
133
|
+
"\\": "\\",
|
|
134
|
+
";": ";",
|
|
135
|
+
"'": "'",
|
|
136
|
+
",": ",",
|
|
137
|
+
".": ".",
|
|
138
|
+
"/": "/",
|
|
139
|
+
"`": "`",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def normalize_key(name: str) -> str:
|
|
144
|
+
key = name.strip().lower()
|
|
145
|
+
if key not in KEY_MAP:
|
|
146
|
+
raise ValueError(f"Unsupported key: {name}")
|
|
147
|
+
return KEY_MAP[key]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def json_output(payload: dict[str, Any]) -> None:
|
|
151
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
152
|
+
sys.stdout.write("\n")
|
|
153
|
+
sys.stdout.flush()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def error_output(message: str, code: str = "runtime_error") -> None:
|
|
157
|
+
json_output({"ok": False, "error": {"code": code, "message": message}})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def bool_env(name: str, default: bool = False) -> bool:
|
|
161
|
+
value = os.environ.get(name)
|
|
162
|
+
if value is None:
|
|
163
|
+
return default
|
|
164
|
+
return value not in {"0", "false", "False", ""}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_osascript(script: str) -> str:
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
["osascript", "-e", script],
|
|
170
|
+
text=True,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
check=False,
|
|
173
|
+
)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "osascript failed")
|
|
176
|
+
return result.stdout.strip()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def applescript_modifier(name: str) -> str:
|
|
180
|
+
if name == "command":
|
|
181
|
+
return "command down"
|
|
182
|
+
if name == "option":
|
|
183
|
+
return "option down"
|
|
184
|
+
if name == "shift":
|
|
185
|
+
return "shift down"
|
|
186
|
+
if name == "ctrl":
|
|
187
|
+
return "control down"
|
|
188
|
+
if name == "fn":
|
|
189
|
+
return "fn down"
|
|
190
|
+
raise ValueError(f"Unsupported AppleScript modifier: {name}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def send_keystroke_via_osascript(character: str, modifiers: list[str] | None = None) -> None:
|
|
194
|
+
escaped = character.replace("\\", "\\\\").replace('"', '\\"')
|
|
195
|
+
if modifiers:
|
|
196
|
+
modifier_expr = ", ".join(applescript_modifier(m) for m in modifiers)
|
|
197
|
+
script = (
|
|
198
|
+
'tell application "System Events" to keystroke '
|
|
199
|
+
f'"{escaped}" using {{{modifier_expr}}}'
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
script = f'tell application "System Events" to keystroke "{escaped}"'
|
|
203
|
+
run_osascript(script)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_displays() -> list[dict[str, Any]]:
|
|
207
|
+
max_displays = 32
|
|
208
|
+
err, active, count = CGGetActiveDisplayList(max_displays, None, None)
|
|
209
|
+
if err != 0:
|
|
210
|
+
raise RuntimeError(f"CGGetActiveDisplayList failed: {err}")
|
|
211
|
+
displays: list[dict[str, Any]] = []
|
|
212
|
+
main_id = CGMainDisplayID()
|
|
213
|
+
for idx, display_id in enumerate(active[:count]):
|
|
214
|
+
bounds = CGDisplayBounds(display_id)
|
|
215
|
+
mode = None
|
|
216
|
+
try:
|
|
217
|
+
from Quartz import CGDisplayCopyDisplayMode
|
|
218
|
+
mode = CGDisplayCopyDisplayMode(display_id)
|
|
219
|
+
except Exception:
|
|
220
|
+
mode = None
|
|
221
|
+
physical_width = int(CGDisplayPixelsWide(display_id))
|
|
222
|
+
physical_height = int(CGDisplayPixelsHigh(display_id))
|
|
223
|
+
logical_width = int(bounds.size.width)
|
|
224
|
+
logical_height = int(bounds.size.height)
|
|
225
|
+
if mode is not None:
|
|
226
|
+
mode_w = int(CGDisplayModeGetPixelWidth(mode))
|
|
227
|
+
mode_h = int(CGDisplayModeGetPixelHeight(mode))
|
|
228
|
+
physical_width = mode_w or physical_width
|
|
229
|
+
physical_height = mode_h or physical_height
|
|
230
|
+
scale_factor = physical_width / logical_width if logical_width else 1
|
|
231
|
+
name = f"Display {idx + 1}"
|
|
232
|
+
displays.append(
|
|
233
|
+
{
|
|
234
|
+
"id": int(display_id),
|
|
235
|
+
"displayId": int(display_id),
|
|
236
|
+
"width": logical_width,
|
|
237
|
+
"height": logical_height,
|
|
238
|
+
"scaleFactor": scale_factor,
|
|
239
|
+
"originX": int(bounds.origin.x),
|
|
240
|
+
"originY": int(bounds.origin.y),
|
|
241
|
+
"isPrimary": bool(display_id == main_id or CGDisplayIsMain(display_id)),
|
|
242
|
+
"name": name,
|
|
243
|
+
"label": name,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
return displays
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def choose_display(display_id: int | None) -> dict[str, Any]:
|
|
250
|
+
displays = get_displays()
|
|
251
|
+
if not displays:
|
|
252
|
+
raise RuntimeError("No active displays found")
|
|
253
|
+
if display_id is None:
|
|
254
|
+
for display in displays:
|
|
255
|
+
if display["isPrimary"]:
|
|
256
|
+
return display
|
|
257
|
+
return displays[0]
|
|
258
|
+
for display in displays:
|
|
259
|
+
if display["displayId"] == display_id or display["id"] == display_id:
|
|
260
|
+
return display
|
|
261
|
+
raise RuntimeError(f"Unknown display: {display_id}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ensure_screen_recording_permission() -> None:
|
|
265
|
+
"""No-op: CGPreflightScreenCaptureAccess is unreliable for child processes
|
|
266
|
+
(returns False even when the parent app has TCC permission), and any actual
|
|
267
|
+
capture attempt triggers a macOS popup on newer versions. Let the actual
|
|
268
|
+
capture call handle errors instead."""
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def capture_display(display_id: int | None, resize: tuple[int, int] | None = None) -> dict[str, Any]:
|
|
273
|
+
ensure_screen_recording_permission()
|
|
274
|
+
display = choose_display(display_id)
|
|
275
|
+
monitor = {
|
|
276
|
+
"left": display["originX"],
|
|
277
|
+
"top": display["originY"],
|
|
278
|
+
"width": display["width"],
|
|
279
|
+
"height": display["height"],
|
|
280
|
+
}
|
|
281
|
+
with mss.mss() as sct:
|
|
282
|
+
raw = sct.grab(monitor)
|
|
283
|
+
image = Image.frombytes("RGB", raw.size, raw.rgb)
|
|
284
|
+
if resize:
|
|
285
|
+
image = image.resize(resize, Image.Resampling.LANCZOS)
|
|
286
|
+
buffer = BytesIO()
|
|
287
|
+
image.save(buffer, format="JPEG", quality=75, optimize=True)
|
|
288
|
+
base64_data = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
289
|
+
return {
|
|
290
|
+
"base64": base64_data,
|
|
291
|
+
"width": image.width,
|
|
292
|
+
"height": image.height,
|
|
293
|
+
"displayWidth": display["width"],
|
|
294
|
+
"displayHeight": display["height"],
|
|
295
|
+
"displayId": display["displayId"],
|
|
296
|
+
"originX": display["originX"],
|
|
297
|
+
"originY": display["originY"],
|
|
298
|
+
"display": display,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def capture_region(region: dict[str, int], resize: tuple[int, int] | None = None) -> dict[str, Any]:
|
|
303
|
+
ensure_screen_recording_permission()
|
|
304
|
+
with mss.mss() as sct:
|
|
305
|
+
raw = sct.grab(region)
|
|
306
|
+
image = Image.frombytes("RGB", raw.size, raw.rgb)
|
|
307
|
+
if resize:
|
|
308
|
+
image = image.resize(resize, Image.Resampling.LANCZOS)
|
|
309
|
+
buffer = BytesIO()
|
|
310
|
+
image.save(buffer, format="JPEG", quality=75, optimize=True)
|
|
311
|
+
base64_data = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
312
|
+
return {"base64": base64_data, "width": image.width, "height": image.height}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def list_windows() -> list[dict[str, Any]]:
|
|
316
|
+
windows = CGWindowListCopyWindowInfo(
|
|
317
|
+
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
|
318
|
+
kCGNullWindowID,
|
|
319
|
+
)
|
|
320
|
+
out: list[dict[str, Any]] = []
|
|
321
|
+
for window in windows or []:
|
|
322
|
+
if int(window.get(kCGWindowLayer, 0)) != 0:
|
|
323
|
+
continue
|
|
324
|
+
if not bool(window.get(kCGWindowIsOnscreen, True)):
|
|
325
|
+
continue
|
|
326
|
+
bounds = window.get(kCGWindowBounds) or {}
|
|
327
|
+
width = int(bounds.get("Width", 0))
|
|
328
|
+
height = int(bounds.get("Height", 0))
|
|
329
|
+
if width <= 1 or height <= 1:
|
|
330
|
+
continue
|
|
331
|
+
out.append(
|
|
332
|
+
{
|
|
333
|
+
"ownerName": window.get(kCGWindowOwnerName, "") or "",
|
|
334
|
+
"title": window.get(kCGWindowName, "") or "",
|
|
335
|
+
"bounds": {
|
|
336
|
+
"x": int(bounds.get("X", 0)),
|
|
337
|
+
"y": int(bounds.get("Y", 0)),
|
|
338
|
+
"width": width,
|
|
339
|
+
"height": height,
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def bundle_id_to_app(bundle_id: str):
|
|
347
|
+
return NSWorkspace.sharedWorkspace().URLForApplicationWithBundleIdentifier_(bundle_id)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def installed_apps() -> list[dict[str, Any]]:
|
|
351
|
+
search_roots = [
|
|
352
|
+
Path("/Applications"),
|
|
353
|
+
Path.home() / "Applications",
|
|
354
|
+
Path("/System/Applications"),
|
|
355
|
+
Path("/System/Applications/Utilities"),
|
|
356
|
+
]
|
|
357
|
+
results: dict[str, dict[str, Any]] = {}
|
|
358
|
+
workspace = NSWorkspace.sharedWorkspace()
|
|
359
|
+
for root in search_roots:
|
|
360
|
+
if not root.exists():
|
|
361
|
+
continue
|
|
362
|
+
for app in root.rglob("*.app"):
|
|
363
|
+
try:
|
|
364
|
+
bundle = workspace.bundleIdentifierForURL_(NSURL.fileURLWithPath_(str(app)))
|
|
365
|
+
except Exception:
|
|
366
|
+
bundle = None
|
|
367
|
+
if not bundle:
|
|
368
|
+
try:
|
|
369
|
+
url = workspace.URLForApplicationWithBundleIdentifier_(str(app))
|
|
370
|
+
bundle = workspace.bundleIdentifierForURL_(url) if url else None
|
|
371
|
+
except Exception:
|
|
372
|
+
bundle = None
|
|
373
|
+
info_plist = app / "Contents/Info.plist"
|
|
374
|
+
display_name = app.stem
|
|
375
|
+
if info_plist.exists():
|
|
376
|
+
try:
|
|
377
|
+
import plistlib
|
|
378
|
+
with info_plist.open("rb") as f:
|
|
379
|
+
plist = plistlib.load(f)
|
|
380
|
+
bundle = bundle or plist.get("CFBundleIdentifier")
|
|
381
|
+
display_name = plist.get("CFBundleDisplayName") or plist.get("CFBundleName") or display_name
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
if not bundle or bundle in results:
|
|
385
|
+
continue
|
|
386
|
+
results[bundle] = {
|
|
387
|
+
"bundleId": str(bundle),
|
|
388
|
+
"displayName": str(display_name),
|
|
389
|
+
"path": str(app),
|
|
390
|
+
}
|
|
391
|
+
return sorted(results.values(), key=lambda item: item["displayName"].lower())
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def running_apps() -> list[dict[str, Any]]:
|
|
395
|
+
apps = []
|
|
396
|
+
seen = set()
|
|
397
|
+
for app in NSWorkspace.sharedWorkspace().runningApplications() or []:
|
|
398
|
+
bundle_id = app.bundleIdentifier()
|
|
399
|
+
if not bundle_id or bundle_id in seen:
|
|
400
|
+
continue
|
|
401
|
+
seen.add(bundle_id)
|
|
402
|
+
name = app.localizedName() or bundle_id
|
|
403
|
+
apps.append({"bundleId": str(bundle_id), "displayName": str(name)})
|
|
404
|
+
return sorted(apps, key=lambda item: item["displayName"].lower())
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def app_display_name(bundle_id: str) -> str | None:
|
|
408
|
+
for app in NSWorkspace.sharedWorkspace().runningApplications() or []:
|
|
409
|
+
if app.bundleIdentifier() == bundle_id:
|
|
410
|
+
return str(app.localizedName() or bundle_id)
|
|
411
|
+
for app in installed_apps():
|
|
412
|
+
if app["bundleId"] == bundle_id:
|
|
413
|
+
return str(app["displayName"])
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def frontmost_app() -> dict[str, str] | None:
|
|
418
|
+
app = NSWorkspace.sharedWorkspace().frontmostApplication()
|
|
419
|
+
if not app:
|
|
420
|
+
return None
|
|
421
|
+
bundle_id = app.bundleIdentifier()
|
|
422
|
+
if not bundle_id:
|
|
423
|
+
return None
|
|
424
|
+
return {
|
|
425
|
+
"bundleId": str(bundle_id),
|
|
426
|
+
"displayName": str(app.localizedName() or bundle_id),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def app_under_point(x: int, y: int) -> dict[str, str] | None:
|
|
431
|
+
point = CGPointMake(x, y)
|
|
432
|
+
running_by_name = {
|
|
433
|
+
str(app.localizedName() or app.bundleIdentifier()): str(app.bundleIdentifier())
|
|
434
|
+
for app in NSWorkspace.sharedWorkspace().runningApplications() or []
|
|
435
|
+
if app.bundleIdentifier()
|
|
436
|
+
}
|
|
437
|
+
for window in list_windows():
|
|
438
|
+
bounds = window["bounds"]
|
|
439
|
+
rect = ((bounds["x"], bounds["y"]), (bounds["width"], bounds["height"]))
|
|
440
|
+
if CGRectContainsPoint(rect, point):
|
|
441
|
+
owner = window["ownerName"]
|
|
442
|
+
bundle = running_by_name.get(owner)
|
|
443
|
+
if bundle:
|
|
444
|
+
return {"bundleId": bundle, "displayName": str(owner)}
|
|
445
|
+
return frontmost_app()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def find_window_displays(bundle_ids: list[str]) -> list[dict[str, Any]]:
|
|
449
|
+
if not bundle_ids:
|
|
450
|
+
return []
|
|
451
|
+
displays = get_displays()
|
|
452
|
+
names_by_bundle = {
|
|
453
|
+
bundle_id: app_display_name(bundle_id) or bundle_id for bundle_id in bundle_ids
|
|
454
|
+
}
|
|
455
|
+
windows = list_windows()
|
|
456
|
+
result = []
|
|
457
|
+
for bundle_id in bundle_ids:
|
|
458
|
+
target_name = names_by_bundle.get(bundle_id)
|
|
459
|
+
display_ids: set[int] = set()
|
|
460
|
+
for window in windows:
|
|
461
|
+
owner = window["ownerName"]
|
|
462
|
+
if not owner:
|
|
463
|
+
continue
|
|
464
|
+
if target_name and owner != target_name:
|
|
465
|
+
continue
|
|
466
|
+
if not target_name and owner != bundle_id:
|
|
467
|
+
continue
|
|
468
|
+
wx = window["bounds"]["x"]
|
|
469
|
+
wy = window["bounds"]["y"]
|
|
470
|
+
ww = window["bounds"]["width"]
|
|
471
|
+
wh = window["bounds"]["height"]
|
|
472
|
+
window_rect = ((wx, wy), (ww, wh))
|
|
473
|
+
for display in displays:
|
|
474
|
+
display_rect = ((display["originX"], display["originY"]), (display["width"], display["height"]))
|
|
475
|
+
intersection = CGRectIntersection(window_rect, display_rect)
|
|
476
|
+
if intersection.size.width > 0 and intersection.size.height > 0:
|
|
477
|
+
display_ids.add(int(display["displayId"]))
|
|
478
|
+
result.append({"bundleId": bundle_id, "displayIds": sorted(display_ids)})
|
|
479
|
+
return result
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def open_app(bundle_id: str) -> None:
|
|
483
|
+
url = bundle_id_to_app(bundle_id)
|
|
484
|
+
if not url:
|
|
485
|
+
raise RuntimeError(f"App not found for bundle identifier: {bundle_id}")
|
|
486
|
+
ok, err = NSWorkspace.sharedWorkspace().launchApplicationAtURL_options_configuration_error_(url, 0, {}, None)
|
|
487
|
+
if not ok:
|
|
488
|
+
raise RuntimeError(str(err) if err else f"Failed to open app {bundle_id}")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def read_clipboard() -> str:
|
|
492
|
+
pb = NSPasteboard.generalPasteboard()
|
|
493
|
+
value = pb.stringForType_(NSPasteboardTypeString)
|
|
494
|
+
return "" if value is None else str(value)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def write_clipboard(text: str) -> None:
|
|
498
|
+
pb = NSPasteboard.generalPasteboard()
|
|
499
|
+
pb.clearContents()
|
|
500
|
+
pb.setString_forType_(text, NSPasteboardTypeString)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def paste_clipboard() -> None:
|
|
504
|
+
send_keystroke_via_osascript("v", ["command"])
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def detect_screen_recording_permission() -> bool | None:
|
|
508
|
+
"""Best-effort passive screen-recording probe with no system prompt.
|
|
509
|
+
|
|
510
|
+
`CGPreflightScreenCaptureAccess()` is fast and explicit when it returns
|
|
511
|
+
True, but on child processes launched by a TCC-authorized app bundle it can
|
|
512
|
+
still return False. As a fallback, inspect the visible window list: Apple
|
|
513
|
+
only exposes other apps' window titles when Screen Recording access is
|
|
514
|
+
granted. If we can see at least one title, treat the permission as granted.
|
|
515
|
+
If we can inspect visible windows but every title is blank, treat it as not
|
|
516
|
+
granted. If window enumeration itself is unavailable, return None.
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
if CGPreflightScreenCaptureAccess():
|
|
521
|
+
return True
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
windows = CGWindowListCopyWindowInfo(
|
|
527
|
+
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
|
528
|
+
kCGNullWindowID,
|
|
529
|
+
)
|
|
530
|
+
except Exception:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
eligible_windows = 0
|
|
534
|
+
for window in windows or []:
|
|
535
|
+
if int(window.get(kCGWindowLayer, 0)) != 0:
|
|
536
|
+
continue
|
|
537
|
+
if not bool(window.get(kCGWindowIsOnscreen, True)):
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
bounds = window.get(kCGWindowBounds) or {}
|
|
541
|
+
width = int(bounds.get("Width", 0))
|
|
542
|
+
height = int(bounds.get("Height", 0))
|
|
543
|
+
if width <= 1 or height <= 1:
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
eligible_windows += 1
|
|
547
|
+
if (window.get(kCGWindowName, "") or "").strip():
|
|
548
|
+
return True
|
|
549
|
+
|
|
550
|
+
if eligible_windows > 0:
|
|
551
|
+
return False
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def detect_accessibility_permission() -> bool:
|
|
556
|
+
"""
|
|
557
|
+
Use the official macOS Accessibility trust API.
|
|
558
|
+
|
|
559
|
+
The previous System Events / AppleScript probe was too weak: it could
|
|
560
|
+
succeed even when the current helper process was not actually trusted for
|
|
561
|
+
input control, which led the desktop UI to report Accessibility as granted
|
|
562
|
+
while mouse/keyboard control still failed at runtime.
|
|
563
|
+
"""
|
|
564
|
+
framework_path = "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"
|
|
565
|
+
try:
|
|
566
|
+
application_services = ctypes.CDLL(framework_path)
|
|
567
|
+
application_services.AXIsProcessTrusted.restype = ctypes.c_bool
|
|
568
|
+
application_services.AXIsProcessTrusted.argtypes = []
|
|
569
|
+
return bool(application_services.AXIsProcessTrusted())
|
|
570
|
+
except Exception:
|
|
571
|
+
# Fail closed: if the trust API can't be queried, treat accessibility
|
|
572
|
+
# as unavailable instead of reporting a misleading success state.
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def check_permissions() -> dict[str, bool | None]:
|
|
577
|
+
accessibility = detect_accessibility_permission()
|
|
578
|
+
screen_recording = detect_screen_recording_permission()
|
|
579
|
+
return {
|
|
580
|
+
"accessibility": accessibility,
|
|
581
|
+
"screenRecording": screen_recording,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def click(x: int, y: int, button: str, count: int, modifiers: list[str] | None) -> None:
|
|
586
|
+
pyautogui.moveTo(x, y)
|
|
587
|
+
if modifiers:
|
|
588
|
+
normalized = [normalize_key(m) for m in modifiers]
|
|
589
|
+
for key in normalized:
|
|
590
|
+
pyautogui.keyDown(key)
|
|
591
|
+
try:
|
|
592
|
+
pyautogui.click(x=x, y=y, button=button, clicks=count, interval=0.08)
|
|
593
|
+
finally:
|
|
594
|
+
for key in reversed(normalized):
|
|
595
|
+
pyautogui.keyUp(key)
|
|
596
|
+
else:
|
|
597
|
+
pyautogui.click(x=x, y=y, button=button, clicks=count, interval=0.08)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def scroll(x: int, y: int, delta_x: int, delta_y: int) -> None:
|
|
601
|
+
pyautogui.moveTo(x, y)
|
|
602
|
+
if delta_y:
|
|
603
|
+
pyautogui.scroll(int(delta_y), x=x, y=y)
|
|
604
|
+
if delta_x:
|
|
605
|
+
pyautogui.hscroll(int(delta_x), x=x, y=y)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def key_action(sequence: str, repeat: int = 1) -> None:
|
|
609
|
+
parts = [normalize_key(part) for part in sequence.split("+") if part.strip()]
|
|
610
|
+
for _ in range(max(1, repeat)):
|
|
611
|
+
if parts == ["command", "v"]:
|
|
612
|
+
paste_clipboard()
|
|
613
|
+
elif parts == ["command", "a"]:
|
|
614
|
+
send_keystroke_via_osascript("a", ["command"])
|
|
615
|
+
elif parts == ["command", "c"]:
|
|
616
|
+
send_keystroke_via_osascript("c", ["command"])
|
|
617
|
+
elif parts == ["command", "x"]:
|
|
618
|
+
send_keystroke_via_osascript("x", ["command"])
|
|
619
|
+
elif len(parts) == 1:
|
|
620
|
+
pyautogui.press(parts[0])
|
|
621
|
+
else:
|
|
622
|
+
pyautogui.hotkey(*parts, interval=0.02)
|
|
623
|
+
time.sleep(0.01)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def hold_keys(keys: list[str], duration_ms: int) -> None:
|
|
627
|
+
normalized = [normalize_key(k) for k in keys]
|
|
628
|
+
for key in normalized:
|
|
629
|
+
pyautogui.keyDown(key)
|
|
630
|
+
try:
|
|
631
|
+
time.sleep(max(duration_ms, 0) / 1000)
|
|
632
|
+
finally:
|
|
633
|
+
for key in reversed(normalized):
|
|
634
|
+
pyautogui.keyUp(key)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def type_text(text: str) -> None:
|
|
638
|
+
pyautogui.write(text, interval=0.008)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def main() -> int:
|
|
642
|
+
parser = argparse.ArgumentParser()
|
|
643
|
+
parser.add_argument("command")
|
|
644
|
+
parser.add_argument("--payload", default="{}")
|
|
645
|
+
args = parser.parse_args()
|
|
646
|
+
payload = json.loads(args.payload)
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
command = args.command
|
|
650
|
+
if command == "check_permissions":
|
|
651
|
+
perms = check_permissions()
|
|
652
|
+
json_output({"ok": True, "result": perms})
|
|
653
|
+
return 0
|
|
654
|
+
if command == "list_displays":
|
|
655
|
+
json_output({"ok": True, "result": get_displays()})
|
|
656
|
+
return 0
|
|
657
|
+
if command == "get_display_size":
|
|
658
|
+
json_output({"ok": True, "result": choose_display(payload.get("displayId"))})
|
|
659
|
+
return 0
|
|
660
|
+
if command == "screenshot":
|
|
661
|
+
resize = None
|
|
662
|
+
if payload.get("targetWidth") and payload.get("targetHeight"):
|
|
663
|
+
resize = (int(payload["targetWidth"]), int(payload["targetHeight"]))
|
|
664
|
+
result = capture_display(payload.get("displayId"), resize)
|
|
665
|
+
json_output({"ok": True, "result": result})
|
|
666
|
+
return 0
|
|
667
|
+
if command == "resolve_prepare_capture":
|
|
668
|
+
resize = None
|
|
669
|
+
if payload.get("targetWidth") and payload.get("targetHeight"):
|
|
670
|
+
resize = (int(payload["targetWidth"]), int(payload["targetHeight"]))
|
|
671
|
+
result = capture_display(payload.get("preferredDisplayId"), resize)
|
|
672
|
+
result["hidden"] = []
|
|
673
|
+
result["resolvedDisplayId"] = result["displayId"]
|
|
674
|
+
json_output({"ok": True, "result": result})
|
|
675
|
+
return 0
|
|
676
|
+
if command == "zoom":
|
|
677
|
+
resize = None
|
|
678
|
+
if payload.get("targetWidth") and payload.get("targetHeight"):
|
|
679
|
+
resize = (int(payload["targetWidth"]), int(payload["targetHeight"]))
|
|
680
|
+
region = {
|
|
681
|
+
"left": int(payload["x"]),
|
|
682
|
+
"top": int(payload["y"]),
|
|
683
|
+
"width": int(payload["width"]),
|
|
684
|
+
"height": int(payload["height"]),
|
|
685
|
+
}
|
|
686
|
+
json_output({"ok": True, "result": capture_region(region, resize)})
|
|
687
|
+
return 0
|
|
688
|
+
if command == "prepare_for_action":
|
|
689
|
+
json_output({"ok": True, "result": []})
|
|
690
|
+
return 0
|
|
691
|
+
if command == "preview_hide_set":
|
|
692
|
+
json_output({"ok": True, "result": []})
|
|
693
|
+
return 0
|
|
694
|
+
if command == "find_window_displays":
|
|
695
|
+
json_output({"ok": True, "result": find_window_displays(list(payload.get("bundleIds") or []))})
|
|
696
|
+
return 0
|
|
697
|
+
if command == "key":
|
|
698
|
+
key_action(str(payload["keySequence"]), int(payload.get("repeat") or 1))
|
|
699
|
+
json_output({"ok": True, "result": True})
|
|
700
|
+
return 0
|
|
701
|
+
if command == "hold_key":
|
|
702
|
+
hold_keys(list(payload.get("keyNames") or []), int(payload.get("durationMs") or 0))
|
|
703
|
+
json_output({"ok": True, "result": True})
|
|
704
|
+
return 0
|
|
705
|
+
if command == "type":
|
|
706
|
+
type_text(str(payload.get("text") or ""))
|
|
707
|
+
json_output({"ok": True, "result": True})
|
|
708
|
+
return 0
|
|
709
|
+
if command == "click":
|
|
710
|
+
click(int(payload["x"]), int(payload["y"]), str(payload.get("button") or "left"), int(payload.get("count") or 1), payload.get("modifiers"))
|
|
711
|
+
json_output({"ok": True, "result": True})
|
|
712
|
+
return 0
|
|
713
|
+
if command == "drag":
|
|
714
|
+
from_point = payload.get("from")
|
|
715
|
+
if from_point:
|
|
716
|
+
pyautogui.moveTo(int(from_point["x"]), int(from_point["y"]))
|
|
717
|
+
pyautogui.dragTo(int(payload["to"]["x"]), int(payload["to"]["y"]), duration=0.2, button="left")
|
|
718
|
+
json_output({"ok": True, "result": True})
|
|
719
|
+
return 0
|
|
720
|
+
if command == "move_mouse":
|
|
721
|
+
pyautogui.moveTo(int(payload["x"]), int(payload["y"]))
|
|
722
|
+
json_output({"ok": True, "result": True})
|
|
723
|
+
return 0
|
|
724
|
+
if command == "scroll":
|
|
725
|
+
scroll(int(payload["x"]), int(payload["y"]), int(payload.get("deltaX") or 0), int(payload.get("deltaY") or 0))
|
|
726
|
+
json_output({"ok": True, "result": True})
|
|
727
|
+
return 0
|
|
728
|
+
if command == "mouse_down":
|
|
729
|
+
pyautogui.mouseDown(button="left")
|
|
730
|
+
json_output({"ok": True, "result": True})
|
|
731
|
+
return 0
|
|
732
|
+
if command == "mouse_up":
|
|
733
|
+
pyautogui.mouseUp(button="left")
|
|
734
|
+
json_output({"ok": True, "result": True})
|
|
735
|
+
return 0
|
|
736
|
+
if command == "cursor_position":
|
|
737
|
+
x, y = pyautogui.position()
|
|
738
|
+
json_output({"ok": True, "result": {"x": int(x), "y": int(y)}})
|
|
739
|
+
return 0
|
|
740
|
+
if command == "frontmost_app":
|
|
741
|
+
json_output({"ok": True, "result": frontmost_app()})
|
|
742
|
+
return 0
|
|
743
|
+
if command == "app_under_point":
|
|
744
|
+
json_output({"ok": True, "result": app_under_point(int(payload["x"]), int(payload["y"]))})
|
|
745
|
+
return 0
|
|
746
|
+
if command == "list_installed_apps":
|
|
747
|
+
json_output({"ok": True, "result": installed_apps()})
|
|
748
|
+
return 0
|
|
749
|
+
if command == "list_running_apps":
|
|
750
|
+
json_output({"ok": True, "result": running_apps()})
|
|
751
|
+
return 0
|
|
752
|
+
if command == "open_app":
|
|
753
|
+
open_app(str(payload["bundleId"]))
|
|
754
|
+
json_output({"ok": True, "result": True})
|
|
755
|
+
return 0
|
|
756
|
+
if command == "read_clipboard":
|
|
757
|
+
json_output({"ok": True, "result": read_clipboard()})
|
|
758
|
+
return 0
|
|
759
|
+
if command == "write_clipboard":
|
|
760
|
+
write_clipboard(str(payload.get("text") or ""))
|
|
761
|
+
json_output({"ok": True, "result": True})
|
|
762
|
+
return 0
|
|
763
|
+
if command == "paste_clipboard":
|
|
764
|
+
paste_clipboard()
|
|
765
|
+
json_output({"ok": True, "result": True})
|
|
766
|
+
return 0
|
|
767
|
+
error_output(f"Unknown command: {command}", code="bad_command")
|
|
768
|
+
return 2
|
|
769
|
+
except Exception as exc:
|
|
770
|
+
error_output(str(exc))
|
|
771
|
+
return 1
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
if __name__ == "__main__":
|
|
775
|
+
raise SystemExit(main())
|