mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
3e4828d68d
* feat: add scale-only screenshot sizing
* fix: refuse retired --max-size inputs on every released surface
Released sizing inputs must fail closed with migration guidance instead of
silently producing native-size artifacts:
- contracts: RETIRED_SCREENSHOT_MAX_SIZE declaration + SCREENSHOT_SCALE_LIMITS
as the single source for the scale bounds and migration messages
- .ad parser: released 'screenshot ... --max-size N' and 'record start ...
--max-size N' lines now refuse at parse time (frozen replay-compat witnesses)
- daemon: screenshot rejects old-client screenshotMaxSize like recording does;
the recording guard now shares the same contract data
- Node client: screenshot/record daemon writers refuse the removed { maxSize }
option before transport
- CLI: --max-size unknown-flag error carries the migration guidance
- config/env: stale screenshotMaxSize config keys and the retired
AGENT_DEVICE_SCREENSHOT_MAX_SIZE env var are refused for sizing commands
(other commands keep working)
Quality: numberField now reuses the canonical readOptionalNumber contract
helper (AppError bounds instead of plain Error); png-resize inlines one-use
wrappers and restores the worker-thread rationale; docs typo fixed.
* test: drop retired maxSize entries from the MCP undocumented-input allowlist
* fix: refuse retired maxSize at the MCP field-projection seam + release-provenance corpus witnesses
- readFieldInput silently dropped undeclared keys before the daemon writers
could refuse them, so an MCP call carrying { maxSize } reached transport and
returned native-size success. New retiredField() combinator declares the
removed key in the field map: the projection seam refuses it with the
canonical migration message and the JSON schema no longer advertises it.
Real-route MCP executor regressions cover screenshot and record.
- replay-compat corpus: derived v0.20.5 witnesses for the released screenshot
and record --max-size forms (SHA-256 pinned, new retired-capture-size
coverage surface) so check:replay-compat proves the shipped syntax refuses
with migration guidance instead of degrading silently.
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
404 lines
13 KiB
Swift
404 lines
13 KiB
Swift
// MARK: - Wire Models
|
|
|
|
enum CommandType: String, Codable {
|
|
case tap
|
|
case mouseClick
|
|
case longPress
|
|
case drag
|
|
case remotePress
|
|
case type
|
|
case swipe
|
|
case scroll
|
|
case desktopScroll
|
|
case findText
|
|
case querySelector
|
|
case readText
|
|
case snapshot
|
|
case screenshot
|
|
case back
|
|
case backInApp
|
|
case backSystem
|
|
case home
|
|
case rotate
|
|
case appSwitcher
|
|
case keyboardDismiss
|
|
case keyboardReturn
|
|
case alert
|
|
case sequence
|
|
case gesture
|
|
case gestureViewport
|
|
case recordStart
|
|
case recordStop
|
|
case status
|
|
case uptime
|
|
case activate
|
|
case terminate
|
|
case targetReset
|
|
case shutdown
|
|
}
|
|
|
|
/// Runner command traits — see CONTEXT.md ("Runner command traits").
|
|
///
|
|
/// Single source of truth for how the runner classifies a command across three
|
|
/// independent axes, replacing the three hand-maintained switches that used to live
|
|
/// in RunnerTests+Lifecycle.swift (isInteractionCommand / isReadOnlyCommand /
|
|
/// isRunnerLifecycleCommand). The classification is load-bearing for ADR-0002 session
|
|
/// invalidation: `readOnly` gates the retry that nulls currentApp/currentBundleId.
|
|
struct CommandTraits {
|
|
/// Whether the command needs the foreground-guard + stabilization preflight before running.
|
|
let isInteraction: Bool
|
|
/// Whether the command is eligible for the session-invalidating retry.
|
|
/// `.conditional` is resolved against the request (alert is read-only only for its `get` action).
|
|
let readOnly: ReadOnly
|
|
/// Whether the command skips the app-activation preflight entirely.
|
|
let isLifecycle: Bool
|
|
|
|
enum ReadOnly {
|
|
case always
|
|
case never
|
|
/// Alert-only today. Resolved in `isReadOnlyCommand` with alert's rule (read-only for the
|
|
/// `get` action, mutating otherwise). A new `.conditional` command would inherit that rule
|
|
/// until the resolver is generalized — give it explicit handling there if its semantics differ.
|
|
case conditional
|
|
}
|
|
}
|
|
|
|
extension CommandType {
|
|
/// The classification for this command. Exhaustive by construction: a new CommandType
|
|
/// cannot compile without being classified here, so commands can no longer silently drift
|
|
/// out of classification the way the parallel switches allowed.
|
|
var traits: CommandTraits {
|
|
switch self {
|
|
// Interaction commands: require the foreground-guard + stabilization preflight.
|
|
// keyboardReturn is the sibling of keyboardDismiss (missing from the historical switch —
|
|
// drift the table now prevents). .scroll is the fused frame-resolve + drag scroll; same
|
|
// classification as .drag. .desktopScroll is the macOS frame-resolve + wheel event sibling.
|
|
// .sequence is the fused multi-step gesture batch.
|
|
case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll, .desktopScroll,
|
|
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
|
|
.keyboardDismiss, .keyboardReturn, .sequence, .gesture:
|
|
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
|
|
|
|
// Read-only reads: eligible for the session-invalidating retry.
|
|
case .findText, .readText, .snapshot, .gestureViewport:
|
|
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false)
|
|
|
|
// Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight).
|
|
case .screenshot:
|
|
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
|
|
|
|
// Alert is read-only only for its `get` action (resolved by isReadOnlyCommand).
|
|
case .alert:
|
|
return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false)
|
|
|
|
// Runner-lifecycle commands: skip the app-activation preflight.
|
|
case .recordStop, .uptime, .terminate, .targetReset, .shutdown:
|
|
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)
|
|
|
|
case .status:
|
|
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
|
|
|
|
// Normal preflight, not retried.
|
|
// NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
|
|
// guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
|
|
// check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only;
|
|
// recordStart is NOT a lifecycle command; home/alert remain non-interaction by design.
|
|
case .mouseClick, .querySelector, .home, .recordStart, .activate:
|
|
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Command: Codable {
|
|
let command: CommandType
|
|
let commandId: String?
|
|
let statusCommandId: String?
|
|
let appBundleId: String?
|
|
let text: String?
|
|
let selectorKey: String?
|
|
let selectorValue: String?
|
|
let allowNonHittableCoordinateFallback: Bool?
|
|
let delayMs: Int?
|
|
let textEntryMode: String?
|
|
let action: String?
|
|
let x: Double?
|
|
let y: Double?
|
|
let button: String?
|
|
let remoteButton: String?
|
|
let x2: Double?
|
|
let y2: Double?
|
|
let durationMs: Double?
|
|
let timeoutMs: Double?
|
|
let direction: String?
|
|
let amount: Double?
|
|
let pixels: Double?
|
|
let orientation: String?
|
|
let gesturePlan: RunnerGesturePlan?
|
|
let outPath: String?
|
|
let fps: Int?
|
|
let interactiveOnly: Bool?
|
|
let preferredBackend: String?
|
|
let depth: Int?
|
|
let scope: String?
|
|
let raw: Bool?
|
|
let fullscreen: Bool?
|
|
let inlineScreenshot: Bool?
|
|
let synthesized: Bool?
|
|
let steps: [SequenceStep]?
|
|
}
|
|
|
|
/// Canonical one- or two-pointer plan produced by the portable TypeScript planner.
|
|
struct RunnerGesturePlan: Codable {
|
|
let topology: String
|
|
let intent: String
|
|
let executionProfile: String?
|
|
let durationMs: Double
|
|
let viewport: RunnerGestureViewport
|
|
let pointers: [RunnerGesturePointer]
|
|
}
|
|
|
|
struct RunnerGestureViewport: Codable {
|
|
let x: Double
|
|
let y: Double
|
|
let width: Double
|
|
let height: Double
|
|
}
|
|
|
|
struct RunnerGesturePointer: Codable {
|
|
let pointerId: Int
|
|
let samples: [RunnerGestureSample]
|
|
}
|
|
|
|
struct RunnerGestureSample: Codable {
|
|
let offsetMs: Double
|
|
let point: RunnerGesturePoint
|
|
}
|
|
|
|
struct RunnerGesturePoint: Codable {
|
|
let x: Double
|
|
let y: Double
|
|
}
|
|
|
|
/// One allowlisted coordinate gesture step inside a fused `sequence` command.
|
|
/// `kind` is decoded as a raw String (not an enum) so the runner can return a clear
|
|
/// INVALID_ARGS for an unknown kind instead of a generic decode failure.
|
|
struct SequenceStep: Codable {
|
|
let kind: String
|
|
let x: Double?
|
|
let y: Double?
|
|
let durationMs: Double?
|
|
let pauseMs: Double?
|
|
/// For `tap` steps on iOS non-tv: use the synthesized HID fast path instead of the
|
|
/// drag-based XCUICoordinate path, matching the individual command behavior.
|
|
let synthesized: Bool?
|
|
}
|
|
|
|
/// Per-step result for a `sequence` response. `ok:false` carries the failing step's
|
|
/// errorCode/errorMessage; execution stops at the first failed step.
|
|
struct SequenceStepResult: Codable {
|
|
let ok: Bool
|
|
let kind: String
|
|
let errorCode: String?
|
|
let errorMessage: String?
|
|
let gestureStartUptimeMs: Double?
|
|
let gestureEndUptimeMs: Double?
|
|
}
|
|
|
|
struct Response: Codable {
|
|
let ok: Bool
|
|
let data: DataPayload?
|
|
let error: ErrorPayload?
|
|
|
|
init(ok: Bool, data: DataPayload? = nil, error: ErrorPayload? = nil) {
|
|
self.ok = ok
|
|
self.data = data
|
|
self.error = error
|
|
}
|
|
}
|
|
|
|
extension Response {
|
|
// The daemon pairs this gesture-clock anchor with its own receipt time to map
|
|
// gesture uptimes onto wall-clock for the recording touch overlay. Error responses
|
|
// carry no anchor so the daemon falls back instead of pairing a stale value.
|
|
func stampingCurrentUptimeMs(_ value: Double) -> Response {
|
|
guard ok else { return self }
|
|
var payload = data ?? DataPayload()
|
|
payload.currentUptimeMs = value
|
|
return Response(ok: ok, data: payload, error: error)
|
|
}
|
|
}
|
|
|
|
struct DataPayload: Codable {
|
|
let message: String?
|
|
let imageBase64: String?
|
|
let text: String?
|
|
let found: Bool?
|
|
let items: [String]?
|
|
let nodes: [SnapshotNode]?
|
|
let truncated: Bool?
|
|
let snapshotQuality: SnapshotQuality?
|
|
let gestureStartUptimeMs: Double?
|
|
let gestureEndUptimeMs: Double?
|
|
let x: Double?
|
|
let y: Double?
|
|
let x2: Double?
|
|
let y2: Double?
|
|
let referenceWidth: Double?
|
|
let referenceHeight: Double?
|
|
var currentUptimeMs: Double?
|
|
let commandId: String?
|
|
let lifecycleState: String?
|
|
let lifecycleCommand: String?
|
|
let lifecycleResponseOk: Bool?
|
|
let lifecycleResponseJson: String?
|
|
let lifecycleErrorCode: String?
|
|
let lifecycleErrorMessage: String?
|
|
let lifecycleErrorHint: String?
|
|
let visible: Bool?
|
|
let wasVisible: Bool?
|
|
let dismissed: Bool?
|
|
let keyboardDismissMechanism: String?
|
|
let orientation: String?
|
|
let gestureFallback: String?
|
|
let gestureFallbackMessage: String?
|
|
let gestureFallbackHint: String?
|
|
let maestroNonHittableCoordinateFallbackUsed: Bool?
|
|
let textEntryRoute: String?
|
|
let runnerFatal: Bool?
|
|
let runnerFatalReason: String?
|
|
let completedSteps: Int?
|
|
let failedStepIndex: Int?
|
|
let sequenceResults: [SequenceStepResult]?
|
|
|
|
init(
|
|
message: String? = nil,
|
|
imageBase64: String? = nil,
|
|
text: String? = nil,
|
|
found: Bool? = nil,
|
|
items: [String]? = nil,
|
|
nodes: [SnapshotNode]? = nil,
|
|
truncated: Bool? = nil,
|
|
snapshotQuality: SnapshotQuality? = nil,
|
|
gestureStartUptimeMs: Double? = nil,
|
|
gestureEndUptimeMs: Double? = nil,
|
|
x: Double? = nil,
|
|
y: Double? = nil,
|
|
x2: Double? = nil,
|
|
y2: Double? = nil,
|
|
referenceWidth: Double? = nil,
|
|
referenceHeight: Double? = nil,
|
|
currentUptimeMs: Double? = nil,
|
|
commandId: String? = nil,
|
|
lifecycleState: String? = nil,
|
|
lifecycleCommand: String? = nil,
|
|
lifecycleResponseOk: Bool? = nil,
|
|
lifecycleResponseJson: String? = nil,
|
|
lifecycleErrorCode: String? = nil,
|
|
lifecycleErrorMessage: String? = nil,
|
|
lifecycleErrorHint: String? = nil,
|
|
visible: Bool? = nil,
|
|
wasVisible: Bool? = nil,
|
|
dismissed: Bool? = nil,
|
|
keyboardDismissMechanism: String? = nil,
|
|
orientation: String? = nil,
|
|
gestureFallback: String? = nil,
|
|
gestureFallbackMessage: String? = nil,
|
|
gestureFallbackHint: String? = nil,
|
|
maestroNonHittableCoordinateFallbackUsed: Bool? = nil,
|
|
textEntryRoute: String? = nil,
|
|
runnerFatal: Bool? = nil,
|
|
runnerFatalReason: String? = nil,
|
|
completedSteps: Int? = nil,
|
|
failedStepIndex: Int? = nil,
|
|
sequenceResults: [SequenceStepResult]? = nil
|
|
) {
|
|
self.message = message
|
|
self.imageBase64 = imageBase64
|
|
self.text = text
|
|
self.found = found
|
|
self.items = items
|
|
self.nodes = nodes
|
|
self.truncated = truncated
|
|
self.snapshotQuality = snapshotQuality
|
|
self.gestureStartUptimeMs = gestureStartUptimeMs
|
|
self.gestureEndUptimeMs = gestureEndUptimeMs
|
|
self.x = x
|
|
self.y = y
|
|
self.x2 = x2
|
|
self.y2 = y2
|
|
self.referenceWidth = referenceWidth
|
|
self.referenceHeight = referenceHeight
|
|
self.currentUptimeMs = currentUptimeMs
|
|
self.commandId = commandId
|
|
self.lifecycleState = lifecycleState
|
|
self.lifecycleCommand = lifecycleCommand
|
|
self.lifecycleResponseOk = lifecycleResponseOk
|
|
self.lifecycleResponseJson = lifecycleResponseJson
|
|
self.lifecycleErrorCode = lifecycleErrorCode
|
|
self.lifecycleErrorMessage = lifecycleErrorMessage
|
|
self.lifecycleErrorHint = lifecycleErrorHint
|
|
self.visible = visible
|
|
self.wasVisible = wasVisible
|
|
self.dismissed = dismissed
|
|
self.keyboardDismissMechanism = keyboardDismissMechanism
|
|
self.orientation = orientation
|
|
self.gestureFallback = gestureFallback
|
|
self.gestureFallbackMessage = gestureFallbackMessage
|
|
self.gestureFallbackHint = gestureFallbackHint
|
|
self.maestroNonHittableCoordinateFallbackUsed = maestroNonHittableCoordinateFallbackUsed
|
|
self.textEntryRoute = textEntryRoute
|
|
self.runnerFatal = runnerFatal
|
|
self.runnerFatalReason = runnerFatalReason
|
|
self.completedSteps = completedSteps
|
|
self.failedStepIndex = failedStepIndex
|
|
self.sequenceResults = sequenceResults
|
|
}
|
|
}
|
|
|
|
struct ErrorPayload: Codable {
|
|
let code: String?
|
|
let message: String
|
|
let hint: String?
|
|
|
|
init(code: String? = nil, message: String, hint: String? = nil) {
|
|
self.code = code
|
|
self.message = message
|
|
self.hint = hint
|
|
}
|
|
}
|
|
|
|
struct SnapshotRect: Codable {
|
|
let x: Double
|
|
let y: Double
|
|
let width: Double
|
|
let height: Double
|
|
}
|
|
|
|
struct SnapshotNode: Codable {
|
|
let index: Int
|
|
let type: String
|
|
let label: String?
|
|
let identifier: String?
|
|
let value: String?
|
|
let rect: SnapshotRect
|
|
let enabled: Bool
|
|
let focused: Bool?
|
|
let selected: Bool?
|
|
let hittable: Bool
|
|
let depth: Int
|
|
let parentIndex: Int?
|
|
let hiddenContentAbove: Bool?
|
|
let hiddenContentBelow: Bool?
|
|
}
|
|
|
|
struct SnapshotOptions {
|
|
let interactiveOnly: Bool
|
|
let depth: Int?
|
|
let scope: String?
|
|
let raw: Bool
|
|
/// Internal daemon ask: capture with this backend first regardless of channel
|
|
/// health ("private-ax"). Same-backend evidence probes (tap-outcome
|
|
/// corroboration) must be captured the way their baseline was.
|
|
var preferredBackend: String? = nil
|
|
}
|