-
-
Notifications
You must be signed in to change notification settings - Fork 314
fix(ios-runner): give app launch its own policy axis #2899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a5dbdf3
f1e0bcb
01d7bf2
5151371
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -222,7 +222,7 @@ extension RunnerTests { | |
| invalidateCachedTarget(reason: "xctest_recorded_failure") | ||
| return failureResponse | ||
| } | ||
| if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) { | ||
| if !hasRetried, command.traits.retryOnSessionLoss, shouldRetryResponse(response) { | ||
| NSLog( | ||
| "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable", | ||
| command.command.rawValue | ||
|
|
@@ -236,7 +236,7 @@ extension RunnerTests { | |
| } | ||
| } | ||
|
|
||
| /// The dispatched snapshot recovery loop: read-only retry + XCTest-recorded-failure invalidation, | ||
| /// The dispatched snapshot recovery loop: session-loss retry + XCTest-recorded-failure invalidation, | ||
| /// matching what `executeOnMainSafely` gives the generic path. `perform` runs the capture and its | ||
| /// own bounded main-thread work. | ||
| func executeDispatchedWithRecovery( | ||
|
|
@@ -281,7 +281,7 @@ extension RunnerTests { | |
| } | ||
| return recordedFailureResponse | ||
| } | ||
| if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) { | ||
| if !hasRetried, command.traits.retryOnSessionLoss, shouldRetryResponse(response) { | ||
| NSLog( | ||
| "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable", | ||
| command.command.rawValue | ||
|
|
@@ -418,88 +418,121 @@ extension RunnerTests { | |
| ) | ||
| } | ||
|
|
||
| /// The target this command runs against, decided by its `launchPolicy` (#2890). Exhaustive over the | ||
| /// policy so a new case is a compile error here rather than a fall-through that quietly launches or | ||
| /// quietly refuses. | ||
| func prepareActiveCommandContext( | ||
| command: Command, | ||
| routeToSpringboard: Bool = false | ||
| ) -> ActiveCommandPreparation { | ||
| var activeApp = currentApp ?? app | ||
| var systemSurface: SystemSurfaceHost? = nil | ||
| if routeToSpringboard { | ||
| activeApp = springboard | ||
| } else if shouldSkipAppActivationPreflight(command) { | ||
| activeApp = resolveAppWithoutActivation(command: command) | ||
| } else if let presented = presentedSystemSurfaceHost() { | ||
| return .context(ActiveCommandContext(app: springboard)) | ||
| } | ||
| switch command.traits.launchPolicy { | ||
| case .noApp: | ||
| // Answers from the runner's own capture and state, so the target is resolved exactly as it | ||
| // stands. | ||
| return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command))) | ||
| case .presentedSurface: | ||
| // The command is about the surface that already has focus; activating an app under it would | ||
| // cancel exactly what the command is about. | ||
| #if os(iOS) | ||
| return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command))) | ||
| #else | ||
| // The platform exception, written once: `SystemSurfaceHostRegistry` registers no hosts off iOS, | ||
| // so nothing is ever served in place there and such a command keeps the activation route this | ||
| // axis found it on. | ||
| return prepareActivatedTarget(command: command) | ||
| #endif | ||
| case .existingApp, .mayLaunch: | ||
| // Asked only where activation is on the table: the bypass decides by querying the cached | ||
| // target's state, and a command that may bring nothing forward has nothing for it to settle. | ||
| if shouldSkipAppActivationPreflight(command) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Inside the combined Prompt for AI agents |
||
| // The one request-dependent bypass: a coordinate-only synthesized tap whose cached target is | ||
| // already foreground needs nothing brought forward. | ||
| return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command))) | ||
| } | ||
| return prepareActivatedTarget(command: command) | ||
| } | ||
| } | ||
|
|
||
| /// The route that may bring something forward: a system surface genuinely on screen is served in | ||
| /// place, and otherwise the requested session app is resolved and activated. What happens to a | ||
| /// stopped app is the caller's `launchPolicy`; the `.existingApp` refusal belongs to | ||
| /// `notRunningRefusal` because it is only meaningful once nothing is presented (#2890). | ||
| private func prepareActivatedTarget(command: Command) -> ActiveCommandPreparation { | ||
| if let presented = presentedSystemSurfaceHost() { | ||
| // Serve and drive the presented surface IN PLACE: never activate it (that cancels what it | ||
| // presents) and never adopt it as the cached session target, so once it is gone the next | ||
| // command resolves back to the still-bound session app (#2438). | ||
| activeApp = presented.app | ||
| systemSurface = presented.host | ||
| if isInteractionCommand(command.command) { | ||
| if command.traits.isInteraction { | ||
| applyInteractionStabilizationIfNeeded() | ||
| } | ||
| } else if !isRunnerLifecycleCommand(command.command) { | ||
| let normalizedBundleId = command.appBundleId? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId | ||
| if let bundleId = requestedBundleId, | ||
| let notRunning = notRunningReadResponse(command: command, bundleId: bundleId) | ||
| { | ||
| return .response(notRunning) | ||
| return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host)) | ||
| } | ||
|
|
||
| let normalizedBundleId = command.appBundleId? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId | ||
| if let bundleId = requestedBundleId, | ||
| let notRunning = notRunningRefusal(command: command, bundleId: bundleId) | ||
| { | ||
| return .response(notRunning) | ||
| } | ||
| if let bundleId = requestedBundleId { | ||
| if currentBundleId != bundleId || currentApp == nil { | ||
| _ = activateTarget(bundleId: bundleId, reason: "bundle_changed") | ||
| } else { | ||
| refreshCachedTargetIfProcessChanged(bundleId: bundleId) | ||
| } | ||
| } else { | ||
| // Do not reuse stale bundle targets when the caller does not explicitly request one. | ||
| invalidateCachedTarget(reason: "missing_app_bundle") | ||
| } | ||
|
|
||
| // Read back after the bundle resolution above, which is what may have just bound a target. | ||
| var activeApp = currentApp ?? app | ||
| if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) { | ||
| activeApp = activateTarget(bundleId: bundleId, reason: "stale_target") | ||
| } else if requestedBundleId == nil, targetNeedsActivation(activeApp) { | ||
| ensureRunnerHostAppActive(reason: "missing_app_bundle") | ||
| activeApp = app | ||
| } | ||
|
|
||
| let skipExistenceWait = canUseFastForegroundAppGuard( | ||
| activeApp: activeApp, | ||
| requestedBundleId: requestedBundleId | ||
| ) | ||
| if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) { | ||
| if let bundleId = requestedBundleId { | ||
| if currentBundleId != bundleId || currentApp == nil { | ||
| _ = activateTarget(bundleId: bundleId, reason: "bundle_changed") | ||
| } else { | ||
| refreshCachedTargetIfProcessChanged(bundleId: bundleId) | ||
| activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait") | ||
| guard activeApp.waitForExistence(timeout: appExistenceTimeout) else { | ||
| return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: bundleId))) | ||
| } | ||
| } else { | ||
| // Do not reuse stale bundle targets when the caller does not explicitly request one. | ||
| invalidateCachedTarget(reason: "missing_app_bundle") | ||
| return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil))) | ||
| } | ||
| } | ||
|
|
||
| activeApp = currentApp ?? app | ||
| if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) { | ||
| activeApp = activateTarget(bundleId: bundleId, reason: "stale_target") | ||
| } else if requestedBundleId == nil, targetNeedsActivation(activeApp) { | ||
| ensureRunnerHostAppActive(reason: "missing_app_bundle") | ||
| if command.traits.isInteraction { | ||
| if let bundleId = requestedBundleId, activeApp.state != .runningForeground { | ||
| activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard") | ||
| } else if requestedBundleId == nil, activeApp.state != .runningForeground { | ||
| ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle") | ||
| activeApp = app | ||
| } | ||
|
|
||
| let skipExistenceWait = canUseFastForegroundAppGuard( | ||
| let skipInteractionExistenceWait = canUseFastForegroundAppGuard( | ||
| activeApp: activeApp, | ||
| requestedBundleId: requestedBundleId | ||
| ) | ||
| if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) { | ||
| if let bundleId = requestedBundleId { | ||
| activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait") | ||
| guard activeApp.waitForExistence(timeout: appExistenceTimeout) else { | ||
| return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: bundleId))) | ||
| } | ||
| } else { | ||
| return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil))) | ||
| } | ||
| } | ||
|
|
||
| if isInteractionCommand(command.command) { | ||
| if let bundleId = requestedBundleId, activeApp.state != .runningForeground { | ||
| activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard") | ||
| } else if requestedBundleId == nil, activeApp.state != .runningForeground { | ||
| ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle") | ||
| activeApp = app | ||
| } | ||
| let skipInteractionExistenceWait = canUseFastForegroundAppGuard( | ||
| activeApp: activeApp, | ||
| requestedBundleId: requestedBundleId | ||
| if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { | ||
| return .response( | ||
| Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId)) | ||
| ) | ||
| if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { | ||
| return .response( | ||
| Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId)) | ||
| ) | ||
| } | ||
| applyInteractionStabilizationIfNeeded() | ||
| } | ||
| applyInteractionStabilizationIfNeeded() | ||
| } | ||
| return .context(ActiveCommandContext(app: activeApp, systemSurface: systemSurface)) | ||
| return .context(ActiveCommandContext(app: activeApp)) | ||
| } | ||
|
|
||
| /// A registered system surface host that is genuinely on screen, or nil. Presence is foreground | ||
|
|
@@ -533,7 +566,7 @@ extension RunnerTests { | |
| if response.data?.runnerFatal == true { | ||
| return nil | ||
| } | ||
| guard !isReadOnlyCommand(command), !isRunnerLifecycleCommand(command.command) else { | ||
| guard command.traits.convertsRecordedFailure else { | ||
| return nil | ||
| } | ||
| return Response( | ||
|
|
@@ -546,19 +579,11 @@ extension RunnerTests { | |
| ) | ||
| } | ||
|
|
||
| /// The one activation bypass that depends on the request rather than on the command: a tap that | ||
| /// needs nothing the preflight would bring forward. Commands whose own classification answers | ||
| /// without the session app's foreground state are handled by their `launchPolicy` (#2890). | ||
| func shouldSkipAppActivationPreflight(_ command: Command) -> Bool { | ||
| #if os(iOS) | ||
| if command.command == .alert { | ||
| return true | ||
| } | ||
| // A hardware Action Button press belongs to the system, not to the session app: the Shortcut or | ||
| // App Intent behind it is expected to run whether that app is foregrounded, backgrounded, or | ||
| // terminated, and activating first would foreground exactly what the press should leave alone. | ||
| // The press keeps its recorded-failure conversion, which `isLifecycle` would have removed | ||
| // (#2699, #2702 review). | ||
| if command.command == .actionButton { | ||
| return true | ||
| } | ||
| // Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not | ||
| // need app activation, window lookup, keyboard lookup, or element resolution. Selector/text | ||
| // interactions intentionally stay on the normal AX path because they need an element query. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,11 @@ extension RunnerTests { | |
| alertDeadline: Date? = nil | ||
| ) throws -> Response { | ||
| var activeApp = activeApp | ||
| if command.command != .tap && command.command != .type && !isReadOnlyCommand(command) { | ||
| // Every command that reaches here with a mutation to prove makes a remembered text-entry tap | ||
| // stale; the two commands that own that witness decide for themselves in their own cases below. | ||
| if command.traits.convertsRecordedFailure, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Do not use Prompt for AI agents |
||
| !CommandTraits.textEntryWitnessOwners.contains(command.command) | ||
| { | ||
| clearRememberedTextEntryTap() | ||
| } | ||
| switch command.command { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -316,11 +316,12 @@ extension RunnerTests { | |
| return foreign.count == 1 ? foreign.first : nil | ||
| } | ||
|
|
||
| /// `activate()` on a not-running app is a bare launch, which would drop the URL of a launch | ||
| /// SpringBoard still holds behind its "Open in …?" confirmation; see `APP_NOT_RUNNING_RUNNER_CODE`. | ||
| func notRunningReadResponse(command: Command, bundleId: String) -> Response? { | ||
| /// The `.existingApp` refusal: `activate()` on a not-running app is a bare launch, which would drop | ||
| /// the URL of a launch SpringBoard still holds behind its "Open in …?" confirmation; see | ||
| /// `APP_NOT_RUNNING_RUNNER_CODE` (#2852). | ||
| func notRunningRefusal(command: Command, bundleId: String) -> Response? { | ||
| #if os(iOS) | ||
| guard isReadOnlyCommand(command), | ||
| guard command.traits.launchPolicy == .existingApp, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The Prompt for AI agents |
||
| XCUIApplication(bundleIdentifier: bundleId).state == .notRunning | ||
| else { return nil } | ||
| NSLog( | ||
|
|
@@ -450,43 +451,19 @@ extension RunnerTests { | |
| } | ||
| } | ||
|
|
||
| func shouldRetryCommand(_ command: Command) -> Bool { | ||
| isReadOnlyCommand(command) | ||
| } | ||
| // MARK: - Session-Loss Retry | ||
|
|
||
| func shouldRetryException(_ command: Command, message: String) -> Bool { | ||
| guard shouldRetryCommand(command) else { return false } | ||
| guard command.traits.retryOnSessionLoss else { return false } | ||
| // XCTest raises this AX error as an ObjC exception whose reason is the only handle on it. | ||
| return message.lowercased().contains("kaxerrorservernotfound") | ||
| } | ||
|
|
||
| // MARK: - Command Classification | ||
|
|
||
| func isReadOnlyCommand(_ command: Command) -> Bool { | ||
| switch command.command.traits.readOnly { | ||
| case .always: | ||
| return true | ||
| case .never: | ||
| return false | ||
| case .conditional: | ||
| // Today only `alert` is conditional: read-only when getting, mutating otherwise. | ||
| return (command.action ?? "get").lowercased() == "get" | ||
| } | ||
| } | ||
|
|
||
| func shouldRetryResponse(_ response: Response) -> Bool { | ||
| guard response.ok == false else { return false } | ||
| return response.error?.retryableFailure != nil | ||
| } | ||
|
|
||
| func isInteractionCommand(_ command: CommandType) -> Bool { | ||
| return command.traits.isInteraction | ||
| } | ||
|
|
||
| func isRunnerLifecycleCommand(_ command: CommandType) -> Bool { | ||
| return command.traits.isLifecycle | ||
| } | ||
|
|
||
| // MARK: - Interaction Stabilization | ||
|
|
||
| func applyInteractionStabilizationIfNeeded() { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: A screenshot taken while a registered system surface is foregrounded now resolves the cached/requested app instead of the presented host. On foldable devices this can select the wrong display (or fall back to SpringBoard), so preserve the presented system-surface target before resolving the no-app target.
Prompt for AI agents