Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fe75be4
fix: write the outline cache with rename(2) on Linux
iopass4 Sep 11, 2026
ac0df92
fix: report a missing adb binary as adbMissing on Linux
iopass4 Sep 11, 2026
5d616a4
build: make SimUseCore and AndroidBackend imports portable
iopass4 Sep 11, 2026
5accf13
refactor: move the daemon command into SimUseCore
iopass4 Sep 11, 2026
d8c753a
feat: accept `ui` on android describe-ui
iopass4 Sep 11, 2026
3b98b50
build(linux): add an Android-only Linux target graph
iopass4 Sep 11, 2026
f3d0413
feat: reach the bridge through a remote adb server
iopass4 Sep 11, 2026
ad68029
docs: document the Linux build and remote adb servers
iopass4 Sep 11, 2026
f2ddcdf
ci: build and unit-test the Linux target graph
iopass4 Sep 11, 2026
27b558c
Merge branch 'main' into feat/linux-android-build
iopass4 Sep 16, 2026
e3c7365
fix: scope remote adb sessions to their adb server
iopass4 Sep 17, 2026
8175e51
fix: close two gaps in adb connection scoping
iopass4 Sep 17, 2026
4583430
fix: remove forwards by serial and report the bridge host
iopass4 Sep 17, 2026
1e41efc
Merge remote-tracking branch 'origin/main' into feat/linux-android-build
iopass4 Oct 2, 2026
3eb6940
fix: follow ANDROID_ADB_SERVER_ADDRESS and normalise adb identities
iopass4 Oct 2, 2026
db39bd1
docs: note that Linux containers need an init process
iopass4 Oct 2, 2026
03188c3
style: move shouldRestartForVersion's doc comment back
iopass4 Oct 2, 2026
bacbe4e
feat: offer long-press and app-state on Linux
iopass4 Oct 2, 2026
358c995
feat: name verbs the Linux build leaves out
iopass4 Oct 2, 2026
34ea016
fix: remove the forward when the bridge stays unreachable
iopass4 Oct 2, 2026
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
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,36 @@ jobs:
working-directory: bridge
run: ./gradlew --no-daemon testDebugUnitTest

# The Android-only Linux build (the `#if os(Linux)` graph in
# Package.swift): SimUseCore, AndroidBackend and the SimUseLinux
# executable, plus the two test targets that build there. Nothing
# Apple-shaped is involved, so it needs no XCFrameworks and stays off
# the macOS critical path. `make` is macOS-only; call swift directly.
linux:
name: Build and unit tests (Linux, Android only)
runs-on: ubuntu-latest
container: swift:6.3
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7

# The workspace belongs to the runner user, not the container's
# root, so git refuses it and VersionPlugin's `git describe`
# would fall back to no version.
- name: Trust the checkout
run: git config --global --add safe.directory "${GITHUB_WORKSPACE}"

- name: Build
run: swift build

- name: Run unit tests
timeout-minutes: 10
run: swift test

- name: Smoke-check built binary
run: .build/debug/sim-use --help

unit-tests:
name: Unit tests (macOS)
runs-on: macos-26
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ Five SwiftPM targets; dependency graph flows in one direction.
| `iOSSimBackend` | `Sources/iOSSimBackend/` | SimUseCore + SimUseVideo + FB* XCFrameworks + AVFoundation |
| `AndroidBackend` | `Sources/AndroidBackend/` | SimUseCore + SimUseVideo + ArgumentParser |
| `SimUse` (executable) | `Sources/SimUse/` | SimUseCore + SimUseVideo + iOSSimBackend + AndroidBackend + FB* |
| `SimUseLinux` (executable, Linux only) | `Sources/SimUseLinux/` | SimUseCore + AndroidBackend |

On Linux, `Package.swift` declares only `SimUseCore`, `AndroidBackend` (without its three video-capture files) and `SimUseLinux` — see `docs/linux.md`. Linux-only shims live in `Sources/SimUseCore/LinuxCompat.swift`; `swift build` / `swift test` run there directly.

`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 @@ -124,7 +127,7 @@ Four verbs are iOS-only (`key`, `key-combo`, `key-sequence`, `batch`) — no top

### Daemon

`SimUseExecutableCommand.run()` forwards UDID-scoped verbs to a per-UDID auto-spawned daemon (`Sources/SimUseCore/Daemon/`). Platform-agnostic — both iOS and Android verbs route through it. Key regression test: `Tests/DaemonCommandParserInjectionTests.swift`.
`SimUseExecutableCommand.run()` forwards UDID-scoped verbs to a per-UDID auto-spawned daemon (`Sources/SimUseCore/Daemon/`). Platform-agnostic — both iOS and Android verbs route through it. The `daemon` command itself lives in SimUseCore; each executable installs `Daemon.installPlatformHooks` (its root parser plus backend probes) at launch. A daemon is keyed by UDID alone, so each executable also installs `DaemonClient.connectionIdentityProvider`: the daemon reports the identity it started under in `_ping`, and the client's version gate restarts it when its own differs (Android: adb server + bridge host, via `BridgeConnection`). Key regression test: `Tests/DaemonCommandParserInjectionTests.swift`.

## Android development

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

### Added

- Linux build, Android only: `Package.swift` now declares an `#if os(Linux)` target graph (`SimUseCore`, `AndroidBackend`, and a new `SimUseLinux` executable) that builds with a Swift 6 toolchain and drives Android devices exactly as the macOS build does — `ui`, `tap`, `long-press`, `type`, `swipe`, `screenshot`, `app-state`, the `android` namespace and the per-device daemon. `long-press` and `app-state` share their Android implementation with the macOS commands, so flags and JSON match. iOS verbs, video capture, the Viewer and `init` stay macOS-only; on Linux, invoking one prints an error naming the verb instead of a misleading parse error. The macOS manifest is unchanged. `scripts/install-linux.sh` builds and installs it; see `docs/linux.md`.
- Remote adb servers: when `ADB_SERVER_SOCKET` points at another machine (`tcp:<host>:<port>`, e.g. WSL using the Windows host's adb), or `ANDROID_ADB_SERVER_ADDRESS` does when no socket is set, the bridge is reached on that host — where `adb forward` actually listens — instead of `127.0.0.1`. `SIM_USE_BRIDGE_HOST` overrides the host. The cached forward and bridge token are scoped to that connection: switching adb servers restarts the device's daemon and re-creates them, and a cached forward is reused only while `adb forward --list` still lists it, so the token is never replayed to another host or an unrelated listener. This covers every adb serial, including wireless-debugging serials too long for the Android UDID heuristic, and a failed `adb forward --list` is reported instead of opening another forward and leaving the old one registered. Equivalent spellings of the default server (no variables, `ANDROID_ADB_SERVER_PORT=5037`, `tcp:localhost:5037`) count as one connection, so switching between them neither restarts the daemon nor leaves a forward behind; when only the bridge host changes, the old forward is removed before a new one is opened. A forward on a different adb server is left in place (see `docs/linux.md`). `android init` reports the host it reached the bridge on as `http_endpoint` (and `bridgeHost` in `--json`) instead of always printing `localhost`.
- `sim-use android describe-ui` accepts `ui`, matching the top-level verb.
- `tap` accepts `--coordinate-space` for explicit `-x/-y` / `--point` coordinates, matching `swipe` and `touch`. `ui` transforms outline (visual-space) coordinates through the same orientation calibration selector taps already ride, so a coordinate read off `describe-ui` lands where it was read on a rotated device; `native` (device-native portrait, the default) is unchanged and stays zero-cost. Aliases and selectors are already resolved in ui space and reject the flag rather than silently ignoring it. Batch `tap` steps ride the batch-wide calibration, and `android tap` accepts the flag for parity and ignores it. The bundled skill's Act table and pitfalls row now name `tap` alongside `swipe`/`touch`. (#142)
- `stream-video --format h264` now works on iOS, not just Android: a native `FBVideoStream` H.264 stream, carried in MPEG-TS, copied straight to stdout with no host-side codec pass. On a booted iPhone 17 Pro this delivers ~24 fps at ~1.3 MB per 6 s, against the screenshot loop's ~4.1 fps at ~11 MB — roughly 5.9x the frame rate for an eighth of the bytes. Preview it with `sim-use stream-video --format h264 --device $UDID | ffplay -f mpegts -probesize 32768 -i -`. It streams at a constant `--fps`, default 30 (the screenshot formats keep their default of 10). MPEG-TS rather than the Android leg's bare Annex B on purpose: Annex B carries no presentation timestamps, so a player paces on a guessed frame rate (ffprobe reads a bare stream as 25 fps regardless of `--fps`) and a 30 fps capture drifts ~5 frames further behind every second, unbounded — minutes of lag within a few minutes of viewing. MPEG-TS carries PTS/DTS, and the player's queue measured empty across a 60 s run. The top-level `stream-video` no longer rejects `h264` for iOS targets, so the only platform-exclusive format left is the experimental iOS-only `bgra`. (#132)

Expand All @@ -27,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Android: a command that cannot reach the bridge even after reconnecting (wrong `SIM_USE_BRIDGE_HOST`, a firewall, an un-initialised device) now removes the forward it opened before reporting the error. No session had been saved for that forward, so nothing ever removed it and each failing attempt left one more forward on the adb server.
- Android: dropping a stale bridge forward now names the device (`adb -s <serial> forward --remove`). With more than one device on the adb server, the old command was rejected with "more than one device/emulator" and the error was swallowed, so every reconnect left another forward behind.
- iOS `stream-video --format h264` / `bgra`: a consumer that stops reading (ffplay paused, a wedged downstream tool) no longer makes Ctrl-C hang. The native stream went through idb's blocking file writer, which sat in `write(2)` on the encoder thread with `stopStreaming()` waiting behind it, so the command could not end short of SIGKILL. It now writes through the same interruptible stdout sink as Android — one that waits for room and checks cancellation between waits, and leaves the descriptor's flags untouched so the terminal (or stderr under `2>&1`) is never switched into non-blocking mode. A consumer that closes the pipe now ends the stream in an orderly way (stop messages, exit 0) instead of the process dying of SIGPIPE.
- The bundled skill preflight now reports a content warning instead of an unqualified pass when `ui` succeeds with a `remote_content_recovery` advisory or with an empty outline on a simulator, emulator or Android device. These reads still exit successfully. The warning points at a new *Missing app controls in the outline* pitfall, which covers the iOS simulator `ApplicationAccessibilityEnabled` launch-time setting. (#139 — thanks @SunsetWan!)
- The bundled skill preflight now rejects an older `sim-use` CLI before device discovery and prints the Homebrew upgrade command, instead of misdiagnosing newly documented device types as disconnected.
Expand Down
85 changes: 85 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,90 @@ let fbLinkerFlags: [String] = [
["-Xlinker", "-weak_library", "-Xlinker", "\(privateHeadersDir)/\($0)/\($0).tbd"]
}

#if os(Linux)
// Linux builds the Android half only. The Android backend drives the
// device through `adb` plus the bridge APK's HTTP server and needs none
// of the Apple frameworks below: no iOS backends, no FB* XCFrameworks,
// and no SimUseVideo (AVFoundation), so `record-video` / `stream-video`
// are left out. `sim-use` is built from `SimUseLinux`, which exposes the
// Android verbs at the top level plus the `android` namespace.
let package = Package(
name: "SimUse",
products: [
.executable(
name: "sim-use",
targets: ["SimUseLinux"]
),
.library(
name: "SimUseCore",
targets: ["SimUseCore"]
),
.library(
name: "AndroidBackend",
targets: ["AndroidBackend"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
],
targets: [
.target(
name: "SimUseCore",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
path: "Sources/SimUseCore",
plugins: ["VersionPlugin"]
),
.target(
name: "AndroidBackend",
dependencies: [
"SimUseCore",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
path: "Sources/AndroidBackend",
// Host-side video capture: the two verbs and the streaming
// process only they use.
exclude: [
"Adb/AdbStreamingProcess.swift",
"Verbs/AndroidRecordVideoCommand.swift",
"Verbs/AndroidStreamVideoCommand.swift",
],
resources: [
.copy("Resources"),
]
),
.executableTarget(
name: "SimUseLinux",
dependencies: [
"SimUseCore",
"AndroidBackend",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
path: "Sources/SimUseLinux",
plugins: ["VersionPlugin"]
),
.testTarget(
name: "SimUseCoreTests",
dependencies: ["SimUseCore"],
path: "Tests/SimUseCoreTests"
),
.testTarget(
name: "AndroidBackendTests",
dependencies: ["AndroidBackend", "SimUseCore"],
path: "Tests/AndroidBackendTests",
exclude: [
"AndroidRecordVideoArgumentTests.swift",
]
),
.plugin(
name: "VersionPlugin",
capability: .buildTool(),
path: "Plugins/VersionPlugin"
),
]
)
#else
let package = Package(
name: "SimUse",
platforms: [
Expand Down Expand Up @@ -273,3 +357,4 @@ let package = Package(
),
]
)
#endif
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ Device Hub is open — the HID transport is selected automatically per
boot. Note that Xcode 27 no longer bundles Simulator.app; the one from an
Xcode 26.x install still works, as does Device Hub itself.

### Linux (Android only)

The Android backend also builds on Linux with a Swift 6 toolchain — iOS
verbs, video capture, the Viewer and `init` are macOS-only. See [docs/linux.md](docs/linux.md) for what is available, how to
build and install, and reaching devices from WSL.

```bash
scripts/install-linux.sh
```

### Agent skill

To install the bundled agent skill into your AI client's skill directory:
Expand All @@ -145,7 +155,7 @@ sim-use drives both **iOS Simulators** and **Android devices / emulators** throu
* `emulator-5554` / `R5CT1ABCD12` / `192.168.1.5:5555` → Android device
* `00008130-...` (8-16 hex) / 40-hex → physical iPhone/iPad (restricted verb set)

For Android, run `sim-use android init --device <serial>` once to install the bridge APK. See `AGENTS.md` for Android toolchain setup.
For Android, run `sim-use android init --device <serial>` once to install the bridge APK. See `AGENTS.md` for Android toolchain setup. When `ADB_SERVER_SOCKET` points at an adb server on another machine (`tcp:<host>:<port>`), sim-use reaches the bridge on that host, since that is where `adb forward` listens; `SIM_USE_BRIDGE_HOST` overrides the host.

**Physical iPhones and iPads** (experimental) route through the same top-level verbs — `sim-use ui`, `sim-use tap '#<id>' / --label` and `sim-use screenshot` work against a plugged-in device's UDID. The channel exposes no element geometry, so it trades coordinate taps, swipes and gestures for accessibility actions, and the remaining verbs reject with the reason and the nearest alternative — never assume capability parity; see the [capability matrix](#physical-ios-devices). sim-use installs and signs no runner and needs no Developer Disk Image; `ui`/`tap` need the foreground app to be development-signed (`get-task-allow=true`), `screenshot` captures any screen.

Expand Down
42 changes: 36 additions & 6 deletions Sources/AndroidBackend/Adb/Adb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,36 @@ public struct Adb: Sendable {
return nil
}

public func forwardRemove(localPort: Int) throws {
_ = try run(args: ["forward", "--remove", "tcp:\(localPort)"])
/// One `adb forward --list` row: `<serial> tcp:<local> <remote>`.
public struct Forward: Equatable, Sendable {
public let serial: String
public let localPort: Int
public let remote: String
}

/// Forwards registered on the adb server this process talks to.
public func forwards() throws -> [Forward] {
Self.parseForwardList(try run(args: ["forward", "--list"]).stdout)
}

/// Parses `adb forward --list`, keeping rows whose local side is a TCP
/// port and skipping anything that does not have that shape.
static func parseForwardList(_ output: String) -> [Forward] {
output.split(separator: "\n").compactMap { line in
let fields = line.split(separator: " ", omittingEmptySubsequences: true)
guard fields.count == 3, fields[1].hasPrefix("tcp:"),
let port = Int(fields[1].dropFirst("tcp:".count)), port > 0 else {
return nil
}
return Forward(serial: String(fields[0]), localPort: port, remote: String(fields[2]))
}
}

/// `adb -s <serial> forward --remove tcp:<local>`. The serial is
/// required in practice: with more than one device on the adb server,
/// adb rejects the command without it ("more than one device/emulator").
public func forwardRemove(serial: String, localPort: Int) throws {
_ = try run(args: ["-s", serial, "forward", "--remove", "tcp:\(localPort)"])
}

@discardableResult
Expand Down Expand Up @@ -193,12 +221,14 @@ public struct Adb: Sendable {
} catch {
// Common failure: binary missing or not executable.
// macOS reports this as NSCocoaErrorDomain code 4
// (NSFileNoSuchFileError); other POSIX hosts surface it
// as ENOENT in NSPOSIXErrorDomain. Map both so CI on
// Linux behaves the same as a developer's Mac.
// (NSFileNoSuchFileError); swift-corelibs-foundation on
// Linux as code 260 (NSFileReadNoSuchFileError); other
// POSIX hosts may surface ENOENT in NSPOSIXErrorDomain.
// Map all three so Linux behaves the same as a developer's Mac.
let nsErr = error as NSError
let isMissing =
(nsErr.domain == NSCocoaErrorDomain && nsErr.code == 4) ||
(nsErr.domain == NSCocoaErrorDomain && nsErr.code == CocoaError.fileNoSuchFile.rawValue) ||
(nsErr.domain == NSCocoaErrorDomain && nsErr.code == CocoaError.fileReadNoSuchFile.rawValue) ||
(nsErr.domain == NSPOSIXErrorDomain && nsErr.code == Int(ENOENT))
if isMissing {
throw BridgeError.adbMissing
Expand Down
Loading