Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Five SwiftPM targets; dependency graph flows in one direction.
| `SimUseCore` | `Sources/SimUseCore/` | Foundation + ArgumentParser |
| `SimUseVideo` | `Sources/SimUseVideo/` | SimUseCore + AVFoundation/ImageIO |
| `iOSSimBackend` | `Sources/iOSSimBackend/` | SimUseCore + SimUseVideo + FB* XCFrameworks + AVFoundation |
| `AndroidBackend` | `Sources/AndroidBackend/` | SimUseCore + ArgumentParser |
| `AndroidBackend` | `Sources/AndroidBackend/` | SimUseCore + SimUseVideo + ArgumentParser |
| `SimUse` (executable) | `Sources/SimUse/` | SimUseCore + SimUseVideo + iOSSimBackend + AndroidBackend + FB* |

`SimUseVideo` holds the platform-neutral host-side video plumbing (H.264 Annex B parsing, passthrough muxing, `AVAssetWriter` encoding, frame utilities) shared by the iOS and Android recording/streaming paths. It must stay FB*-free — anything that needs FBSimulatorControl belongs in `iOSSimBackend` (e.g. the `VideoFrameUtilities.captureScreenshotData` extension), anything adb-shaped in `AndroidBackend`.
Expand All @@ -88,7 +88,7 @@ A verb (tap, swipe, type, ...) reaches three surfaces:
2. **`sim-use ios <verb>`** — `Sources/iOSSimBackend/Verbs/IOSSim<Verb>Command.swift`.
3. **`sim-use android <verb>`** — `Sources/AndroidBackend/Verbs/Android<Verb>Command.swift`.

Five verbs are iOS-only (`key`, `key-combo`, `key-sequence`, `stream-video`, `batch`) — no top-level alias.
Four verbs are iOS-only (`key`, `key-combo`, `key-sequence`, `batch`) — no top-level alias.

### Adding a new verb

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Android support for `stream-video`, and a top-level cross-platform `stream-video` verb (#78). `sim-use android stream-video --format h264` streams native `adb screenrecord` H.264 Annex B passthrough to stdout (variable frame rate, survives screenrecord's per-invocation time limit by restarting mid-stream — each new segment re-emits SPS/PPS, which ffplay/ffmpeg accept); `mjpeg` / `raw` / `ffmpeg` provide a screencap-based JPEG loop (~7–8 FPS ceiling) with byte-identical framing to the iOS formats. The new top-level `sim-use stream-video` routes by UDID shape: `bgra` stays iOS-only, `h264` is Android-only for now, and the platform-mismatch cases fail with a pointer to the right alternative. Streaming now also treats consumer hangup (ffplay quit, `head -c` done) as an orderly end-of-stream on Android instead of dying on SIGPIPE. `SIM_USE_SCREENRECORD_TIME_LIMIT=<seconds>` (debug) forces short screenrecord segments so the restart path can be exercised on API ≥ 34 devices; it applies to Android `record-video` and `stream-video` alike. `--json` is rejected on every `stream-video` surface — stdout carries the raw video bytes and the envelope would corrupt the stream (the run summary goes to stderr). The Android segment loops only restart screenrecord after a clean exit 0 (time limit); a screenrecord that dies mid-run now aborts with its stderr instead of being blindly restarted — `record-video` still finalizes and keeps the partial MP4.
- `sim-use android record-video`: the Android recording engine moved out of the top-level forwarder into `AndroidBackend`, giving `record-video` the same three-surface layout as every other cross-platform verb (top-level + `ios` + `android`). Flags and behavior are unchanged; `sim-use record-video` with an Android serial routes to the same implementation.
- `make e2e-matrix` (`scripts/e2e-matrix.sh`): iOS E2E across the supported host environments — Xcode 26.x / Xcode 27.x × Device Hub closed at boot (the classic Simulator.app workflow, legacy `indigo` HID) / Device Hub open at boot (CoreDevice `dtuhid`). One leg runs the full suite (default `x27-hub`, the primary workflow once Xcode 27 ships; `--full <leg>|all|none` to change), the rest run the new smoke tier (`test-runner.sh --smoke`: describe-ui, tap, type, scroll); legs whose Xcode is not installed are skipped. The package builds once on the xcode-select toolchain (`build_products/` is toolchain-locked); each leg swaps only the *runtime* Xcode — `SIM_USE_TEST_DEVELOPER_DIR`, which the test harness injects as `DEVELOPER_DIR` into every process the suites spawn — and boots a device whose iOS runtime matches the leg's Xcode generation (runtimes are system-wide, so "newest available" would cross-contaminate). Every leg is double-gated against silently green-running the wrong combination: dtuhidd process-tree checks before and after the suites, plus a `SIM_USE_DEBUG` probe that must report the matching HID transport-selection predicate. Per-leg logs and a combination-evidence file land in `.build/e2e-matrix/<timestamp>/`. `test-runner.sh` now also accepts arbitrary repeatable `swift test --filter` patterns (previously one suite from a fixed whitelist) and resolves its default simulator by exact device name on the newest *available* runtime (previously a substring match that could pick an unavailable device or an "… Pro Max" sibling).
- `swipe` / `touch --coordinate-space native|ui` (top-level and `ios` subcommands): explicit coordinates keep their device-native portrait default, and `ui` opts them into the visual space `describe-ui` prints — endpoints are orientation-calibrated per command (per batch for batch steps), so outline coordinates stay correct on a rotated device (#66). `touch` accepts `ui` in the atomic `--down --up` form only: a split touch could straddle a rotation and land its two halves in different spaces. A failed calibration degrades to native dispatch and says so via the `advisory` envelope key. On Android the flag is accepted and ignored — on the top-level commands and the `android swipe` / `android touch` direct commands alike (display coordinates already rotate with the UI).
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ For Android, run `sim-use android init --device <serial>` once to install the br

All device-scoped commands accept `--device <ID>` (optional when only one simulator is booted). Three command layers:

* **Top-level** — cross-platform verbs: `ui`, `tap`, `swipe`, `type`, `paste`, `button`, `gesture`, `keyboard-state`, `screenshot`, `record-video`, `app-state`. Same flags on iOS and Android.
* **`sim-use ios <verb>`** — iOS-only: `key`, `key-combo`, `key-sequence`, `stream-video`, `batch`.
* **Top-level** — cross-platform verbs: `ui`, `tap`, `swipe`, `type`, `paste`, `button`, `gesture`, `keyboard-state`, `screenshot`, `record-video`, `stream-video`, `app-state`. Same flags on iOS and Android.
* **`sim-use ios <verb>`** — iOS-only: `key`, `key-combo`, `key-sequence`, `batch`.
* **`sim-use android <verb>`** — Android-only: `init`, `devices`, `ping`.

Run `sim-use --help` or `sim-use <command> --help` for the full flag set.
Expand Down Expand Up @@ -326,13 +326,18 @@ The output path goes to stdout; progress messages go to stderr.
### Video streaming & recording

```bash
# MJPEG stream (iOS-only — no Android stream-video implementation)
sim-use ios stream-video --device $UDID --fps 10 --format mjpeg > stream.mjpeg
# MJPEG stream (cross-platform)
sim-use stream-video --device $UDID --fps 10 --format mjpeg > stream.mjpeg

# Pipe into ffmpeg
sim-use ios stream-video --device $UDID --fps 30 --format ffmpeg | \
sim-use stream-video --device $UDID --fps 30 --format ffmpeg | \
ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -preset ultrafast out.mp4

# Native H.264 live stream (Android-only): adb screenrecord passthrough —
# variable frame rate, cheap, high quality. Preview it live in ffplay:
sim-use stream-video --device emulator-5554 --format h264 | \
ffplay -f h264 -probesize 32 -fflags nobuffer -

# Record MP4 directly (cross-platform)
sim-use record-video --device $UDID --output recording.mp4 # 30 fps default
sim-use record-video --device $UDID --fps 60 --output smooth.mp4 # up to 60 fps
Expand Down
1 change: 1 addition & 0 deletions Sources/AndroidBackend/Verbs/AndroidCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public struct AndroidCommand: ParsableCommand {
AndroidButtonCommand.self,
AndroidScreenshotCommand.self,
AndroidRecordVideoCommand.self,
AndroidStreamVideoCommand.self,
AndroidTypeCommand.self,
]
)
Expand Down
56 changes: 42 additions & 14 deletions Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
}
}

private static func assertAdbDeviceOnline(adb: Adb, serial: String) throws {
static func assertAdbDeviceOnline(adb: Adb, serial: String) throws {
let devices: [Adb.Device]
do {
devices = try adb.devices()
Expand Down Expand Up @@ -197,7 +197,7 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
let recordingSize = scale < 1.0 ? baseSize.map { scaledSize($0, scale: scale) } : nil
let bitrateSize = recordingSize ?? baseSize
let bitrate = bitrateSize.map { H264StreamRecorder.estimateBitrate(width: $0.width, height: $0.height, fps: 30, quality: quality) }
let arguments = screenrecordArguments(serial: serial, sdk: sdk, bitrate: bitrate, size: recordingSize)
let arguments = screenrecordArguments(serial: serial, sdk: sdk, bitrate: bitrate, size: recordingSize, timeLimitOverride: screenrecordTimeLimitOverride())

let recorder = try H264PassthroughRecorder(outputURL: outputURL)
var recorderFinalized = false
Expand All @@ -210,7 +210,10 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
})

var firstSegment = true
var disconnected = false
// Set when the stream ends abnormally (device stopped feeding or
// screenrecord died); the recorder still finalizes so the partial
// MP4 survives, then the failure surfaces as the thrown error.
var streamFailure: String?

segmentLoop: while true {
if Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil { break }
Expand Down Expand Up @@ -244,18 +247,26 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
break
}

// The process exited on its own — either the API-level time limit
// was reached (restart to continue) or the device stopped feeding.
// The process exited on its own — a clean exit 0 is the API-level
// time limit (restart to continue); anything else is a dead
// device, adb, or encoder, which must NOT be blind-restarted
// into a crash loop just because the segment produced bytes.
let exitCode = process.waitForExit(timeout: 2)
let bytesThisSegment = process.stdoutByteCount - segmentStartBytes
let stderrTail = process.collectedStderr.trimmingCharacters(in: .whitespacesAndNewlines)
if bytesThisSegment == 0 {
if !pipeline.firstFrameReceived {
let exitDescription = exitCode.map(String.init) ?? "timeout"
throw ScreenrecordUnavailableError(
underlying: "screenrecord produced no output (exit \(exitDescription)): \(process.collectedStderr.trimmingCharacters(in: .whitespacesAndNewlines))"
underlying: "screenrecord produced no output (exit \(exitDescription)): \(stderrTail)"
)
}
disconnected = true
streamFailure = "Android device stopped producing frames during recording"
break segmentLoop
}
guard exitCode == 0 else {
let exitDescription = exitCode.map(String.init) ?? "timeout"
streamFailure = "screenrecord exited unexpectedly (exit \(exitDescription)): \(stderrTail)"
break segmentLoop
}
FileHandle.standardError.write(Data("screenrecord segment ended (Android time limit); restarting (~100-300ms gap)\n".utf8))
Expand All @@ -271,19 +282,19 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
}

if let fatal = fatalBox.first { throw fatal }
if disconnected {
throw CLIError(errorDescription: "Android device stopped producing frames during recording; partial recording saved to \(outputURL.path)")
if let streamFailure {
throw CLIError(errorDescription: "\(streamFailure); partial recording saved to \(outputURL.path)")
}
}

private static func detectSDK(adb: Adb, serial: String) -> Int {
static func detectSDK(adb: Adb, serial: String) -> Int {
guard let result = try? adb.shell(serial: serial, args: ["getprop", "ro.build.version.sdk"]) else {
return 30
}
return Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 30
}

private static func detectSize(adb: Adb, serial: String) -> (width: Int, height: Int)? {
static func detectSize(adb: Adb, serial: String) -> (width: Int, height: Int)? {
guard let result = try? adb.shell(serial: serial, args: ["wm", "size"]) else {
return nil
}
Expand Down Expand Up @@ -318,12 +329,29 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
return nil
}

/// Debug override for screenrecord's per-invocation time limit
/// (`SIM_USE_SCREENRECORD_TIME_LIMIT`, seconds). API ≥ 34 devices
/// stream unlimited (`--time-limit 0`), so the segment-restart path
/// never fires naturally there — this forces short segments so tests
/// and manual runs can exercise restarts without an API < 34 device.
static func screenrecordTimeLimitOverride(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Int? {
guard let raw = environment["SIM_USE_SCREENRECORD_TIME_LIMIT"],
let value = Int(raw), value > 0 else {
return nil
}
return value
}

/// Build the `adb screenrecord` argument vector. `--time-limit 0`
/// (unlimited) is only valid on API ≥ 34; older devices hard-cap at 180 s,
/// which the segment loop handles by restarting.
static func screenrecordArguments(serial: String, sdk: Int, bitrate: Int?, size: (width: Int, height: Int)?) -> [String] {
static func screenrecordArguments(serial: String, sdk: Int, bitrate: Int?, size: (width: Int, height: Int)?, timeLimitOverride: Int? = nil) -> [String] {
var arguments = ["-s", serial, "exec-out", "screenrecord", "--output-format=h264"]
if sdk >= 34 {
if let timeLimitOverride {
arguments.append(contentsOf: ["--time-limit", "\(timeLimitOverride)"])
} else if sdk >= 34 {
arguments.append(contentsOf: ["--time-limit", "0"])
}
if let bitrate {
Expand Down Expand Up @@ -435,7 +463,7 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
/// while the screencap itself is the dominant cost; raising this
/// TODO is the cheaper performance lever to reach for first when
/// the frame loop becomes the bottleneck.
private static func captureAndroidScreencap(adbPath: String, serial: String) throws -> Data {
static func captureAndroidScreencap(adbPath: String, serial: String) throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: adbPath)
process.arguments = ["-s", serial, "exec-out", "screencap", "-p"]
Expand Down
Loading