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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ 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 pinned idb ref is bumped from `76639e4d` (2025-05-29) to `1f6943f8` (2026-07-23) — a migration, not a version bump (upstream Swiftified the HID layer and much of FBControlCore, moved the build to XcodeGen-generated projects producing **static** frameworks, and rewrote the video layer in async Swift). What it buys:
- **Device Hub workflows now just work on Xcode 27.** The HID transport is selected automatically per simulator boot: simulators whose legacy HID was suppressed at boot (booted while Device Hub was open) are driven through dtuhidd's CoreDevice HID service (upstream's new `FBSimulatorDTUHIDTransport`), everything else through the legacy SimulatorKit path. `tap`/`type`/`swipe` deliver in both states — no reboot dance.
- **Multi-touch verbs ride upstream's native two-finger API** (`FBSimulatorHIDEvent.twoFingerTouch`); the whole gesture is assembled into one composite event so the transport is drained exactly once per gesture, as the DTUHID transport requires.
- **The FB* frameworks are statically linked into the sim-use binary.** The release payload shrinks to the executable plus the two resource bundles (no `Frameworks/` directory, no framework codesigning), and the entire dev-loop rpath machinery — `Package.swift` slice rpaths, `scripts/stage-fb-frameworks.sh`, release-time rpath stripping — is retired. `Package.swift` instead wires the idb `PrivateHeaders` Clang module maps, force-loads the three ObjC-bearing archives (their category-only members export no referenced symbol and would otherwise be dropped — deliberately per-archive rather than `-ObjC`, which makes Xcode 26.5-built binaries trip a Swift task-allocator abort via CompanionUtilities), and weak-links the CoreSimulator / AccessibilityPlatformTranslation `.tbd` stubs at the final link.
- All four `patches/idb/` patches are retired: `xcode27-simulatorkit-sharedframeworks` and `multi-touch-spike` are superseded upstream; `headerpad-shims` targeted the removed checked-in project's shim build phase; `fbprocess-runtime-rename` renamed a class that no longer exists (and was never referenced by sim-use).

### 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.
- Building the XCFrameworks now requires [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`) — the idb checkout generates `FBSimulatorControl.xcodeproj` from `project.yml`. The frameworks are built without library evolution, so `build_products/` is locked to the toolchain that produced it: re-run `./scripts/build.sh dev` after switching Xcode versions (CI keys its cache on the Xcode version for the same reason).

### Removed
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,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