|
14 | 14 | from __future__ import annotations |
15 | 15 |
|
16 | 16 | import asyncio |
| 17 | +import concurrent.futures |
17 | 18 | import getpass |
18 | 19 | import logging |
19 | 20 | import shlex |
20 | 21 | import subprocess |
21 | 22 | from pathlib import Path |
22 | 23 |
|
| 24 | +# Bootstrap CoreGraphics/AppKit once for non-app processes (idempotent). |
| 25 | +# Required so SCScreenshotManager doesn't trip CGS_REQUIRE_INIT inside CLI |
| 26 | +# Python invocations. Skipped on non-macOS hosts where AppKit is absent. |
| 27 | +try: |
| 28 | + import AppKit as _AppKit # type: ignore[import-not-found] |
| 29 | + _AppKit.NSApplicationLoad() |
| 30 | +except ImportError: |
| 31 | + pass |
| 32 | + |
23 | 33 | from open_shrimp.config import SandboxConfig |
24 | 34 | from open_shrimp.sandbox.lima_helpers import ( |
25 | 35 | _lima_env, |
|
78 | 88 | } |
79 | 89 |
|
80 | 90 |
|
| 91 | +def _ax_find_content_rect( |
| 92 | + pid: int, sc_frame: tuple[float, float, float, float], |
| 93 | +) -> tuple[float, float, float, float] | None: |
| 94 | + """Return the VZ scroll-area's frame in window-local points. |
| 95 | +
|
| 96 | + Walks the limactl process's accessibility tree, finds the AXWindow |
| 97 | + matching *sc_frame* (its screen-coord frame from ScreenCaptureKit), |
| 98 | + and returns ``(x, y, w, h)`` of its AXScrollArea child relative to |
| 99 | + the window's origin. Returns ``None`` if AX is unavailable or the |
| 100 | + expected structure isn't present. |
| 101 | +
|
| 102 | + Requires Accessibility TCC permission for the calling process. |
| 103 | + """ |
| 104 | + try: |
| 105 | + from ApplicationServices import ( # type: ignore[import-not-found] |
| 106 | + AXUIElementCopyAttributeValue, |
| 107 | + AXUIElementCreateApplication, |
| 108 | + AXValueGetValue, |
| 109 | + kAXValueCGPointType, |
| 110 | + kAXValueCGSizeType, |
| 111 | + ) |
| 112 | + except ImportError: |
| 113 | + return None |
| 114 | + |
| 115 | + def _ax_get(elem, attr): |
| 116 | + err, val = AXUIElementCopyAttributeValue(elem, attr, None) |
| 117 | + return val if err == 0 else None |
| 118 | + |
| 119 | + def _ax_frame(elem) -> tuple[float, float, float, float] | None: |
| 120 | + pos = _ax_get(elem, "AXPosition") |
| 121 | + siz = _ax_get(elem, "AXSize") |
| 122 | + if pos is None or siz is None: |
| 123 | + return None |
| 124 | + _, p = AXValueGetValue(pos, kAXValueCGPointType, None) |
| 125 | + _, s = AXValueGetValue(siz, kAXValueCGSizeType, None) |
| 126 | + return (p.x, p.y, s.width, s.height) |
| 127 | + |
| 128 | + app = AXUIElementCreateApplication(pid) |
| 129 | + windows = _ax_get(app, "AXWindows") |
| 130 | + if not windows: |
| 131 | + return None |
| 132 | + sx, sy, sw, sh = sc_frame |
| 133 | + for w in windows: |
| 134 | + wf = _ax_frame(w) |
| 135 | + if wf is None: |
| 136 | + continue |
| 137 | + wx, wy, ww, wh = wf |
| 138 | + if abs(wx - sx) > 2 or abs(wy - sy) > 2 \ |
| 139 | + or abs(ww - sw) > 2 or abs(wh - sh) > 2: |
| 140 | + continue |
| 141 | + children = _ax_get(w, "AXChildren") or [] |
| 142 | + for c in children: |
| 143 | + if _ax_get(c, "AXRole") != "AXScrollArea": |
| 144 | + continue |
| 145 | + cf = _ax_frame(c) |
| 146 | + if cf is None: |
| 147 | + return None |
| 148 | + cx, cy, cw, ch = cf |
| 149 | + return (cx - wx, cy - wy, cw, ch) |
| 150 | + return None |
| 151 | + return None |
| 152 | + |
| 153 | + |
81 | 154 | class LimaSandbox: |
82 | 155 | """Lima VM sandbox implementing the Sandbox protocol. |
83 | 156 |
|
@@ -118,6 +191,14 @@ def __init__( |
118 | 191 | # Cached VNC credentials (read once from the guest). |
119 | 192 | self._vnc_credentials_cached: tuple[str, str] | None = None |
120 | 193 |
|
| 194 | + # Cached chrome-crop rect, keyed by the Lima window's screen frame. |
| 195 | + # Walking the limactl AX tree costs a few IPC roundtrips per call; |
| 196 | + # the rect is stable until the user resizes the window. |
| 197 | + self._crop_cache: dict[ |
| 198 | + tuple[float, float, float, float], |
| 199 | + tuple[float, float, float, float] | None, |
| 200 | + ] = {} |
| 201 | + |
121 | 202 | # -- Sandbox protocol ----------------------------------------------------- |
122 | 203 |
|
123 | 204 | @property |
@@ -408,18 +489,135 @@ def _exec_in_vm_sync( |
408 | 489 | return result.returncode, result.stdout, result.stderr |
409 | 490 |
|
410 | 491 | def take_screenshot(self, output_path: Path) -> None: |
| 492 | + if self._guest_os == "macos": |
| 493 | + self._capture_lima_window_macos(output_path) |
| 494 | + return |
411 | 495 | ts = int(output_path.stem.split("-")[-1]) if "-" in output_path.stem else 0 |
412 | 496 | guest_path = f"/tmp/screenshots/screenshot-{ts}.png" |
413 | | - if self._guest_os == "macos": |
414 | | - rc, _, stderr = self._exec_in_vm_sync( |
415 | | - f"screencapture -x {guest_path}" |
| 497 | + rc, _, stderr = self._exec_in_vm_sync(f"grim {guest_path}") |
| 498 | + if rc != 0: |
| 499 | + raise RuntimeError(f"grim failed: {stderr.strip()}") |
| 500 | + |
| 501 | + def _capture_lima_window_macos(self, output_path: Path) -> None: |
| 502 | + """Capture the Lima VM window from the host via ScreenCaptureKit. |
| 503 | +
|
| 504 | + Avoids in-guest ``screencapture`` which fails when the guest is |
| 505 | + locked or the user session is unavailable. Lima's hostagent owns |
| 506 | + ~6 windows for a graphical VM: the VM display (e.g. 1165x780) |
| 507 | + plus several off-screen menubar/toolbar surfaces (e.g. 1440x30); |
| 508 | + we pick the largest on-screen one. The capture is cropped to |
| 509 | + exclude the host window's titlebar/toolbar chrome (~52pt) using |
| 510 | + the AX tree, so coordinates in the resulting PNG map directly |
| 511 | + to the guest framebuffer. Falls back to a full-window capture |
| 512 | + with chrome if Accessibility permission isn't granted. |
| 513 | +
|
| 514 | + Requires Screen Recording permission for OpenShrimp. |
| 515 | + """ |
| 516 | + from Foundation import NSURL # type: ignore[import-not-found] |
| 517 | + from Quartz import ( # type: ignore[import-not-found] |
| 518 | + CGImageDestinationAddImage, |
| 519 | + CGImageDestinationCreateWithURL, |
| 520 | + CGImageDestinationFinalize, |
| 521 | + kCVPixelFormatType_32BGRA, |
| 522 | + ) |
| 523 | + from ScreenCaptureKit import ( # type: ignore[import-not-found] |
| 524 | + SCContentFilter, |
| 525 | + SCScreenshotManager, |
| 526 | + SCShareableContent, |
| 527 | + SCStreamConfiguration, |
| 528 | + ) |
| 529 | + |
| 530 | + pid = self._read_ha_pid() |
| 531 | + future: concurrent.futures.Future = concurrent.futures.Future() |
| 532 | + |
| 533 | + def on_image(image, error): |
| 534 | + if error is not None or image is None: |
| 535 | + future.set_exception(RuntimeError(f"capture failed: {error}")) |
| 536 | + else: |
| 537 | + future.set_result(image) |
| 538 | + |
| 539 | + def on_content(content, error): |
| 540 | + if error is not None: |
| 541 | + future.set_exception( |
| 542 | + RuntimeError(f"shareable content failed: {error}")) |
| 543 | + return |
| 544 | + best = None |
| 545 | + best_area = 0 |
| 546 | + for w in content.windows(): |
| 547 | + app = w.owningApplication() |
| 548 | + if app is None or app.processID() != pid: |
| 549 | + continue |
| 550 | + frame = w.frame() |
| 551 | + area = int(frame.size.width * frame.size.height) |
| 552 | + if area < 100_000: # skip menubar/toolbar surfaces |
| 553 | + continue |
| 554 | + if area > best_area: |
| 555 | + best_area, best = area, w |
| 556 | + if best is None: |
| 557 | + future.set_exception(RuntimeError( |
| 558 | + f"no on-screen Lima window for hostagent pid={pid}; " |
| 559 | + "is the VM running with video.display=vz?")) |
| 560 | + return |
| 561 | + |
| 562 | + sc_frame = ( |
| 563 | + best.frame().origin.x, best.frame().origin.y, |
| 564 | + best.frame().size.width, best.frame().size.height, |
416 | 565 | ) |
417 | | - if rc != 0: |
418 | | - raise RuntimeError(f"screencapture failed: {stderr.strip()}") |
419 | | - else: |
420 | | - rc, _, stderr = self._exec_in_vm_sync(f"grim {guest_path}") |
421 | | - if rc != 0: |
422 | | - raise RuntimeError(f"grim failed: {stderr.strip()}") |
| 566 | + crop = self._crop_for_window(pid, sc_frame) |
| 567 | + if crop is not None: |
| 568 | + cx, cy, cw, ch = crop |
| 569 | + else: |
| 570 | + cx, cy, cw, ch = 0.0, 0.0, sc_frame[2], sc_frame[3] |
| 571 | + |
| 572 | + filt = SCContentFilter.alloc().initWithDesktopIndependentWindow_(best) |
| 573 | + cfg = SCStreamConfiguration.alloc().init() |
| 574 | + cfg.setSourceRect_(((cx, cy), (cw, ch))) |
| 575 | + cfg.setWidth_(int(cw * 2)) |
| 576 | + cfg.setHeight_(int(ch * 2)) |
| 577 | + cfg.setShowsCursor_(False) |
| 578 | + cfg.setPixelFormat_(kCVPixelFormatType_32BGRA) |
| 579 | + SCScreenshotManager.captureImageWithFilter_configuration_completionHandler_( |
| 580 | + filt, cfg, on_image) |
| 581 | + |
| 582 | + SCShareableContent.getShareableContentExcludingDesktopWindows_onScreenWindowsOnly_completionHandler_( |
| 583 | + False, True, on_content) |
| 584 | + |
| 585 | + try: |
| 586 | + image = future.result(timeout=10.0) |
| 587 | + except concurrent.futures.TimeoutError as e: |
| 588 | + raise RuntimeError("ScreenCaptureKit timed out after 10s") from e |
| 589 | + |
| 590 | + url = NSURL.fileURLWithPath_(str(output_path)) |
| 591 | + dest = CGImageDestinationCreateWithURL(url, "public.png", 1, None) |
| 592 | + if dest is None: |
| 593 | + raise RuntimeError( |
| 594 | + f"CGImageDestinationCreateWithURL failed for {output_path}") |
| 595 | + CGImageDestinationAddImage(dest, image, None) |
| 596 | + if not CGImageDestinationFinalize(dest): |
| 597 | + raise RuntimeError( |
| 598 | + f"CGImageDestinationFinalize failed for {output_path}") |
| 599 | + |
| 600 | + def _crop_for_window( |
| 601 | + self, pid: int, sc_frame: tuple[float, float, float, float], |
| 602 | + ) -> tuple[float, float, float, float] | None: |
| 603 | + if sc_frame in self._crop_cache: |
| 604 | + return self._crop_cache[sc_frame] |
| 605 | + crop = _ax_find_content_rect(pid, sc_frame) |
| 606 | + if crop is None: |
| 607 | + logger.warning( |
| 608 | + "AX content rect unavailable for pid=%d; capturing full " |
| 609 | + "window with chrome — grant Accessibility permission to " |
| 610 | + "remove the ~52pt offset.", pid, |
| 611 | + ) |
| 612 | + self._crop_cache[sc_frame] = crop |
| 613 | + return crop |
| 614 | + |
| 615 | + def _read_ha_pid(self) -> int: |
| 616 | + pid_file = Path(self._env["LIMA_HOME"]) / self._inst_name / "ha.pid" |
| 617 | + try: |
| 618 | + return int(pid_file.read_text().strip()) |
| 619 | + except (OSError, ValueError) as e: |
| 620 | + raise RuntimeError(f"cannot read {pid_file}: {e}") from e |
423 | 621 |
|
424 | 622 | def send_click(self, x: int, y: int, button: str = "left") -> None: |
425 | 623 | if self._guest_os == "macos": |
|
0 commit comments