Files
luma/Luma/scripts/capture-screenshots.py
Ole André Vadla Ravnås f2ed003880 luma: Add macOS screenshot capture tool
Frida-based equivalent of the GTK capture tool. Navigates
the sidebar via NSAccessibility and captures each view with
CGWindowListCreateImage (in-process, no TCC needed). Produces
a per-view PNG gallery matching the GTK tool's output.
2026-04-09 23:14:09 +02:00

370 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Capture screenshots of Luma (macOS/SwiftUI) views using Frida + Accessibility.
Usage:
python3 scripts/capture-screenshots.py <pid-or-name> <output-dir>
The agent navigates the sidebar via NSAccessibility, captures
each view with CGWindowListCreateImage (in-process, no TCC
needed for your own window), and sends raw RGBA back to Python.
"""
import frida
import os
import struct
import sys
import time
import zlib
AGENT = r"""
const pinned = [];
const AXUIElement = ObjCObject;
rpc.exports = {
getWindowSize() {
return withMainThread(() => {
const win = getKeyWindow();
const frame = win.frame();
return [frame[2], frame[3]];
});
},
captureScreenshot() {
return withMainThread(() => {
const win = getKeyWindow();
const wid = win.windowNumber();
const bounds = CGRectNull();
const opts = 1 << 3; // kCGWindowListOptionIncludingWindow
const imageOpts = 1; // kCGWindowImageBestResolution
const cgImage = CGWindowListCreateImage(bounds, opts, wid, imageOpts);
if (cgImage.isNull()) return null;
const w = CGImageGetWidth(cgImage);
const h = CGImageGetHeight(cgImage);
const bitsPerComponent = 8;
const bytesPerRow = w * 4;
const total = bytesPerRow * h;
const cs = CGColorSpaceCreateDeviceRGB();
const ctx = CGBitmapContextCreate(NULL, w, h, bitsPerComponent, bytesPerRow, cs,
1 /* kCGImageAlphaPremultipliedLast */ | (1 << 13) /* kCGBitmapByteOrder32Big */);
CGContextDrawImage(ctx, [0, 0, w, h], cgImage);
const data = CGBitmapContextGetData(ctx);
const buf = data.readByteArray(total);
CGContextRelease(ctx);
CGColorSpaceRelease(cs);
CGImageRelease(cgImage);
return { width: w, height: h, rgba: buf };
});
},
listSidebar() {
return withMainThread(() => {
const win = getKeyWindow();
const axWin = axElement(win);
const rows = findSidebarRows(axWin);
return rows.map((r, i) => ({
index: i,
label: axTitle(r) || axDescription(r) || `row-${i}`,
}));
});
},
selectItem(index) {
return withMainThread(() => {
const win = getKeyWindow();
const axWin = axElement(win);
const rows = findSidebarRows(axWin);
if (index >= rows.length) return false;
const row = rows[index];
axPerformAction(row, 'AXPress');
return true;
});
},
};
function getKeyWindow() {
const app = ObjC.classes.NSApplication.sharedApplication();
const win = app.keyWindow();
if (win.isNull()) {
const windows = app.orderedWindows();
if (windows.count() === 0) throw new Error('No windows');
return windows.objectAtIndex_(0);
}
return win;
}
function axElement(nsObj) {
return nsObj;
}
function findSidebarRows(axWin) {
const rows = [];
walkAccessibility(axWin, (el) => {
const role = axRole(el);
if (role === 'AXRow' || role === 'AXCell') {
const parent = axParent(el);
if (parent !== null) {
const parentRole = axRole(parent);
if (parentRole === 'AXOutlineRow' || parentRole === 'AXList'
|| parentRole === 'AXOutline' || parentRole === 'AXTable') {
rows.push(el);
return false;
}
}
const subrole = axSubrole(el);
if (subrole === 'AXOutlineRow') {
rows.push(el);
return false;
}
}
if (role === 'AXOutlineRow') {
rows.push(el);
return false;
}
return true;
}, 0, 6);
return rows;
}
function walkAccessibility(el, visitor, depth, maxDepth) {
if (depth > maxDepth) return;
const shouldDescend = visitor(el);
if (!shouldDescend) return;
const children = axChildren(el);
if (children === null) return;
for (let i = 0; i < children.length; i++) {
walkAccessibility(children[i], visitor, depth + 1, maxDepth);
}
}
function axRole(el) {
try {
const sel = ObjC.selector('accessibilityRole');
if (el.respondsToSelector_(sel)) {
const v = el.accessibilityRole();
return v ? v.toString() : null;
}
} catch {}
return null;
}
function axSubrole(el) {
try {
const sel = ObjC.selector('accessibilitySubrole');
if (el.respondsToSelector_(sel)) {
const v = el.accessibilitySubrole();
return v ? v.toString() : null;
}
} catch {}
return null;
}
function axTitle(el) {
try {
const sel = ObjC.selector('accessibilityTitle');
if (el.respondsToSelector_(sel)) {
const v = el.accessibilityTitle();
return v ? v.toString() : null;
}
} catch {}
return null;
}
function axDescription(el) {
try {
const sel = ObjC.selector('accessibilityLabel');
if (el.respondsToSelector_(sel)) {
const v = el.accessibilityLabel();
return v ? v.toString() : null;
}
} catch {}
return null;
}
function axParent(el) {
try {
const sel = ObjC.selector('accessibilityParent');
if (el.respondsToSelector_(sel)) {
const v = el.accessibilityParent();
return v && !v.isNull() ? v : null;
}
} catch {}
return null;
}
function axChildren(el) {
try {
const sel = ObjC.selector('accessibilityChildren');
if (el.respondsToSelector_(sel)) {
const arr = el.accessibilityChildren();
if (arr === null || arr.isNull()) return null;
const count = arr.count();
const result = [];
for (let i = 0; i < count; i++) {
result.push(arr.objectAtIndex_(i));
}
return result;
}
} catch {}
return null;
}
function axPerformAction(el, action) {
try {
const sel = ObjC.selector('accessibilityPerformPress');
if (el.respondsToSelector_(sel)) {
el.accessibilityPerformPress();
return;
}
} catch {}
try {
if (el.respondsToSelector_(ObjC.selector('setAccessibilityFocused:'))) {
el.setAccessibilityFocused_(true);
}
} catch {}
}
function withMainThread(callback) {
if (ObjC.classes.NSThread.isMainThread()) {
return callback();
}
return new Promise((resolve, reject) => {
ObjC.schedule(ObjC.mainQueue, () => {
try { resolve(callback()); } catch (e) { reject(e); }
});
});
}
const CG = Process.getModuleByName('CoreGraphics');
const CGWindowListCreateImage = new NativeFunction(
CG.getExportByName('CGWindowListCreateImage'),
'pointer', [['double', 'double', 'double', 'double'], 'uint32', 'uint32', 'uint32']);
const CGImageGetWidth = new NativeFunction(
CG.getExportByName('CGImageGetWidth'), 'uint64', ['pointer']);
const CGImageGetHeight = new NativeFunction(
CG.getExportByName('CGImageGetHeight'), 'uint64', ['pointer']);
const CGImageRelease = new NativeFunction(
CG.getExportByName('CGImageRelease'), 'void', ['pointer']);
const CGColorSpaceCreateDeviceRGB = new NativeFunction(
CG.getExportByName('CGColorSpaceCreateDeviceRGB'), 'pointer', []);
const CGColorSpaceRelease = new NativeFunction(
CG.getExportByName('CGColorSpaceRelease'), 'void', ['pointer']);
const CGBitmapContextCreate = new NativeFunction(
CG.getExportByName('CGBitmapContextCreate'),
'pointer', ['pointer', 'uint64', 'uint64', 'uint64', 'uint64', 'pointer', 'uint32']);
const CGBitmapContextGetData = new NativeFunction(
CG.getExportByName('CGBitmapContextGetData'), 'pointer', ['pointer']);
const CGContextDrawImage = new NativeFunction(
CG.getExportByName('CGContextDrawImage'),
'void', ['pointer', ['double', 'double', 'double', 'double'], 'pointer']);
const CGContextRelease = new NativeFunction(
CG.getExportByName('CGContextRelease'), 'void', ['pointer']);
function CGRectNull() { return [0, 0, 0, 0]; }
"""
def main():
target = sys.argv[1]
outdir = sys.argv[2] if len(sys.argv) > 2 else "screenshots"
os.makedirs(outdir, exist_ok=True)
local = [d for d in frida.get_device_manager().enumerate_devices() if d.type == "local"][0]
try:
pid = int(target)
session = local.attach(pid)
except ValueError:
session = local.attach(target)
script = session.create_script(AGENT)
script.on("message", on_message)
script.load()
time.sleep(2)
w, h = script.exports_sync.get_window_size()
print(f"Window: {w}x{h}", flush=True)
capture(script, outdir, "notebook")
items = poll_sidebar(script)
print(f"Sidebar: {[i['label'] for i in items]}", flush=True)
seen = {"notebook"}
for item in items:
slug = slugify(item.get("label", ""))
if not slug:
continue
base = slug
n = 1
while slug in seen:
n += 1
slug = f"{base}-{n}"
seen.add(slug)
script.exports_sync.select_item(item["index"])
time.sleep(2)
capture(script, outdir, slug)
session.detach()
print("Done.", flush=True)
def capture(script, outdir, name):
path = os.path.join(outdir, f"{name}.png")
result = script.exports_sync.capture_screenshot()
if result is None:
print(f" {name}: FAILED", flush=True)
return
w = result["width"]
h = result["height"]
rgba = result["rgba"]
png = encode_png(w, h, rgba)
with open(path, "wb") as f:
f.write(png)
print(f" {name}: {w}x{h}, {len(png)} bytes", flush=True)
def poll_sidebar(script):
items = []
for _ in range(20):
items = script.exports_sync.list_sidebar()
if len(items) > 1:
return items
time.sleep(0.5)
return items
def slugify(label):
slug = "".join(c if c.isascii() and c.isalnum() else "-" for c in label)
return "-".join(s for s in slug.strip("-").lower().split("-") if s)
def on_message(msg, data):
if msg.get("type") == "error":
print(f" agent error: {msg.get('description', msg)}", flush=True)
def encode_png(width, height, rgba_data):
stride = width * 4
raw = b""
for y in range(height):
raw += b"\x00" + rgba_data[y * stride:(y + 1) * stride]
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return sig + png_chunk(b"IHDR", ihdr) + png_chunk(b"IDAT", zlib.compress(raw)) + png_chunk(b"IEND", b"")
def png_chunk(ctype, data):
c = ctype + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
if __name__ == "__main__":
main()