Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `record-video` now captures a real H.264 stream and muxes it straight into the MP4 (passthrough) instead of polling screenshots and re-encoding each frame. iOS drives `FBSimulatorVideoStream` in eager H.264 mode at a constant `--fps` (frames laid out at exactly `1/fps`, so the rate is honored and playback is smooth); Android pipes `adb screenrecord --output-format=h264` at its native variable frame rate, stitching across `screenrecord` restarts past the API < 34 per-invocation limit. `--fps` is now `1–60` (default 30, was `1–30` default 10) and is ignored on Android; `--quality`/`--scale` map to bitrate/size on both platforms. A mid-recording rotation stops Android capture (an MP4 track can't change frame size).
- The tap family (`tap`, `long-press`, `ios tap`) declares its shared flags once via `TapTargetingOptions` / `TapTimingOptions` option groups instead of three hand-kept copies (#42). Flag names, defaults, validation messages, and `--json` envelopes are unchanged; the only user-visible deltas are in `--help` output, where per-verb wording ("Tap the center…" / "Long-press the center…") unifies to verb-neutral phrasing ("Target the center…") and the timing flags render as a contiguous block after `--duration`.
- `make e2e` now runs both the iOS and Android E2E suites in sequence (continuing past a platform failure and failing if either did); the iOS-only target is `make e2e-ios`, Android-only stays `make e2e-android`.
- `scripts/test-runner.sh` keeps running after a failed suite and prints a full pass/fail map at the end (a release gate needs the whole picture, not the first crash). The hardcoded suite list gained the missing `KeyboardStateTests` and the new suites, and the misnamed `StreamVideoDebugTests` entry — which silently matched zero tests — now points at the real `StreamVideoDebugTest` suite.

### Fixed

- `record-video` frame rate is no longer capped at the ~8–10 fps the screenshot-polling loop topped out at (iOS honors `--fps` up to 60 with smooth constant-rate playback; an Android emulator captured ~50 fps under motion). Recording no longer burns CPU decoding/re-encoding every frame.
- Four E2E suites (KeyTests, KeyComboTests, KeySequenceTests, StreamVideoTests) still invoked the pre-0.5.x top-level verb forms and had failed ever since the five iOS-only verbs moved under the `ios` namespace; they now call `sim-use ios <verb>`.
- KeyComboTests' Cmd+A test asserted a cleared text field reads nil/empty — an empty `UITextField` exposes its placeholder as the accessibility value, so the assertion could never pass. It now accepts the placeholder form.

Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,27 @@ sim-use ios stream-video --device $UDID --fps 30 --format ffmpeg | \
ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -preset ultrafast out.mp4

# Record MP4 directly (cross-platform)
sim-use record-video --device $UDID --fps 15 --output recording.mp4
sim-use record-video --device $UDID --fps 10 --quality 60 --scale 0.5 --output low-bw.mp4
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
sim-use record-video --device $UDID --quality 60 --scale 0.5 --output low-bw.mp4
```

Press Ctrl+C to stop; sim-use finalises the MP4 before exiting.
`record-video` captures a real H.264 stream and muxes it straight into the
MP4 (passthrough — no per-frame screenshot re-encoding):

* **iOS** drives `FBSimulatorVideoStream` in eager H.264 mode at a constant
`--fps` (default 30, max 60). Because the stream carries no timestamps,
frames are laid out at exactly `1/fps`, so playback is smooth and the
requested rate is honored.
* **Android** pipes `adb screenrecord --output-format=h264` at the device's
native variable frame rate, so `--fps` is ignored there; `--quality` maps
to bitrate and `--scale` to `--size`. Recordings past the per-invocation
limit on API < 34 are stitched across `screenrecord` restarts
automatically.

Rotating the display mid-recording stops capture on Android (an MP4 track
can't change frame size). Press Ctrl+C to stop; sim-use finalises the MP4
before exiting.

### Accessibility inspection

Expand Down
131 changes: 131 additions & 0 deletions Sources/AndroidBackend/Adb/AdbStreamingProcess.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: Apache-2.0
import Foundation
import os

/// A long-running `adb` child process whose stdout is delivered
/// incrementally as raw binary chunks — the streaming counterpart to
/// `Adb.run`, which buffers all output into a `String` and only returns
/// after exit. Used to pipe `adb exec-out screenrecord --output-format=h264`
/// straight into the H.264 muxer.
///
/// Follows the same drain / termination-semaphore patterns as `Adb.run`
/// (readabilityHandler to avoid the 64 KB pipe deadlock, exit-driven wakeup,
/// ENOENT → `BridgeError.adbMissing`).
public final class AdbStreamingProcess: Sendable {
/// The non-Sendable subprocess objects plus the byte tallies, confined
/// to the lock.
private struct State {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
var stdoutByteCount: Int64 = 0
var stderrBuffer = Data()
}

private let adbPath: String
private let arguments: [String]
private let onStdout: @Sendable (Data) -> Void
private let onStderr: (@Sendable (String) -> Void)?
private let state = OSAllocatedUnfairLock(initialState: State())
private let exitSemaphore = DispatchSemaphore(value: 0)

public init(
adbPath: String,
arguments: [String],
onStdout: @escaping @Sendable (Data) -> Void,
onStderr: (@Sendable (String) -> Void)? = nil
) {
self.adbPath = adbPath
self.arguments = arguments
self.onStdout = onStdout
self.onStderr = onStderr
}

public func start() throws {
let resolvedPath = Adb.resolveOnPATH(adbPath) ?? adbPath
try state.withLock { state in
state.process.executableURL = URL(fileURLWithPath: resolvedPath)
state.process.arguments = arguments
state.process.standardOutput = state.stdoutPipe
state.process.standardError = state.stderrPipe

state.stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let chunk = handle.availableData
guard let self, !chunk.isEmpty else { return }
self.state.withLock { $0.stdoutByteCount += Int64(chunk.count) }
self.onStdout(chunk)
}
state.stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let chunk = handle.availableData
guard let self, !chunk.isEmpty else { return }
self.state.withLock { $0.stderrBuffer.append(chunk) }
}
state.process.terminationHandler = { [exitSemaphore] _ in exitSemaphore.signal() }

do {
try state.process.run()
} catch {
let nsErr = error as NSError
let isMissing =
(nsErr.domain == NSCocoaErrorDomain && nsErr.code == 4) ||
(nsErr.domain == NSPOSIXErrorDomain && nsErr.code == Int(ENOENT))
if isMissing {
throw BridgeError.adbMissing
}
throw BridgeError.transport(underlying: "Failed to spawn adb: \(error.localizedDescription)", serial: nil)
}
}
}

/// Send SIGINT — `screenrecord`'s clean-stop signal (flushes the encoder
/// and finalizes its output before exiting).
public func interrupt() {
state.withLock { state in
if state.process.isRunning { kill(state.process.processIdentifier, SIGINT) }
}
}

public func terminate() {
state.withLock { state in
if state.process.isRunning { state.process.terminate() }
}
}

public var isRunning: Bool { state.withLock { $0.process.isRunning } }

public var stdoutByteCount: Int64 { state.withLock { $0.stdoutByteCount } }

public var collectedStderr: String {
state.withLock { String(data: $0.stderrBuffer, encoding: .utf8) ?? "" }
}

/// Block until the child exits (or `timeout` elapses), then detach the
/// handlers and drain any residual stdout. Returns the exit status, or
/// nil on timeout (after a SIGTERM escalation).
@discardableResult
public func waitForExit(timeout: TimeInterval) -> Int32? {
let timedOut = exitSemaphore.wait(timeout: .now() + timeout) == .timedOut
if timedOut {
state.withLock { if $0.process.isRunning { $0.process.terminate() } }
_ = exitSemaphore.wait(timeout: .now() + 0.5)
}

let (residualOut, status): (Data, Int32) = state.withLock { state in
state.stdoutPipe.fileHandleForReading.readabilityHandler = nil
state.stderrPipe.fileHandleForReading.readabilityHandler = nil
let residual = state.stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let residualErr = state.stderrPipe.fileHandleForReading.readDataToEndOfFile()
state.stdoutByteCount += Int64(residual.count)
state.stderrBuffer.append(residualErr)
return (residual, state.process.terminationStatus)
}

if !residualOut.isEmpty { onStdout(residualOut) }
if let onStderr {
let stderr = collectedStderr
if !stderr.isEmpty { onStderr(stderr) }
}

return timedOut ? nil : status
}
}
Loading
Loading