diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index 00f57813a..e7ddabdcd 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -72,12 +72,32 @@ + (NSArray *)routes } } } + NSMutableArray *dismissButtonLabels = [NSMutableArray array]; + id dismissLabelsArg = request.arguments[@"dismissButtonLabels"]; + if ([dismissLabelsArg isKindOfClass:NSArray.class]) { + for (id label in (NSArray *)dismissLabelsArg) { + if ([label isKindOfClass:NSString.class] && [(NSString *)label length] > 0) { + [dismissButtonLabels addObject:label]; + } + } + } + NSMutableArray *goToApplicationButtonLabels = [NSMutableArray array]; + id goToApplicationLabelsArg = request.arguments[@"goToApplicationButtonLabels"]; + if ([goToApplicationLabelsArg isKindOfClass:NSArray.class]) { + for (id label in (NSArray *)goToApplicationLabelsArg) { + if ([label isKindOfClass:NSString.class] && [(NSString *)label length] > 0) { + [goToApplicationButtonLabels addObject:label]; + } + } + } NSNumber *restoreArg = request.arguments[@"restoreForegroundApp"]; BOOL restoreForegroundApp = [restoreArg isKindOfClass:NSNumber.class] ? restoreArg.boolValue : YES; NSError *error; if (![FBBroadcastManager.sharedInstance startBroadcastWithTimeout:timeout confirmButtonLabels:confirmButtonLabels + dismissButtonLabels:dismissButtonLabels + goToApplicationButtonLabels:goToApplicationButtonLabels restoreForegroundApp:restoreForegroundApp error:&error]) { if ([error.domain isEqualToString:FBBroadcastManagerErrorDomain]) { @@ -97,8 +117,29 @@ + (NSArray *)routes + (id)handleStopBroadcast:(FBRouteRequest *)request { + NSMutableArray *dismissButtonLabels = [NSMutableArray array]; + id dismissLabelsArg = request.arguments[@"dismissButtonLabels"]; + if ([dismissLabelsArg isKindOfClass:NSArray.class]) { + for (id label in (NSArray *)dismissLabelsArg) { + if ([label isKindOfClass:NSString.class] && [(NSString *)label length] > 0) { + [dismissButtonLabels addObject:label]; + } + } + } + NSMutableArray *goToApplicationButtonLabels = [NSMutableArray array]; + id goToApplicationLabelsArg = request.arguments[@"goToApplicationButtonLabels"]; + if ([goToApplicationLabelsArg isKindOfClass:NSArray.class]) { + for (id label in (NSArray *)goToApplicationLabelsArg) { + if ([label isKindOfClass:NSString.class] && [(NSString *)label length] > 0) { + [goToApplicationButtonLabels addObject:label]; + } + } + } + NSError *error; - if (![FBBroadcastManager.sharedInstance stopBroadcastWithError:&error]) { + if (![FBBroadcastManager.sharedInstance stopBroadcastWithDismissButtonLabels:dismissButtonLabels + goToApplicationButtonLabels:goToApplicationButtonLabels + error:&error]) { return FBResponseWithStatus([FBCommandStatus timeoutErrorWithMessage:error.localizedDescription traceback:nil]); } return FBResponseWithObject([FBBroadcastManager.sharedInstance statusDictionary]); diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.h b/WebDriverAgentLib/Utilities/FBBroadcastManager.h index 0d137e23c..b2737c5c6 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.h +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.h @@ -55,24 +55,54 @@ typedef NS_ERROR_ENUM(FBBroadcastManagerErrorDomain, FBBroadcastManagerError) { @param timeout The overall time budget in seconds for the broadcast to reach the connected state @param confirmButtonLabels Labels to look for on the system confirmation sheet + @param dismissButtonLabels Labels for dismissing the system's stale "Screen Broadcasting" alert + that SpringBoard posts whenever a broadcast ends, which otherwise blocks the picker dance from + completing. Defaults to ["OK"] when empty/nil + @param goToApplicationButtonLabels Labels for the alert's other button. Together with + dismissButtonLabels this anchors the alert's identity: it is only treated as the Screen + Broadcasting alert, and auto-dismissed, when one button matches dismissButtonLabels and the + other matches this list. Defaults to ["Go to Application"] when empty/nil @param restoreForegroundApp YES to re-activate the previously active application afterwards @param error If there is an error, upon return contains an NSError describing the problem @return NO in case of a failure */ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(nullable NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(nullable NSArray *)goToApplicationButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error; /** Asks the extension to finish the broadcast and waits for it to disconnect. - Idempotent when no broadcast is running. + Idempotent when no broadcast is running. Equivalent to calling + stopBroadcastWithDismissButtonLabels:goToApplicationButtonLabels:error: with nil for both + label lists. @param error If there is an error, upon return contains an NSError describing the problem @return NO in case of a failure */ - (BOOL)stopBroadcastWithError:(NSError **)error; +/** + Asks the extension to finish the broadcast and waits for it to disconnect, then makes a + best-effort attempt to dismiss the system's stale "Screen Broadcasting" alert that SpringBoard + posts once the broadcast ends. Idempotent when no broadcast is running. + + @param dismissButtonLabels Labels for dismissing the system's stale "Screen Broadcasting" alert. + Defaults to ["OK"] when empty/nil + @param goToApplicationButtonLabels Labels for the alert's other button. Together with + dismissButtonLabels this anchors the alert's identity: it is only treated as the Screen + Broadcasting alert, and auto-dismissed, when one button matches dismissButtonLabels and the + other matches this list. Defaults to ["Go to Application"] when empty/nil + @param error If there is an error, upon return contains an NSError describing the problem + @return NO in case of a failure. The alert-dismissal attempt is best-effort and never causes + this to return NO by itself + */ +- (BOOL)stopBroadcastWithDismissButtonLabels:(nullable NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(nullable NSArray *)goToApplicationButtonLabels + error:(NSError **)error; + /** Notifies the manager that a capture session started (sends SESSION_ADD when connected). */ - (void)notifySessionAdded:(FBVideoStreamSession *)session; diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 8bd149a56..d050dad4b 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -21,6 +21,7 @@ #import "FBScreen.h" #import "FBUnattachedAppLauncher.h" #import "FBVideoStreamManager.h" +#import "FBXCTestDaemonsProxy.h" #import "XCUIApplication+FBTouchAction.h" #import "XCUIApplication.h" #import "XCUIApplication+FBHelpers.h" @@ -33,11 +34,44 @@ // The picker press is dropped silently by the system when it fires before the scene is fully // active, so it is re-fired periodically until the confirmation sheet shows up. static const uint64_t PICKER_RETRIGGER_INTERVAL_MS = 2000; +// How long the post-stop sweep waits for the delayed "Screen Broadcasting" alert to appear +// before giving up on it, and the overall cap on the sweep (appearance wait plus dismissal). +static const NSTimeInterval ALERT_APPEARANCE_GRACE_SEC = 2.0; +static const NSTimeInterval ALERT_SWEEP_TIMEOUT_SEC = 5.0; static uint64_t FBBroadcastNowMs(void) { return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / NSEC_PER_MSEC; } + +// Tap via WDA's own event synthesis instead of XCUIElement.tap: a missed XCUIElement tap (e.g. +// the element disappeared in between) records an XCTest failure that tears down the whole test +// session, whereas a missed synthesized tap is harmless and surfaces as a timeout to the caller. +// `app` must be the app whose coordinate space produced `frame`: the synthesized event record is +// stamped with the receiver's interfaceOrientation. +// `waitForAck:NO` is for taps whose outcome the caller observes via state (e.g. does the alert +// still exist on the next spin iteration) and which must not block inside a bounded spin: the +// synthesis acknowledgement can take up to the event-synthesis timeout margin when the system +// sheds the event, and that wait cannot be interrupted by a spinner's own, much shorter, deadline. +static BOOL FBBroadcastTapFrameCenter(XCUIApplication *app, CGRect frame, BOOL waitForAck, NSError **error) +{ + CGFloat scale = (CGFloat)[FBScreen scale]; + CGPoint center = CGPointMake(CGRectGetMidX(frame) * scale, CGRectGetMidY(frame) * scale); + NSArray *tapActions = @[ + @{@"type": @"pointerDown", @"x": @(center.x), @"y": @(center.y)}, + @{@"type": @"pause", @"duration": @60}, + @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, + ]; + if (waitForAck) { + return [app fb_performMobilerunActions:tapActions scale:scale error:error]; + } + XCSynthesizedEventRecord *record = [app fb_mobilerunEventRecordFromActions:tapActions scale:scale error:error]; + if (nil == record) { + return NO; + } + [FBXCTestDaemonsProxy synthesizeEventAsyncWithRecord:record]; + return YES; +} #endif static const NSTimeInterval STOP_TIMEOUT = 5.0; @@ -53,12 +87,33 @@ @interface FBBroadcastManager () @property (atomic) BOOL paused; /** YES while a start dance is driving the system UI (used to serialize concurrent starts). */ @property (atomic) BOOL startInProgress; +#if !TARGET_OS_SIMULATOR && !TARGET_OS_TV +/** Monotonic ms timestamp of the last dismissal tap dispatch; guards the re-attempt cooldown. */ +@property (atomic) uint64_t lastAlertDismissalAttemptMs; +#endif #if !TARGET_OS_SIMULATOR && !TARGET_OS_TV - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error; +// Finds, but does not tap, the dismiss button of the system's stale "Screen Broadcasting" alert +// (posted by SpringBoard whenever a broadcast ends) when one is on screen. The alert is matched +// structurally, not by its (localized) title: exactly two buttons, of which exactly one matches +// dismissLabels and the OTHER matches goToAppLabels - the second button anchors the alert's +// identity, since "exactly one of two buttons matches the dismiss labels" alone still matches +// unrelated two-button prompts (e.g. "Settings" / "OK"). Both label lists are localizable via +// the request arguments. Anything else - including two-button alerts whose second button is +// unrecognized - is left alone; misfiring on an unrelated system dialog would silently +// acknowledge it, which is worse than letting the dance time out. +- (nullable XCUIElement *)matchingDismissButtonForAlertWithDismissLabels:(NSArray *)dismissLabels + goToApplicationLabels:(NSArray *)goToAppLabels; +// Dismisses the alert matched by matchingDismissButtonForAlertWithDismissLabels:goToApplicationLabels: +// above: applies the re-attempt cooldown, verifies the button's frame, and dispatches the tap. +- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels + goToApplicationLabels:(NSArray *)goToApplicationLabels; #endif @end @@ -140,6 +195,8 @@ - (NSDictionary *)statusDictionary - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error { @@ -182,6 +239,8 @@ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout @try { return [self performBroadcastStartWithTimeout:timeout confirmButtonLabels:confirmButtonLabels + dismissButtonLabels:dismissButtonLabels + goToApplicationButtonLabels:goToApplicationButtonLabels restoreForegroundApp:restoreForegroundApp error:error]; } @finally { @@ -193,16 +252,29 @@ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout #if !TARGET_OS_SIMULATOR && !TARGET_OS_TV - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error { + NSArray *dismissLabels = dismissButtonLabels.count > 0 ? dismissButtonLabels : @[@"OK"]; + NSArray *goToAppLabels = goToApplicationButtonLabels.count > 0 ? goToApplicationButtonLabels : @[@"Go to Application"]; + // The screen may already be captured by a live broadcast even though the extension is not // connected (it crashed, or it is between TCP reconnect attempts). Driving the picker on top // of a live broadcast makes iOS kill both, so wait for the extension instead. if (UIScreen.mainScreen.isCaptured) { [FBLogger log:@"broadcast/start: the screen is already being captured; waiting for the extension to connect instead of starting another broadcast"]; [[[[FBRunLoopSpinner new] timeout:5.0] interval:0.2] spinUntilTrue:^BOOL{ - return self.isExtensionConnected || !UIScreen.mainScreen.isCaptured; + if (self.isExtensionConnected || !UIScreen.mainScreen.isCaptured) { + return YES; + } + // A stale "Screen Broadcasting" alert left over from a previous broadcast's end can be + // the very thing pinning isCaptured; clear it so the flag can drop. + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { + [FBLogger log:@"broadcast/start: dispatched a dismissal tap for the stale Screen Broadcasting alert while waiting out the active capture"]; + } + return NO; }]; if (self.isExtensionConnected) { return YES; @@ -280,6 +352,10 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout __block CGRect confirmFrame = CGRectZero; __block uint64_t lastTriggerMs = FBBroadcastNowMs(); [[[[FBRunLoopSpinner new] timeout:CONFIRM_BUTTON_TIMEOUT] interval:0.25] spinUntilTrue:^BOOL{ + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { + [FBLogger logFmt:@"broadcast/start: dispatched a dismissal tap for the stale Screen Broadcasting alert after %llums", FBBroadcastNowMs() - startedMs]; + return NO; + } for (XCUIApplication *app in candidateApps) { for (NSString *label in labels) { XCUIElement *candidate = app.buttons[label]; @@ -313,18 +389,10 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } return NO; } - // Tap via WDA's own event synthesis instead of XCUIElement.tap: a missed XCUIElement tap - // (e.g. the sheet dismissed in between) records an XCTest failure that tears down the whole - // test session, whereas a missed synthesized tap is harmless and surfaces as a connect timeout. - CGFloat scale = (CGFloat)[FBScreen scale]; - CGPoint center = CGPointMake(CGRectGetMidX(confirmFrame) * scale, CGRectGetMidY(confirmFrame) * scale); - NSArray *tapActions = @[ - @{@"type": @"pointerDown", @"x": @(center.x), @"y": @(center.y)}, - @{@"type": @"pause", @"duration": @60}, - @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, - ]; + // A missed tap here is harmless (surfaces as a connect timeout below); see + // FBBroadcastTapFrameCenter for why this goes through WDA's own event synthesis. NSError *tapError; - if (![runner fb_performMobilerunActions:tapActions scale:scale error:&tapError]) { + if (!FBBroadcastTapFrameCenter(runner, confirmFrame, YES, &tapError)) { [FBBroadcastPickerHost dismiss]; if (error) { *error = [NSError errorWithDomain:FBBroadcastManagerErrorDomain @@ -355,9 +423,88 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } return YES; } + +// Finds, but does not tap, the dismiss button of the system's stale "Screen Broadcasting" alert +// (posted by SpringBoard whenever a broadcast ends) when one is on screen. The alert is matched +// structurally, not by its (localized) title: exactly two buttons, of which exactly one matches +// dismissLabels and the OTHER matches goToAppLabels - the second button anchors the alert's +// identity, since "exactly one of two buttons matches the dismiss labels" alone still matches +// unrelated two-button prompts (e.g. "Settings" / "OK"). Both label lists are localizable via +// the request arguments. Anything else - including two-button alerts whose second button is +// unrecognized - is left alone; misfiring on an unrelated system dialog would silently +// acknowledge it, which is worse than letting the dance time out. +- (nullable XCUIElement *)matchingDismissButtonForAlertWithDismissLabels:(NSArray *)dismissLabels + goToApplicationLabels:(NSArray *)goToAppLabels +{ + XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; + XCUIElement *alert = systemApp.alerts.firstMatch; + if (!alert.exists) { + return nil; + } + NSArray *buttons = [alert.buttons allElementsBoundByIndex]; + if (buttons.count != 2) { + return nil; + } + XCUIElement *dismissButton = nil; + XCUIElement *otherButton = nil; + for (XCUIElement *button in buttons) { + if ([dismissLabels containsObject:button.label]) { + if (nil != dismissButton) { + // Both buttons match the dismiss labels - ambiguous, not the alert we expect. + return nil; + } + dismissButton = button; + } else { + otherButton = button; + } + } + if (nil == dismissButton || nil == otherButton) { + return nil; + } + if (![goToAppLabels containsObject:otherButton.label]) { + // The other button is not the expected "Go to Application" anchor - some other two-button + // system prompt, not the Screen Broadcasting alert. + return nil; + } + return dismissButton; +} + +// Dismisses the alert matched by matchingDismissButtonForAlertWithDismissLabels:goToApplicationLabels: +// above: applies the re-attempt cooldown, verifies the button's frame, and dispatches the tap. +- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels + goToApplicationLabels:(NSArray *)goToApplicationLabels +{ + XCUIElement *dismissButton = [self matchingDismissButtonForAlertWithDismissLabels:labels + goToApplicationLabels:goToApplicationLabels]; + if (nil == dismissButton) { + return NO; + } + // The dismissal tap is fire-and-forget (see FBBroadcastTapFrameCenter), so without a cooldown + // the next 0.25s spin iteration could re-tap the same coordinates while the alert's dismissal + // animation is still running, landing the extra tap on the UI underneath. + if (FBBroadcastNowMs() - self.lastAlertDismissalAttemptMs < 1000) { + return NO; + } + CGRect frame = dismissButton.frame; + if (CGRectIsEmpty(frame)) { + return NO; + } + // The frame is in SpringBoard's coordinate space, and the synthesized event record is stamped + // with the RECEIVER's interface orientation - so the tap must be synthesized via the system + // app, not the (possibly backgrounded, orientation-stale) runner. + self.lastAlertDismissalAttemptMs = FBBroadcastNowMs(); + return FBBroadcastTapFrameCenter(XCUIApplication.fb_systemApplication, frame, NO, nil); +} #endif - (BOOL)stopBroadcastWithError:(NSError **)error +{ + return [self stopBroadcastWithDismissButtonLabels:nil goToApplicationButtonLabels:nil error:error]; +} + +- (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels + error:(NSError **)error { if (!self.isExtensionConnected) { return YES; @@ -374,6 +521,45 @@ - (BOOL)stopBroadcastWithError:(NSError **)error } return NO; } +#if !TARGET_OS_SIMULATOR && !TARGET_OS_TV + // The delayed "Screen Broadcasting" alert is an iOS 26 behavior; on older iOS the sweep below + // would only add stop latency waiting for an alert that never appears, so gate it here - the + // start dance's own dismissal (see performBroadcastStartWithTimeout:...) remains the safety + // net on every iOS version. + if (@available(iOS 26.0, *)) { + // Best-effort: a broadcast stop almost always leaves the stale "Screen Broadcasting" alert + // behind, so proactively clear it here instead of waiting for the next start dance to hit it. + // This never affects the return value below. + NSArray *dismissLabels = dismissButtonLabels.count > 0 ? dismissButtonLabels : @[@"OK"]; + NSArray *goToAppLabels = goToApplicationButtonLabels.count > 0 ? goToApplicationButtonLabels : @[@"Go to Application"]; + uint64_t sweepStartedMs = FBBroadcastNowMs(); + __block BOOL alertSeen = NO; + // Survives both failure modes seen in review: exiting once a dismissal tap is merely + // dispatched (FBBroadcastTapFrameCenter's waitForAck:NO tap is fire-and-forget and can be + // shed by the system, so the sweep must keep spinning on the matcher's OBSERVED state, not + // on the dispatch call succeeding), and exiting before the alert - which SpringBoard + // publishes with a delay AFTER the extension socket closes - has appeared at all. So: once + // the alert has been seen, declare success only when it is next observed gone (retrying the + // dismissal tap in between, paced by the 1s cooldown inside + // dismissBroadcastStoppedAlertWithLabels:goToApplicationLabels:); until it has been seen, + // keep waiting out the appearance grace period rather than exiting on the first (empty) + // read. + [[[[FBRunLoopSpinner new] timeout:ALERT_SWEEP_TIMEOUT_SEC] interval:0.25] spinUntilTrue:^BOOL{ + XCUIElement *dismissButton = [self matchingDismissButtonForAlertWithDismissLabels:dismissLabels goToApplicationLabels:goToAppLabels]; + if (nil != dismissButton) { + alertSeen = YES; + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { + [FBLogger log:@"broadcast/stop: dispatched a dismissal tap for the Screen Broadcasting alert"]; + } + return NO; + } + if (alertSeen) { + return YES; + } + return (FBBroadcastNowMs() - sweepStartedMs) >= (uint64_t)(ALERT_APPEARANCE_GRACE_SEC * 1000); + }]; + } +#endif return YES; } diff --git a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.h b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.h index 2445c9c51..126b36905 100644 --- a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.h +++ b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.h @@ -26,6 +26,13 @@ NS_ASSUME_NONNULL_BEGIN + (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSError *__autoreleasing*)error; +/** + Dispatches the synthesized event without waiting for the acknowledgement. Use when the caller + verifies the outcome by observing state (so a lost acknowledgement must not block it); failures + are logged and otherwise ignored. + */ ++ (void)synthesizeEventAsyncWithRecord:(XCSynthesizedEventRecord *)record; + + (BOOL)openURL:(NSURL *)url usingApplication:(NSString *)bundleId error:(NSError **)error; + (BOOL)openDefaultApplicationForURL:(NSURL *)url error:(NSError **)error; diff --git a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m index 0e6f784ea..dde0e26ee 100644 --- a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m +++ b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.m @@ -140,6 +140,15 @@ + (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSEr return YES; } ++ (void)synthesizeEventAsyncWithRecord:(XCSynthesizedEventRecord *)record +{ + [[XCUIDevice.sharedDevice eventSynthesizer] synthesizeEvent:record completion:(id)^(BOOL result, NSError *invokeError) { + if (nil != invokeError) { + [FBLogger logFmt:@"Asynchronous event synthesis failed: %@", invokeError.localizedDescription]; + } + }]; +} + + (BOOL)openURL:(NSURL *)url usingApplication:(NSString *)bundleId error:(NSError *__autoreleasing*)error { XCTRunnerDaemonSession *session = [XCTRunnerDaemonSession sharedSession]; diff --git a/docs/broadcast-extension.md b/docs/broadcast-extension.md index f1e9d1c21..572b7ff18 100644 --- a/docs/broadcast-extension.md +++ b/docs/broadcast-extension.md @@ -19,7 +19,7 @@ the legacy screenshot pipeline; each session reports its current origin via the |---|---|---| | `/mobilerun/screencapture/broadcast/start` | POST | Starts a system broadcast targeting the bundled extension. Foregrounds the runner app, triggers `RPSystemBroadcastPickerView` and confirms the system sheet via UI automation, then waits for the extension to connect. Idempotent while connected. | | `/mobilerun/screencapture/broadcast` | GET | Broadcast status: `state` (`idle`/`connected`/`paused`), control port, extension id, last heartbeat (frames received, orientation, screen size) and the capture sessions with their active `source`. | -| `/mobilerun/screencapture/broadcast/stop` | POST | Asks the extension to finish the broadcast. Live sessions fall back to the screenshot source with a forced key frame; clients do not need to reconnect. | +| `/mobilerun/screencapture/broadcast/stop` | POST | Asks the extension to finish the broadcast. Live sessions fall back to the screenshot source with a forced key frame; clients do not need to reconnect. Also accepts `dismissButtonLabels` and `goToApplicationButtonLabels` (see below); on iOS 26+ it waits briefly (a couple of seconds) for the system's delayed "Screen Broadcasting" alert and clears it before returning. On older iOS versions the stop returns immediately. | `broadcast/start` body (all optional): @@ -27,6 +27,8 @@ the legacy screenshot pipeline; each session reports its current origin via the { "timeout": 30, "confirmButtonLabels": ["Start Broadcast"], + "dismissButtonLabels": ["OK"], + "goToApplicationButtonLabels": ["Go to Application"], "restoreForegroundApp": true } ``` @@ -35,6 +37,17 @@ the legacy screenshot pipeline; each session reports its current origin via the - `confirmButtonLabels` — labels to look for on the system confirmation sheet. Pass the localized label when the device language is not English (a button starting with "Start" is used as fallback). +- `dismissButtonLabels` — labels of the button that dismisses the system's "Screen Broadcasting" + alert left behind by a previous broadcast's end (SpringBoard posts this on iOS 26 whenever a + broadcast terminates, and it otherwise blocks the picker dance). Defaults to `["OK"]`. Pass the + localized label when the device language is not English. Also accepted by `broadcast/stop`. +- `goToApplicationButtonLabels` — labels of the alert's other button. Defaults to + `["Go to Application"]`. Together, `dismissButtonLabels` and `goToApplicationButtonLabels` + identify the system's "Screen Broadcasting" alert: it is only auto-dismissed when one button + matches `dismissButtonLabels` and the other matches `goToApplicationButtonLabels` — this + second-button check is what keeps the auto-dismiss from firing on an unrelated two-button + system prompt. Pass the localized label when the device language is not English. Also accepted + by `broadcast/stop`. - `restoreForegroundApp` — re-activate the previously active app after the broadcast starts (the start dance briefly foregrounds the runner app, ~2-3 s).