Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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. Key regression test: `Tests/DaemonCommandParserInjectionTests.swift`.

## Android development

Expand Down
3 changes: 3 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`, `type`, `swipe`, `screenshot`, the `android` namespace and the per-device daemon. iOS verbs, video capture, `long-press`, `app-state`, the Viewer and `init` stay macOS-only. 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), 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.
- `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 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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ 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, `long-press`, `app-state`, 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 +156,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
10 changes: 6 additions & 4 deletions Sources/AndroidBackend/Adb/Adb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,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
40 changes: 39 additions & 1 deletion Sources/AndroidBackend/Bridge/BridgeClient.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
import Foundation
#if canImport(FoundationNetworking)
// URLSession / URLRequest / HTTPURLResponse live in a separate module in
// swift-corelibs-foundation; on Apple platforms this import does not exist.
import FoundationNetworking
#endif
import SimUseCore

/// HTTP client that speaks the bridge wire protocol served by the
Expand Down Expand Up @@ -349,6 +354,39 @@ public final class BridgeClient: @unchecked Sendable {
BridgeSessionStore.write(session, udid: serial)
}

/// Host the `adb forward` listener lives on, resolved once per process.
static let bridgeHost = resolveBridgeHost(environment: ProcessInfo.processInfo.environment)

/// `adb forward tcp:0 tcp:8080` opens its local socket on the machine
/// running the **adb server**, which is only this machine when the
/// server is local. When `ADB_SERVER_SOCKET` points at a remote server
/// (`tcp:<host>:<port>` — e.g. WSL using the Windows host's adb so USB
/// devices stay visible), the forward listens over there and loopback
/// has nothing behind it, so the bridge host follows that server.
/// `SIM_USE_BRIDGE_HOST` overrides both. The result is ready to
/// interpolate into a URL authority: IPv6 hosts come back bracketed,
/// with any zone id's `%` escaped.
static func resolveBridgeHost(environment: [String: String]) -> String {
let loopback = "127.0.0.1"
let host: String
if let explicit = environment["SIM_USE_BRIDGE_HOST"], !explicit.isEmpty {
host = explicit
} else if let socket = environment["ADB_SERVER_SOCKET"], socket.hasPrefix("tcp:") {
// "tcp:<port>" is a local server; only "tcp:<host>:<port>" is remote.
let rest = socket.dropFirst("tcp:".count)
guard let separator = rest.lastIndex(of: ":") else { return loopback }
host = String(rest[..<separator])
} else {
return loopback
}
let bare = host.hasPrefix("[") && host.hasSuffix("]") ? String(host.dropFirst().dropLast()) : host
if bare.isEmpty || bare == "localhost" || bare == "127.0.0.1" || bare == "::1" {
return loopback
}
guard bare.contains(":") else { return bare }
return "[\(bare.replacingOccurrences(of: "%", with: "%25"))]"
}

private func buildRequest(
method: String,
path: String,
Expand All @@ -357,7 +395,7 @@ public final class BridgeClient: @unchecked Sendable {
contentType: String?
) throws -> URLRequest {
let port = try currentLocalPort()
guard let url = URL(string: "http://127.0.0.1:\(port)\(path)") else {
guard let url = URL(string: "http://\(Self.bridgeHost):\(port)\(path)") else {
throw BridgeError.transport(underlying: "Could not build URL for \(path)", serial: nil)
}
var req = URLRequest(url: url)
Expand Down
5 changes: 5 additions & 0 deletions Sources/AndroidBackend/Bridge/BridgeClientRegistry.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
import Foundation
#if canImport(FoundationNetworking)
// URLSession / URLRequest / HTTPURLResponse live in a separate module in
// swift-corelibs-foundation; on Apple platforms this import does not exist.
import FoundationNetworking
#endif

/// Process-global `BridgeClient` registry, keyed by adb serial.
///
Expand Down
5 changes: 5 additions & 0 deletions Sources/AndroidBackend/Bridge/BridgeEnvelope.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
import Foundation
#if canImport(FoundationNetworking)
// URLSession / URLRequest / HTTPURLResponse live in a separate module in
// swift-corelibs-foundation; on Apple platforms this import does not exist.
import FoundationNetworking
#endif
import SimUseCore

/// Common response envelope emitted by every bridge endpoint (built in
Expand Down
14 changes: 12 additions & 2 deletions Sources/AndroidBackend/Verbs/AndroidCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,21 @@ public struct AndroidCommand: ParsableCommand {
AndroidScrollCommand.self,
AndroidButtonCommand.self,
AndroidScreenshotCommand.self,
AndroidRecordVideoCommand.self,
AndroidStreamVideoCommand.self,
] + videoSubcommands + [
AndroidTypeCommand.self,
]
)

// Video capture muxes/encodes on the host through SimUseVideo
// (AVFoundation), which the Linux build does not have.
#if canImport(SimUseVideo)
private static let videoSubcommands: [ParsableCommand.Type] = [
AndroidRecordVideoCommand.self,
AndroidStreamVideoCommand.self,
]
#else
private static let videoSubcommands: [ParsableCommand.Type] = []
#endif

public init() {}
}
3 changes: 2 additions & 1 deletion Sources/AndroidBackend/Verbs/AndroidDescribeUICommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import SimUseCore
public struct AndroidDescribeUICommand: SimUseExecutableCommand {
public static let configuration = CommandConfiguration(
commandName: "describe-ui",
abstract: "Describe the Android device's current UI via the bridge."
abstract: "Describe the Android device's current UI via the bridge.",
aliases: ["ui"]
)

@OptionGroup public var device: AndroidDeviceOptions
Expand Down
5 changes: 5 additions & 0 deletions Sources/AndroidBackend/Verbs/AndroidDeviceController.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
import Foundation
#if canImport(FoundationNetworking)
// URLSession / URLRequest / HTTPURLResponse live in a separate module in
// swift-corelibs-foundation; on Apple platforms this import does not exist.
import FoundationNetworking
#endif
import SimUseCore

/// High-level Android backend operations: describe-ui, devices listing,
Expand Down
Loading