From a433ec18fd15b81b0532be6d18387fe530af583c Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:14:33 +0200 Subject: [PATCH 1/6] feat: auto-dismiss the stale Screen Broadcasting alert during broadcast start/stop --- .../Commands/FBScreenCaptureCommands.m | 22 +++- .../Utilities/FBBroadcastManager.h | 21 +++- .../Utilities/FBBroadcastManager.m | 106 +++++++++++++++--- docs/broadcast-extension.md | 7 +- 4 files changed, 140 insertions(+), 16 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index 00f57813a0..f050318922 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -72,12 +72,22 @@ + (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]; + } + } + } 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 restoreForegroundApp:restoreForegroundApp error:&error]) { if ([error.domain isEqualToString:FBBroadcastManagerErrorDomain]) { @@ -97,8 +107,18 @@ + (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]; + } + } + } + NSError *error; - if (![FBBroadcastManager.sharedInstance stopBroadcastWithError:&error]) { + if (![FBBroadcastManager.sharedInstance stopBroadcastWithDismissButtonLabels:dismissButtonLabels 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 0d137e23cf..625e9440b9 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.h +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.h @@ -55,24 +55,43 @@ 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 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 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:error: with a nil dismissButtonLabels. @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 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 + 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 8bd149a56e..19896ea148 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -38,6 +38,21 @@ 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. +static BOOL FBBroadcastTapFrameCenter(XCUIApplication *runner, CGRect frame, 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)}, + ]; + return [runner fb_performMobilerunActions:tapActions scale:scale error:error]; +} #endif static const NSTimeInterval STOP_TIMEOUT = 5.0; @@ -57,8 +72,16 @@ @interface FBBroadcastManager () #if !TARGET_OS_SIMULATOR && !TARGET_OS_TV - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(NSArray *)dismissButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error; +// Dismisses the system's stale "Screen Broadcasting" alert (posted by SpringBoard whenever a +// broadcast ends) when one is on screen. The alert blocks the broadcast picker dance until it is +// dismissed. Only a button exactly matching one of the given labels is ever tapped, so the +// alert's "Go to Application" action is structurally unreachable. Returns YES iff the alert was +// found and successfully tapped. +- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels + runner:(XCUIApplication *)runner; #endif @end @@ -140,6 +163,7 @@ - (NSDictionary *)statusDictionary - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(NSArray *)dismissButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error { @@ -182,6 +206,7 @@ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout @try { return [self performBroadcastStartWithTimeout:timeout confirmButtonLabels:confirmButtonLabels + dismissButtonLabels:dismissButtonLabels restoreForegroundApp:restoreForegroundApp error:error]; } @finally { @@ -193,16 +218,30 @@ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout #if !TARGET_OS_SIMULATOR && !TARGET_OS_TV - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels + dismissButtonLabels:(NSArray *)dismissButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error { + NSArray *dismissLabels = dismissButtonLabels.count > 0 ? dismissButtonLabels : @[@"OK"]; + // Hoisted so the already-captured branch below (which runs before the dance's own timing base + // is established) can also drive the dismissal tap. + XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; + // 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 runner:runner]) { + [FBLogger log:@"broadcast/start: dismissed a stale Screen Broadcasting alert while waiting for the extension to connect"]; + } + return NO; }]; if (self.isExtensionConnected) { return YES; @@ -219,7 +258,6 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } uint64_t startedMs = FBBroadcastNowMs(); - XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; XCUIApplication *previousApp = nil; BOOL runnerIsActive = UIApplication.sharedApplication.applicationState == UIApplicationStateActive; // When the runner is already frontmost there is neither an app to restore nor a need for the @@ -280,6 +318,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 runner:runner]) { + [FBLogger logFmt:@"broadcast/start: dismissed 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 +355,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, &tapError)) { [FBBroadcastPickerHost dismiss]; if (error) { *error = [NSError errorWithDomain:FBBroadcastManagerErrorDomain @@ -355,9 +389,41 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } return YES; } + +// Dismisses the system's stale "Screen Broadcasting" alert (posted by SpringBoard whenever a +// broadcast ends) when one is on screen. The alert blocks the broadcast picker dance until it is +// dismissed. Only a button exactly matching one of the given labels is ever tapped, so the +// alert's "Go to Application" action is structurally unreachable. +- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels + runner:(XCUIApplication *)runner +{ + XCUIElement *alert = XCUIApplication.fb_systemApplication.alerts.firstMatch; + if (!alert.exists) { + return NO; + } + for (NSString *label in labels) { + XCUIElement *dismissButton = alert.buttons[label]; + if (dismissButton.exists) { + CGRect frame = dismissButton.frame; + if (CGRectIsEmpty(frame)) { + continue; + } + if (FBBroadcastTapFrameCenter(runner, frame, nil)) { + return YES; + } + } + } + return NO; +} #endif - (BOOL)stopBroadcastWithError:(NSError **)error +{ + return [self stopBroadcastWithDismissButtonLabels:nil error:error]; +} + +- (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButtonLabels + error:(NSError **)error { if (!self.isExtensionConnected) { return YES; @@ -374,6 +440,20 @@ - (BOOL)stopBroadcastWithError:(NSError **)error } return NO; } +#if !TARGET_OS_SIMULATOR && !TARGET_OS_TV + // 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"]; + XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; + [[[[FBRunLoopSpinner new] timeout:3.0] interval:0.25] spinUntilTrue:^BOOL{ + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels runner:runner]) { + [FBLogger log:@"broadcast/stop: dismissed the Screen Broadcasting alert"]; + return YES; + } + return NO; + }]; +#endif return YES; } diff --git a/docs/broadcast-extension.md b/docs/broadcast-extension.md index f1e9d1c214..af7bbe0e92 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` (see below); it proactively clears the "Screen Broadcasting" alert after stopping. | `broadcast/start` body (all optional): @@ -27,6 +27,7 @@ the legacy screenshot pipeline; each session reports its current origin via the { "timeout": 30, "confirmButtonLabels": ["Start Broadcast"], + "dismissButtonLabels": ["OK"], "restoreForegroundApp": true } ``` @@ -35,6 +36,10 @@ 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`. - `restoreForegroundApp` — re-activate the previously active app after the broadcast starts (the start dance briefly foregrounds the runner app, ~2-3 s). From c712160611d2abba0f445be64ebae82e56c13bc5 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:56:53 +0200 Subject: [PATCH 2/6] fix: match the broadcast alert structurally and tap it in the system app's orientation --- .../Utilities/FBBroadcastManager.m | 78 +++++++++++-------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 19896ea148..7c43c34ccd 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -42,7 +42,9 @@ static uint64_t FBBroadcastNowMs(void) // 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. -static BOOL FBBroadcastTapFrameCenter(XCUIApplication *runner, CGRect frame, NSError **error) +// `app` must be the app whose coordinate space produced `frame`: the synthesized event record is +// stamped with the receiver's interfaceOrientation. +static BOOL FBBroadcastTapFrameCenter(XCUIApplication *app, CGRect frame, NSError **error) { CGFloat scale = (CGFloat)[FBScreen scale]; CGPoint center = CGPointMake(CGRectGetMidX(frame) * scale, CGRectGetMidY(frame) * scale); @@ -51,7 +53,7 @@ static BOOL FBBroadcastTapFrameCenter(XCUIApplication *runner, CGRect frame, NSE @{@"type": @"pause", @"duration": @60}, @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, ]; - return [runner fb_performMobilerunActions:tapActions scale:scale error:error]; + return [app fb_performMobilerunActions:tapActions scale:scale error:error]; } #endif static const NSTimeInterval STOP_TIMEOUT = 5.0; @@ -76,12 +78,13 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error; // Dismisses the system's stale "Screen Broadcasting" alert (posted by SpringBoard whenever a -// broadcast ends) when one is on screen. The alert blocks the broadcast picker dance until it is -// dismissed. Only a button exactly matching one of the given labels is ever tapped, so the -// alert's "Go to Application" action is structurally unreachable. Returns YES iff the alert was -// found and successfully tapped. -- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels - runner:(XCUIApplication *)runner; +// broadcast ends) when one is on screen; it blocks the broadcast picker dance until dismissed. +// The alert is matched structurally, not by its (localized) title: exactly two buttons +// (dismiss + "Go to Application"), of which exactly one matches the configured dismiss labels. +// Single-button alerts (a bare "OK" is too common a shape) and anything more complex are left +// alone - misfiring on an unrelated system dialog would silently acknowledge it, which is worse +// than letting the dance time out. +- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels; #endif @end @@ -223,9 +226,6 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout error:(NSError **)error { NSArray *dismissLabels = dismissButtonLabels.count > 0 ? dismissButtonLabels : @[@"OK"]; - // Hoisted so the already-captured branch below (which runs before the dance's own timing base - // is established) can also drive the dismissal tap. - XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; // 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 @@ -238,7 +238,7 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } // 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 runner:runner]) { + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels]) { [FBLogger log:@"broadcast/start: dismissed a stale Screen Broadcasting alert while waiting for the extension to connect"]; } return NO; @@ -258,6 +258,7 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } uint64_t startedMs = FBBroadcastNowMs(); + XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; XCUIApplication *previousApp = nil; BOOL runnerIsActive = UIApplication.sharedApplication.applicationState == UIApplicationStateActive; // When the runner is already frontmost there is neither an app to restore nor a need for the @@ -318,7 +319,7 @@ - (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 runner:runner]) { + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels]) { [FBLogger logFmt:@"broadcast/start: dismissed stale Screen Broadcasting alert after %llums", FBBroadcastNowMs() - startedMs]; return NO; } @@ -391,29 +392,45 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } // Dismisses the system's stale "Screen Broadcasting" alert (posted by SpringBoard whenever a -// broadcast ends) when one is on screen. The alert blocks the broadcast picker dance until it is -// dismissed. Only a button exactly matching one of the given labels is ever tapped, so the -// alert's "Go to Application" action is structurally unreachable. +// broadcast ends) when one is on screen; it blocks the broadcast picker dance until dismissed. +// The alert is matched structurally, not by its (localized) title: exactly two buttons +// (dismiss + "Go to Application"), of which exactly one matches the configured dismiss labels. +// Single-button alerts (a bare "OK" is too common a shape) and anything more complex are left +// alone - misfiring on an unrelated system dialog would silently acknowledge it, which is worse +// than letting the dance time out. - (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels - runner:(XCUIApplication *)runner { - XCUIElement *alert = XCUIApplication.fb_systemApplication.alerts.firstMatch; + XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; + XCUIElement *alert = systemApp.alerts.firstMatch; if (!alert.exists) { return NO; } - for (NSString *label in labels) { - XCUIElement *dismissButton = alert.buttons[label]; - if (dismissButton.exists) { - CGRect frame = dismissButton.frame; - if (CGRectIsEmpty(frame)) { - continue; - } - if (FBBroadcastTapFrameCenter(runner, frame, nil)) { - return YES; - } + NSArray *buttons = [alert.buttons allElementsBoundByIndex]; + if (buttons.count != 2) { + return NO; + } + XCUIElement *dismissButton = nil; + for (XCUIElement *button in buttons) { + if (![labels containsObject:button.label]) { + continue; } + if (nil != dismissButton) { + // Both buttons match the dismiss labels - ambiguous, not the alert we expect. + return NO; + } + dismissButton = button; } - return NO; + if (nil == dismissButton) { + 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. + return FBBroadcastTapFrameCenter(systemApp, frame, nil); } #endif @@ -445,9 +462,8 @@ - (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButto // 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"]; - XCUIApplication *runner = [[XCUIApplication alloc] initWithBundleIdentifier:(NSString *)NSBundle.mainBundle.bundleIdentifier]; [[[[FBRunLoopSpinner new] timeout:3.0] interval:0.25] spinUntilTrue:^BOOL{ - if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels runner:runner]) { + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels]) { [FBLogger log:@"broadcast/stop: dismissed the Screen Broadcasting alert"]; return YES; } From 14457dd982c5e35b51f1f1aa3e07b938033cc4f8 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:19:06 +0200 Subject: [PATCH 3/6] fix: dispatch the alert dismissal tap without blocking on the synthesis acknowledgement The alert-dismissal tap synthesized inside dismissBroadcastStoppedAlertWithLabels: previously waited synchronously for the synthesis acknowledgement (up to the event-synthesis timeout margin, ~15s, when the system sheds the event). That call runs inside run-loop spinners with much shorter deadlines (the ~3s post-stop sweep, the 5s already-captured wait, the confirm spin), and a spinner deadline cannot interrupt a nested synchronous call - so under the exact overload scenario these bounds exist for, the automation queue could stay blocked ~15s. The dismissal doesn't need the acknowledgement: every caller re-checks the alert's existence on its next spin iteration, so the tap is now fire-and-forget via a new FBXCTestDaemonsProxy +synthesizeEventAsyncWithRecord: that dispatches without waiting, logging (and otherwise ignoring) failures. A 1s re-attempt cooldown avoids re-tapping the same coordinates while the alert's dismissal animation is still in flight. The CONFIRM tap stays synchronous and untouched. Co-Authored-By: Claude Fable 5 --- .../Utilities/FBBroadcastManager.m | 36 +++++++++++++++---- .../Utilities/FBXCTestDaemonsProxy.h | 7 ++++ .../Utilities/FBXCTestDaemonsProxy.m | 9 +++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 7c43c34ccd..40674b3615 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" @@ -44,7 +45,11 @@ static uint64_t FBBroadcastNowMs(void) // 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. -static BOOL FBBroadcastTapFrameCenter(XCUIApplication *app, CGRect frame, NSError **error) +// `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); @@ -53,7 +58,15 @@ static BOOL FBBroadcastTapFrameCenter(XCUIApplication *app, CGRect frame, NSErro @{@"type": @"pause", @"duration": @60}, @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, ]; - return [app fb_performMobilerunActions:tapActions scale:scale error:error]; + 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; @@ -70,6 +83,10 @@ @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 @@ -320,7 +337,7 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout __block uint64_t lastTriggerMs = FBBroadcastNowMs(); [[[[FBRunLoopSpinner new] timeout:CONFIRM_BUTTON_TIMEOUT] interval:0.25] spinUntilTrue:^BOOL{ if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels]) { - [FBLogger logFmt:@"broadcast/start: dismissed stale Screen Broadcasting alert after %llums", FBBroadcastNowMs() - startedMs]; + [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) { @@ -359,7 +376,7 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout // 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 (!FBBroadcastTapFrameCenter(runner, confirmFrame, &tapError)) { + if (!FBBroadcastTapFrameCenter(runner, confirmFrame, YES, &tapError)) { [FBBroadcastPickerHost dismiss]; if (error) { *error = [NSError errorWithDomain:FBBroadcastManagerErrorDomain @@ -405,6 +422,12 @@ - (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels if (!alert.exists) { 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; + } NSArray *buttons = [alert.buttons allElementsBoundByIndex]; if (buttons.count != 2) { return NO; @@ -430,7 +453,8 @@ - (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels // 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. - return FBBroadcastTapFrameCenter(systemApp, frame, nil); + self.lastAlertDismissalAttemptMs = FBBroadcastNowMs(); + return FBBroadcastTapFrameCenter(systemApp, frame, NO, nil); } #endif @@ -464,7 +488,7 @@ - (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButto NSArray *dismissLabels = dismissButtonLabels.count > 0 ? dismissButtonLabels : @[@"OK"]; [[[[FBRunLoopSpinner new] timeout:3.0] interval:0.25] spinUntilTrue:^BOOL{ if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels]) { - [FBLogger log:@"broadcast/stop: dismissed the Screen Broadcasting alert"]; + [FBLogger log:@"broadcast/stop: dispatched a dismissal tap for the Screen Broadcasting alert"]; return YES; } return NO; diff --git a/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.h b/WebDriverAgentLib/Utilities/FBXCTestDaemonsProxy.h index 2445c9c517..126b369051 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 0e6f784ead..dde0e26ee9 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]; From 55f85010b758f1256e154caef0963bfec9090b3f Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:20:44 +0200 Subject: [PATCH 4/6] chore: align the capture-wait dismissal log with the dispatch wording The isCaptured-wait loop's dismissal log still said "dismissed" from before the dismissal tap became fire-and-forget; bring it in line with the other two dismissal log sites updated in 14457dd9. Co-Authored-By: Claude Fable 5 --- WebDriverAgentLib/Utilities/FBBroadcastManager.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 40674b3615..99515f5713 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -256,7 +256,7 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout // 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]) { - [FBLogger log:@"broadcast/start: dismissed a stale Screen Broadcasting alert while waiting for the extension to connect"]; + [FBLogger log:@"broadcast/start: dispatched a dismissal tap for the stale Screen Broadcasting alert while waiting out the active capture"]; } return NO; }]; From 60907f584378af95ea0444e4b7b5ccb04c0dfcea Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:14:16 +0200 Subject: [PATCH 5/6] fix: anchor the broadcast alert on its second button and sweep until it is gone Co-Authored-By: Claude Fable 5 --- .../Commands/FBScreenCaptureCommands.m | 23 +++- .../Utilities/FBBroadcastManager.h | 13 +- .../Utilities/FBBroadcastManager.m | 119 ++++++++++++------ docs/broadcast-extension.md | 10 +- 4 files changed, 125 insertions(+), 40 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m index f050318922..e7ddabdcd4 100644 --- a/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m +++ b/WebDriverAgentLib/Commands/FBScreenCaptureCommands.m @@ -81,6 +81,15 @@ + (NSArray *)routes } } } + 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; @@ -88,6 +97,7 @@ + (NSArray *)routes if (![FBBroadcastManager.sharedInstance startBroadcastWithTimeout:timeout confirmButtonLabels:confirmButtonLabels dismissButtonLabels:dismissButtonLabels + goToApplicationButtonLabels:goToApplicationButtonLabels restoreForegroundApp:restoreForegroundApp error:&error]) { if ([error.domain isEqualToString:FBBroadcastManagerErrorDomain]) { @@ -116,9 +126,20 @@ + (NSArray *)routes } } } + 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 stopBroadcastWithDismissButtonLabels:dismissButtonLabels error:&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 625e9440b9..b2737c5c65 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.h +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.h @@ -58,6 +58,10 @@ typedef NS_ERROR_ENUM(FBBroadcastManagerErrorDomain, FBBroadcastManagerError) { @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 @@ -65,13 +69,15 @@ typedef NS_ERROR_ENUM(FBBroadcastManagerErrorDomain, FBBroadcastManagerError) { - (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. Equivalent to calling - stopBroadcastWithDismissButtonLabels:error: with a nil dismissButtonLabels. + 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 @@ -85,11 +91,16 @@ typedef NS_ERROR_ENUM(FBBroadcastManagerErrorDomain, FBBroadcastManagerError) { @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). */ diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 99515f5713..0182a2a65f 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -92,16 +92,24 @@ @interface FBBroadcastManager () - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels dismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error; -// Dismisses the system's stale "Screen Broadcasting" alert (posted by SpringBoard whenever a -// broadcast ends) when one is on screen; it blocks the broadcast picker dance until dismissed. -// The alert is matched structurally, not by its (localized) title: exactly two buttons -// (dismiss + "Go to Application"), of which exactly one matches the configured dismiss labels. -// Single-button alerts (a bare "OK" is too common a shape) and anything more complex are left -// alone - misfiring on an unrelated system dialog would silently acknowledge it, which is worse -// than letting the dance time out. -- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels; +// 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 @@ -184,6 +192,7 @@ - (NSDictionary *)statusDictionary - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout confirmButtonLabels:(NSArray *)confirmButtonLabels dismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels restoreForegroundApp:(BOOL)restoreForegroundApp error:(NSError **)error { @@ -227,6 +236,7 @@ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout return [self performBroadcastStartWithTimeout:timeout confirmButtonLabels:confirmButtonLabels dismissButtonLabels:dismissButtonLabels + goToApplicationButtonLabels:goToApplicationButtonLabels restoreForegroundApp:restoreForegroundApp error:error]; } @finally { @@ -239,10 +249,12 @@ - (BOOL)startBroadcastWithTimeout:(NSTimeInterval)timeout - (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 @@ -255,7 +267,7 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout } // 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]) { + 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; @@ -336,7 +348,7 @@ - (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]) { + 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; } @@ -408,44 +420,67 @@ - (BOOL)performBroadcastStartWithTimeout:(NSTimeInterval)timeout return YES; } -// Dismisses the system's stale "Screen Broadcasting" alert (posted by SpringBoard whenever a -// broadcast ends) when one is on screen; it blocks the broadcast picker dance until dismissed. -// The alert is matched structurally, not by its (localized) title: exactly two buttons -// (dismiss + "Go to Application"), of which exactly one matches the configured dismiss labels. -// Single-button alerts (a bare "OK" is too common a shape) and anything more complex are left -// alone - misfiring on an unrelated system dialog would silently acknowledge it, which is worse -// than letting the dance time out. -- (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels +// 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 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; + return nil; } NSArray *buttons = [alert.buttons allElementsBoundByIndex]; if (buttons.count != 2) { - return NO; + return nil; } XCUIElement *dismissButton = nil; + XCUIElement *otherButton = nil; for (XCUIElement *button in buttons) { - if (![labels containsObject:button.label]) { - continue; - } - if (nil != dismissButton) { - // Both buttons match the dismiss labels - ambiguous, not the alert we expect. - return NO; + 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; } - dismissButton = 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; @@ -454,16 +489,17 @@ - (BOOL)dismissBroadcastStoppedAlertWithLabels:(NSArray *)labels // 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(systemApp, frame, NO, nil); + return FBBroadcastTapFrameCenter(XCUIApplication.fb_systemApplication, frame, NO, nil); } #endif - (BOOL)stopBroadcastWithError:(NSError **)error { - return [self stopBroadcastWithDismissButtonLabels:nil error:error]; + return [self stopBroadcastWithDismissButtonLabels:nil goToApplicationButtonLabels:nil error:error]; } - (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButtonLabels + goToApplicationButtonLabels:(NSArray *)goToApplicationButtonLabels error:(NSError **)error { if (!self.isExtensionConnected) { @@ -486,11 +522,20 @@ - (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButto // 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"]; + // The sweep must exit on the alert's OBSERVED disappearance, not merely on a dismissal tap + // being dispatched: FBBroadcastTapFrameCenter's waitForAck:NO tap is fire-and-forget, and a + // tap the system sheds leaves the alert (and the isCaptured pin it causes) in place. Spinning + // on the matcher itself means a shed tap gets retried - paced by the 1s cooldown inside + // dismissBroadcastStoppedAlertWithLabels:goToApplicationLabels: - within the 3s deadline + // instead of the sweep declaring success after a single fire-and-forget dispatch. [[[[FBRunLoopSpinner new] timeout:3.0] interval:0.25] spinUntilTrue:^BOOL{ - if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels]) { - [FBLogger log:@"broadcast/stop: dispatched a dismissal tap for the Screen Broadcasting alert"]; + if (nil == [self matchingDismissButtonForAlertWithDismissLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { return YES; } + if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { + [FBLogger log:@"broadcast/stop: dispatched a dismissal tap for the Screen Broadcasting alert"]; + } return NO; }]; #endif diff --git a/docs/broadcast-extension.md b/docs/broadcast-extension.md index af7bbe0e92..50da92f1e4 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. Also accepts `dismissButtonLabels` (see below); it proactively clears the "Screen Broadcasting" alert after stopping. | +| `/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); it proactively clears the "Screen Broadcasting" alert after stopping. | `broadcast/start` body (all optional): @@ -28,6 +28,7 @@ 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 } ``` @@ -40,6 +41,13 @@ the legacy screenshot pipeline; each session reports its current origin via the 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). From 6b70a9a8b274977c59e335344055a7bf71b6dbc3 Mon Sep 17 00:00:00 2001 From: timo <44401485+Timo972@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:38:40 +0200 Subject: [PATCH 6/6] fix: wait out the delayed Screen Broadcasting alert before ending the stop sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-stop sweep previously exited YES the instant no matching alert was present, but SpringBoard publishes the iOS 26 "Screen Broadcasting" alert with a delay after the extension socket closes, so the sweep saw nothing and exited at t≈0, leaving the alert armed for the next start. Rework the sweep into a two-phase wait, gated on iOS 26+ (the alert does not exist on older iOS, where the start-side dismissal remains the safety net): wait out a 2s appearance grace period if the alert has not been seen yet, then once it has been seen, spin (retrying the dismissal tap, paced by the existing 1s cooldown) until it is observed gone, capped at a 5s overall timeout. The sweep stays best-effort and never affects the method's return value. --- .../Utilities/FBBroadcastManager.m | 61 +++++++++++++------ docs/broadcast-extension.md | 2 +- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBBroadcastManager.m b/WebDriverAgentLib/Utilities/FBBroadcastManager.m index 0182a2a65f..d050dad4bf 100644 --- a/WebDriverAgentLib/Utilities/FBBroadcastManager.m +++ b/WebDriverAgentLib/Utilities/FBBroadcastManager.m @@ -34,6 +34,10 @@ // 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) { @@ -518,26 +522,43 @@ - (BOOL)stopBroadcastWithDismissButtonLabels:(NSArray *)dismissButto return NO; } #if !TARGET_OS_SIMULATOR && !TARGET_OS_TV - // 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"]; - // The sweep must exit on the alert's OBSERVED disappearance, not merely on a dismissal tap - // being dispatched: FBBroadcastTapFrameCenter's waitForAck:NO tap is fire-and-forget, and a - // tap the system sheds leaves the alert (and the isCaptured pin it causes) in place. Spinning - // on the matcher itself means a shed tap gets retried - paced by the 1s cooldown inside - // dismissBroadcastStoppedAlertWithLabels:goToApplicationLabels: - within the 3s deadline - // instead of the sweep declaring success after a single fire-and-forget dispatch. - [[[[FBRunLoopSpinner new] timeout:3.0] interval:0.25] spinUntilTrue:^BOOL{ - if (nil == [self matchingDismissButtonForAlertWithDismissLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { - return YES; - } - if ([self dismissBroadcastStoppedAlertWithLabels:dismissLabels goToApplicationLabels:goToAppLabels]) { - [FBLogger log:@"broadcast/stop: dispatched a dismissal tap for the Screen Broadcasting alert"]; - } - return NO; - }]; + // 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/docs/broadcast-extension.md b/docs/broadcast-extension.md index 50da92f1e4..572b7ff18b 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. Also accepts `dismissButtonLabels` and `goToApplicationButtonLabels` (see below); it proactively clears the "Screen Broadcasting" alert after stopping. | +| `/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):