Skip to content

Commit 8ffc692

Browse files
committed
fix(apple-runner): refuse a macOS read of a stopped app instead of launching it
`notRunningRefusal` was `#if os(iOS)`, so on macOS a `.existingApp` read of a session app that was not running fell through to `activateTarget` and bare-launched it — which on macOS also takes the user's frontmost app away. The comment claiming only iOS can read an app's state without launching it does not hold: measured on a macOS host, a fresh `XCUIApplication(bundleIdentifier:)` for a stopped app answers `.notRunning` and no process appears. The macOS host lane now proves the refusal is reachable there (a user-level read, a mutation's leading read, and the selector-resolving read) and that a command whose job is to bring the app up still does. Two measures from the same host lane settle what the other activation sites may do, and are recorded where a reader will find them. An app-targeted `screenshot` keeps its `activate()` because it is load-bearing for what the pixels show: with a known window frame and a second window laid over it, a backgrounded `screenshotRoot(app:).screenshot()` returned the occluding app's windows inside the session window's own rect, and after `activate()` it returned the session app's own content. This backend captures the region, not the window buffer. A read has no such reason, and none was invented for it: a control captured the same app foreground, background, and foreground again, and the name-matched rows whose rect moved were 38 in the foreground-versus-background pair and 39 in the foreground-versus-later-foreground pair, so the drift is time and relayout rather than foreground state. Skipping the repair for reads would therefore rest on a guarantee nothing measured supports, and on an activation the interaction paths still need for XCTest's own event delivery, so it stays as it is. Documents `AGENT_DEVICE_MACOS_APP_BACKEND` in versioned `help macos` and names in the commands docs which macOS commands still activate the app or move the real pointer. Partial #3254
1 parent 24b2ce6 commit 8ffc692

7 files changed

Lines changed: 90 additions & 11 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,16 @@ extension RunnerTests {
429429
case .screenshot:
430430
#if os(macOS)
431431
// macOS keeps the app-targeted capture behavior for window-level screenshots.
432+
//
433+
// This activation is load-bearing for what the pixels show, so it stays. Measured on a macOS
434+
// host with a known window frame and a second window laid over it: for a
435+
// `.runningBackground` app `screenshotRoot(app:).screenshot()` returns that window's own rect
436+
// at the right scale but not its own content — the occluding app's windows are in the image —
437+
// and the same call after `activate()` returns the app's content (#3254). An app-targeted
438+
// capture on this backend is a region grab, so skipping the activation would silently report
439+
// another app's screen as this app's window. The macOS helper captures background app windows
440+
// by a different mechanism, `SCContentFilter(desktopIndependentWindow:)` in
441+
// `AppWindowScreenshot.swift`, which is the native backend's path and is not reachable here.
432442
if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
433443
let targetApp = XCUIApplication(bundleIdentifier: bundleId)
434444
targetApp.activate()

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,14 @@ extension RunnerTests {
323323
}
324324

325325
/// The `.existingApp` refusal: `activate()` on a not-running app is a bare launch, which would drop
326-
/// the URL of a launch SpringBoard still holds behind its "Open in …?" confirmation; see
327-
/// `APP_NOT_RUNNING_RUNNER_CODE` (#2852).
326+
/// the URL of a launch SpringBoard still holds behind its "Open in …?" confirmation (#2852), and on
327+
/// macOS it would take the user's frontmost app away for a command that only asked to look; see
328+
/// `APP_NOT_RUNNING_RUNNER_CODE`. Both platforms can answer `.state` without launching anything,
329+
/// which is what makes the guard readable here rather than a guess: measured on a macOS host, a
330+
/// fresh `XCUIApplication(bundleIdentifier:)` for a stopped app reports `.notRunning` and no process
331+
/// appears, so this check costs the launch it prevents rather than causing one (#3254).
328332
func notRunningRefusal(command: Command, bundleId: String) -> Response? {
329-
#if os(iOS)
333+
#if os(iOS) || os(macOS)
330334
guard command.traits.launchPolicy == .existingApp,
331335
XCUIApplication(bundleIdentifier: bundleId).state == .notRunning
332336
else { return nil }

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ enum CommandLaunchPolicy: Equatable {
6363
/// Refuses with `APP_NOT_RUNNING` rather than starting a stopped app, because `activate()` on a
6464
/// not-running app is a bare launch (#2852). The refusal is about a session app, so it answers an
6565
/// explicitly requested bundle id; a request naming no app has no session app to refuse. It is
66-
/// enforced on iOS only: `notRunningRefusal` is `#if os(iOS)`, the platform that can read an app's
67-
/// state without launching it. Off iOS these commands keep the activation route they had before
68-
/// this axis existed, and no refusal can occur there.
66+
/// enforced where the platform can read an app's state without launching it, which `notRunningRefusal`
67+
/// measures to be iOS and macOS: a fresh handle for a stopped macOS app answers `.notRunning` and no
68+
/// process appears (#3254). The tvOS and visionOS runners keep the activation route these commands
69+
/// had before this axis existed, and no refusal can occur there.
6970
case existingApp
7071
/// Brings the app forward, which bare-launches it when it is not running.
7172
case mayLaunch
@@ -148,8 +149,9 @@ fileprivate extension CommandTraits {
148149

149150
/// Selector resolution is an observation: it refuses a stopped app instead of bare-launching it,
150151
/// and the runner still must not replay it after session invalidation. Those are two facts about
151-
/// one command, which is why they are two declarations (#2890). The refusal is the iOS-enforced
152-
/// half: off iOS a selector read of a stopped app still activates it, as it did before this axis.
152+
/// one command, which is why they are two declarations (#2890). Which platforms enforce the refusal
153+
/// is `CommandLaunchPolicy.existingApp`'s claim alone, so nothing here repeats it: a selector read
154+
/// where no refusal can occur keeps the activation route it had before this axis.
153155
static let selectorResolution = CommandTraits(
154156
launchPolicy: .existingApp,
155157
convertsRecordedFailure: true,

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,54 @@ extension RunnerTests {
192192
}
193193
}
194194
#endif
195+
196+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS)
197+
extension RunnerTests {
198+
/// Part of every macOS install and cheap to leave terminated, which makes it the host-lane twin of
199+
/// the Simulator's `com.apple.Preferences`.
200+
private static let notRunningMacTargetBundleId = "com.apple.systempreferences"
201+
202+
@MainActor
203+
private func executeOnStoppedMacTarget(_ json: String) throws -> (Response, XCUIApplication) {
204+
let target = XCUIApplication(bundleIdentifier: Self.notRunningMacTargetBundleId)
205+
target.terminate()
206+
invalidateCachedTarget(reason: "unit_test_setup")
207+
defer { invalidateCachedTarget(reason: "unit_test_cleanup") }
208+
return (try execute(command: try runnerCommandFixture(json)), target)
209+
}
210+
211+
/// The `.existingApp` refusal is reachable on the macOS host lane, which is the half the iOS-only
212+
/// guard left unproved (#3254). Three things have to be true at once and this pins all three: the
213+
/// read is refused rather than answered, the refusal is the typed `APP_NOT_RUNNING` the host keys
214+
/// recovery on, and the `.state` read the guard performs launched nothing — an app the caller never
215+
/// asked to start must stay stopped, because on macOS a bare launch also takes the user's frontmost
216+
/// app. Covers a user-level read, a mutation's leading read, and the read that resolves a selector
217+
/// tap, the same three shapes the Simulator lane covers.
218+
@MainActor
219+
func testMacReadRefusesToLaunchANotRunningSessionApp() throws {
220+
let bundleId = Self.notRunningMacTargetBundleId
221+
for request in [
222+
#"{"command":"snapshot","commandId":"read","appBundleId":"\#(bundleId)"}"#,
223+
#"{"command":"gestureViewport","commandId":"read","appBundleId":"\#(bundleId)"}"#,
224+
#"{"command":"querySelector","selectorKey":"label","selectorValue":"General","commandId":"read","appBundleId":"\#(bundleId)"}"#
225+
] {
226+
let (response, target) = try executeOnStoppedMacTarget(request)
227+
XCTAssertEqual(response.error?.code, RunnerWireErrorCode.appNotRunning, request)
228+
XCTAssertEqual(target.state, .notRunning, "\(request) must not launch the session app")
229+
target.terminate()
230+
}
231+
}
232+
233+
/// The refusal is scoped to `.existingApp`, not to the platform: the command whose whole job is to
234+
/// bring the app up still brings it up on macOS, so no caller that needs the activation lost it.
235+
@MainActor
236+
func testMacNonReadCommandStillLaunchesANotRunningSessionApp() throws {
237+
let (response, target) = try executeOnStoppedMacTarget(
238+
#"{"command":"activate","commandId":"repair","appBundleId":"\#(Self.notRunningMacTargetBundleId)"}"#
239+
)
240+
defer { target.terminate() }
241+
XCTAssertTrue(response.ok)
242+
XCTAssertNotEqual(target.state, .notRunning)
243+
}
244+
}
245+
#endif

‎packages/platform-apple/src/runner/runner-contract.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ export const RUNNER_WEDGED_RUNNER_CODE = 'RUNNER_WEDGED';
4141
/**
4242
* The runner's own code for a read whose session app is not running. No runner read launches the
4343
* app — a bare launch would drop the payload of a launch still pending, such as a deep link held
44-
* behind SpringBoard's confirmation — so the runner refuses any command carrying its read-only
44+
* behind SpringBoard's confirmation, and on macOS it would take the user's frontmost app away for a
45+
* command that only asked to look — so the runner refuses any command carrying its read-only
4546
* trait, including a mutation's leading read (a gesture's viewport read, a selector's resolving
46-
* capture). Only the iOS runner refuses (`#if os(iOS)`); the macOS, tvOS and visionOS runners keep the
47+
* capture). The iOS and macOS runners refuse (`#if os(iOS) || os(macOS)`), the two platforms measured
48+
* to answer `.state` for a stopped app without launching it; the tvOS and visionOS runners keep the
4749
* activate repair. The refusal describes one poll: the launch that confirmation releases may still be
4850
* starting when the next read arrives, so it is retriable for a `wait`, while the transport reads
4951
* it as a definite answer and never resends it.

‎src/commands/schema/cli-help.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,13 @@ Rules:
739739
Context menus are not ambient UI: secondary-click a visible target, then re-snapshot and use the new menu-item refs.
740740
Do not let iOS simulator-set scoping hide macOS desktop targets.
741741
Prefer refs/selectors over raw coordinates.
742-
macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates.`,
742+
macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates.
743+
744+
Activation and the pointer (what a run does to the user's screen):
745+
An app session on the default XCTest backend brings the session app forward before a command that names it, and shows the Automation Mode overlay. A read of a session app that is not running answers APP_NOT_RUNNING rather than starting it.
746+
AGENT_DEVICE_MACOS_APP_BACKEND=native (a daemon setting; restart the daemon after changing it) serves app sessions through the macOS helper's accessibility actions instead: no runner, no overlay, and snapshot, screenshot, get, click, press, fill, type, and scroll leave the app where it sits. A command only the runner can serve refuses with UNSUPPORTED_OPERATION (details.reason unsupported-device-backend).
747+
press and click on the frontmost-app and menubar surfaces post synthetic mouse events at screen coordinates on either backend: the real pointer moves, and macOS activates the app that receives them.
748+
On the XCTest backend an app-targeted screenshot activates the app first, because XCTest captures the screen region the window occupies and a background app would come back with whatever is drawn over it.`,
743749
},
744750
web: {
745751
summary: 'Minimal browser workflow with the managed web backend',

‎website/docs/docs/commands.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ agent-device snapshot -i --platform apple --target desktop
348348
- Use `click --button secondary` for context menus on macOS, then run `snapshot -i` again.
349349
- On `frontmost-app` and `menubar` surfaces, `press` and `click` post synthetic mouse events through the macOS helper (the `desktop` surface inspects only): `--hold-ms` is how long the button stays down (at least 40 ms, 60 ms by default, because AppKit drops a release posted in the same tick as its press), `--count` is that many independent clicks (apps that detect a double-click by timing, such as Finder, still read two clicks at the default interval as one; pass an `--interval-ms` longer than the system double-click time to keep them apart), and `--double-tap` posts each click as a double-click pair. A long schedule such as `--hold-ms 10000 --count 4` is given the time it needs, and a helper stopped mid-hold — by a cancelled request, a dropped client, or its deadline — releases the button before it exits. `--jitter-px` is not applied on these surfaces.
350350
- With `AGENT_DEVICE_MACOS_APP_BACKEND=native`, macOS app sessions run without XCTest: no Automation Mode overlay, the app can stay behind other windows, and the real pointer stays with you while a drawn pointer shows each action. `click`, `press`, and `fill` use accessibility actions on the element at their target point, which must lie inside one of the app's own on-screen windows and not on its menu bar; `type` inserts at the focused control and falls back to key events sent to the app; `scroll` moves the accessibility scroll bar under the front window's center. Chromium-based apps expose their full tree. The daemon then never starts the Apple runner on macOS: `record`, `prepare`, `back`, `longpress` and `--hold-ms`, `--double-tap`, `--button secondary`, and gestures are refused with `UNSUPPORTED_OPERATION` (`reason: unsupported-device-backend`), as is a click on an element with no accessibility action. Use the default XCTest backend for those. `screenshot` captures only the app's front window, even when other windows cover it.
351+
- Which macOS commands still take the app forward, or move the real pointer:
352+
- Default (XCTest) app sessions: a command that resolves or drives the session app activates it first unless it is already frontmost, the runner moves the real pointer for taps and gestures, and the runner shows the Automation Mode overlay while the session lives. A read (`snapshot`, `wait`, `is`, `get`, a reading `find`, and an interaction's leading read) of a session app that is not running answers `APP_NOT_RUNNING` rather than starting it. An app-targeted `screenshot` also activates the app, because XCTest captures the screen region the window occupies — a background window would come back with whatever is drawn over it.
353+
- `AGENT_DEVICE_MACOS_APP_BACKEND=native` app sessions: `snapshot`, `screenshot`, `get`, `click`, `press`, `fill`, `type`, and `scroll` act through accessibility on the app's own windows, so none of them activates the app or moves the real pointer. `open` still launches or reopens the app, which brings it forward.
354+
- `press` and `click` on `frontmost-app` and `menubar` surfaces, on either backend: these post synthetic mouse events at screen coordinates through the macOS helper (`CGEvent.post(tap: .cghidEventTap)` in `MouseClickDelivery.swift`). The real pointer moves to the point, the click lands on whichever window is topmost there, and macOS activates the app that receives it.
351355
- Mobile-only helpers are unsupported on macOS: `boot`, `shutdown`, `home`, `orientation`, `app-switcher`, `action-button`, `fold`, `install`, `reinstall`, `install-from-source`, and `push`.
352356
353357
Recommended loops:

0 commit comments

Comments
 (0)