diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h index c41683ae33..640e9c0bcf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h @@ -14,8 +14,8 @@ typedef NS_ENUM(NSInteger, RunnerTapSynthesisStatus) { @interface RunnerSynthesizedGesture : NSObject // `resolvedWindow` is the app window the caller already resolved for the gesture's reference -// frame. The record's display ID is read from that same window so geometry and routing can never -// name different windows; pass nil to fall back to the application's first window. +// frame, so the record's display ID is read from that same window. Pass nil for the sole-screen +// route: the app's tree could not name its window and the screen is proven instead. + (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application resolvedWindow:(id _Nullable)resolvedWindow x:(double)x diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m index 8074550cba..fa61a820cc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m @@ -29,7 +29,6 @@ typedef id (*RunnerDragPointerPathFactory)( CGPoint, double ); -typedef double (*RunnerDragProgressFunction)(double); static NSString * _Nullable RunnerResolveGestureEventBridge( id application, @@ -58,14 +57,6 @@ static id RunnerControlledScrollPointerPath( CGPoint end, double durationMs ); -static id RunnerSampledDragPointerPath( - const RunnerGestureEventBridge *bridge, - CGPoint start, - CGPoint end, - double durationMs, - RunnerDragProgressFunction progress, - NSInteger frameCount -); static NSString * _Nullable RunnerTrySynthesizeDrag( id application, id _Nullable resolvedWindow, @@ -332,8 +323,10 @@ static RunnerTapSynthesisStatus RunnerTrySynthesizeTap( NSUInteger displayID = 0; // A nil window is the caller's sole-screen route: the app tree could not name its window. + // Re-asking here, at dispatch, is what keeps the record's display the one the app fills now. + id soleScreen = nil; NSString *displayError = resolvedWindow == nil - ? RunnerResolveSoleScreenDisplayID(application, &displayID) + ? RunnerResolveSoleScreen(application, &soleScreen, &displayID) : RunnerResolveWindowDisplayID(resolvedWindow, &displayID); if (displayError != nil) return displayError; NSInteger interfaceOrientation = @@ -418,34 +411,17 @@ static id RunnerControlledScrollPointerPath( // A stationary hold is not a reliable zero-velocity release in XCTest. Cubic ease-out decreases // velocity from the first movement and reaches near zero before lift. Profiles that accelerate // after pan recognition can still be classified as flings despite a slow final sample. - return RunnerSampledDragPointerPath( - bridge, - start, - end, - durationMs, - RunnerControlledScrollProgress, - RunnerControlledScrollFrameCount(durationMs) - ); -} - -static id RunnerSampledDragPointerPath( - const RunnerGestureEventBridge *bridge, - CGPoint start, - CGPoint end, - double durationMs, - RunnerDragProgressFunction progress, - NSInteger frameCount -) { id path = ((RunnerMsgSendInitPath)objc_msgSend)([bridge->core.pathClass alloc], bridge->initPathSelector, start, 0.0); if (path == nil) { return nil; } + NSInteger frameCount = RunnerControlledScrollFrameCount(durationMs); NSTimeInterval durationSeconds = durationMs / 1000.0; for (NSInteger index = 1; index <= frameCount; index += 1) { double t = (double)index / (double)frameCount; - double easedT = progress(t); + double easedT = RunnerControlledScrollProgress(t); CGPoint point = CGPointMake( start.x + (end.x - start.x) * easedT, start.y + (end.y - start.y) * easedT diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h index 8d3b4dd8d0..cf357347d9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h @@ -3,20 +3,10 @@ NS_ASSUME_NONNULL_BEGIN -// Shared reflection surface for XCTest's private event-synthesis API -// (XCSynthesizedEventRecord / XCPointerEventPath). Both gesture synthesis -// (RunnerSynthesizedGesture) and text-entry synthesis (RunnerSynthesizedTextEntry) -// reflect into this same private API; resolving the part they have in common here -// means a selector rename only has to be found in one place. -// -// Each caller additionally resolves whichever XCSynthesizedEventRecord init -// overload and XCPointerEventPath factory/mutator selectors its own event kind -// needs: gesture uses `initWithName:displayID:interfaceOrientation:` plus -// touch-path selectors (`initForTouchAtPoint:offset:`, `moveToPoint:atOffset:`, -// `liftUpAtOffset:`); text entry uses the 1-arg `initWithName:` plus text-input -// selectors (`initForTextInput`, `typeText:atOffset:typingSpeed:shouldRedact:`, -// `typeKey:modifiers:atOffset:`). Those genuinely differ and are resolved by -// each caller on top of this shared core, not forced into one shape here. +// Shared reflection core for XCTest's private event-synthesis API (XCSynthesizedEventRecord / +// XCPointerEventPath). Gesture and text-entry synthesis each resolve on top of this core the +// record-init overload and mutator selectors their own event kind needs, so a rename in the part +// they share is found in one place. typedef struct { Class recordClass; // XCSynthesizedEventRecord Class pathClass; // XCPointerEventPath @@ -32,7 +22,7 @@ typedef void (*RunnerMsgSendAddPath)(id, SEL, id); typedef BOOL (*RunnerMsgSendSynthesize)(id, SEL, NSError **); // Why the display hosting the target app could not be named. Callers branch on this rather than -// on the message, so a refusal keeps its reason across the language boundary (#2728). +// on the message, so a refusal keeps its reason across the language boundary. typedef NS_ENUM(NSUInteger, RunnerApplicationScreenFailure) { RunnerApplicationScreenFailureNone, // No window with a usable frame, so nothing resolves and its screen would only report main. @@ -41,28 +31,20 @@ typedef NS_ENUM(NSUInteger, RunnerApplicationScreenFailure) { RunnerApplicationScreenFailureUnresolvedScreen, }; -// Resolves the shared core described above against the live XCTest runtime. -// Returns nil and populates `bridge` on success. Returns a non-nil -// "private XCTest synthesis unavailable: ..." message and leaves -// `bridge` unspecified when a class or selector the private API is expected to -// expose is missing (the API changed, or is unavailable on this OS/Xcode). -// `surface` names the calling feature ("event" or "text") so the message text -// matches what that caller has always reported. +// Resolves the shared core against the live XCTest runtime. Returns nil on success, or a +// "private XCTest synthesis unavailable: ..." message when a class or selector the +// private API is expected to expose is missing. `surface` names the calling feature. FOUNDATION_EXPORT NSString * _Nullable RunnerResolveXCTestEventBridge( id application, NSString *surface, RunnerXCTestEventBridge *bridge ); -// Resolves the `XCUIScreen` hosting the application's window and that screen's `displayID`, which -// is the only display identity that follows a foldable app to whichever panel it currently lights. -// Reading the window frame must precede reading its screen: an unresolved element still reports the -// main screen. Never selects a screen by number or by position in `XCUIScreen.screens`. -// Returns YES and writes the screen to capture into `screen` and its identity into `displayID`. -// Otherwise returns NO, leaves both empty, and names the reason in `failure` so callers branch on -// the reason rather than on message text (#2728). Private KVC raises rather than returns nil when -// XCTest has no such value, so both reads are guarded here and surface as a typed failure instead -// of an exception reaching the caller. +// Resolves the `XCUIScreen` hosting the application's window and its `displayID`, the only display +// identity that follows an app to the panel it lights. Reading the window frame must precede +// reading its screen: an unresolved element still reports the main screen, and neither read is +// ever selected by number or position. Private KVC raises rather than returns nil, so both reads +// are guarded here and any failure surfaces as the typed `failure`, never an exception. FOUNDATION_EXPORT BOOL RunnerResolveApplicationScreen( id application, id _Nullable *screen, @@ -70,27 +52,19 @@ FOUNDATION_EXPORT BOOL RunnerResolveApplicationScreen( RunnerApplicationScreenFailure *failure ); -// Reads the display ID off an already-resolved window. When a caller has resolved the app window -// for geometry, routing the synthesized gesture by this window keeps the record's display and the -// booked reference frame on the same window instead of re-walking `windows.firstMatch`. +// Reads the display ID off an already-resolved window, keeping a synthesized record's display and +// the caller's booked reference frame on the same window. The frame is still read before the +// screen; a window that answers empty or raises fails closed with a refusal message naming the +// same reason the application walk gives. FOUNDATION_EXPORT NSString * _Nullable RunnerResolveWindowDisplayID( id window, NSUInteger *displayID ); -// Reads the display ID of the device's only screen, for a gesture whose app window XCTest cannot -// resolve because the app's accessibility tree is too deep to serialize. On an iPhone with exactly -// one screen that screen hosts the full-screen app, so no window is needed to name it. On an iPad -// the same route opens only while the app provably fills that screen, decided in-process by the -// evidence seam below without the app's tree. Anywhere else this returns a message and leaves -// `displayID` untouched: a windowed iPad app sits in part of its screen, and only a window can say -// which panel lights a foldable's app. -FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreenDisplayID( - id application, - NSUInteger *displayID -); - -// Resolves the screen and display identity under the same sole-screen rule used by synthesis. +// Resolves the device's only screen and its `displayID` under the sole-screen rule that synthesis +// and capture share: an iPhone with one screen always, an iPad only while the AXRuntime probe +// proves the app fills that screen. Returns nil on success; on refusal it returns a message, +// empties `screen`, and leaves `displayID` untouched. FOUNDATION_EXPORT NSString * _Nullable RunnerResolveSoleScreen( id application, id _Nullable *screen, @@ -111,12 +85,9 @@ typedef NS_ENUM(NSUInteger, RunnerFullScreenEvidence) { RunnerFullScreenEvidenceUnknown, }; -// Decides the evidence from the values the AXRuntime probe reads. AXRuntime reports the primary -// app's window and the system app, which spans the screen, in one native space; this is the same -// comparison the Simulator snapshot bridge refuses a capture on (`app-window-not-full-screen`, -// ADR 0004). Neither read walks the app's tree, so the decision survives a tree XCTest cannot -// serialize. A pid that cannot name a process (<= 0) or an unreadable frame answers Unknown, never -// a guess. +// Decides the evidence from the values the AXRuntime probe reads, which AXRuntime reports in one +// native space (ADR 0004). Neither read walks the app's tree, so the decision survives a tree +// XCTest cannot serialize. An unreadable pid or frame answers Unknown, never a guess. FOUNDATION_EXPORT RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( pid_t targetProcessID, pid_t primaryProcessID, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m index c02720b395..f16705845a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m @@ -114,17 +114,10 @@ } } -// One window read. XCTest answers with a value or raises. The window object is handed back because -// resolving this instance is what its `screen` will answer from — re-querying `firstMatch` would -// return an unresolved element that still names the main screen. -// -// Both answers are required. An app with no window answers `frame` with `(0,0 0x0)` without raising, -// which geometry alone would read as a window the runtime has not measured, and only the query -// itself says whether it has one. Measured on a live Duo: the runner host process, which has no -// window, answers `exists` NO and `frame` `(0,0 0x0)` (#2728). -// -// The frame the read produced is reported even when it is refused: a capture that fails closed has -// to say what it looked at, or the reason code cannot be told apart from a stale device. +// One window read. Both answers are required: an app with no window answers `frame` with +// `(0,0 0x0)` without raising, so only `exists` says whether the window is there. The window +// object is handed back because resolving this instance is what its `screen` will answer from. +// The frame is reported even when refused, so a refusal names what it looked at. static RunnerApplicationScreenFailure RunnerResolveUsableWindow( id application, id _Nullable *window, @@ -158,9 +151,8 @@ static RunnerApplicationScreenFailure RunnerResolveUsableWindow( return RunnerApplicationScreenFailureNone; } -// The screen read that must follow window resolution on that same window instance. Before the -// frame above is asked for, the window's `screen` can still name main, so this is never merged -// into the read ahead of it. +// The screen read that must follow window resolution on that same window instance: before the frame +// is asked for, `screen` can still name main, so this never merges into the read ahead of it. static RunnerApplicationScreenFailure RunnerReadResolvedWindowScreen( id window, CGRect windowFrame, @@ -212,31 +204,18 @@ BOOL RunnerResolveApplicationScreen( return *failure == RunnerApplicationScreenFailureNone; } -// Reads the display ID off a window the caller already resolved, so a synthesized record can be -// routed by the same window its reference frame was measured on. The window still resolves here: -// its frame is read before its screen, and a window that answers empty or raises fails closed with -// the same reasons the application walk gives. -NSString * _Nullable RunnerResolveSoleScreenDisplayID(id application, NSUInteger *displayID) { - id screen = nil; - return RunnerResolveSoleScreen(application, &screen, displayID); -} - RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( pid_t targetProcessID, pid_t primaryProcessID, CGRect appFrame, CGRect screenFrame ) { - // A pid that could not be read (or cannot own a process) proves nothing about ownership, so the - // answer is Unknown, never a guess that the app is not the primary app. if (targetProcessID <= 0 || primaryProcessID <= 0) { return RunnerFullScreenEvidenceUnknown; } - if (!AgentDeviceForegroundOwnershipMatches(primaryProcessID, targetProcessID)) { + if (primaryProcessID != targetProcessID) { return RunnerFullScreenEvidenceNotPrimaryApp; } - // A frame that is not a usable measurement is a runtime that said nothing, not a window that - // differs, so the answer is Unknown, never the Windowed a comparison of the numbers would read as. if (!AgentDeviceForegroundFramesAreMeasurable(appFrame, screenFrame)) { return RunnerFullScreenEvidenceUnknown; } @@ -255,9 +234,8 @@ RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( @"/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework/XCTAutomationSupport"; static Class _Nullable RunnerForegroundElementClass(void) { - // Cache the answer including its absence: a runtime without the class would otherwise pay two - // RTLD_NOW lookups per window-less gesture forever. Code availability is the only thing cached - // here — the probe re-measures every attempt, because a resize changes the answer. + // Cache the answer including its absence; code availability is all that is cached — the probe + // re-measures every attempt because a resize changes the answer. static BOOL resolved = NO; static Class _Nullable cached = Nil; if (!resolved) { @@ -273,11 +251,9 @@ RunnerFullScreenEvidence RunnerFullScreenEvidenceFromForeground( return cached; } -// One contained probe: it checks each selector before sending it and reads the target pid, the -// primary app's frame, and the system app's frame inside one exception scope, so any failed read -// answers Unknown — the same typed nothing-a-guess the decision seam already reports. The target -// pid comes from the existing runner owner for that read (RunnerAXSnapshotBridge), which picks the -// correctly sized send for the pid_t return through the method signature. +// One contained probe: it checks each selector before sending it and reads the target pid and both +// frames inside one exception scope, so any failed read answers Unknown. The target pid comes from +// the existing runner owner for that read (RunnerAXSnapshotBridge). static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application) { if (application == nil) { return RunnerFullScreenEvidenceUnknown; @@ -346,9 +322,7 @@ static RunnerFullScreenEvidence RunnerProbeApplicationFillsScreen(id application return @"private XCTest event synthesis unavailable: no resolved application window and no sole screen to route to"; } // An iPhone keeps every app full-screen, so its idiom answers the rule and the route never pays - // for the probe. Off the phone idiom the route opens only on the fills-screen proof — an iPad can - // show an app in part of its only screen — and windowed or unknown evidence keeps requiring a - // window. The probe answers only what it proved or failed to prove, never "not probed". + // for the probe. Off the phone idiom the route opens only on the fills-screen proof. if (UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPhone && RunnerProbeApplicationFillsScreen(application) != RunnerFullScreenEvidenceFillsScreen) { return @"private XCTest event synthesis unavailable: no resolved application window, and the sole screen is not provably the app's full-screen frame"; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift index e9fa814df9..86ce04521b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInteractionTests.swift @@ -179,9 +179,11 @@ extension RunnerTests { return } + var screen: AnyObject? var displayID: UInt = 0 - let refusal = RunnerResolveSoleScreenDisplayID(springboard, &displayID) + let refusal = RunnerResolveSoleScreen(springboard, &screen, &displayID) XCTAssertNotNil(refusal, "an iPad app that does not own the screen names no sole screen for its taps") + XCTAssertNil(screen) XCTAssertEqual(displayID, 0) } diff --git a/apple/snapshot-bridge/ForegroundApplicationGeometry.h b/apple/snapshot-bridge/ForegroundApplicationGeometry.h index c45954ce64..3bf9fcc55f 100644 --- a/apple/snapshot-bridge/ForegroundApplicationGeometry.h +++ b/apple/snapshot-bridge/ForegroundApplicationGeometry.h @@ -1,13 +1,7 @@ -// ForegroundApplicationGeometry.h — the one full-screen rule two native targets ask. -// -// The Simulator snapshot bridge refuses a capture whose app window does not fill the screen -// (`app-window-not-full-screen`, #3844), and the iOS runner refuses to route a window-less -// gesture onto a screen it cannot prove the app fills. Both include this header, so the rule is -// one compilation, not a pinned copy. Each caller keeps its own unknown policy: the bridge keeps -// a capture it cannot measure, the runner refuses authorization it cannot prove. -// -// Both consumers include this file by bare name: the snapshot bridge compiles it from its own -// directory, the runner's UITests target finds it through HEADER_SEARCH_PATHS. +// ForegroundApplicationGeometry.h — the one full-screen rule the Simulator snapshot bridge and +// the iOS runner both include, by bare name, so the rule is one compilation and not a pinned +// copy. Each keeps its own unknown policy: the bridge keeps a capture it cannot measure, the +// runner refuses to authorize a route it cannot prove. #import #import @@ -22,12 +16,6 @@ NS_ASSUME_NONNULL_BEGIN - (CGRect)frame; @end -// While a system surface owns the foreground, no screen is the target app's to fill and no tree is -// the target app's to capture (ADR 0004, foreground ownership). -static inline BOOL AgentDeviceForegroundOwnershipMatches(pid_t primaryProcessID, pid_t targetProcessID) { - return primaryProcessID == targetProcessID; -} - // Sub-pixel layout rounding is not a window; anything past this is one that shows the app in part // of the screen (Windowed Apps or Stage Manager). #define AGENT_DEVICE_FULL_SCREEN_FRAME_TOLERANCE 0.5 diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index 8dc5b2a6a6..83a98bd2e9 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -182,7 +182,7 @@ - (BOOL)isPrimaryForegroundProcess:(pid_t)pid if (![elementClass respondsToSelector:@selector(primaryApp)]) return NO; id application = [elementClass primaryApp]; return [application respondsToSelector:@selector(pid)] && - AgentDeviceForegroundOwnershipMatches([application pid], pid); + [application pid] == pid; } // The snapshot API reports the frames of an app that runs in an iPadOS window relative to that @@ -224,7 +224,6 @@ - (nullable id)jsonValue:(id)value name:(NSString *)name CGRect rect = CGRectZero; if (_valueGetValue(raw, 3, &rect)) return rectDictionary(rect); } - if ([name isEqualToString:kAttributeFrame]) return nil; return nil; }