Skip to content

Commit 34fb65a

Browse files
yjwongclaude
andcommitted
feat(lima): capture macOS guest screenshots from the host via ScreenCaptureKit
In-guest screencapture fails when the guest is locked or no user session is available. Capture the Lima window from the host instead, picking the largest on-screen window owned by the hostagent and cropping out the window chrome via the AX tree so coordinates map directly to the guest framebuffer. Falls back to a full-window capture when Accessibility permission isn't granted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 26e3188 commit 34fb65a

3 files changed

Lines changed: 234 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ dependencies = [
2525
]
2626

2727
[project.optional-dependencies]
28-
macos = ["rumps>=0.4"]
28+
macos = [
29+
"rumps>=0.4",
30+
"pyobjc-framework-ApplicationServices>=10.0",
31+
"pyobjc-framework-Cocoa>=10.0",
32+
"pyobjc-framework-Quartz>=10.0",
33+
"pyobjc-framework-ScreenCaptureKit>=10.0",
34+
]
2935
libvirt = ["libvirt-python>=10.0"]
3036

3137
[project.scripts]

src/open_shrimp/sandbox/lima.py

Lines changed: 207 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,22 @@
1414
from __future__ import annotations
1515

1616
import asyncio
17+
import concurrent.futures
1718
import getpass
1819
import logging
1920
import shlex
2021
import subprocess
2122
from pathlib import Path
2223

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+
2333
from open_shrimp.config import SandboxConfig
2434
from open_shrimp.sandbox.lima_helpers import (
2535
_lima_env,
@@ -78,6 +88,69 @@
7888
}
7989

8090

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+
81154
class LimaSandbox:
82155
"""Lima VM sandbox implementing the Sandbox protocol.
83156
@@ -118,6 +191,14 @@ def __init__(
118191
# Cached VNC credentials (read once from the guest).
119192
self._vnc_credentials_cached: tuple[str, str] | None = None
120193

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+
121202
# -- Sandbox protocol -----------------------------------------------------
122203

123204
@property
@@ -408,18 +489,135 @@ def _exec_in_vm_sync(
408489
return result.returncode, result.stdout, result.stderr
409490

410491
def take_screenshot(self, output_path: Path) -> None:
492+
if self._guest_os == "macos":
493+
self._capture_lima_window_macos(output_path)
494+
return
411495
ts = int(output_path.stem.split("-")[-1]) if "-" in output_path.stem else 0
412496
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,
416565
)
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
423621

424622
def send_click(self, x: int, y: int, button: str = "left") -> None:
425623
if self._guest_os == "macos":

uv.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)