fix: validate owned-window capture and recorder geometry

This commit is contained in:
Kinneyzhang 2026-09-06 13:53:45 +08:00
parent 805323a7f2
commit 630567da8f
6 changed files with 837 additions and 71 deletions

View File

@ -1,19 +1,35 @@
# Generic Emacs GUI verification
This directory owns the reusable verification mechanism, not application
scenarios:
For an already running Emacs, use its existing server through `emacsclient`.
Load the checkout and call the example entry point in an explicit buffer, show
that buffer in the existing graphical frame, and capture only that owned
window. Preserve the user's font and chrome. Do not start another daemon or
frame for this workflow. The `emacsclient-render-capture` skill supplies the
foreground and before/after target guards.
This directory also owns reusable verification mechanisms; application
scenarios live in their respective example repositories:
- `emacs-gui-verifier.el` defines `Scenario`, ordered `Action`, run-local
`Context`, checkpoint sequencing, assertions, completion, and fail-closed
evidence finalization.
- `run-emacs-gui-verification.sh` owns one named daemon, explicit load paths,
- The legacy isolated runner `run-emacs-gui-verification.sh` owns one named daemon, explicit load paths,
external application activation, recorder lifecycle, driver loading, report
generation, and exact cleanup.
- `record-screen.exp` keeps macOS `screencapture -v` attached to a PTY and stops
it through an explicit signal. It contains no Emacs or application logic.
- `record-emacs-window.swift` uses macOS 15 ScreenCaptureKit to record only the
frame owned by the supplied Emacs PID. It follows replacement window IDs and
rejects changed pixel mappings. Its canvas is fixed at recording start:
when the scenario includes resize, prepare the largest tested frame before
starting the recorder. Smaller windows retain native pixels with padding;
a window larger than the original canvas invalidates the recording.
- `capture-emacs-window.sh` supplies window-only checkpoint screenshots and
rejects missing, ambiguous, or changing frame identities. The runner compiles
the video helper using the system Swift compiler; no package install is needed.
Concrete repositories provide adapter files that construct a Scenario and an
entry function. The generic command is:
entry function. They can run through the existing server. The following command
instead starts the legacy isolated environment; use it only when that separate
environment is explicitly intended:
```sh
scripts/run-emacs-gui-verification.sh run ADAPTER.el ENTRY \
@ -21,11 +37,14 @@ scripts/run-emacs-gui-verification.sh run ADAPTER.el ENTRY \
--run-dir /private/tmp/my-gui-run
```
Fresh captures intentionally remain `INCOMPLETE` until their selected images
In that isolated runner, fresh captures remain `INCOMPLETE` until their selected images
and contact sheet are reviewed. Finalize the same evidence directory with:
```sh
scripts/run-emacs-gui-verification.sh review /private/tmp/my-gui-run
```
Only `VERDICT=PASS` is completion evidence.
Only `VERDICT=PASS` completes that runner's evidence bundle. An existing-server
run reports its actual interaction assertions and inspected screenshots
separately. A failed recorder is never evidence of continuous capture, and
neither screenshots nor recording establish an operation latency bound.

69
scripts/capture-emacs-window.sh Executable file
View File

@ -0,0 +1,69 @@
#!/bin/sh
# Select an owned frame each time: fullscreen transitions can replace its
# WindowServer ID. Never fall back to capturing the desktop.
set -eu
usage() {
echo "usage: $0 --window-id PID | -x OUTPUT" >&2
exit 2
}
window_id() {
case $1 in
''|*[!0-9]*) echo "invalid Emacs capture PID: $1" >&2; return 1 ;;
esac
[ "$1" -gt 1 ] && kill -0 "$1" 2>/dev/null || {
echo "Emacs capture process is not alive: $1" >&2
return 1
}
osascript -l JavaScript - "$1" <<'JXA'
ObjC.import("CoreGraphics");
function run(argv) {
var pid = Number(argv[0]);
var windows = ObjC.deepUnwrap(ObjC.castRefToObject(
$.CGWindowListCopyWindowInfo($.kCGWindowListOptionAll,
$.kCGNullWindowID)));
var candidates = windows.filter(function (window) {
var bounds = window.kCGWindowBounds;
return window.kCGWindowOwnerPID === pid &&
window.kCGWindowOwnerName === "Emacs" &&
window.kCGWindowLayer === 0 && window.kCGWindowAlpha > 0 &&
window.kCGWindowName && bounds &&
bounds.Width > 0 && bounds.Height > 0;
});
candidates.sort(function (a, b) {
return b.kCGWindowBounds.Width * b.kCGWindowBounds.Height -
a.kCGWindowBounds.Width * a.kCGWindowBounds.Height;
});
if (!candidates.length) {
throw new Error("No named Emacs frame owned by PID " + pid);
}
if (candidates.length > 1 &&
candidates[0].kCGWindowBounds.Width * candidates[0].kCGWindowBounds.Height ===
candidates[1].kCGWindowBounds.Width * candidates[1].kCGWindowBounds.Height) {
throw new Error("Ambiguous largest Emacs frame for PID " + pid);
}
return String(candidates[0].kCGWindowNumber);
}
JXA
}
[ "$#" -eq 2 ] || usage
case $1 in
--window-id) window_id "$2" ;;
-x)
GUI_CAPTURE_PID=${ETAF_GUI_CAPTURE_PID:?ETAF_GUI_CAPTURE_PID is required}
GUI_CAPTURE_ID=$(window_id "$GUI_CAPTURE_PID")
GUI_CAPTURE_BIN=${ETAF_GUI_SCREENCAPTURE:-/usr/sbin/screencapture}
"$GUI_CAPTURE_BIN" -l "$GUI_CAPTURE_ID" -o -x "$2"
# Reject a capture made while macOS replaced the intended frame.
GUI_CAPTURE_AFTER=$(window_id "$GUI_CAPTURE_PID")
if [ "$GUI_CAPTURE_ID" != "$GUI_CAPTURE_AFTER" ]; then
rm -f -- "$2"
echo "Emacs frame changed during screenshot; capture again" >&2
exit 1
fi
;;
*) usage ;;
esac

View File

@ -0,0 +1,322 @@
// Record only the owned Emacs frame. Build with swiftc -parse-as-library.
import Foundation
import CoreGraphics
import ScreenCaptureKit
import AVFoundation
struct CaptureFailure: Error, CustomStringConvertible {
let description: String
init(_ description: String) { self.description = description }
}
// Delegate callbacks and signal handlers do not share an execution queue.
final class CaptureEvents: NSObject, SCStreamDelegate, SCRecordingOutputDelegate,
SCStreamOutput, @unchecked Sendable {
private let lock = NSLock()
private var started = false
private var finished = false
private var stopping = false
private var frames = 0
private var failure: String?
private var nativeSize: (width: Int, height: Int)?
private var pixelMapping: Double?
private var firstPTS: Double?
private var lastPTS: Double?
private let requireNative: Bool
private let expectedPixelScale: Double
init(pixelScale: Double, requireNative: Bool = true) {
self.expectedPixelScale = pixelScale
self.requireNative = requireNative
}
func state() -> (started: Bool, finished: Bool, stopping: Bool,
frames: Int, failure: String?, nativeSize: (width: Int, height: Int)?,
pixelMapping: Double?, firstPTS: Double?, lastPTS: Double?) {
lock.withLock {
(started, finished, stopping, frames, failure, nativeSize,
pixelMapping, firstPTS, lastPTS)
}
}
func stop() { lock.withLock { stopping = true } }
func fail(_ message: String) { lock.withLock { failure = message } }
func recordingOutputDidStartRecording(_ output: SCRecordingOutput) {
lock.withLock { started = true }
}
func recordingOutputDidFinishRecording(_ output: SCRecordingOutput) {
lock.withLock { finished = true }
}
func recordingOutput(_ output: SCRecordingOutput, didFailWithError error: Error) {
fail("Recording failed: \(error)")
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
fail("Capture stream stopped: \(error)")
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
of type: SCStreamOutputType) {
guard type == .screen else { return }
inspectScreenSample(sampleBuffer)
}
func inspectScreenSample(_ sampleBuffer: CMSampleBuffer) {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let info = attachments.first,
let status = info[.status] as? Int,
status == SCFrameStatus.complete.rawValue else { return }
guard let scale = info[.contentScale] as? Double, scale.isFinite, scale > 0,
let backing = info[.scaleFactor] as? Double, backing.isFinite, backing > 0,
let rectDictionary = info[.contentRect] as? [String: Any],
let rect = CGRect(dictionaryRepresentation: rectDictionary as CFDictionary),
rect.origin.x.isFinite, rect.origin.y.isFinite,
rect.width.isFinite, rect.height.isFinite,
rect.width > 0, rect.height > 0 else {
fail("Captured frame has no usable native geometry")
return
}
// SCK can render at a higher resolution than the window's display.
// Its source-to-surface scale and source backing scale together define
// output pixels per logical point; compare that with the real display.
let mapping = scale * backing / expectedPixelScale
if requireNative && abs(mapping - 1) >= 0.0001 {
let image = CMSampleBufferGetImageBuffer(sampleBuffer)
let dimensions = image.map { "\(CVPixelBufferGetWidth($0))x\(CVPixelBufferGetHeight($0))" } ?? "missing"
fail("Captured frame was scaled; native pixels are required. Buffer=\(dimensions), attachments=\(info)")
return
}
let sourceWidth = rect.width / scale * expectedPixelScale
let sourceHeight = rect.height / scale * expectedPixelScale
if requireNative {
guard let image = CMSampleBufferGetImageBuffer(sampleBuffer) else {
fail("Captured frame has no pixel buffer")
return
}
let width = Double(CVPixelBufferGetWidth(image))
let height = Double(CVPixelBufferGetHeight(image))
guard sourceWidth <= width, sourceHeight <= height,
rect.minX >= 0, rect.minY >= 0,
rect.maxX * backing <= width, rect.maxY * backing <= height else {
fail("Captured frame exceeds original native-pixel canvas or is clipped")
return
}
}
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
guard timestamp.isFinite else { fail("Captured frame has no valid timestamp"); return }
lock.withLock {
nativeSize = (Int(ceil(sourceWidth)), Int(ceil(sourceHeight)))
pixelMapping = mapping
if abs(mapping - 1) < 0.0001 {
frames += 1
if firstPTS == nil { firstPTS = timestamp }
lastPTS = timestamp
}
}
}
}
@main
struct RecordEmacsWindow {
static func windowID(pid: pid_t) throws -> CGWindowID {
guard kill(pid, 0) == 0,
let inventory = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID)
as? [[String: Any]] else {
throw CaptureFailure("Owned Emacs process is unavailable: \(pid)")
}
let candidates = inventory.compactMap { item -> (CGWindowID, Double)? in
guard item[kCGWindowOwnerPID as String] as? Int == Int(pid),
item[kCGWindowOwnerName as String] as? String == "Emacs",
item[kCGWindowLayer as String] as? Int == 0,
let alpha = item[kCGWindowAlpha as String] as? Double, alpha > 0,
let title = item[kCGWindowName as String] as? String, !title.isEmpty,
let bounds = item[kCGWindowBounds as String] as? [String: Double],
let width = bounds["Width"], let height = bounds["Height"],
width > 0, height > 0,
let number = item[kCGWindowNumber as String] as? UInt32 else { return nil }
return (number, width * height)
}.sorted { $0.1 > $1.1 }
guard let first = candidates.first else {
throw CaptureFailure("No named Emacs frame owned by PID \(pid)")
}
guard candidates.count == 1 || first.1 != candidates[1].1 else {
throw CaptureFailure("Ambiguous largest Emacs frame for PID \(pid)")
}
return first.0
}
static func filter(pid: pid_t, id: CGWindowID) async throws -> SCContentFilter {
let content = try await SCShareableContent.excludingDesktopWindows(
true, onScreenWindowsOnly: false)
guard let window = content.windows.first(where: {
$0.windowID == id && $0.owningApplication?.processID == pid
&& $0.windowLayer == 0
}) else { throw CaptureFailure("Owned frame is not shareable: \(id)") }
return SCContentFilter(desktopIndependentWindow: window)
}
static func emit(_ values: [String: Any]) throws {
let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
FileHandle.standardOutput.write(data + Data([10]))
}
static func configureNativeCanvas(filter: SCContentFilter,
configuration: SCStreamConfiguration) async throws {
// Fullscreen WindowServer bounds can exclude native window decoration.
// Discover the full source extent from one unrecorded sample; never
// guess offsets or grow the canvas iteratively after recording starts.
let probe = CaptureEvents(pixelScale: Double(filter.pointPixelScale), requireNative: false)
let stream = SCStream(filter: filter, configuration: configuration, delegate: probe)
try stream.addStreamOutput(probe, type: .screen,
sampleHandlerQueue: DispatchQueue(label: "capture-probe"))
try await stream.startCapture()
let deadline = Date().addingTimeInterval(10)
do {
while probe.state().nativeSize == nil {
if let failure = probe.state().failure { throw CaptureFailure(failure) }
guard Date() < deadline else { throw CaptureFailure("No native geometry sample") }
try await Task.sleep(nanoseconds: 20_000_000)
}
let size = probe.state().nativeSize!
configuration.width = size.width + size.width % 2
configuration.height = size.height + size.height % 2
try await stream.updateConfiguration(configuration)
while probe.state().pixelMapping.map({ abs($0 - 1) >= 0.0001 }) ?? true {
if let failure = probe.state().failure { throw CaptureFailure(failure) }
guard Date() < deadline else {
throw CaptureFailure("Measured canvas did not preserve native pixels")
}
try await Task.sleep(nanoseconds: 20_000_000)
}
try await stream.stopCapture()
} catch {
try? await stream.stopCapture()
throw error
}
}
static func record(pid: pid_t, output: URL, ready: URL) async throws {
guard !FileManager.default.fileExists(atPath: output.path),
!FileManager.default.fileExists(atPath: ready.path) else {
throw CaptureFailure("Recording output or readiness file already exists")
}
var id = try windowID(pid: pid)
var currentFilter = try await filter(pid: pid, id: id)
let scale = Double(currentFilter.pointPixelScale)
let width = Int(ceil(currentFilter.contentRect.width * scale))
let height = Int(ceil(currentFilter.contentRect.height * scale))
guard width > 0, height > 0 else { throw CaptureFailure("Empty frame") }
let configuration = SCStreamConfiguration()
// Fixed canvas; smaller windows are padded, never enlarged. The frame
// callback rejects any change to the display's native pixel mapping.
configuration.width = width + width % 2
configuration.height = height + height % 2
configuration.scalesToFit = false
configuration.captureResolution = .automatic
configuration.preservesAspectRatio = true
configuration.ignoreShadowsSingleWindow = true
configuration.showsCursor = false
let background = CGColor(gray: 0, alpha: 1)
defer { withExtendedLifetime(background) {} }
configuration.backgroundColor = background
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 60)
configuration.capturesAudio = false
try await configureNativeCanvas(filter: currentFilter, configuration: configuration)
let events = CaptureEvents(pixelScale: scale)
try emit(["event": "configuration", "window_id": id, "pid": pid,
"width": configuration.width, "height": configuration.height,
"pixel_scale": scale, "content_rect": NSStringFromRect(currentFilter.contentRect)])
let stream = SCStream(filter: currentFilter, configuration: configuration,
delegate: events)
try stream.addStreamOutput(events, type: .screen,
sampleHandlerQueue: DispatchQueue(label: "capture-frames"))
let recordingConfiguration = SCRecordingOutputConfiguration()
recordingConfiguration.outputURL = output
recordingConfiguration.outputFileType = .mov
let recording = SCRecordingOutput(configuration: recordingConfiguration, delegate: events)
try stream.addRecordingOutput(recording)
let signals = [SIGTERM, SIGINT].map { number -> DispatchSourceSignal in
signal(number, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: number, queue: .global())
source.setEventHandler { events.stop() }
source.resume()
return source
}
defer { signals.forEach { $0.cancel() } }
try await stream.startCapture()
let startedAt = Date()
var announced = false
do {
while true {
let nextID = try windowID(pid: pid)
// SCContentFilter is a snapshot: refresh even for the same ID,
// whose source can resize independently of window replacement.
currentFilter = try await filter(pid: pid, id: nextID)
let nextScale = Double(currentFilter.pointPixelScale)
guard nextScale.isFinite, nextScale > 0,
currentFilter.contentRect.width > 0,
currentFilter.contentRect.height > 0,
currentFilter.contentRect.width * nextScale <= Double(configuration.width),
currentFilter.contentRect.height * nextScale <= Double(configuration.height) else {
throw CaptureFailure("Owned frame exceeds original native-pixel canvas or has invalid geometry")
}
if nextID != id {
try await stream.updateContentFilter(currentFilter)
id = nextID
try emit(["event": "window-replaced", "window_id": id, "pid": pid])
}
// Check the current source before readiness and requested stop;
// also re-read callback failures after the asynchronous refresh.
let state = events.state()
if let failure = state.failure { throw CaptureFailure(failure) }
if state.stopping { break }
if state.finished { throw CaptureFailure("Recording ended before requested stop") }
let elapsed = Date().timeIntervalSince(startedAt)
if elapsed > 600 { throw CaptureFailure("Recording exceeded 600 seconds") }
if !announced && elapsed > 10 { throw CaptureFailure("No verified recording frames") }
if !announced && state.started && state.frames > 0 {
let metadata: [String: Any] = ["pid": pid, "window_id": id,
"width": configuration.width, "height": configuration.height,
"pixel_scale": scale, "capture": "desktop-independent-window",
"ready_wall_time": Date().timeIntervalSince1970,
"source_first_pts": state.firstPTS!]
try JSONSerialization.data(withJSONObject: metadata).write(to: ready, options: .atomic)
try emit(metadata)
announced = true
}
try await Task.sleep(nanoseconds: 100_000_000)
}
try await stream.stopCapture()
let stopDeadline = Date().addingTimeInterval(4)
while !events.state().finished {
if let failure = events.state().failure { throw CaptureFailure(failure) }
guard Date() < stopDeadline else { throw CaptureFailure("Recording did not finalize") }
try await Task.sleep(nanoseconds: 20_000_000)
}
if let failure = events.state().failure { throw CaptureFailure(failure) }
guard announced, events.state().frames > 0 else {
throw CaptureFailure("Recording stopped without verified frames")
}
let final = events.state()
try emit(["event": "finished", "frames": final.frames,
"source_first_pts": final.firstPTS!, "source_last_pts": final.lastPTS!,
"source_span_seconds": final.lastPTS! - final.firstPTS!,
"recorded_duration_seconds": CMTimeGetSeconds(recording.recordedDuration),
"wall_duration_seconds": Date().timeIntervalSince(startedAt)])
} catch {
try? await stream.stopCapture()
throw error
}
}
static func main() async {
do {
let arguments = CommandLine.arguments
guard arguments.count == 4, let pid = Int32(arguments[1]), pid > 1 else {
throw CaptureFailure("usage: record-emacs-window PID OUTPUT.mov READY_FILE")
}
try await record(pid: pid, output: URL(fileURLWithPath: arguments[2]),
ready: URL(fileURLWithPath: arguments[3]))
} catch {
FileHandle.standardError.write(Data("\(error)\n".utf8))
exit(1)
}
}
}

View File

@ -1,31 +0,0 @@
#!/usr/bin/expect -f
# Keep macOS screencapture attached to a real PTY. Redirecting its stdin to
# /dev/null makes current macOS builds stop recording immediately.
set maximum_seconds 600
if {$argc != 1} {
puts stderr "usage: record-screen.exp OUTPUT"
exit 2
}
set output [lindex $argv 0]
spawn -noecho /usr/sbin/screencapture -v -D 1 -x $output
set deadline [expr {[clock seconds] + $maximum_seconds}]
trap {
send -- "q"
expect eof
exit 0
} {SIGTERM SIGINT}
while {1} {
after 1000
if {[clock seconds] >= $deadline} {
send -- "q"
expect eof
puts stderr "screen recording exceeded $maximum_seconds seconds"
exit 1
}
}

View File

@ -4,11 +4,11 @@ set -eu
GUI_CORE_SCRIPT_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd)
GUI_ENGINE="$GUI_CORE_SCRIPT_DIR/emacs-gui-verifier.el"
GUI_RECORDER="$GUI_CORE_SCRIPT_DIR/record-screen.exp"
GUI_RECORDER_SOURCE="$GUI_CORE_SCRIPT_DIR/record-emacs-window.swift"
GUI_CAPTURE="$GUI_CORE_SCRIPT_DIR/capture-emacs-window.sh"
GUI_EMACS_APP=${EMACS_APP:-/Applications/Emacs.app}
GUI_EMACS_BIN="$GUI_EMACS_APP/Contents/MacOS/Emacs"
GUI_EMACSCLIENT="$GUI_EMACS_APP/Contents/MacOS/bin/emacsclient"
GUI_EMACS_APP_NAME=${EMACS_APP_NAME:-Emacs}
GUI_CODEX_ROOT=${CODEX_HOME:-${HOME}/.codex}
GUI_DYNAMIC_SKILL=${EMACS_DYNAMIC_UI_SKILL_DIR:-$GUI_CODEX_ROOT/skills/emacs-dynamic-ui-verification}
GUI_CHECKPOINT_EL="$GUI_DYNAMIC_SKILL/scripts/capture-checkpoint.el"
@ -18,7 +18,7 @@ usage() {
echo "usage:" >&2
echo " $0 doctor" >&2
echo " $0 review RUN_DIR" >&2
echo " $0 run ADAPTER_EL ENTRY_FUNCTION [--run-dir DIR] [--load-path DIR]..." >&2
echo " $0 run ADAPTER_EL ENTRY_FUNCTION [--initialize-function FUNCTION] [--run-dir DIR] [--load-path DIR]..." >&2
exit 2
}
@ -33,11 +33,12 @@ doctor() {
require_file "$GUI_EMACS_BIN"
require_file "$GUI_EMACSCLIENT"
require_file "$GUI_ENGINE"
require_file "$GUI_RECORDER"
require_file "$GUI_RECORDER_SOURCE"
require_file "$GUI_CAPTURE"
require_file "$GUI_CHECKPOINT_EL"
require_file "$GUI_EVIDENCE_PY"
command -v osascript >/dev/null 2>&1
command -v expect >/dev/null 2>&1
xcrun --find swiftc >/dev/null 2>&1
command -v ffmpeg >/dev/null 2>&1
command -v python3 >/dev/null 2>&1
echo "EMACS-GUI-VERIFIER DOCTOR PASS"
@ -55,7 +56,6 @@ review_run() {
GUI_DAEMON=""
GUI_DAEMON_PID=""
GUI_RECORDER_PID=""
GUI_RECORDER_CHILD_PID=""
GUI_DAEMON_STARTED=false
valid_pid() {
@ -101,22 +101,15 @@ stop_recorder() {
GUI_RECORDER_STOP_FAILED=1
elif ! wait_for_exit "$GUI_RECORDER_PID" 50; then
GUI_RECORDER_STOP_FAILED=1
if ! force_owned_exit "$GUI_RECORDER_PID" "recorder wrapper"; then
if ! force_owned_exit "$GUI_RECORDER_PID" "window recorder"; then
GUI_RECORDER_STOP_FAILED=1
fi
fi
fi
if process_alive "$GUI_RECORDER_CHILD_PID"; then
GUI_RECORDER_STOP_FAILED=1
if ! force_owned_exit "$GUI_RECORDER_CHILD_PID" "screen recorder"; then
GUI_RECORDER_STOP_FAILED=1
fi
fi
if ! wait "$GUI_RECORDER_PID" 2>/dev/null; then
GUI_RECORDER_STOP_FAILED=1
fi
GUI_RECORDER_PID=""
GUI_RECORDER_CHILD_PID=""
[ "$GUI_RECORDER_STOP_FAILED" -eq 0 ]
}
@ -165,17 +158,16 @@ trap on_exit EXIT
trap 'exit 130' HUP INT TERM
activate_emacs() {
osascript -e "tell application \"$GUI_EMACS_APP_NAME\" to activate"
osascript -e "tell application \"System Events\" to set frontmost of first application process whose unix id is $GUI_DAEMON_PID to true"
GUI_ACTIVATE_ATTEMPTS=0
while [ "$GUI_ACTIVATE_ATTEMPTS" -lt 30 ]; do
GUI_FRONTMOST=$(osascript -e \
'tell application "System Events" to get name of first application process whose frontmost is true')
case $GUI_FRONTMOST in
*Emacs*)
sleep 0.2
return 0
;;
esac
'tell application "System Events" to get unix id of first application process whose frontmost is true')
if [ "$GUI_FRONTMOST" = "$GUI_DAEMON_PID" ]; then
# macOS fullscreen/Space animations continue after focus changes.
sleep 1
return 0
fi
sleep 0.1
GUI_ACTIVATE_ATTEMPTS=$((GUI_ACTIVATE_ATTEMPTS + 1))
done
@ -184,26 +176,23 @@ activate_emacs() {
}
start_recorder() {
/usr/bin/expect "$GUI_RECORDER" "$GUI_RUN_DIR/recording.mov" \
"$GUI_RECORDER" "$GUI_DAEMON_PID" "$GUI_RUN_DIR/recording.mov" \
"$GUI_RUN_DIR/recorder.ready" \
>"$GUI_RUN_DIR/recorder.log" 2>&1 &
GUI_RECORDER_PID=$!
GUI_RECORDER_ATTEMPTS=0
while [ "$GUI_RECORDER_ATTEMPTS" -lt 30 ]; do
while [ "$GUI_RECORDER_ATTEMPTS" -lt 150 ]; do
if ! kill -0 "$GUI_RECORDER_PID" 2>/dev/null; then
echo "screen recorder exited before the scenario started" >&2
exit 1
fi
GUI_RECORDER_CHILD_PID=$(
pgrep -P "$GUI_RECORDER_PID" -x screencapture 2>/dev/null |
sed -n '1p'
)
if process_alive "$GUI_RECORDER_CHILD_PID"; then
if [ -s "$GUI_RUN_DIR/recorder.ready" ]; then
return 0
fi
sleep 0.1
GUI_RECORDER_ATTEMPTS=$((GUI_RECORDER_ATTEMPTS + 1))
done
echo "screen recorder did not create its PTY child" >&2
echo "window recorder did not produce a verified frame" >&2
exit 1
}
@ -214,8 +203,14 @@ parse_run_arguments() {
shift 2
GUI_RUN_DIR=""
GUI_LOAD_PATHS=""
GUI_INITIALIZE_FUNCTION=""
while [ "$#" -gt 0 ]; do
case $1 in
--initialize-function)
[ "$#" -ge 2 ] || usage
GUI_INITIALIZE_FUNCTION=$2
shift 2
;;
--run-dir)
[ "$#" -ge 2 ] || usage
GUI_RUN_DIR=$2
@ -252,9 +247,14 @@ parse_run_arguments() {
run_adapter() {
doctor >/dev/null
GUI_RECORDER="$GUI_RUN_DIR/record-emacs-window"
xcrun swiftc -parse-as-library -warnings-as-errors -O \
"$GUI_RECORDER_SOURCE" -o "$GUI_RECORDER"
export ETAF_GUI_RUN_DIR="$GUI_RUN_DIR"
export ETAF_GUI_LOAD_PATHS="$GUI_LOAD_PATHS"
export ETAF_GUI_ENTRY="$GUI_ENTRY"
export ETAF_GUI_INITIALIZE_FUNCTION="$GUI_INITIALIZE_FUNCTION"
export SCREENCAPTURE="$GUI_CAPTURE"
GUI_DAEMON="emacs-gui-verify-$$"
"$GUI_EMACS_BIN" -Q --daemon="$GUI_DAEMON" \
--eval '(setq native-comp-jit-compilation nil load-prefer-newer t)'
@ -276,8 +276,13 @@ run_adapter() {
(when (fboundp 'tool-bar-mode) (tool-bar-mode -1))
(when (fboundp 'menu-bar-mode) (menu-bar-mode -1))
(when (fboundp 'scroll-bar-mode) (scroll-bar-mode -1))
(set-frame-parameter nil 'fullscreen 'maximized)
(raise-frame)
(setenv \"ETAF_GUI_CAPTURE_PID\" (number-to-string (emacs-pid)))
(set-frame-parameter nil 'fullscreen 'fullboth)
(when-let* ((initialize (getenv \"ETAF_GUI_INITIALIZE_FUNCTION\"))
((> (length initialize) 0)))
(funcall (intern initialize)))
(select-frame-set-input-focus (selected-frame))
(message nil)
(redisplay t)
t)" >"$GUI_RUN_DIR/bootstrap.out"
activate_emacs

View File

@ -0,0 +1,382 @@
"""Owned-window capture routing preflight; never captures a real screen."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
CAPTURE = ROOT / "scripts/capture-emacs-window.sh"
RUNNER = ROOT / "scripts/run-emacs-gui-verification.sh"
RECORDER_SOURCE = ROOT / "scripts/record-emacs-window.swift"
class RecorderCanvasTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.build = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.build.cleanup)
source = RECORDER_SOURCE.read_text()
# Run the actual polling guard with fixture-only providers. Compiling
# the rest of the recorder also keeps its real validation available;
# the recorder's main and all capture APIs remain uninvoked.
guard = source.split(" let nextID = try windowID(pid: pid)\n", 1)[1]
guard_end = ' try emit(["event": "window-replaced", "window_id": id, "pid": pid])\n }'
guard = ("let nextID = try windowID(pid: pid)\n" + guard.split(
guard_end, 1)[0] + guard_end)
driver = r'''
struct FixtureFilter {
let contentRect: CGRect
let pointPixelScale: Double
}
final class FixtureStream {
var updates = 0
func updateContentFilter(_ filter: FixtureFilter) async throws { updates += 1 }
}
extension RecordEmacsWindow {
static func checkFixture(_ input: [String: Any]) async -> [String: Any] {
let pid: pid_t = 123
var id = CGWindowID(input["current_id"] as! Int)
var currentFilter = FixtureFilter(contentRect: CGRect(x: 0, y: 0, width: 500, height: 400),
pointPixelScale: 2)
let configuration = SCStreamConfiguration()
configuration.width = 1000
configuration.height = 800
let stream = FixtureStream()
var reads = 0
func windowID(pid: pid_t) throws -> CGWindowID { CGWindowID(input["next_id"] as! Int) }
func filter(pid: pid_t, id: CGWindowID) async throws -> FixtureFilter {
reads += 1
return FixtureFilter(contentRect: CGRect(x: 0, y: 0,
width: input["width"] as! Double, height: input["height"] as! Double),
pointPixelScale: input["scale"] as! Double)
}
func emit(_ values: [String: Any]) throws {}
var failure: String?
do {
''' + guard + r'''
} catch { failure = String(describing: error) }
return ["error": failure as Any? ?? NSNull(), "filter_reads": reads,
"updates": stream.updates, "window_id": id]
}
}
func checkFrameFixture(_ input: [String: Any]) throws -> [String: Any] {
var image: CVPixelBuffer?
guard CVPixelBufferCreate(kCFAllocatorDefault, 1000, 800, kCVPixelFormatType_32BGRA,
nil, &image) == kCVReturnSuccess, let image else {
throw CaptureFailure("Cannot create fixture pixels")
}
var format: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault,
imageBuffer: image, formatDescriptionOut: &format) == noErr, let format else {
throw CaptureFailure("Cannot create fixture video format")
}
var timing = CMSampleTimingInfo(duration: CMTime(value: 1, timescale: 60),
presentationTimeStamp: CMTime(value: 1, timescale: 60), decodeTimeStamp: .invalid)
var sample: CMSampleBuffer?
guard CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault,
imageBuffer: image, formatDescription: format, sampleTiming: &timing,
sampleBufferOut: &sample) == noErr, let sample else {
throw CaptureFailure("Cannot create fixture sample")
}
let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true)!
as! [NSMutableDictionary]
attachments[0][SCStreamFrameInfo.status.rawValue] = SCFrameStatus.complete.rawValue
attachments[0][SCStreamFrameInfo.contentScale.rawValue] = input["content_scale"] as! Double
attachments[0][SCStreamFrameInfo.scaleFactor.rawValue] = input["backing_scale"] as! Double
attachments[0][SCStreamFrameInfo.contentRect.rawValue] = CGRect(
x: input["x"] as! Double, y: input["y"] as! Double,
width: input["width"] as! Double, height: input["height"] as! Double).dictionaryRepresentation
let events = CaptureEvents(pixelScale: 2)
events.inspectScreenSample(sample)
let result = events.state()
return ["error": result.failure as Any? ?? NSNull(), "frames": result.frames]
}
@main
struct CanvasFixtureMain {
static func main() async throws {
let input = try JSONSerialization.jsonObject(with: FileHandle.standardInput.readDataToEndOfFile())
as! [String: Any]
let result = input["frame"] as? Bool == true
? try checkFrameFixture(input) : await RecordEmacsWindow.checkFixture(input)
try RecordEmacsWindow.emit(result)
}
}
'''
path = Path(cls.build.name) / "canvas-fixture.swift"
path.write_text(source.replace("@main\nstruct RecordEmacsWindow",
"struct RecordEmacsWindow", 1) + driver)
cls.fixture = Path(cls.build.name) / "canvas-fixture"
subprocess.run(
["xcrun", "swiftc", "-parse-as-library", "-warnings-as-errors", "-O",
str(path), "-o", str(cls.fixture)], check=True,
)
def check_canvas(self, next_id, width, height, scale=2):
result = subprocess.run(
[str(self.fixture)], input=json.dumps(dict(current_id=123, next_id=next_id,
width=width, height=height, scale=scale)),
capture_output=True, text=True, check=True, timeout=10,
)
return json.loads(result.stdout)
def test_same_id_fit_uses_fresh_geometry(self):
result = self.check_canvas(123, 400, 300)
self.assertIsNone(result["error"])
self.assertEqual(result["filter_reads"], 1)
self.assertEqual(result["updates"], 0)
def test_same_id_growth_rejects_either_dimension(self):
for width, height in ((501, 400), (500, 401)):
with self.subTest(width=width, height=height):
result = self.check_canvas(123, width, height)
self.assertIsNotNone(result["error"])
self.assertIn("exceeds original native-pixel canvas", result["error"])
self.assertEqual(result["updates"], 0)
def test_replacement_fit_updates_filter(self):
result = self.check_canvas(456, 400, 300)
self.assertIsNone(result["error"])
self.assertEqual(result["filter_reads"], 1)
self.assertEqual(result["updates"], 1)
self.assertEqual(result["window_id"], 456)
def test_replacement_growth_rejects_before_filter_update(self):
for width, height in ((501, 400), (500, 401)):
with self.subTest(width=width, height=height):
result = self.check_canvas(456, width, height)
self.assertIn("exceeds original native-pixel canvas", result["error"])
self.assertEqual(result["updates"], 0)
self.assertEqual(result["window_id"], 123)
def test_exact_native_canvas_boundary(self):
for identifier in (123, 456):
for width, height, scale in ((500, 400, 2), (1000, 800, 1)):
with self.subTest(identifier=identifier, scale=scale):
result = self.check_canvas(identifier, width, height, scale)
self.assertIsNone(result["error"])
self.assertEqual(result["filter_reads"], 1)
def test_fractional_native_pixel_overflow_rejects(self):
for identifier in (123, 456):
for width, height in ((500.01, 400), (500, 400.01)):
with self.subTest(identifier=identifier, width=width, height=height):
self.assertIsNotNone(self.check_canvas(identifier, width, height)["error"])
def test_unusable_fresh_geometry_is_rejected(self):
for identifier in (123, 456):
for width, height, scale in ((0, 400, 2), (500, 0, 2), (500, 400, 0)):
with self.subTest(identifier=identifier, width=width, height=height, scale=scale):
result = self.check_canvas(identifier, width, height, scale)
self.assertIsNotNone(result["error"])
self.assertEqual(result["updates"], 0)
def check_frame(self, width, height, x=0, y=0, content_scale=1, backing_scale=2):
result = subprocess.run(
[str(self.fixture)], input=json.dumps(dict(frame=True, width=width, height=height,
x=x, y=y, content_scale=content_scale, backing_scale=backing_scale)),
capture_output=True, text=True, check=True, timeout=10,
)
return json.loads(result.stdout)
def test_native_frame_exact_boundary_and_padding(self):
for width, height, x, y in ((500, 400, 0, 0), (400, 300, 50, 50)):
with self.subTest(width=width, height=height):
result = self.check_frame(width, height, x, y)
self.assertIsNone(result["error"])
self.assertEqual(result["frames"], 1)
def test_native_frame_extent_cannot_exceed_buffer(self):
for width, height in ((501, 400), (500, 401), (500.01, 400)):
with self.subTest(width=width, height=height):
result = self.check_frame(width, height)
self.assertIsNotNone(result["error"])
self.assertEqual(result["frames"], 0)
def test_native_frame_content_cannot_extend_outside_buffer(self):
for x, y in ((-1, 0), (0, -1), (1, 0), (0, 1)):
with self.subTest(x=x, y=y):
result = self.check_frame(500, 400, x, y)
self.assertIsNotNone(result["error"])
self.assertEqual(result["frames"], 0)
def test_scaled_frame_stays_rejected(self):
result = self.check_frame(400, 320, content_scale=0.8)
self.assertIn("scaled", result["error"])
self.assertEqual(result["frames"], 0)
class WindowCaptureTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.build = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.build.cleanup)
cls.recorder = Path(cls.build.name) / "recorder"
subprocess.run(
["xcrun", "swiftc", "-parse-as-library", "-warnings-as-errors", "-O",
str(RECORDER_SOURCE), "-o", str(cls.recorder)], check=True,
)
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name)
self.output = self.path / "a frame.png"
self.log = self.path / "arguments.json"
self.environment = dict(os.environ)
self.environment.update(
PATH=f"{self.path}:{os.environ['PATH']}",
ETAF_GUI_CAPTURE_PID=str(os.getpid()),
CAPTURE_TEST_LOG=str(self.log),
CAPTURE_TEST_ID="123",
)
self.script("osascript", "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$CAPTURE_TEST_ID\"\n")
self.capture = self.script(
"screencapture",
"#!/usr/bin/env python3\n"
"import json, os, pathlib, sys\n"
"pathlib.Path(os.environ['CAPTURE_TEST_LOG']).write_text(json.dumps(sys.argv[1:]))\n"
"pathlib.Path(sys.argv[-1]).write_bytes(b'capture stub')\n"
)
self.environment["ETAF_GUI_SCREENCAPTURE"] = str(self.capture)
def script(self, name, contents):
path = self.path / name
path.write_text(contents)
path.chmod(0o755)
return path
def run_capture(self, *arguments):
return subprocess.run(
[str(CAPTURE), *arguments], env=self.environment,
capture_output=True, text=True, check=False,
)
def test_screenshot_captures_exact_owned_window_without_shadow(self):
result = self.run_capture("-x", str(self.output))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(self.log.read_text()),
["-l", "123", "-o", "-x", str(self.output)])
def test_screenshot_resolves_replacement_window_for_next_capture(self):
for window_id in ("123", "456"):
self.environment["CAPTURE_TEST_ID"] = window_id
result = self.run_capture("-x", str(self.output))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(self.log.read_text())[1], window_id)
def test_missing_owner_fails_without_invoking_capture(self):
self.script("osascript", "#!/bin/sh\nexit 1\n")
result = self.run_capture("-x", str(self.output))
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.log.exists())
def test_invalid_pid_fails_without_invoking_capture(self):
self.environment["ETAF_GUI_CAPTURE_PID"] = "1; echo unsafe"
result = self.run_capture("-x", str(self.output))
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.log.exists())
def test_window_replacement_during_capture_rejects_image(self):
counter = self.path / "counter"
self.script("osascript", f'''#!/bin/sh
cat >/dev/null
if [ -f '{counter}' ]; then echo 456; else touch '{counter}'; echo 123; fi
''')
result = self.run_capture("-x", str(self.output))
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.output.exists())
def test_video_rejects_missing_or_invalid_owner_before_capture(self):
for arguments in ([], ["0", str(self.output), "ready"],
["123; echo unsafe", str(self.output), "ready"]):
result = subprocess.run(
[str(self.recorder), *arguments],
env=self.environment, capture_output=True, check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.log.exists())
def run_recorder_lifecycle(self, recorder):
source = RUNNER.read_text().rsplit('[ "$#" -ge 1 ] || usage', 1)[0]
self.environment.update(TEST_RECORDER=str(recorder), TEST_RUN_DIR=str(self.path))
return subprocess.run(
["sh", "-c", source + '''
GUI_RUN_DIR=$TEST_RUN_DIR
GUI_RECORDER=$TEST_RECORDER
GUI_DAEMON_PID=$ETAF_GUI_CAPTURE_PID
start_recorder
stop_recorder
'''], env=self.environment, capture_output=True, text=True, timeout=10, check=False,
)
def test_runner_waits_for_verified_frame_and_stops_owned_recorder(self):
recorder = self.script(
"recorder-stub", "#!/usr/bin/env python3\n"
"import json, os, pathlib, signal, sys\n"
"signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n"
"pathlib.Path(os.environ['CAPTURE_TEST_LOG']).write_text(json.dumps(sys.argv[1:]))\n"
"pathlib.Path(sys.argv[3]).write_text('verified frame')\n"
"signal.pause()\n",
)
result = self.run_recorder_lifecycle(recorder)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(self.log.read_text()),
[str(os.getpid()), str(self.path / "recording.mov"),
str(self.path / "recorder.ready")])
def test_runner_rejects_recording_exit_before_ready(self):
recorder = self.script("recorder-stub", "#!/bin/sh\nexit 1\n")
result = self.run_recorder_lifecycle(recorder)
self.assertNotEqual(result.returncode, 0)
self.assertIn("exited before", result.stderr)
def test_selector_rejects_other_owners_helpers_and_ambiguous_frames(self):
source = CAPTURE.read_text().split("<<'JXA'\n", 1)[1].split("\nJXA", 1)[0]
source = source[source.index("function run(argv)"):]
def window(identifier, **overrides):
result = dict(kCGWindowNumber=identifier, kCGWindowOwnerPID=123,
kCGWindowOwnerName="Emacs", kCGWindowLayer=0,
kCGWindowAlpha=1, kCGWindowName="*target*",
kCGWindowBounds=dict(Width=1000, Height=800))
result.update(overrides)
return result
def select(windows):
# Evaluate the actual JXA selector against a fixed WindowServer
# inventory. The test does not enumerate or capture live windows.
fixture = (
"var ObjC = {deepUnwrap: function(x) {return x;}, "
"castRefToObject: function(x) {return x;}};\n"
"var $ = {CGWindowListCopyWindowInfo: function() {return "
+ json.dumps(windows) + ";}};\n"
)
script = source.replace("function run(argv) {",
"function run(argv) {\n" + fixture, 1)
return subprocess.run(
["/usr/bin/osascript", "-l", "JavaScript", "-", "123"],
input=script, capture_output=True, text=True, check=False,
)
candidates = [
window(1, kCGWindowOwnerPID=456),
window(2, kCGWindowOwnerName="Other"),
window(3, kCGWindowName=""),
window(4, kCGWindowAlpha=0),
window(5, kCGWindowLayer=1),
window(6, kCGWindowBounds=dict(Width=1000, Height=24)),
window(7),
]
result = select(candidates)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "7")
self.assertNotEqual(select(candidates + [window(8)]).returncode, 0)
self.assertNotEqual(select(candidates[:5]).returncode, 0)
if __name__ == "__main__":
unittest.main()