diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6bb2e3fc..3c7195c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 66643156..6917393d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b06b713e..d5291237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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::`, 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) @@ -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 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. diff --git a/Package.swift b/Package.swift index 3b5b2260..52e54b18 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ @@ -273,3 +357,4 @@ let package = Package( ), ] ) +#endif diff --git a/README.md b/README.md index fd64eb4e..f4eb2973 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 ` once to install the bridge APK. See `AGENTS.md` for Android toolchain setup. +For Android, run `sim-use android init --device ` 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::`), 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 '#' / --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. diff --git a/Sources/AndroidBackend/Adb/Adb.swift b/Sources/AndroidBackend/Adb/Adb.swift index 4e0b86bf..7c0a93a1 100644 --- a/Sources/AndroidBackend/Adb/Adb.swift +++ b/Sources/AndroidBackend/Adb/Adb.swift @@ -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: ` tcp: `. + 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 forward --remove tcp:`. 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 @@ -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 diff --git a/Sources/AndroidBackend/Bridge/BridgeClient.swift b/Sources/AndroidBackend/Bridge/BridgeClient.swift index b15082ea..93e8280b 100644 --- a/Sources/AndroidBackend/Bridge/BridgeClient.swift +++ b/Sources/AndroidBackend/Bridge/BridgeClient.swift @@ -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 @@ -52,8 +57,15 @@ public final class BridgeClient: @unchecked Sendable { public let connectionTimeout: TimeInterval public let readTimeout: TimeInterval + /// Adb server + bridge host this client's traffic goes to. + public let connection: BridgeConnection + private let sessionHome: URL + private let lock = NSLock() private var cachedLocalPort: Int? + /// Set while `cachedLocalPort` came from disk and has not yet been + /// confirmed as this serial's forward on the current adb server. + private var restoredPortUnconfirmed = false private var cachedAuthToken: String? private var verifiedProtocolVersion: Bool = false private var cachedDisplay: DisplayMetrics? @@ -63,10 +75,14 @@ public final class BridgeClient: @unchecked Sendable { serial: String, urlSession: URLSession? = nil, connectionTimeout: TimeInterval = 5, - readTimeout: TimeInterval = 15 + readTimeout: TimeInterval = 15, + environment: [String: String] = ProcessInfo.processInfo.environment, + sessionHome: URL = BridgeSessionStore.homeDirectory ) { self.adb = adb self.serial = serial + self.connection = BridgeConnection(environment: environment) + self.sessionHome = sessionHome let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = readTimeout config.timeoutIntervalForResource = readTimeout @@ -82,12 +98,27 @@ public final class BridgeClient: @unchecked Sendable { // Hydrate from disk cache so successive CLI invocations skip the // ~1s `adb shell content query` + ~50ms `adb forward` startup. - // We don't validate up-front — the first HTTP attempt will hit - // 401 / ECONNREFUSED if the cached values are stale, and we - // re-bootstrap then. - if let cached = BridgeSessionStore.read(udid: serial) { - self.cachedAuthToken = cached.token - self.cachedLocalPort = cached.localPort + // Only a session created under this same connection is a + // candidate: its port is a forward on *that* adb server, so + // replaying it against another one (or a cache that predates the + // field) could hand the token to whatever listens there. The port + // itself is still confirmed with `adb forward --list` before the + // first request (see `currentLocalPort`), because a listener that + // answers is not proof the forward is still ours; a stale token on + // a confirmed forward falls back to the 401 re-fetch. When only + // the bridge host changed, the old forward is on this same adb + // server: remove it best effort and drop the cache. A forward on + // a different adb server is left alone because this process cannot + // address that server. + if let cached = BridgeSessionStore.read(udid: serial, home: sessionHome) { + if cached.connection == connection.identity { + self.cachedAuthToken = cached.token + self.cachedLocalPort = cached.localPort + self.restoredPortUnconfirmed = true + } else if cached.adbServer == connection.adbServer { + _ = try? adb.forwardRemove(serial: serial, localPort: cached.localPort) + BridgeSessionStore.invalidate(udid: serial, home: sessionHome) + } } } @@ -293,23 +324,42 @@ public final class BridgeClient: @unchecked Sendable { lock.lock() defer { lock.unlock() } if let port = cachedLocalPort { - _ = try? adb.forwardRemove(localPort: port) + _ = try? adb.forwardRemove(serial: serial, localPort: port) } cachedLocalPort = nil cachedAuthToken = nil + restoredPortUnconfirmed = false verifiedProtocolVersion = false - BridgeSessionStore.invalidate(udid: serial) + BridgeSessionStore.invalidate(udid: serial, home: sessionHome) } // MARK: - Internal private func currentLocalPort() throws -> Int { lock.lock() - if let port = cachedLocalPort { + let cached = cachedLocalPort + let unconfirmed = restoredPortUnconfirmed + lock.unlock() + if let port = cached { + guard unconfirmed else { return port } + // A failed `adb forward --list` throws rather than counting + // as "gone": the forward may well still be registered, and + // opening another one here would strand it on the adb server. + // The session stays cached for the next call to confirm. + if try restoredForwardIsLive(localPort: port) { + lock.lock(); restoredPortUnconfirmed = false; lock.unlock() + return port + } + // Confirmed not our forward any more (removed, adb server + // restarted, or the port now belongs to something else), so + // there is nothing of ours left to remove. Drop the token with + // it so it is only ever sent through a forward we created. + lock.lock() + cachedLocalPort = nil + cachedAuthToken = nil + restoredPortUnconfirmed = false lock.unlock() - return port } - lock.unlock() let port = try adb.forward(serial: serial, remotePort: Self.defaultRemotePort) lock.lock() @@ -319,6 +369,10 @@ public final class BridgeClient: @unchecked Sendable { return port } + private func restoredForwardIsLive(localPort: Int) throws -> Bool { + try adb.forwards().contains(Adb.Forward(serial: serial, localPort: localPort, remote: "tcp:\(Self.defaultRemotePort)")) + } + private func currentAuthToken() throws -> String { lock.lock() if let token = cachedAuthToken { @@ -344,9 +398,11 @@ public final class BridgeClient: @unchecked Sendable { let session = BridgeSession( token: token, localPort: port, - remotePort: Self.defaultRemotePort + remotePort: Self.defaultRemotePort, + connection: connection.identity, + adbServer: connection.adbServer ) - BridgeSessionStore.write(session, udid: serial) + BridgeSessionStore.write(session, udid: serial, home: sessionHome) } private func buildRequest( @@ -357,7 +413,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://\(connection.bridgeHost):\(port)\(path)") else { throw BridgeError.transport(underlying: "Could not build URL for \(path)", serial: nil) } var req = URLRequest(url: url) @@ -432,8 +488,12 @@ public final class BridgeClient: @unchecked Sendable { // reachable but nothing is answering on the bridge port. One // cheap probe disambiguates the dominant cause — a device that // was never `sim-use android init`-ed — from a genuine drop on - // an already-bootstrapped bridge. - throw connectionFailure(underlying: error) + // an already-bootstrapped bridge. Compute the error before + // dropping caches and removing the retry's forward, which + // would otherwise be stranded without a persisted session. + let failure = connectionFailure(underlying: error) + invalidate() + throw failure } guard let http = response as? HTTPURLResponse else { throw BridgeError.transport(underlying: "Non-HTTP response from bridge", serial: serial) diff --git a/Sources/AndroidBackend/Bridge/BridgeClientRegistry.swift b/Sources/AndroidBackend/Bridge/BridgeClientRegistry.swift index ae93d4d5..1d7b377f 100644 --- a/Sources/AndroidBackend/Bridge/BridgeClientRegistry.swift +++ b/Sources/AndroidBackend/Bridge/BridgeClientRegistry.swift @@ -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. /// diff --git a/Sources/AndroidBackend/Bridge/BridgeConnection.swift b/Sources/AndroidBackend/Bridge/BridgeConnection.swift new file mode 100644 index 00000000..6ef016cc --- /dev/null +++ b/Sources/AndroidBackend/Bridge/BridgeConnection.swift @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import SimUseCore + +/// Where this process's bridge traffic goes: the adb server that owns +/// the `adb forward`, and the host that forward listens on. +/// +/// Both come from the environment (`ADB_SERVER_SOCKET` and friends, which +/// `adb` itself reads, plus `SIM_USE_BRIDGE_HOST`), so two invocations +/// for the same serial can target different adb servers. Anything cached +/// per serial — a persisted `BridgeSession`, a warm daemon — is only +/// valid for the connection it was created under; `identity` is what +/// those caches record and compare. +public struct BridgeConnection: Equatable, Sendable { + /// The canonical adb server `adb` will talk to: the + /// `ADB_SERVER_SOCKET` spec, else `ANDROID_ADB_SERVER_ADDRESS` / + /// `ANDROID_ADB_SERVER_PORT`, else the local server on port 5037. + public let adbServer: String + /// Host the bridge's forwarded port is reached on. + public let bridgeHost: String + + public init(environment: [String: String] = ProcessInfo.processInfo.environment) { + func value(_ key: String) -> String? { + guard let raw = environment[key], !raw.isEmpty else { return nil } + return raw + } + if let socket = value("ADB_SERVER_SOCKET") { + adbServer = Self.normalizeAdbServer(socket) + } else if value("ANDROID_ADB_SERVER_ADDRESS") != nil || value("ANDROID_ADB_SERVER_PORT") != nil { + adbServer = Self.normalizeAdbServer("tcp:\(value("ANDROID_ADB_SERVER_ADDRESS") ?? "localhost"):\(value("ANDROID_ADB_SERVER_PORT") ?? "5037")") + } else { + adbServer = Self.normalizeAdbServer("default") + } + bridgeHost = Self.resolveBridgeHost(environment: environment) + } + + /// Equal adb servers need equal identities so switching equivalent specs + /// neither restarts the daemon nor orphans a forward. + static func normalizeAdbServer(_ spec: String) -> String { + if spec == "default" { return "tcp:localhost:5037" } + guard spec.hasPrefix("tcp:") else { return spec } + let rest = spec.dropFirst(4) + let host: String + let portText: String + if let separator = rest.lastIndex(of: ":") { + host = String(rest[..:` — 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. + /// `ANDROID_ADB_SERVER_ADDRESS` selects the host when no socket is set. + /// `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.isEmpty { + guard socket.hasPrefix("tcp:") else { return loopback } + // "tcp:" is a local server; only "tcp::" is remote. + let rest = socket.dropFirst("tcp:".count) + guard let separator = rest.lastIndex(of: ":") else { return loopback } + host = String(rest[.. String? { + let trimmed = udid.trimmingCharacters(in: .whitespacesAndNewlines) + if PlatformRouter.looksLikeIOSSim(trimmed) || PlatformRouter.looksLikePhysicalIOSDevice(trimmed) { + return nil + } + return BridgeConnection(environment: environment).identity + } +} diff --git a/Sources/AndroidBackend/Bridge/BridgeEnvelope.swift b/Sources/AndroidBackend/Bridge/BridgeEnvelope.swift index 880c796a..1f05296d 100644 --- a/Sources/AndroidBackend/Bridge/BridgeEnvelope.swift +++ b/Sources/AndroidBackend/Bridge/BridgeEnvelope.swift @@ -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 diff --git a/Sources/AndroidBackend/Bridge/BridgeSession.swift b/Sources/AndroidBackend/Bridge/BridgeSession.swift index 5b74ebc7..f0970237 100644 --- a/Sources/AndroidBackend/Bridge/BridgeSession.swift +++ b/Sources/AndroidBackend/Bridge/BridgeSession.swift @@ -7,9 +7,11 @@ import Foundation /// `adb forward` (~50ms) every call. /// /// File layout: `~/.sim-use//bridge.json`. Best-effort — if the -/// file is unreadable or the cached forward is dead, the BridgeClient -/// falls back to a cold bootstrap (`AuthTokenFetcher.fetch` + `adb -/// forward`) and rewrites the cache on success. +/// file is unreadable, was written under another `BridgeConnection` +/// (or before sessions recorded one), or its forward is no longer +/// listed by `adb forward --list`, the BridgeClient falls back to a cold +/// bootstrap (`AuthTokenFetcher.fetch` + `adb forward`) and rewrites the +/// cache on success. /// /// This is a stopgap for V1 while a proper Android daemon (C11) is /// pending. The daemon will subsume this when it lands. @@ -17,12 +19,19 @@ public struct BridgeSession: Codable, Equatable, Sendable { public let token: String public let localPort: Int public let remotePort: Int + /// `BridgeConnection.identity` the forward and token were created + /// under. Nil in caches written before sessions recorded it. + public let connection: String? + /// Canonical adb server owning the forward; nil in older caches. + public let adbServer: String? public let writtenAt: Date - public init(token: String, localPort: Int, remotePort: Int, writtenAt: Date = Date()) { + public init(token: String, localPort: Int, remotePort: Int, connection: String? = nil, adbServer: String? = nil, writtenAt: Date = Date()) { self.token = token self.localPort = localPort self.remotePort = remotePort + self.connection = connection + self.adbServer = adbServer self.writtenAt = writtenAt } } diff --git a/Sources/AndroidBackend/Verbs/AndroidAppStateCommand.swift b/Sources/AndroidBackend/Verbs/AndroidAppStateCommand.swift new file mode 100644 index 00000000..bcd59dea --- /dev/null +++ b/Sources/AndroidBackend/Verbs/AndroidAppStateCommand.swift @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +import ArgumentParser +import Foundation +import SimUseCore + +/// Android implementation shared by the Linux root and macOS forwarder. +public struct AndroidAppStateCommand: SimUseExecutableCommand { + public static let configuration = CommandConfiguration( + commandName: "app-state", + abstract: AppStateHelp.abstract + ) + + @OptionGroup public var device: AndroidDeviceOptions + + @Option( + name: .customLong("bundle-id"), + help: ArgumentHelp( + AppStateHelp.bundleId, + valueName: "id" + ) + ) + public var bundleId: String? + + @Flag( + name: .customLong("reset"), + help: ArgumentHelp(AppStateHelp.reset) + ) + public var reset: Bool = false + + @OptionGroup public var json: JSONOutputOptions + + public var jsonOutput: Bool { json.enabled } + + public var simulatorUDIDForDaemon: String? { device.resolved } + + public var managesLivenessState: Bool { true } + + public mutating func resolveDeferredArguments() throws { + try device.resolve() + try AndroidCommandDeviceValidation.requireAndroid(serial: device.resolved, verb: "app-state") + } + + public init() {} + + public typealias ExecutionResult = AppStateReport.ExecutionResult + + public func execute() async throws -> ExecutionResult { + try Self.performAppState(serial: device.resolved, bundleId: bundleId, reset: reset) + } + + public static func performAppState( + serial: String, + bundleId: String?, + reset: Bool + ) throws -> ExecutionResult { + guard let snapshot = AndroidProcessLister.appSnapshot(serial: serial) else { + throw CLIError(errorDescription: + "Could not read the running-process list from \(serial). " + + "The device may be busy, mid-boot, or disconnected — retry in a moment.") + } + if reset { + DaemonDispatch.processTracker.reset(to: snapshot, now: Date()) + } + return AppStateReport.buildResult( + platform: "android", snapshot: snapshot, bundleId: bundleId, didReset: reset + ) + } + + public func format(_ result: ExecutionResult) -> CommandOutput { + AppStateReport.format(result) + } +} diff --git a/Sources/AndroidBackend/Verbs/AndroidCommand.swift b/Sources/AndroidBackend/Verbs/AndroidCommand.swift index d5283bc5..248ab920 100644 --- a/Sources/AndroidBackend/Verbs/AndroidCommand.swift +++ b/Sources/AndroidBackend/Verbs/AndroidCommand.swift @@ -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() {} } \ No newline at end of file diff --git a/Sources/AndroidBackend/Verbs/AndroidCommandDeviceValidation.swift b/Sources/AndroidBackend/Verbs/AndroidCommandDeviceValidation.swift new file mode 100644 index 00000000..3ab1807d --- /dev/null +++ b/Sources/AndroidBackend/Verbs/AndroidCommandDeviceValidation.swift @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +import SimUseCore + +/// Rejects iOS identifiers on Android commands that take the shared +/// cross-platform flag surface, before they reach adb. +enum AndroidCommandDeviceValidation { + static func requireAndroid(serial: String, verb: String) throws { + guard !PlatformRouter.looksLikeIOSSim(serial), + !PlatformRouter.looksLikePhysicalIOSDevice(serial) else { + throw CLIError(errorDescription: "\(verb) in the Android backend drives Android only; pass an adb serial.") + } + } +} diff --git a/Sources/AndroidBackend/Verbs/AndroidDescribeUICommand.swift b/Sources/AndroidBackend/Verbs/AndroidDescribeUICommand.swift index ee1d8df5..57c6928e 100644 --- a/Sources/AndroidBackend/Verbs/AndroidDescribeUICommand.swift +++ b/Sources/AndroidBackend/Verbs/AndroidDescribeUICommand.swift @@ -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 diff --git a/Sources/AndroidBackend/Verbs/AndroidDeviceController.swift b/Sources/AndroidBackend/Verbs/AndroidDeviceController.swift index 194172ff..86270daa 100644 --- a/Sources/AndroidBackend/Verbs/AndroidDeviceController.swift +++ b/Sources/AndroidBackend/Verbs/AndroidDeviceController.swift @@ -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, @@ -137,6 +142,7 @@ public final class AndroidDeviceController { public let protocolVersion: Int public let authTokenInstalled: Bool public let portForward: Int + public let bridgeHost: String } /// Runs the 6-step bridge bootstrap: install APK → register the @@ -183,7 +189,8 @@ public final class AndroidDeviceController { bridgeVersion: ping.bridgeVersion, protocolVersion: ping.protocolVersion, authTokenInstalled: tokenOK, - portForward: BridgeClient.defaultRemotePort + portForward: BridgeClient.defaultRemotePort, + bridgeHost: client.connection.bridgeHost ) } diff --git a/Sources/AndroidBackend/Verbs/AndroidInitCommand.swift b/Sources/AndroidBackend/Verbs/AndroidInitCommand.swift index 0c7ab7a8..79dda644 100644 --- a/Sources/AndroidBackend/Verbs/AndroidInitCommand.swift +++ b/Sources/AndroidBackend/Verbs/AndroidInitCommand.swift @@ -16,7 +16,7 @@ public struct AndroidInitCommand: SimUseExecutableCommand { @Option(name: .customLong("apk-path"), help: "Override the bundled APK location (advanced).") public var apkPathOverride: String? - @Flag(name: .customLong("json"), help: "Emit the unified `{ok, data: {serial, bridgeVersion, protocolVersion, authTokenInstalled, portForward}}` envelope.") + @Flag(name: .customLong("json"), help: "Emit the unified `{ok, data: {serial, bridgeVersion, protocolVersion, authTokenInstalled, portForward, bridgeHost}}` envelope.") public var jsonOutput: Bool = false public init() {} @@ -27,6 +27,9 @@ public struct AndroidInitCommand: SimUseExecutableCommand { public let protocolVersion: Int public let authTokenInstalled: Bool public let portForward: Int + /// Host the bridge's forwarded port is reached on: localhost, or the + /// adb server's host when `ADB_SERVER_SOCKET` points elsewhere. + public let bridgeHost: String } /// `init` is a one-time bootstrap that runs before any daemon @@ -50,7 +53,8 @@ public struct AndroidInitCommand: SimUseExecutableCommand { bridgeVersion: report.bridgeVersion, protocolVersion: report.protocolVersion, authTokenInstalled: report.authTokenInstalled, - portForward: report.portForward + portForward: report.portForward, + bridgeHost: report.bridgeHost ) } @@ -60,7 +64,7 @@ public struct AndroidInitCommand: SimUseExecutableCommand { " bridge_version \(result.bridgeVersion)", " protocol_version \(result.protocolVersion)", " auth_token \(result.authTokenInstalled ? "ok" : "missing")", - " http_endpoint localhost (forward → device tcp:\(result.portForward))", + " http_endpoint \(result.bridgeHost) (forward → device tcp:\(result.portForward))", ]) } } \ No newline at end of file diff --git a/Sources/AndroidBackend/Verbs/AndroidLongPressCommand.swift b/Sources/AndroidBackend/Verbs/AndroidLongPressCommand.swift new file mode 100644 index 00000000..fb69c047 --- /dev/null +++ b/Sources/AndroidBackend/Verbs/AndroidLongPressCommand.swift @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +import ArgumentParser +import Foundation +import SimUseCore + +/// Android implementation shared by the Linux root and macOS forwarder. +public struct AndroidLongPressCommand: SimUseExecutableCommand { + public static let configuration = CommandConfiguration( + commandName: "long-press", + abstract: LongPressHelp.abstract, + discussion: LongPressHelp.discussion + ) + + @Argument(help: ArgumentHelp( + LongPressHelp.alias, + valueName: "alias" + )) + public var alias: String? + + @OptionGroup public var targeting: TapTargetingOptions + + @Option( + name: .customLong("duration"), + help: ArgumentHelp( + LongPressHelp.duration + ) + ) + public var duration: Double = 0.8 + + @OptionGroup public var timing: TapTimingOptions + + @OptionGroup public var multiTouch: MultiTouchOptions + + @OptionGroup public var device: AndroidDeviceOptions + + @OptionGroup public var json: JSONOutputOptions + + public var jsonOutput: Bool { json.enabled } + + public mutating func resolveDeferredArguments() throws { + try device.resolve() + try AndroidCommandDeviceValidation.requireAndroid(serial: device.resolved, verb: "long-press") + } + + public var simulatorUDIDForDaemon: String? { device.resolved } + + /// Android emits only the x/y keys of the iOS tap result, as doubles. + public struct ExecutionResult: Codable { + public let x: Double + public let y: Double + } + + /// Same shared group validators as `Tap` / `IOSSimTapCommand` — + /// same selector / coordinate / delay constraints apply, and the + /// duration default (0.8) is in the [0, 10] range so the validator + /// accepts it. ArgumentParser does not auto-validate nested option + /// groups, so these explicit calls are load-bearing + /// (`TapValidationParityTests` pins that every surface makes them). + public func validate() throws { + try targeting.validate(alias: alias) + try timing.validate() + try TapTimingOptions.validateDuration(duration) + try multiTouch.validate() + } + + public init() {} + + public func execute() async throws -> ExecutionResult { + try Self.performLongPress( + serial: device.resolved, alias: alias, targeting: targeting, + duration: duration, multiTouch: multiTouch + ) + } + + public func format(_ result: ExecutionResult) -> CommandOutput { + .line("✓ Long-press at (\(result.x), \(result.y)) completed successfully") + } + + /// Preserve the existing Android path: timing flags are validated by + /// the command but do not introduce delays or selector polling here. + public static func performLongPress( + serial: String, + alias: String?, + targeting: TapTargetingOptions, + duration: Double, + multiTouch: MultiTouchOptions + ) throws -> ExecutionResult { + let frameFilter: SelectorFrameFilter? = { + guard !targeting.frameSpecs.isEmpty else { return nil } + return (try? SelectorFrameFilter(specs: targeting.frameSpecs)) + }() + let selector = AndroidSelector( + id: targeting.elementID, + label: targeting.elementLabel, + labelContains: targeting.labelContains, + labelRegex: targeting.labelRegex, + value: targeting.elementValue, + valueContains: nil, + valueRegex: nil, + elementType: targeting.elementType, + frame: frameFilter + ) + let explicit = try TapCoordinateResolver.resolve(x: targeting.pointX, y: targeting.pointY, point: targeting.point) + let result = try AndroidTapCommand.performTap( + udid: serial, + alias: alias, + x: explicit.map { Int($0.x.rounded()) }, + y: explicit.map { Int($0.y.rounded()) }, + selector: selector, + duration: duration, + multiTouch: multiTouch + ) + return ExecutionResult(x: Double(result.x), y: Double(result.y)) + } +} diff --git a/Sources/SimUse/Commands/AppState.swift b/Sources/SimUse/Commands/AppState.swift index 92f683ad..55263a64 100644 --- a/Sources/SimUse/Commands/AppState.swift +++ b/Sources/SimUse/Commands/AppState.swift @@ -16,7 +16,7 @@ import iOSSimBackend struct AppState: SimUseExecutableCommand { static let configuration = CommandConfiguration( commandName: "app-state", - abstract: "Report which apps are running on the device; --reset re-baselines crash detection." + abstract: AppStateHelp.abstract ) @OptionGroup var device: DeviceOptions @@ -24,7 +24,7 @@ struct AppState: SimUseExecutableCommand { @Option( name: .customLong("bundle-id"), help: ArgumentHelp( - "Report running|not_running for this bundle id / package only.", + AppStateHelp.bundleId, valueName: "id" ) ) @@ -32,7 +32,7 @@ struct AppState: SimUseExecutableCommand { @Flag( name: .customLong("reset"), - help: "Re-baseline crash detection to the current process set and clear any pending crash signal. Use after intentionally relaunching the app, attaching to an already-running app, or accepting a crash." + help: ArgumentHelp(AppStateHelp.reset) ) var reset: Bool = false @@ -50,32 +50,17 @@ struct AppState: SimUseExecutableCommand { // MARK: - Result - struct AppProcess: Codable, Equatable { - let bundleId: String - let pid: Int - } - - struct AppStateQuery: Codable, Equatable { - let bundleId: String - /// "running" | "not_running". Liveness only — the - /// foreground-vs-background distinction needs foreground info the - /// lightweight probe does not carry and is left to describe-ui. - let state: String - } - - struct ExecutionResult: Codable { - let platform: String - let apps: [AppProcess] - let query: AppStateQuery? - let didReset: Bool - } + typealias AppProcess = AppStateReport.AppProcess + typealias AppStateQuery = AppStateReport.AppStateQuery + typealias ExecutionResult = AppStateReport.ExecutionResult func execute() async throws -> ExecutionResult { let udid = device.resolved - let isAndroid: Bool switch PlatformRouter.resolve(udid: udid) { case .android: - isAndroid = true + return try AndroidAppStateCommand.performAppState( + serial: udid, bundleId: bundleId, reset: reset + ) case .iOSDevice: throw TargetCapabilityError.physicalIOS( verb: "app-state", @@ -83,11 +68,9 @@ struct AppState: SimUseExecutableCommand { alternative: "Read the foreground app with `sim-use ui` instead; its outline header and content reflect what is currently on screen." ) case .iOSSim, .none: - isAndroid = false + break } - let probed = isAndroid - ? AndroidProcessLister.appSnapshot(serial: udid) - : BundleIdentifierResolver.appSnapshot(udid: udid) + let probed = BundleIdentifierResolver.appSnapshot(udid: udid) // A nil probe means the process list could not be read (device // busy, mid-boot, or disconnected). Surface that as an error @@ -106,7 +89,7 @@ struct AppState: SimUseExecutableCommand { } return Self.buildResult( - platform: isAndroid ? "android" : "ios", + platform: "ios", snapshot: snapshot, bundleId: bundleId, didReset: reset @@ -120,36 +103,12 @@ struct AppState: SimUseExecutableCommand { bundleId: String?, didReset: Bool ) -> ExecutionResult { - let apps = snapshot.appsByPid - .map { AppProcess(bundleId: $0.value, pid: $0.key) } - .sorted { $0.bundleId < $1.bundleId } - let query: AppStateQuery? = bundleId.map { id in - let state: String - switch snapshot.liveness(ofBundleId: id) { - case .alive: state = "running" - case .dead: state = "not_running" - } - return AppStateQuery(bundleId: id, state: state) - } - return ExecutionResult(platform: platform, apps: apps, query: query, didReset: didReset) + AppStateReport.buildResult( + platform: platform, snapshot: snapshot, bundleId: bundleId, didReset: didReset + ) } func format(_ result: ExecutionResult) -> CommandOutput { - var lines: [String] = [] - if let query = result.query { - lines.append("\(query.bundleId): \(query.state)") - } - if result.apps.isEmpty { - lines.append("No tracked app processes running.") - } else { - lines.append("Running apps (\(result.apps.count)):") - for app in result.apps { - lines.append(" \(app.bundleId) pid=\(app.pid)") - } - } - if result.didReset { - lines.append("Crash-detection baseline reset.") - } - return .lines(lines) + AppStateReport.format(result) } -} \ No newline at end of file +} diff --git a/Sources/SimUse/Commands/LongPress.swift b/Sources/SimUse/Commands/LongPress.swift index 8ac173a0..5981de9c 100644 --- a/Sources/SimUse/Commands/LongPress.swift +++ b/Sources/SimUse/Commands/LongPress.swift @@ -24,35 +24,12 @@ import iOSSimBackend struct LongPress: SimUseExecutableCommand { static let configuration = CommandConfiguration( commandName: "long-press", - abstract: "Long-press an element by alias, selector, or coordinate (default hold 0.8s).", - discussion: """ - Sugar over `sim-use tap --duration ` with `--duration` - defaulting to 0.8s, the standard threshold that triggers - long-press recognisers on both iOS and Android (above - `UILongPressGestureRecognizer.minimumPressDuration` and - `ViewConfiguration.getLongPressTimeout()`). Useful for - chat-bubble action menus, launcher icon popups, and any UI - where the action-press distinction matters. - - Targeting is identical to `tap` — same alias / selector / - coordinate forms, same precedence, same describe-ui cache. - See `sim-use tap --help` for the full workflow walkthrough; - every targeting form documented there works here, just with - a longer default hold. - - Examples: - sim-use describe-ui # populate the outline cache - sim-use long-press @5 # 0.8s hold on outline entry 5 - sim-use long-press '#3' # long-press the 3rd cell of the dominant list - sim-use long-press --label "Photos" # exact AXLabel - sim-use long-press --label-contains "メキシコ" --element-type Button # substring + type filter - sim-use long-press --label-regex '^[0-9]{1,2}:[0-9]{2}(\\s(AM|PM))?$' # anchored regex (timestamp labels) - sim-use long-press -x 540 -y 1268 --duration 1.2 # custom hold, raw coordinates - """ + abstract: LongPressHelp.abstract, + discussion: LongPressHelp.discussion ) @Argument(help: ArgumentHelp( - "Shortcut alias for the element to long-press. `@N` selects the N-th entry of the most recent `describe-ui` snapshot; `#N` selects the N-th cell of the dominant detected list; `#N@M` selects the N-th cell of the M-th list (1-indexed, M=1 = dominant); `#` resolves an AXUniqueId via the live AX tree. Exclusive with --point/-x/-y and --id/--label/--value.", + LongPressHelp.alias, valueName: "alias" )) var alias: String? @@ -62,7 +39,7 @@ struct LongPress: SimUseExecutableCommand { @Option( name: .customLong("duration"), help: ArgumentHelp( - "How long to hold the touch in seconds. Defaults to 0.8 — clears the OS long-press threshold on both iOS (~0.5s) and Android (~0.5s) with margin. Increase if a stubborn recogniser needs more time; values above 10s are rejected." + LongPressHelp.duration ) ) var duration: Double = 0.8 @@ -147,31 +124,10 @@ struct LongPress: SimUseExecutableCommand { /// (`AccessibilityService.dispatchGesture` cannot hold a stroke /// across calls). private func executeAndroid() throws -> ExecutionResult { - let frameFilter: SelectorFrameFilter? = { - guard !targeting.frameSpecs.isEmpty else { return nil } - return (try? SelectorFrameFilter(specs: targeting.frameSpecs)) - }() - let selector = AndroidSelector( - id: targeting.elementID, - label: targeting.elementLabel, - labelContains: targeting.labelContains, - labelRegex: targeting.labelRegex, - value: targeting.elementValue, - valueContains: nil, - valueRegex: nil, - elementType: targeting.elementType, - frame: frameFilter + let result = try AndroidLongPressCommand.performLongPress( + serial: device.resolved, alias: alias, targeting: targeting, + duration: duration, multiTouch: multiTouch ) - let explicit = try TapCoordinateResolver.resolve(x: targeting.pointX, y: targeting.pointY, point: targeting.point) - let result = try AndroidTapCommand.performTap( - udid: device.resolved, - alias: alias, - x: explicit.map { Int($0.x.rounded()) }, - y: explicit.map { Int($0.y.rounded()) }, - selector: selector, - duration: duration, - multiTouch: multiTouch - ) - return ExecutionResult(x: Double(result.x), y: Double(result.y)) + return ExecutionResult(x: result.x, y: result.y) } -} \ No newline at end of file +} diff --git a/Sources/SimUse/main.swift b/Sources/SimUse/main.swift index 41f25d1d..5d018bf1 100644 --- a/Sources/SimUse/main.swift +++ b/Sources/SimUse/main.swift @@ -18,9 +18,10 @@ import iOSDeviceBackend // // The daemon-side command parser (used by `DaemonDispatch.handle` when // the daemon server routes requests through ArgumentParser) is wired -// inside `Daemon.Start.run()`. The daemon SERVER process is always the -// one that needs it; client-side `daemon stop` / `daemon status` and -// non-daemon commands never touch DaemonDispatch. +// by `Daemon.installPlatformHooks`, which `Daemon.Start.run()` calls. +// The daemon SERVER process is always the one that needs it; +// client-side `daemon stop` / `daemon status` and non-daemon commands +// never touch DaemonDispatch. /// iOS-only verb names that 0.5.x (pre-Path-B) exposed at the top /// level. Typing `sim-use ` for any of these today produces a @@ -44,6 +45,13 @@ enum EntryPoint { // value; dev / dirty builds leave it nil so the check is a // no-op locally. BridgeClient.expectedBridgeVersion = ReleaseVersion.normalize(VERSION) + Daemon.installPlatformHooks = installDaemonPlatformHooks + // A per-device daemon only serves clients configured for the same + // connection (for Android: the adb server and bridge host), so both + // sides of the daemon protocol need to compute it. + DaemonClient.connectionIdentityProvider = { udid in + BridgeConnection.daemonConnectionIdentity(udid: udid) + } if let typed = CommandLine.arguments.dropFirst().first, let canonical = iOSOnlyVerbRedirects[typed] { @@ -64,6 +72,37 @@ enum EntryPoint { } await SimUse.main() } + + @MainActor + private static func installDaemonPlatformHooks(deviceId: String) { + // Wire SimUse's ArgumentParser as the daemon's command parser + // so DaemonDispatch can route requests without owning a + // back-reference to the top-level command tree. + DaemonDispatch.commandParser = { args in + try SimUse.parseAsRoot(args) + } + // Register the iOS-specific cleanup that fires when an iOS + // verb raises `staleSimulator`. The daemon module lives in + // SimUseCore and stays platform-neutral; the actual HID + // teardown lives in iOSSimBackend. Android-only daemons + // never raise `staleSimulator` so this hook is a no-op for + // them — it's still installed to keep the code path uniform. + DaemonDispatch.platformStaleCleanup = { udid in + HIDInteractor.clearHIDConnection(for: udid) + } + // Wire the platform-appropriate live-app probe so the daemon + // can detect a target process disappearing between commands + // (issue #81). The daemon serves a single device, so the + // probe is bound to this UDID/serial for its lifetime. + if PlatformRouter.looksLikeAndroid(deviceId) { + // `livenessSnapshot` caches the rarely-changing third-party + // package allowlist, so each command costs one `adb shell` + // (the fresh `ps`), not two (issue #81 perf follow-up). + DaemonDispatch.livenessProbe = { AndroidProcessLister.livenessSnapshot(serial: deviceId) } + } else { + DaemonDispatch.livenessProbe = { BundleIdentifierResolver.appSnapshot(udid: deviceId) } + } + } } struct SimUse: AsyncParsableCommand { diff --git a/Sources/SimUseCore/AppStateReport.swift b/Sources/SimUseCore/AppStateReport.swift new file mode 100644 index 00000000..cca1db9c --- /dev/null +++ b/Sources/SimUseCore/AppStateReport.swift @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Shared app-state result mapping and formatting for macOS and Linux/Android +/// commands, keeping their JSON representation identical. +public enum AppStateReport { + public struct AppProcess: Codable, Equatable { + public let bundleId: String + public let pid: Int + + public init(bundleId: String, pid: Int) { + self.bundleId = bundleId + self.pid = pid + } + } + + public struct AppStateQuery: Codable, Equatable { + public let bundleId: String + /// "running" | "not_running". Liveness only — the + /// foreground-vs-background distinction needs foreground info the + /// lightweight probe does not carry and is left to describe-ui. + public let state: String + + public init(bundleId: String, state: String) { + self.bundleId = bundleId + self.state = state + } + } + + public struct ExecutionResult: Codable, Equatable { + public let platform: String + public let apps: [AppProcess] + public let query: AppStateQuery? + public let didReset: Bool + } + + /// Pure snapshot → result mapping. Exposed for tests. + public static func buildResult( + platform: String, + snapshot: AppSnapshot, + bundleId: String?, + didReset: Bool + ) -> ExecutionResult { + let apps = snapshot.appsByPid + .map { AppProcess(bundleId: $0.value, pid: $0.key) } + .sorted { $0.bundleId < $1.bundleId } + let query: AppStateQuery? = bundleId.map { id in + let state: String + switch snapshot.liveness(ofBundleId: id) { + case .alive: state = "running" + case .dead: state = "not_running" + } + return AppStateQuery(bundleId: id, state: state) + } + return ExecutionResult(platform: platform, apps: apps, query: query, didReset: didReset) + } + + public static func format(_ result: ExecutionResult) -> CommandOutput { + var lines: [String] = [] + if let query = result.query { + lines.append("\(query.bundleId): \(query.state)") + } + if result.apps.isEmpty { + lines.append("No tracked app processes running.") + } else { + lines.append("Running apps (\(result.apps.count)):") + for app in result.apps { + lines.append(" \(app.bundleId) pid=\(app.pid)") + } + } + if result.didReset { + lines.append("Crash-detection baseline reset.") + } + return .lines(lines) + } +} + +/// Help text for `app-state`, shared like `LongPressHelp`. +public enum AppStateHelp { + public static let abstract = "Report which apps are running on the device; --reset re-baselines crash detection." + public static let bundleId = "Report running|not_running for this bundle id / package only." + public static let reset = "Re-baseline crash detection to the current process set and clear any pending crash signal. Use after intentionally relaunching the app, attaching to an already-running app, or accepting a crash." +} diff --git a/Sources/SimUseCore/Daemon/DaemonClient.swift b/Sources/SimUseCore/Daemon/DaemonClient.swift index 22cb65c7..79185d0f 100644 --- a/Sources/SimUseCore/Daemon/DaemonClient.swift +++ b/Sources/SimUseCore/Daemon/DaemonClient.swift @@ -1,5 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation /// Client-side transport for the per-UDID daemon. Encapsulates: @@ -41,14 +45,19 @@ public enum DaemonClient { // Version gate: if a daemon is live but was spawned from a // different binary (git checkout + rebuild mid-session, stale - // dev iteration, etc.), restart it now so the client never - // dispatches real work to a server that no longer reflects - // the CLI's code. Returns early when the daemon is either + // dev iteration, etc.), or under a different connection (e.g. an + // Android daemon started against another adb server), restart it + // now so the client never dispatches real work to a server that + // no longer reflects the CLI's code or configuration. Returns early when the daemon is either // absent, already compatible, or the probe itself was // inconclusive — in all those cases the existing fast/slow // paths handle the rest. if case .probablyAlive = liveness, - await ensureCompatibleDaemon(paths: paths, currentVersion: VERSION) { + await ensureCompatibleDaemon( + paths: paths, + currentVersion: VERSION, + currentConnectionIdentity: connectionIdentity(for: udid) + ) { liveness = paths.filesystemLiveness() trace("post-gate liveness=\(liveness)") } @@ -222,17 +231,27 @@ public enum DaemonClient { /// hard enough that the existing transport-error handling should /// take over. /// + /// The same probe compares the daemon's connection identity with + /// `currentConnectionIdentity` (see `connectionIdentityProvider`): a + /// daemon started under another connection is restarted too. + /// /// Opt-out: `SIM_USE_DAEMON_VERSION_CHECK=0` in the environment - /// disables the gate entirely, falling back to pre-gate behaviour - /// for emergency use. + /// disables the version comparison, falling back to pre-gate + /// behaviour for emergency use. It does not disable the connection + /// comparison, which guards against sending commands to the wrong + /// device server. public static func ensureCompatibleDaemon( paths: DaemonPaths, - currentVersion: String + currentVersion: String, + currentConnectionIdentity: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment ) async -> Bool { - if ProcessInfo.processInfo.environment["SIM_USE_DAEMON_VERSION_CHECK"] == "0" { + let checkVersion = environment["SIM_USE_DAEMON_VERSION_CHECK"] != "0" + guard checkVersion || currentConnectionIdentity != nil else { return false } let daemonVersion: String + let daemonConnectionIdentity: String? do { let responseData = try sendToExistingDaemon( socketPath: paths.socketURL.path, @@ -243,20 +262,47 @@ public enum DaemonClient { .decode(DaemonClientSuccessPayload.self, from: responseData) .data daemonVersion = ping.simUseVersion + daemonConnectionIdentity = ping.connectionIdentity } catch { trace("version probe failed: \(error); letting fast-path take over") return false } - guard shouldRestartForVersion(daemon: daemonVersion, current: currentVersion) else { + if checkVersion, shouldRestartForVersion(daemon: daemonVersion, current: currentVersion) { + trace("version mismatch daemon=\(daemonVersion) cli=\(currentVersion); restarting") + } else if shouldRestartForConnection(daemon: daemonConnectionIdentity, current: currentConnectionIdentity) { + // Deliberately not tracing the identities: they can carry + // host addresses, and trace output ends up in shared logs. + trace("connection identity differs from the daemon's; restarting") + } else { return false } - - trace("version mismatch daemon=\(daemonVersion) cli=\(currentVersion); restarting") await stopDaemon(paths: paths, timeout: 2.0) return true } + /// Decides whether a live daemon's connection identity disqualifies it + /// for a client whose own identity is `current`. A nil `current` means + /// the target has no connection-dependent state (never restarts); a + /// daemon reporting nil predates the field and cannot prove a match. + public static func shouldRestartForConnection(daemon: String?, current: String?) -> Bool { + guard let current else { return false } + return daemon != current + } + + /// What a per-device daemon depends on beyond its UDID — for Android, + /// the adb server and bridge host its environment selects. Installed + /// by each executable's entry point (backends live above SimUseCore); + /// nil means the target has no such dependency. Read on both sides: + /// the daemon reports its value in `_ping`, the client compares. + nonisolated(unsafe) public static var connectionIdentityProvider: ((_ udid: String) -> String?)? + + /// The identity `connectionIdentityProvider` gives `udid`, or nil when + /// no provider is installed. + public static func connectionIdentity(for udid: String) -> String? { + connectionIdentityProvider?(udid) + } + /// Pure comparator: decides whether an extant daemon should be /// torn down for a fresh one. Mirrors exact-string equality for /// now; empty or whitespace-only strings on either side are diff --git a/Sources/SimUse/Commands/Daemon.swift b/Sources/SimUseCore/Daemon/DaemonCommand.swift similarity index 89% rename from Sources/SimUse/Commands/Daemon.swift rename to Sources/SimUseCore/Daemon/DaemonCommand.swift index 300c21d8..d7d5b25d 100644 --- a/Sources/SimUse/Commands/Daemon.swift +++ b/Sources/SimUseCore/Daemon/DaemonCommand.swift @@ -1,22 +1,39 @@ // SPDX-License-Identifier: Apache-2.0 import ArgumentParser +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation -import AndroidBackend -import iOSSimBackend -import SimUseCore /// Management CLI for the per-UDID auto-start daemon. The daemon itself /// is hosted in-process by `sim-use daemon start`; `stop` and `status` are /// client-side commands that talk to already-running daemons via the /// shared wire protocol (`_stop` / `_ping`). -struct Daemon: AsyncParsableCommand { - static let configuration = CommandConfiguration( +/// +/// Lives in SimUseCore so every `sim-use` executable (macOS and the +/// Android-only Linux build) registers the same command. What differs per +/// executable — its root command parser and the backend-specific probes — +/// is injected through `installPlatformHooks`. +public struct Daemon: AsyncParsableCommand { + public static let configuration = CommandConfiguration( commandName: "daemon", abstract: "Manage the sim-use background daemon that amortises per-call init cost.", subcommands: [Start.self, Stop.self, Status.self] ) + /// Wires the host executable into the daemon, called by `daemon start` + /// with the resolved device id before the server accepts connections. + /// It must set `DaemonDispatch.commandParser` to the executable's root + /// parser, and may set `DaemonDispatch.platformStaleCleanup` and + /// `DaemonDispatch.livenessProbe` — those live in backend modules + /// SimUseCore cannot import. Each entry point installs this once at + /// launch. + nonisolated(unsafe) public static var installPlatformHooks: (@MainActor (_ deviceId: String) -> Void)? + + public init() {} + // MARK: - start struct Start: AsyncParsableCommand { @@ -42,35 +59,13 @@ struct Daemon: AsyncParsableCommand { @MainActor func run() async throws { - // Wire SimUse's ArgumentParser as the daemon's command parser - // so DaemonDispatch can route requests without owning a - // back-reference to the top-level command tree. Must happen - // before the server starts accepting connections. - DaemonDispatch.commandParser = { args in - try SimUse.parseAsRoot(args) - } - // Register the iOS-specific cleanup that fires when an iOS - // verb raises `staleSimulator`. The daemon module lives in - // SimUseCore and stays platform-neutral; the actual HID - // teardown lives here in iOSSimBackend. Android-only daemons - // never raise `staleSimulator` so this hook is a no-op for - // them — it's still installed to keep the code path uniform. - DaemonDispatch.platformStaleCleanup = { udid in - HIDInteractor.clearHIDConnection(for: udid) - } - // Wire the platform-appropriate live-app probe so the daemon - // can detect a target process disappearing between commands - // (issue #81). The daemon serves a single device, so the - // probe is bound to this UDID/serial for its lifetime. - let deviceId = device.resolved - if PlatformRouter.looksLikeAndroid(deviceId) { - // `livenessSnapshot` caches the rarely-changing third-party - // package allowlist, so each command costs one `adb shell` - // (the fresh `ps`), not two (issue #81 perf follow-up). - DaemonDispatch.livenessProbe = { AndroidProcessLister.livenessSnapshot(serial: deviceId) } - } else { - DaemonDispatch.livenessProbe = { BundleIdentifierResolver.appSnapshot(udid: deviceId) } + // The host executable's root parser and backend probes must be + // wired before the server starts accepting connections — a + // daemon without a parser answers every request with an error. + guard let installPlatformHooks = Daemon.installPlatformHooks else { + throw CLIError(errorDescription: "This sim-use build did not install its daemon platform hooks.") } + installPlatformHooks(device.resolved) let effectiveTimeout: TimeInterval = idleTimeout == 0 ? .infinity : idleTimeout let server = DaemonServer(udid: device.resolved, idleTimeout: effectiveTimeout) try await server.run() diff --git a/Sources/SimUseCore/Daemon/DaemonDispatch.swift b/Sources/SimUseCore/Daemon/DaemonDispatch.swift index 728d4ffa..14fbc9a4 100644 --- a/Sources/SimUseCore/Daemon/DaemonDispatch.swift +++ b/Sources/SimUseCore/Daemon/DaemonDispatch.swift @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import ArgumentParser +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation /// Routes a `DaemonRequest` to the right handler and returns the @@ -25,8 +29,9 @@ public enum DaemonDispatch { /// client establishes a fresh `adb forward` on every request /// already. The shutdown side of `staleSimulatorOutcome` is the /// part Android cares about (clears the zombie daemon so the next - /// call re-spawns clean). Set this once during daemon boot in - /// `Daemon.Start.run()` before the server starts accepting requests. + /// call re-spawns clean). Set this once during daemon boot, from the host + /// executable's `Daemon.installPlatformHooks`, before the server starts + /// accepting requests. public static var platformStaleCleanup: ((String) -> Void)? public struct Snapshot { @@ -34,12 +39,14 @@ public enum DaemonDispatch { public let startTime: Date public let udid: String public let simUseVersion: String + public let connectionIdentity: String? - public init(pid: pid_t, startTime: Date, udid: String, simUseVersion: String) { + public init(pid: pid_t, startTime: Date, udid: String, simUseVersion: String, connectionIdentity: String? = nil) { self.pid = pid self.startTime = startTime self.udid = udid self.simUseVersion = simUseVersion + self.connectionIdentity = connectionIdentity } } @@ -254,7 +261,8 @@ public enum DaemonDispatch { uptimeSeconds: Date().timeIntervalSince(snapshot.startTime), protocolVersion: DaemonProtocol.version, simUseVersion: snapshot.simUseVersion, - udid: snapshot.udid + udid: snapshot.udid, + connectionIdentity: snapshot.connectionIdentity ) let envelope = DaemonSuccessResponse(id: request.id, data: ping) return Outcome(responseData: encode(envelope), shouldStopDaemon: false) diff --git a/Sources/SimUseCore/Daemon/DaemonProtocol.swift b/Sources/SimUseCore/Daemon/DaemonProtocol.swift index 55e56e6c..e7486a9a 100644 --- a/Sources/SimUseCore/Daemon/DaemonProtocol.swift +++ b/Sources/SimUseCore/Daemon/DaemonProtocol.swift @@ -160,12 +160,17 @@ public struct DaemonPingData: Codable { public let protocolVersion: Int public let simUseVersion: String public let udid: String + /// Connection identity the daemon was started under (see + /// `DaemonClient.connectionIdentityProvider`). Nil for targets + /// without one and for daemons that predate the field. + public let connectionIdentity: String? - public init(pid: Int32, uptimeSeconds: Double, protocolVersion: Int, simUseVersion: String, udid: String) { + public init(pid: Int32, uptimeSeconds: Double, protocolVersion: Int, simUseVersion: String, udid: String, connectionIdentity: String? = nil) { self.pid = pid self.uptimeSeconds = uptimeSeconds self.protocolVersion = protocolVersion self.simUseVersion = simUseVersion self.udid = udid + self.connectionIdentity = connectionIdentity } } diff --git a/Sources/SimUseCore/Daemon/DaemonServer.swift b/Sources/SimUseCore/Daemon/DaemonServer.swift index fb2df301..a2442671 100644 --- a/Sources/SimUseCore/Daemon/DaemonServer.swift +++ b/Sources/SimUseCore/Daemon/DaemonServer.swift @@ -1,5 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Dispatch import Foundation @@ -26,6 +30,7 @@ public final class DaemonServer { private let paths: DaemonPaths private let idleTimeout: TimeInterval private let simUseVersion: String + private let connectionIdentity: String? private let startTime = Date() private var listenFd: Int32 = -1 @@ -48,9 +53,14 @@ public final class DaemonServer { udid: String, idleTimeout: TimeInterval = 600, paths: DaemonPaths? = nil, - simUseVersion: String? = nil + simUseVersion: String? = nil, + connectionIdentity: String? = nil ) { self.udid = udid + // Captured at start from this process's environment (inherited + // from the client that spawned the daemon) and reported in `_ping` + // so clients configured for another connection restart it. + self.connectionIdentity = connectionIdentity ?? DaemonClient.connectionIdentity(for: udid) self.paths = paths ?? DaemonPaths(udid: udid) self.idleTimeout = idleTimeout // VERSION is generated per-target by VersionPlugin and is @@ -195,7 +205,8 @@ public final class DaemonServer { pid: getpid(), startTime: startTime, udid: udid, - simUseVersion: simUseVersion + simUseVersion: simUseVersion, + connectionIdentity: connectionIdentity ) logInfo("sim-use-daemon: dispatching cmd=\(request.cmd) (conn=\(connectionId))") let outcome = await DaemonDispatch.handle(request, snapshot: snapshot) diff --git a/Sources/SimUseCore/Daemon/DaemonSocket.swift b/Sources/SimUseCore/Daemon/DaemonSocket.swift index 0f99cb85..01eda5e7 100644 --- a/Sources/SimUseCore/Daemon/DaemonSocket.swift +++ b/Sources/SimUseCore/Daemon/DaemonSocket.swift @@ -1,5 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation /// Thin wrappers around `socket(2)` / `bind(2)` / `connect(2)` for Unix diff --git a/Sources/SimUseCore/DaemonPaths.swift b/Sources/SimUseCore/DaemonPaths.swift index 563bb964..68f79add 100644 --- a/Sources/SimUseCore/DaemonPaths.swift +++ b/Sources/SimUseCore/DaemonPaths.swift @@ -1,5 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation /// Filesystem locations for a per-UDID daemon: its Unix socket, pidfile, diff --git a/Sources/SimUseCore/LinuxBuildVerbRedirects.swift b/Sources/SimUseCore/LinuxBuildVerbRedirects.swift new file mode 100644 index 00000000..8c509ea3 --- /dev/null +++ b/Sources/SimUseCore/LinuxBuildVerbRedirects.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Diagnostics for commands excluded from the Android-only Linux build. +public enum LinuxBuildVerbRedirects { + private static let hints: [String: String] = [ + "ios": "The Linux build drives Android only.", + "ios-device": "The Linux build drives Android only.", + "record-video": "Use the macOS build for video capture.", + "stream-video": "Use the macOS build for video streaming.", + "viewer": "Use the macOS build for the Viewer.", + "init": "Copy skills/sim-use/ by hand to install the skill; use `sim-use android init` to set up an Android device.", + ] + + public static func message(for verb: String) -> String? { + guard let hint = hints[verb] else { return nil } + return "Error: `sim-use \(verb)` is not available in the Linux build (Android only).\nHint: \(hint)\n" + } +} diff --git a/Sources/SimUseCore/LinuxCompat.swift b/Sources/SimUseCore/LinuxCompat.swift new file mode 100644 index 00000000..6be30a38 --- /dev/null +++ b/Sources/SimUseCore/LinuxCompat.swift @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Linux shims for the two Apple-only surfaces SimUseCore reaches for in +// otherwise portable code. Everything here is compiled out on Apple +// platforms and is `internal`, so no other module can see it. + +#if !canImport(Darwin) && canImport(Glibc) +import Glibc + +/// The daemon layer qualifies its POSIX calls (`Darwin.close(fd)`) so +/// they cannot be shadowed by same-named Swift members. Glibc exports +/// the same symbols; this namespace forwards to them so those call +/// sites compile on Linux without an `#if` at each one. +enum Darwin { + @discardableResult static func close(_ fd: Int32) -> Int32 { Glibc.close(fd) } + + @discardableResult static func read(_ fd: Int32, _ buf: UnsafeMutableRawPointer!, _ count: Int) -> Int { + Glibc.read(fd, buf, count) + } + + @discardableResult static func write(_ fd: Int32, _ buf: UnsafeRawPointer!, _ count: Int) -> Int { + Glibc.write(fd, buf, count) + } + + // Glibc types SOCK_STREAM as `__socket_type`, Darwin as Int32. + @discardableResult static func socket(_ domain: Int32, _ type: __socket_type, _ proto: Int32) -> Int32 { + Glibc.socket(domain, Int32(type.rawValue), proto) + } + + @discardableResult static func bind(_ fd: Int32, _ addr: UnsafePointer!, _ len: socklen_t) -> Int32 { + Glibc.bind(fd, addr, len) + } + + @discardableResult static func listen(_ fd: Int32, _ backlog: Int32) -> Int32 { + Glibc.listen(fd, backlog) + } + + @discardableResult static func connect(_ fd: Int32, _ addr: UnsafePointer!, _ len: socklen_t) -> Int32 { + Glibc.connect(fd, addr, len) + } + + @discardableResult static func accept( + _ fd: Int32, + _ addr: UnsafeMutablePointer!, + _ len: UnsafeMutablePointer! + ) -> Int32 { + Glibc.accept(fd, addr, len) + } + + @discardableResult static func kill(_ pid: pid_t, _ signal: Int32) -> Int32 { + Glibc.kill(pid, signal) + } + + static func exit(_ code: Int32) -> Never { Glibc.exit(code) } +} +#endif + +#if !canImport(os) +import Foundation + +/// Stand-in for `os.OSAllocatedUnfairLock`, which `ProcessControl` uses +/// as a cheap synchronous mutex around a value. Only the +/// `init(initialState:)` / `withLock` surface it needs is provided; +/// `NSLock` likewise avoids an actor hop per check. +final class OSAllocatedUnfairLock: @unchecked Sendable { + private let lock = NSLock() + private var state: State + + init(initialState: State) { + self.state = initialState + } + + @discardableResult + func withLock(_ body: (inout State) throws -> R) rethrows -> R { + lock.lock() + defer { lock.unlock() } + return try body(&state) + } +} +#endif diff --git a/Sources/SimUseCore/LongPressHelp.swift b/Sources/SimUseCore/LongPressHelp.swift new file mode 100644 index 00000000..27b18237 --- /dev/null +++ b/Sources/SimUseCore/LongPressHelp.swift @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// Help text for `long-press`, shared by the macOS cross-platform command and +/// the Android one the Linux build registers, so their `--help` cannot drift. +public enum LongPressHelp { + public static let abstract = "Long-press an element by alias, selector, or coordinate (default hold 0.8s)." + public static let discussion = """ + Sugar over `sim-use tap --duration ` with `--duration` + defaulting to 0.8s, the standard threshold that triggers + long-press recognisers on both iOS and Android (above + `UILongPressGestureRecognizer.minimumPressDuration` and + `ViewConfiguration.getLongPressTimeout()`). Useful for + chat-bubble action menus, launcher icon popups, and any UI + where the action-press distinction matters. + + Targeting is identical to `tap` — same alias / selector / + coordinate forms, same precedence, same describe-ui cache. + See `sim-use tap --help` for the full workflow walkthrough; + every targeting form documented there works here, just with + a longer default hold. + + Examples: + sim-use describe-ui # populate the outline cache + sim-use long-press @5 # 0.8s hold on outline entry 5 + sim-use long-press '#3' # long-press the 3rd cell of the dominant list + sim-use long-press --label "Photos" # exact AXLabel + sim-use long-press --label-contains "メキシコ" --element-type Button # substring + type filter + sim-use long-press --label-regex '^[0-9]{1,2}:[0-9]{2}(\\s(AM|PM))?$' # anchored regex (timestamp labels) + sim-use long-press -x 540 -y 1268 --duration 1.2 # custom hold, raw coordinates + """ + public static let alias = "Shortcut alias for the element to long-press. `@N` selects the N-th entry of the most recent `describe-ui` snapshot; `#N` selects the N-th cell of the dominant detected list; `#N@M` selects the N-th cell of the M-th list (1-indexed, M=1 = dominant); `#` resolves an AXUniqueId via the live AX tree. Exclusive with --point/-x/-y and --id/--label/--value." + public static let duration = "How long to hold the touch in seconds. Defaults to 0.8 — clears the OS long-press threshold on both iOS (~0.5s) and Android (~0.5s) with margin. Increase if a stubborn recogniser needs more time; values above 10s are rejected." +} diff --git a/Sources/SimUseCore/OutlineCache.swift b/Sources/SimUseCore/OutlineCache.swift index db0c37b9..6a074a7c 100644 --- a/Sources/SimUseCore/OutlineCache.swift +++ b/Sources/SimUseCore/OutlineCache.swift @@ -159,7 +159,19 @@ public enum OutlineCache { // successful replace, and the cleanup itself is best-effort. defer { try? FileManager.default.removeItem(at: tempURL) } try data.write(to: tempURL, options: [.atomic]) + #if canImport(Darwin) _ = try FileManager.default.replaceItemAt(target, withItemAt: tempURL) + #else + // swift-corelibs-foundation's `replaceItemAt` fails with "file + // doesn't exist" whether or not the target exists, so replace it + // with rename(2) — the same atomic swap, created or overwritten. + guard rename(tempURL.path, target.path) == 0 else { + throw CocoaError(.fileWriteUnknown, userInfo: [ + NSFilePathErrorKey: target.path, + NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(errno)), + ]) + } + #endif } // MARK: - Read diff --git a/Sources/SimUseCore/ProcessControl.swift b/Sources/SimUseCore/ProcessControl.swift index d012692f..c6a07de6 100644 --- a/Sources/SimUseCore/ProcessControl.swift +++ b/Sources/SimUseCore/ProcessControl.swift @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation +#if canImport(os) import os +#endif /// Synchronous cancellation flag used by the streaming/recording commands. /// diff --git a/Sources/SimUseCore/Types/SimUseExecutableCommand.swift b/Sources/SimUseCore/Types/SimUseExecutableCommand.swift index 8c8b54d3..0e8800fd 100644 --- a/Sources/SimUseCore/Types/SimUseExecutableCommand.swift +++ b/Sources/SimUseCore/Types/SimUseExecutableCommand.swift @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import ArgumentParser +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation /// Commands implementing this protocol split their side-effecting work diff --git a/Sources/SimUseLinux/SimUseLinux.swift b/Sources/SimUseLinux/SimUseLinux.swift new file mode 100644 index 00000000..21e3a395 --- /dev/null +++ b/Sources/SimUseLinux/SimUseLinux.swift @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +import AndroidBackend +import ArgumentParser +import Foundation +import Glibc +import SimUseCore + +// MARK: - Main Entry Point +// +// Linux counterpart of `Sources/SimUse/main.swift` for the Android-only +// build (see the `#if os(Linux)` manifest in Package.swift). + +@main +enum EntryPoint { + static func main() async { + // Same bridge-version gate the macOS entry point installs: a + // release build pins the APK it expects, dev builds skip it. + BridgeClient.expectedBridgeVersion = ReleaseVersion.normalize(VERSION) + Daemon.installPlatformHooks = installDaemonPlatformHooks + // A per-device daemon only serves clients configured for the same + // connection (for Android: the adb server and bridge host), so both + // sides of the daemon protocol need to compute it. + DaemonClient.connectionIdentityProvider = { udid in + BridgeConnection.daemonConnectionIdentity(udid: udid) + } + // Verbs this build leaves out would otherwise be parsed as an + // argument to the root command and fail with a misleading + // "Unknown option" error; name them instead. + if let verb = CommandLine.arguments.dropFirst().first, + let message = LinuxBuildVerbRedirects.message(for: verb) { + FileHandle.standardError.write(Data(message.utf8)) + Glibc.exit(64) + } + await SimUseLinux.main() + } + + @MainActor + private static func installDaemonPlatformHooks(deviceId: String) { + DaemonDispatch.commandParser = { args in + try SimUseLinux.parseAsRoot(args) + } + // No `platformStaleCleanup`: that tears down cached iOS HID + // connections, and Android holds no such handle. + // + // `livenessSnapshot` caches the rarely-changing third-party + // package allowlist, so each command costs one `adb shell` + // (the fresh `ps`), not two (issue #81 perf follow-up). + DaemonDispatch.livenessProbe = { AndroidProcessLister.livenessSnapshot(serial: deviceId) } + } +} + +/// Root command of the Linux build. +/// +/// The macOS root registers cross-platform forwarders that route to the +/// iOS or Android backend by UDID shape. With no iOS backend there is +/// nothing to route, so the Android implementations of those verbs are +/// registered at the top level under the same names — `sim-use ui`, +/// `sim-use tap`, `sim-use screenshot` behave as they do on macOS against +/// an adb serial. The `android` namespace is registered too, so +/// `sim-use android init` and the other Android-only verbs keep working. +struct SimUseLinux: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sim-use", + abstract: "Observe and act on Android device / emulator screens (Linux build — Android only).", + discussion: """ + This build ships the Android backend only. iOS verbs, video \ + capture (`record-video`, `stream-video`), the Viewer and the \ + skill installer (`init`) are macOS-only and are absent here. + + First run against a device: + + adb devices + sim-use android init --device + sim-use ui --device + sim-use tap @5 --device + """, + version: VERSION, + subcommands: [ + AndroidDescribeUICommand.self, + AndroidDevicesCommand.self, + AndroidTapCommand.self, + AndroidLongPressCommand.self, + AndroidAppStateCommand.self, + AndroidTypeCommand.self, + AndroidPasteCommand.self, + AndroidKeyboardStateCommand.self, + AndroidSwipeCommand.self, + AndroidButtonCommand.self, + AndroidTouchCommand.self, + AndroidGestureCommand.self, + AndroidMultiTouchCommand.self, + AndroidScreenshotCommand.self, + Daemon.self, + AndroidCommand.self, + ] + ) +} diff --git a/Tests/AndroidBackendTests/AdbParserTests.swift b/Tests/AndroidBackendTests/AdbParserTests.swift index b2429870..d08a0c7d 100644 --- a/Tests/AndroidBackendTests/AdbParserTests.swift +++ b/Tests/AndroidBackendTests/AdbParserTests.swift @@ -72,4 +72,24 @@ final class AdbParserTests: XCTestCase { XCTAssertNil(Adb.parseForwardPort("0")) // 0 is not a valid port XCTAssertNil(Adb.parseForwardPort("-1234")) } -} \ No newline at end of file + + func testParseForwardListKeepsOnlyTcpForwards() { + let output = """ + emulator-5554 tcp:18080 tcp:8080 + 192.0.2.5:5555 tcp:41000 tcp:8080 + emulator-5556 tcp:6100 localabstract:chrome_devtools_remote + garbage line + + """ + let entries = Adb.parseForwardList(output) + XCTAssertEqual(entries, [ + Adb.Forward(serial: "emulator-5554", localPort: 18080, remote: "tcp:8080"), + Adb.Forward(serial: "192.0.2.5:5555", localPort: 41000, remote: "tcp:8080"), + Adb.Forward(serial: "emulator-5556", localPort: 6100, remote: "localabstract:chrome_devtools_remote"), + ]) + } + + func testParseForwardListEmpty() { + XCTAssertEqual(Adb.parseForwardList(""), []) + } +} diff --git a/Tests/AndroidBackendTests/AdbRunnerTests.swift b/Tests/AndroidBackendTests/AdbRunnerTests.swift index 168ca32a..892fe26c 100644 --- a/Tests/AndroidBackendTests/AdbRunnerTests.swift +++ b/Tests/AndroidBackendTests/AdbRunnerTests.swift @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import XCTest +#if canImport(os) import os +#endif @testable import AndroidBackend /// Integration-level tests for `Adb.run(args:)`. These spawn real @@ -48,6 +50,7 @@ final class AdbRunnerTests: XCTestCase { XCTAssertEqual(result.exitCode, 0) } + #if canImport(os) /// `waitForExit` must not finalize the consumer while a readability /// callback has already taken bytes out of the pipe but not yet delivered /// them. The blocked first callback reproduces the shutdown race that @@ -93,6 +96,7 @@ final class AdbRunnerTests: XCTestCase { XCTAssertEqual(waitReturned.wait(timeout: .now() + 2), .success) XCTAssertEqual(String(data: received.withLock { $0 }, encoding: .utf8), "firstsecond") } + #endif /// Sanity check that a process exceeding the timeout still /// surfaces a timeout error (not a deadlock, not a 0-exit diff --git a/Tests/AndroidBackendTests/AndroidJSONEnvelopeTests.swift b/Tests/AndroidBackendTests/AndroidJSONEnvelopeTests.swift index 8f3e94ce..6983d10f 100644 --- a/Tests/AndroidBackendTests/AndroidJSONEnvelopeTests.swift +++ b/Tests/AndroidBackendTests/AndroidJSONEnvelopeTests.swift @@ -98,16 +98,32 @@ final class AndroidJSONEnvelopeTests: XCTestCase { bridgeVersion: "0.6.0", protocolVersion: 3, authTokenInstalled: true, - portForward: 8765 + portForward: 8765, + bridgeHost: "192.0.2.10" ) let bytes = try JSONEnvelopeWriter.encodeSuccess(payload) let json = try XCTUnwrap(String(data: bytes, encoding: .utf8)) XCTAssertEqual( json, - #"{"data":{"authTokenInstalled":true,"bridgeVersion":"0.6.0","portForward":8765,"protocolVersion":3,"serial":"emulator-5554"},"ok":true}"# + #"{"data":{"authTokenInstalled":true,"bridgeHost":"192.0.2.10","bridgeVersion":"0.6.0","portForward":8765,"protocolVersion":3,"serial":"emulator-5554"},"ok":true}"# ) } + /// The text output names the host the bridge is actually reached on, + /// which is not localhost when the adb server is remote. + func testInitTextOutputNamesTheBridgeHost() { + let result = AndroidInitCommand.ExecutionResult( + serial: "emulator-5554", + bridgeVersion: "0.6.0", + protocolVersion: 3, + authTokenInstalled: true, + portForward: 8080, + bridgeHost: "192.0.2.10" + ) + let stdout = AndroidInitCommand().format(result).stdout + XCTAssertTrue(stdout.contains(" http_endpoint 192.0.2.10 (forward → device tcp:8080)\n"), stdout) + } + func testDevicesPayloadEnvelopeShape() throws { let devices: [Device] = [ Device( diff --git a/Tests/AndroidBackendTests/BridgeConnectionScopingTests.swift b/Tests/AndroidBackendTests/BridgeConnectionScopingTests.swift new file mode 100644 index 00000000..f31d0cd4 --- /dev/null +++ b/Tests/AndroidBackendTests/BridgeConnectionScopingTests.swift @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import XCTest +@testable import AndroidBackend + +/// A persisted bridge session (forwarded port + bearer token) is only +/// valid for the adb server that created the forward. These tests pin +/// that a session is never replayed against a different connection — +/// switched adb server, a cache written before sessions recorded their +/// connection, or a cached port now answered by something other than +/// our forward — so the token is never sent to an unrelated listener. +/// +/// `adb` is a recording shell script; HTTP goes through a URLProtocol +/// that answers every request like a live listener would and records +/// where it went and which token it carried. +final class BridgeConnectionScopingTests: XCTestCase { + private let serial = "emulator-5554" + private let serverA = ["ADB_SERVER_SOCKET": "tcp:192.0.2.10:5037"] + private let serverB = ["ADB_SERVER_SOCKET": "tcp:192.0.2.20:5037"] + + private var root: URL! + private var home: URL { root.appendingPathComponent("home", isDirectory: true) } + private var adbLog: URL { root.appendingPathComponent("adb.log") } + private var forwardList: URL { root.appendingPathComponent("forwards.txt") } + private var failForwardList = false + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-bridge-scope-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + RecordingBridgeProtocol.reset() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + // MARK: - Regressions + + /// Session written against server A, client now pointed at server B: + /// the cached port and token must not be used, even though a + /// listener answers on B at A's old port. + func testSwitchingAdbServerRecreatesForwardAndToken() throws { + persistSession(token: "token-A", localPort: 18080, environment: serverA) + + try makeClient(environment: serverB).pressKey(3) + + assertNothingSent(toPort: 18080, withToken: "token-A") + XCTAssertFalse(adbCalls().contains { $0.contains("forward --remove") }) + assertAllRequests(host: "192.0.2.20", port: 18081, authorizedWith: "token-B") + XCTAssertTrue(adbCalls().contains { $0.contains("forward tcp:0 tcp:8080") }, "a new forward must be created") + XCTAssertTrue(adbCalls().contains { $0.contains("content query") }, "the token must be fetched again") + } + + /// A `bridge.json` from before sessions recorded their connection + /// cannot prove which adb server it belongs to. + func testLegacySessionWithoutConnectionIsNotReused() throws { + let legacy = #"{"token":"token-A","localPort":18080,"remotePort":8080,"writtenAt":"2026-09-01T00:00:00Z"}"# + let file = BridgeSessionStore.file(for: serial, home: home) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(legacy.utf8).write(to: file) + + try makeClient(environment: [:]).pressKey(3) + + assertNothingSent(toPort: 18080, withToken: "token-A") + assertAllRequests(host: "127.0.0.1", port: 18081, authorizedWith: "token-B") + } + + /// Same connection, but the cached port is no longer our forward + /// (it is answered by an unrelated listener): responding is not + /// proof of identity, so the forward and token are recreated. + func testUnrelatedListenerOnCachedPortIsNotTrusted() throws { + persistSession(token: "token-A", localPort: 18080, environment: [:]) + try "other-device tcp:18080 tcp:8080\n".write(to: forwardList, atomically: true, encoding: .utf8) + + try makeClient(environment: [:]).pressKey(3) + + assertNothingSent(toPort: 18080, withToken: "token-A") + assertAllRequests(host: "127.0.0.1", port: 18081, authorizedWith: "token-B") + } + + /// The fast path survives: same connection and the forward is still + /// registered for this serial, so no adb bootstrap is repeated. + func testMatchingSessionWithLiveForwardIsReused() throws { + persistSession(token: "token-A", localPort: 18080, environment: serverA) + try "\(serial) tcp:18080 tcp:8080\n".write(to: forwardList, atomically: true, encoding: .utf8) + + try makeClient(environment: serverA).pressKey(3) + + assertAllRequests(host: "192.0.2.10", port: 18080, authorizedWith: "token-A") + XCTAssertFalse(adbCalls().contains { $0.contains("forward tcp:0") }, "no new forward expected") + XCTAssertFalse(adbCalls().contains { $0.contains("content query") }, "no token fetch expected") + } + + /// The rewritten session records the connection it was created for. + func testRecreatedSessionRecordsItsConnection() throws { + persistSession(token: "token-A", localPort: 18080, environment: serverA) + + try makeClient(environment: serverB).pressKey(3) + + let stored = try XCTUnwrap(BridgeSessionStore.read(udid: serial, home: home)) + XCTAssertEqual(stored.localPort, 18081) + XCTAssertEqual(stored.token, "token-B") + XCTAssertEqual(stored.connection, BridgeConnection(environment: serverB).identity) + XCTAssertEqual(stored.adbServer, BridgeConnection(environment: serverB).adbServer) + } + + func testHostOnlyChangeRemovesOldForwardBeforeReplacement() throws { + persistSession(token: "token-A", localPort: 18080, environment: serverA) + var changed = serverA + changed["SIM_USE_BRIDGE_HOST"] = "192.0.2.30" + + try makeClient(environment: changed).pressKey(3) + + let calls = adbCalls() + let removal = try XCTUnwrap(calls.firstIndex(of: "-s \(serial) forward --remove tcp:18080")) + let creation = try XCTUnwrap(calls.firstIndex { $0.contains("forward tcp:0") }) + XCTAssertLessThan(removal, creation) + assertNothingSent(toPort: 18080, withToken: "token-A") + assertAllRequests(host: "192.0.2.30", port: 18081, authorizedWith: "token-B") + } + + func testLegacyUnnormalisedIdentityWithoutServerIsRejectedWithoutRemoval() throws { + let session = BridgeSession(token: "token-A", localPort: 18080, remotePort: 8080, + connection: "adb=default host=127.0.0.1") + BridgeSessionStore.write(session, udid: serial, home: home) + XCTAssertNil(BridgeSessionStore.read(udid: serial, home: home)?.adbServer) + + try makeClient(environment: [:]).pressKey(3) + + assertNothingSent(toPort: 18080, withToken: "token-A") + XCTAssertFalse(adbCalls().contains { $0.contains("forward --remove") }) + } + + func testDefaultPortChangeReusesSession() throws { + persistSession(token: "token-A", localPort: 18080, environment: [:]) + try "\(serial) tcp:18080 tcp:8080\n".write(to: forwardList, atomically: true, encoding: .utf8) + + try makeClient(environment: ["ANDROID_ADB_SERVER_PORT": "5037"]).pressKey(3) + + assertAllRequests(host: "127.0.0.1", port: 18080, authorizedWith: "token-A") + XCTAssertFalse(adbCalls().contains { $0.contains("forward --remove") || $0.contains("forward tcp:0") }) + } + + func testEquivalentDefaultDaemonIdentities() { + XCTAssertEqual( + BridgeConnection.daemonConnectionIdentity(udid: serial, environment: [:]), + BridgeConnection.daemonConnectionIdentity(udid: serial, environment: ["ANDROID_ADB_SERVER_PORT": "5037"]) + ) + } + + func testAdbServerNormalisation() { + for spec in ["default", "tcp:5037", "tcp:localhost:5037", "tcp:127.0.0.1:5037", "tcp:[::1]:5037"] { + XCTAssertEqual(BridgeConnection.normalizeAdbServer(spec), "tcp:localhost:5037", spec) + } + for address in ["localhost", "127.0.0.1", "::1", "[::1]"] { + for port in [nil, "5037", ""] as [String?] { + var environment = ["ANDROID_ADB_SERVER_ADDRESS": address] + environment["ANDROID_ADB_SERVER_PORT"] = port + XCTAssertEqual(BridgeConnection(environment: environment).adbServer, "tcp:localhost:5037") + } + } + for (spec, expected) in [ + ("tcp:5038", "tcp:localhost:5038"), + ("tcp:192.0.2.10:5037", "tcp:192.0.2.10:5037"), + ("tcp:ADB.EXAMPLE:5037", "tcp:adb.example:5037"), + ("tcp:[fd00::1]:5037", "tcp:[fd00::1]:5037"), + ("tcp:bad-port", "tcp:bad-port"), + ("tcp:localhost:nope", "tcp:localhost:nope"), + ("tcp:localhost:70000", "tcp:localhost:70000"), + ("localfilesystem:/tmp/adb.sock", "localfilesystem:/tmp/adb.sock"), + ] { + XCTAssertEqual(BridgeConnection.normalizeAdbServer(spec), expected) + } + XCTAssertEqual(BridgeConnection(environment: ["ANDROID_ADB_SERVER_ADDRESS": "192.0.2.10"]).adbServer, + "tcp:192.0.2.10:5037") + } + + // MARK: - Connection identity + + func testIdentityTracksEveryVariableThatMovesTheConnection() { + let base = BridgeConnection(environment: [:]).identity + XCTAssertEqual(BridgeConnection(environment: ["ADB_SERVER_SOCKET": ""]).identity, base, "empty equals unset") + for env in [ + serverA, + ["ANDROID_ADB_SERVER_ADDRESS": "192.0.2.10"], + ["ANDROID_ADB_SERVER_PORT": "5038"], + ["SIM_USE_BRIDGE_HOST": "192.0.2.30"], + ] { + XCTAssertNotEqual(BridgeConnection(environment: env).identity, base, "\(env) must change the identity") + } + XCTAssertNotEqual(BridgeConnection(environment: serverA).identity, BridgeConnection(environment: serverB).identity) + } + + func testDaemonIdentityIsScopedToAndroidTargets() { + XCTAssertNotNil(BridgeConnection.daemonConnectionIdentity(udid: serial, environment: serverA)) + XCTAssertNotEqual( + BridgeConnection.daemonConnectionIdentity(udid: serial, environment: serverA), + BridgeConnection.daemonConnectionIdentity(udid: serial, environment: serverB) + ) + XCTAssertNil( + BridgeConnection.daemonConnectionIdentity(udid: "1A2B3C4D-1A2B-1A2B-1A2B-1A2B3C4D5E6F", environment: serverA), + "iOS simulator daemons do not depend on the adb connection" + ) + XCTAssertNil( + BridgeConnection.daemonConnectionIdentity(udid: "00008130-00066D2A10EB8D3A", environment: serverA), + "physical iOS devices do not depend on the adb connection" + ) + } + + /// Any serial adb can hand out reaches the per-device daemon, not just + /// the shapes `PlatformRouter.looksLikeAndroid` recognises — wireless + /// debugging (mDNS) serials run past its 32-character cap. Those + /// daemons talk to adb all the same, so they must be scoped too. + func testDaemonIdentityCoversSerialsOutsideTheAndroidHeuristic() { + for udid in [ + "adb-R58M123ABC-AbCdEf._adb-tls-connect._tcp", + "adb-R58M123ABC-AbCdEf._adb-tls-connect._tcp.", + "192.0.2.5:5555", + ] { + let a = BridgeConnection.daemonConnectionIdentity(udid: udid, environment: serverA) + let b = BridgeConnection.daemonConnectionIdentity(udid: udid, environment: serverB) + XCTAssertNotNil(a, "\(udid) must carry a connection identity") + XCTAssertNotEqual(a, b, "\(udid) must follow the adb server") + } + } + + /// `adb forward --list` failing says nothing about whether the cached + /// forward is gone. Treating it as gone would open another forward on + /// every such failure and strand the old one on the adb server, so + /// the failure surfaces instead and the session is kept for the next + /// call to confirm. + func testForwardListFailureDoesNotOpenAnotherForward() throws { + persistSession(token: "token-A", localPort: 18080, environment: serverA) + failForwardList = true + + XCTAssertThrowsError(try makeClient(environment: serverA).pressKey(3)) + + XCTAssertFalse(adbCalls().contains { $0.contains("forward tcp:0") }, "no new forward expected: \(adbCalls())") + XCTAssertTrue(RecordingBridgeProtocol.requests().isEmpty, "unconfirmed forward must not be used") + let stored = try XCTUnwrap(BridgeSessionStore.read(udid: serial, home: home)) + XCTAssertEqual(stored.localPort, 18080) + XCTAssertEqual(stored.token, "token-A") + } + + /// A forward that is confirmed gone is not ours to reuse, and there + /// is nothing left on the server to clean up: the replacement is the + /// only forward opened. + func testConfirmedMissingForwardOpensExactlyOneReplacement() throws { + persistSession(token: "token-A", localPort: 18080, environment: serverA) + + try makeClient(environment: serverA).pressKey(3) + + XCTAssertEqual(adbCalls().filter { $0.contains("forward tcp:0") }.count, 1, "\(adbCalls())") + assertAllRequests(host: "192.0.2.10", port: 18081, authorizedWith: "token-B") + } + + /// With more than one device on the adb server, `adb forward --remove` + /// without `-s` fails ("more than one device/emulator"), so dropping a + /// forward has to name the serial or the forward is left behind. + func testInvalidateRemovesTheForwardForThisSerial() throws { + let client = try makeClient(environment: serverA) + try client.pressKey(3) + + client.invalidate() + + XCTAssertTrue( + adbCalls().contains("-s \(serial) forward --remove tcp:18081"), + "forward must be removed for this serial: \(adbCalls())" + ) + } + + /// Nothing answers on the bridge port, even after reconnecting: + /// both attempts must remove their forward for this serial, since + /// no persisted session will be available to clean it up later. + func testFailedReconnectRemovesEveryCreatedForward() throws { + RecordingBridgeProtocol.reset(listenerAvailable: false) + let client = try makeClient(environment: serverA) + + XCTAssertThrowsError(try client.ping()) + + let calls = adbCalls() + let creations = calls.filter { $0 == "-s \(serial) forward tcp:0 tcp:8080" } + let removals = calls.filter { $0 == "-s \(serial) forward --remove tcp:18081" } + XCTAssertEqual(creations.count, 2, "initial attempt and reconnect must each open a forward: \(calls)") + XCTAssertEqual(removals.count, creations.count, "every created forward must be removed for this serial: \(calls)") + XCTAssertNil(BridgeSessionStore.read(udid: serial, home: home)) + } + + // MARK: - Fixtures + + private func persistSession(token: String, localPort: Int, environment: [String: String]) { + let session = BridgeSession( + token: token, + localPort: localPort, + remotePort: BridgeClient.defaultRemotePort, + connection: BridgeConnection(environment: environment).identity, + adbServer: BridgeConnection(environment: environment).adbServer + ) + BridgeSessionStore.write(session, udid: serial, home: home) + } + + private func makeClient(environment: [String: String]) throws -> BridgeClient { + let script = root.appendingPathComponent("adb") + let body = """ + #!/bin/sh + echo "$*" >> '\(adbLog.path)' + case "$*" in + *"forward --remove"*) + # Real adb with several devices refuses this without -s. + [ "$1" = "-s" ] || { echo "adb: error: more than one device/emulator" >&2; exit 1; } ;; + *"forward --list"*) \(failForwardList ? "echo 'error: cannot connect to daemon' >&2; exit 1" : "cat '\(forwardList.path)' 2>/dev/null") ;; + *"forward tcp:0 tcp:8080"*) echo 18081 ;; + *"content query"*) echo "Row: 0 result=token-B" ;; + esac + exit 0 + + """ + try body.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RecordingBridgeProtocol.self] + return BridgeClient( + adb: Adb(binaryPath: script.path, defaultTimeout: 5), + serial: serial, + urlSession: URLSession(configuration: config), + environment: environment, + sessionHome: home + ) + } + + private func adbCalls() -> [String] { + ((try? String(contentsOf: adbLog, encoding: .utf8)) ?? "").split(separator: "\n").map(String.init) + } + + private func assertNothingSent(toPort port: Int, withToken token: String, file: StaticString = #filePath, line: UInt = #line) { + let requests = RecordingBridgeProtocol.requests() + XCTAssertFalse(requests.contains { $0.port == port }, "request reached cached port \(port): \(requests)", file: file, line: line) + XCTAssertFalse( + requests.contains { $0.authorization == "Bearer \(token)" }, + "cached token \(token) was sent: \(requests)", file: file, line: line + ) + } + + private func assertAllRequests( + host: String, port: Int, authorizedWith token: String, + file: StaticString = #filePath, line: UInt = #line + ) { + let requests = RecordingBridgeProtocol.requests() + XCTAssertFalse(requests.isEmpty, "no bridge request was made", file: file, line: line) + for request in requests { + XCTAssertEqual(request.host, host, "\(request)", file: file, line: line) + XCTAssertEqual(request.port, port, "\(request)", file: file, line: line) + } + XCTAssertTrue( + requests.contains { $0.authorization == "Bearer \(token)" }, + "expected an authorized request with \(token): \(requests)", file: file, line: line + ) + } +} + +/// Answers bridge requests successfully unless no listener is available, +/// and records where they went. +final class RecordingBridgeProtocol: URLProtocol { + struct Recorded: CustomStringConvertible { + let host: String? + let port: Int? + let path: String + let authorization: String? + var description: String { "\(host ?? "?"):\(port ?? -1)\(path) auth=\(authorization ?? "none")" } + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var recorded: [Recorded] = [] + nonisolated(unsafe) private static var listenerAvailable = true + + static func reset(listenerAvailable: Bool = true) { + lock.lock() + recorded = [] + Self.listenerAvailable = listenerAvailable + lock.unlock() + } + + static func requests() -> [Recorded] { + lock.lock(); defer { lock.unlock() } + return recorded + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let url = request.url + Self.lock.lock() + Self.recorded.append(Recorded( + host: url?.host, + port: url?.port, + path: url?.path ?? "", + authorization: request.value(forHTTPHeaderField: "Authorization") + )) + let listenerAvailable = Self.listenerAvailable + Self.lock.unlock() + + guard listenerAvailable else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + return + } + + let body = url?.path == "/ping" + ? #"{"status":"success","result":"pong","protocol_version":2,"bridge_version":"test"}"# + : #"{"status":"success"}"# + let response = HTTPURLResponse(url: url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/Tests/AndroidBackendTests/BridgeHostTests.swift b/Tests/AndroidBackendTests/BridgeHostTests.swift new file mode 100644 index 00000000..f920125b --- /dev/null +++ b/Tests/AndroidBackendTests/BridgeHostTests.swift @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +@testable import AndroidBackend + +/// `adb forward` listens on the machine running the adb server, so the +/// bridge host follows `ADB_SERVER_SOCKET` when that server is remote +/// (e.g. WSL talking to the Windows host's adb), or +/// `ANDROID_ADB_SERVER_ADDRESS` when `ADB_SERVER_SOCKET` is unset, matching adb. +/// `SIM_USE_BRIDGE_HOST` is an explicit override. +@Suite("BridgeConnection.resolveBridgeHost") +struct BridgeHostTests { + @Test("No adb server override → loopback") + func defaultIsLoopback() { + #expect(BridgeConnection.resolveBridgeHost(environment: [:]) == "127.0.0.1") + } + + @Test("Local adb server sockets stay on loopback", arguments: [ + "tcp:5037", + "tcp:localhost:5037", + "tcp:127.0.0.1:5037", + "tcp:[::1]:5037", + "localfilesystem:/tmp/adb.sock", + "", + ]) + func localServerIsLoopback(socket: String) { + #expect(BridgeConnection.resolveBridgeHost(environment: ["ADB_SERVER_SOCKET": socket]) == "127.0.0.1") + } + + @Test("Remote adb server → its host") + func remoteServerHost() { + let env = ["ADB_SERVER_SOCKET": "tcp:172.20.64.1:5037"] + #expect(BridgeConnection.resolveBridgeHost(environment: env) == "172.20.64.1") + } + + @Test("Remote IPv6 adb server keeps its brackets") + func remoteIPv6ServerHost() { + let env = ["ADB_SERVER_SOCKET": "tcp:[fd00::1]:5037"] + #expect(BridgeConnection.resolveBridgeHost(environment: env) == "[fd00::1]") + } + + @Test("SIM_USE_BRIDGE_HOST wins over ADB_SERVER_SOCKET") + func explicitOverrideWins() { + let env = ["SIM_USE_BRIDGE_HOST": "10.0.0.7", "ADB_SERVER_SOCKET": "tcp:172.20.64.1:5037"] + #expect(BridgeConnection.resolveBridgeHost(environment: env) == "10.0.0.7") + } + + @Test("A bare IPv6 override is bracketed") + func bareIPv6OverrideIsBracketed() { + #expect(BridgeConnection.resolveBridgeHost(environment: ["SIM_USE_BRIDGE_HOST": "fd00::7"]) == "[fd00::7]") + } + + @Test("ADDRESS selects the bridge host", arguments: [ + ("192.0.2.10", "192.0.2.10"), + ("adb.example.test", "adb.example.test"), + ("fd00::1", "[fd00::1]"), + ("fe80::1%eth0", "[fe80::1%25eth0]"), + ("localhost", "127.0.0.1"), + ("127.0.0.1", "127.0.0.1"), + ("::1", "127.0.0.1"), + ("", "127.0.0.1"), + ]) + func addressHost(address: String, expected: String) { + for port in [nil, "5038"] as [String?] { + var environment = ["ANDROID_ADB_SERVER_ADDRESS": address] + environment["ANDROID_ADB_SERVER_PORT"] = port + #expect(BridgeConnection.resolveBridgeHost(environment: environment) == expected) + } + } + + @Test("Socket overrides ADDRESS", arguments: [ + ("tcp:192.0.2.20:5037", "192.0.2.20"), + ("tcp:5037", "127.0.0.1"), + ("localfilesystem:/tmp/adb.sock", "127.0.0.1"), + ]) + func socketOverridesAddress(socket: String, expected: String) { + #expect(BridgeConnection.resolveBridgeHost(environment: [ + "ANDROID_ADB_SERVER_ADDRESS": "192.0.2.10", + "ADB_SERVER_SOCKET": socket, + ]) == expected) + } + + @Test("Explicit host overrides ADDRESS") + func explicitOverridesAddress() { + #expect(BridgeConnection.resolveBridgeHost(environment: [ + "ANDROID_ADB_SERVER_ADDRESS": "192.0.2.10", + "SIM_USE_BRIDGE_HOST": "192.0.2.30", + ]) == "192.0.2.30") + } + + @Test("Every resolved host forms a valid bridge URL", arguments: [ + ["ADB_SERVER_SOCKET": "tcp:172.20.64.1:5037"], + ["ADB_SERVER_SOCKET": "tcp:[fd00::1]:5037"], + ["ADB_SERVER_SOCKET": "tcp:[fe80::1%eth0]:5037"], + ["SIM_USE_BRIDGE_HOST": "fe80::1%eth0"], + ]) + func resolvedHostFormsURL(environment: [String: String]) { + let host = BridgeConnection.resolveBridgeHost(environment: environment) + #expect(URL(string: "http://\(host):8080/ping") != nil, "host \(host)") + } +} diff --git a/Tests/AndroidBackendTests/LinuxCommandTests.swift b/Tests/AndroidBackendTests/LinuxCommandTests.swift new file mode 100644 index 00000000..562095e7 --- /dev/null +++ b/Tests/AndroidBackendTests/LinuxCommandTests.swift @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +import AndroidBackend +import ArgumentParser +import Foundation +import SimUseCore +import Testing + +@Suite("Linux Android command surfaces") +struct LinuxCommandTests { + @Test func longPressDefaultsAndFlags() throws { + let defaults = try AndroidLongPressCommand.parse(["@5", "--device", "emulator-5554"]) + #expect(defaults.duration == 0.8) + #expect(defaults.alias == "@5") + let command = try AndroidLongPressCommand.parse([ + "--label-contains", "foo", "--element-type", "Button", "--frame", "minY=0.5r", + "--duration", "1.2", "--pre-delay", "0.1", "--post-delay", "0.2", + "--wait-timeout", "1", "--poll-interval", "0.5", "--fingers", "2", + "--finger-distance", "30", "--device", "emulator-5554", "--json", + ]) + #expect(command.duration == 1.2) + #expect(command.timing.preDelay == 0.1) + #expect(command.multiTouch.fingers == 2) + #expect(command.jsonOutput) + } + + @Test func longPressRejectsInvalidArguments() { + for argv in [ + ["@1", "--label", "foo"], ["-x", "1"], ["--label", "a", "--duration", "11"], + ["--label", "a", "--pre-delay", "11"], ["--label", "a", "--post-delay", "11"], + ["--label", "a", "--wait-timeout=-1"], + ["--label", "a", "--wait-timeout", "1", "--poll-interval", "0"], + ["--label", "a", "--fingers", "3"], + ] { + #expect(throws: (any Error).self) { _ = try AndroidLongPressCommand.parse(argv) } + } + } + + @Test func appStateFlags() throws { + let defaults = try AndroidAppStateCommand.parse([]) + #expect(!defaults.reset) + #expect(defaults.bundleId == nil) + var command = try AndroidAppStateCommand.parse([ + "--device", "emulator-5554", "--bundle-id", "com.example", "--reset", "--json", + ]) + try command.resolveDeferredArguments() + #expect(command.simulatorUDIDForDaemon == "emulator-5554") + #expect(command.bundleId == "com.example") + #expect(command.reset && command.jsonOutput && command.managesLivenessState) + } + + /// Resolve deferred device arguments without executing or contacting a device. + private func resolutionFailure( + _ type: C.Type, _ argv: [String] + ) throws -> String? { + var command = try type.parse(argv) + do { + try command.resolveDeferredArguments() + return nil + } catch { + return type.message(for: error) + } + } + + @Test func missingDeviceRequiresADBSerial() throws { + let messages = [ + try resolutionFailure(AndroidLongPressCommand.self, ["@1"]), + try resolutionFailure(AndroidAppStateCommand.self, []), + ] + for message in messages { + #expect(message?.contains("Pass --device ") == true) + #expect(message?.contains("simctl") == false) + } + } + + @Test func bothCommandsRejectIOSIdentifiers() throws { + for serial in ["9CD7C6E7-45B3-4E59-BBF2-4D12A9457CD0", "00008130-00066D2A10EB8D3A"] { + // Both flag spellings must reach the same deferred validation. + for flag in ["--device", "--udid"] { + #expect(try resolutionFailure( + AndroidLongPressCommand.self, ["@1", flag, serial] + ) == "long-press in the Android backend drives Android only; pass an adb serial.") + #expect(try resolutionFailure( + AndroidAppStateCommand.self, [flag, serial] + ) == "app-state in the Android backend drives Android only; pass an adb serial.") + } + } + } + + @Test func bothCommandsAcceptMDNSSerial() throws { + let serial = "adb-R5CT1234567-AbCdEf._adb-tls-connect._tcp" + for flag in ["--device", "--udid"] { + var longPress = try AndroidLongPressCommand.parse(["@1", flag, serial]) + var appState = try AndroidAppStateCommand.parse([flag, serial]) + try longPress.resolveDeferredArguments() + try appState.resolveDeferredArguments() + #expect(longPress.simulatorUDIDForDaemon == serial) + #expect(appState.simulatorUDIDForDaemon == serial) + } + } + + @Test func longPressJSONShape() throws { + let result = try JSONDecoder().decode(AndroidLongPressCommand.ExecutionResult.self, from: Data("{\"x\":1.5,\"y\":2.5}".utf8)) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + #expect(String(decoding: try encoder.encode(result), as: UTF8.self) == "{\"x\":1.5,\"y\":2.5}") + let command = try AndroidLongPressCommand.parse(["@1"]) + #expect(command.format(result).stdout == "✓ Long-press at (1.5, 2.5) completed successfully\n") + } +} diff --git a/Tests/DaemonCommandParserInjectionTests.swift b/Tests/DaemonCommandParserInjectionTests.swift index 4d5b9b04..a909f045 100644 --- a/Tests/DaemonCommandParserInjectionTests.swift +++ b/Tests/DaemonCommandParserInjectionTests.swift @@ -7,7 +7,7 @@ import Testing @testable import SimUseCore // Coverage for the `DaemonDispatch.commandParser` injection wired -// inside `Daemon.Start.run()`. The dispatch module deliberately +// by the host CLI's `Daemon.installPlatformHooks`. The dispatch module deliberately // doesn't reach back into the top-level SimUse command tree — the // host CLI sets the closure once at daemon startup so the daemon // server can route requests through ArgumentParser without owning a @@ -16,7 +16,7 @@ import Testing // Without this wiring, the daemon server is dead on arrival: // `DaemonDispatch.handle` falls into a permanent error every time it // sees a real subcommand. Worth pinning so a future refactor that -// touches Daemon.swift can't silently drop the assignment. +// touches the daemon hooks can't silently drop the assignment. @Suite("DaemonDispatch.commandParser injection") @MainActor struct DaemonCommandParserInjectionTests { @@ -54,7 +54,7 @@ struct DaemonCommandParserInjectionTests { } } - /// `Daemon.Start.run()` configures the parser as + /// The macOS entry point's daemon hooks configure the parser as /// `SimUse.parseAsRoot`. A canary command (`--version`) round-trips /// through that closure to assert it actually parses the SimUse /// command tree, not just any closure. diff --git a/Tests/SimUseCoreTests/AppStateReportTests.swift b/Tests/SimUseCoreTests/AppStateReportTests.swift new file mode 100644 index 00000000..a3a8a92d --- /dev/null +++ b/Tests/SimUseCoreTests/AppStateReportTests.swift @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import SimUseCore +import Testing + +@Suite("AppState — result building") +struct AppStateReportTests { + + private func snap(_ pairs: [Int: String]) -> AppSnapshot { AppSnapshot(appsByPid: pairs) } + + @Test("Lists live apps sorted by bundle id; no query when bundle-id absent") + func listsAppsSorted() { + let result = AppStateReport.buildResult( + platform: "ios", + snapshot: snap([100: "com.x", 50: "a.b"]), + bundleId: nil, + didReset: false + ) + #expect(result.platform == "ios") + #expect(result.apps == [AppStateReport.AppProcess(bundleId: "a.b", pid: 50), + AppStateReport.AppProcess(bundleId: "com.x", pid: 100)]) + #expect(result.query == nil) + #expect(result.didReset == false) + } + + @Test("A queried, live bundle reports running") + func queriedRunning() { + let result = AppStateReport.buildResult( + platform: "android", + snapshot: snap([100: "com.example.app"]), + bundleId: "com.example.app", + didReset: false + ) + #expect(result.query == AppStateReport.AppStateQuery(bundleId: "com.example.app", state: "running")) + } + + @Test("A queried, absent bundle reports not_running") + func queriedNotRunning() { + let result = AppStateReport.buildResult( + platform: "ios", + snapshot: snap([:]), + bundleId: "com.example.app", + didReset: false + ) + #expect(result.query == AppStateReport.AppStateQuery(bundleId: "com.example.app", state: "not_running")) + } + + @Test("didReset flag is propagated") + func resetFlag() { + let result = AppStateReport.buildResult(platform: "ios", snapshot: snap([:]), bundleId: nil, didReset: true) + #expect(result.didReset == true) + } +} +@Test func appStateTextFormatting() { + let empty = AppStateReport.buildResult(platform: "android", snapshot: AppSnapshot(appsByPid: [:]), bundleId: "missing", didReset: true) + #expect(AppStateReport.format(empty).stdout == "missing: not_running\nNo tracked app processes running.\nCrash-detection baseline reset.\n") + let live = AppStateReport.buildResult(platform: "android", snapshot: AppSnapshot(appsByPid: [12: "app"]), bundleId: nil, didReset: false) + #expect(AppStateReport.format(live).stdout == "Running apps (1):\n app pid=12\n") +} diff --git a/Tests/SimUseCoreTests/DaemonConnectionGateTests.swift b/Tests/SimUseCoreTests/DaemonConnectionGateTests.swift new file mode 100644 index 00000000..ebd32006 --- /dev/null +++ b/Tests/SimUseCoreTests/DaemonConnectionGateTests.swift @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +@testable import SimUseCore + +// A per-device daemon is keyed by UDID alone, but what it talks to can +// depend on the environment it was started in (for Android, which adb +// server). A warm daemon started against one connection must not serve +// a client configured for another, so the reuse gate compares the +// connection identity alongside the version. + +@Suite("DaemonClient.shouldRestartForConnection") +struct DaemonConnectionComparatorTests { + @Test("A target without a connection identity never restarts") + func noIdentity() { + #expect(!DaemonClient.shouldRestartForConnection(daemon: nil, current: nil)) + #expect(!DaemonClient.shouldRestartForConnection(daemon: "adb=A", current: nil)) + } + + @Test("Same identity keeps the daemon") + func same() { + #expect(!DaemonClient.shouldRestartForConnection(daemon: "adb=A", current: "adb=A")) + } + + @Test("Different identity restarts the daemon") + func different() { + #expect(DaemonClient.shouldRestartForConnection(daemon: "adb=A", current: "adb=B")) + } + + @Test("A daemon that reports no identity cannot prove it matches") + func legacyDaemon() { + #expect(DaemonClient.shouldRestartForConnection(daemon: nil, current: "adb=A")) + } +} + +// Serialised: DaemonServer installs process-wide SIGTERM/SIGINT sources. +@Suite("DaemonClient.ensureCompatibleDaemon connection gate", .serialized) +@MainActor +struct DaemonConnectionGateTests { + @Test("A warm daemon started for another connection is restarted") + func switchedConnectionRestartsWarmDaemon() async throws { + let (paths, task, dir) = try await startDaemon(connectionIdentity: "adb=tcp:192.0.2.10:5037") + defer { try? FileManager.default.removeItem(at: dir) } + + let restarted = await DaemonClient.ensureCompatibleDaemon( + paths: paths, + currentVersion: "gate-test", + currentConnectionIdentity: "adb=tcp:192.0.2.20:5037" + ) + + #expect(restarted) + await waitForShutdown(paths) + #expect(!FileManager.default.fileExists(atPath: paths.socketURL.path)) + #expect(paths.readPidfile() == nil) + _ = try? await task.value + } + + @Test("A warm daemon for the same connection is reused") + func sameConnectionKeepsWarmDaemon() async throws { + let identity = "adb=tcp:192.0.2.10:5037" + let (paths, task, dir) = try await startDaemon(connectionIdentity: identity) + defer { try? FileManager.default.removeItem(at: dir) } + let pid = paths.readPidfile() + + let restarted = await DaemonClient.ensureCompatibleDaemon( + paths: paths, + currentVersion: "gate-test", + currentConnectionIdentity: identity + ) + + #expect(!restarted) + #expect(paths.readPidfile() == pid) + await DaemonClient.stopDaemon(paths: paths, timeout: 2.0) + _ = try? await task.value + } + + @Test("The version opt-out does not disable the connection check") + func versionOptOutKeepsConnectionCheck() async throws { + let (paths, task, dir) = try await startDaemon(connectionIdentity: "adb=tcp:192.0.2.10:5037") + defer { try? FileManager.default.removeItem(at: dir) } + + let restarted = await DaemonClient.ensureCompatibleDaemon( + paths: paths, + currentVersion: "gate-test", + currentConnectionIdentity: "adb=tcp:192.0.2.20:5037", + environment: ["SIM_USE_DAEMON_VERSION_CHECK": "0"] + ) + + #expect(restarted) + await waitForShutdown(paths) + _ = try? await task.value + } + + // MARK: - Fixtures + + enum FixtureError: Error { case daemonNeverReady } + + private func startDaemon(connectionIdentity: String) async throws -> (DaemonPaths, Task, URL) { + // Short /tmp path: sockaddr_un.sun_path is ~104 bytes. + let dir = URL(fileURLWithPath: "/tmp/sim-use-cg-\(UUID().uuidString.prefix(6))", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let udid = "emulator-\(Int.random(in: 5554...5680))" + let paths = DaemonPaths(udid: udid, baseDirectory: dir) + try paths.ensureBaseDirectory() + let server = DaemonServer( + udid: udid, + idleTimeout: 30, + paths: paths, + simUseVersion: "gate-test", + connectionIdentity: connectionIdentity + ) + let task = Task { try await server.run() } + for _ in 0..<100 { + if FileManager.default.fileExists(atPath: paths.socketURL.path), paths.readPidfile() != nil { + return (paths, task, dir) + } + try? await Task.sleep(nanoseconds: 20_000_000) + } + task.cancel() + throw FixtureError.daemonNeverReady + } + + private func waitForShutdown(_ paths: DaemonPaths) async { + for _ in 0..<50 { + if !FileManager.default.fileExists(atPath: paths.socketURL.path), paths.readPidfile() == nil { return } + try? await Task.sleep(nanoseconds: 20_000_000) + } + } +} diff --git a/Tests/SimUseCoreTests/DaemonPathsTests.swift b/Tests/SimUseCoreTests/DaemonPathsTests.swift index 82e3d278..a12f8164 100644 --- a/Tests/SimUseCoreTests/DaemonPathsTests.swift +++ b/Tests/SimUseCoreTests/DaemonPathsTests.swift @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 @testable import SimUseCore +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation import Testing diff --git a/Tests/SimUseCoreTests/LinuxBuildVerbRedirectsTests.swift b/Tests/SimUseCoreTests/LinuxBuildVerbRedirectsTests.swift new file mode 100644 index 00000000..15ab34c0 --- /dev/null +++ b/Tests/SimUseCoreTests/LinuxBuildVerbRedirectsTests.swift @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +import SimUseCore +import Testing + +@Test func linuxVerbDiagnostics() { + for verb in ["ios", "ios-device", "record-video", "stream-video", "viewer", "init"] { + let message = LinuxBuildVerbRedirects.message(for: verb) + #expect(message?.contains("`sim-use \(verb)`") == true) + #expect(message?.contains("not available in the Linux build (Android only)") == true) + } + for verb in ["app-state", "long-press", "android", "tap", "--help", "unknown"] { + #expect(LinuxBuildVerbRedirects.message(for: verb) == nil) + } + #expect(LinuxBuildVerbRedirects.message(for: "init")?.contains("Copy skills/sim-use/ by hand") == true) +} diff --git a/Tests/SimUseCoreTests/OutlineCacheRoundTripTests.swift b/Tests/SimUseCoreTests/OutlineCacheRoundTripTests.swift index 2cfd4cc4..e43ad345 100644 --- a/Tests/SimUseCoreTests/OutlineCacheRoundTripTests.swift +++ b/Tests/SimUseCoreTests/OutlineCacheRoundTripTests.swift @@ -41,6 +41,40 @@ final class OutlineCacheRoundTripTests: XCTestCase { XCTAssertEqual(payload.screen.width, 1080) } + /// Every `ui` after the first overwrites an existing cache file, so the + /// replace path must hold alongside the first-write path above. + func testSecondWriteReplacesFirst() throws { + func outline(label: String) -> Outline { + Outline( + text: "@1 Button \"\(label)\"\n", + entries: [ + Outline.Entry( + aliases: .init(at: 1), + role: "Button", + label: label, + frame: .init(x: 0, y: 0, width: 10, height: 10), + region: .init(kind: "Content"), + states: [], + uniqueId: nil + ) + ], + lists: [], + screen: .init(x: 0, y: 0, width: 1080, height: 1920), + appLabel: "App" + ) + } + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-core-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + + try OutlineCache.write(outline: outline(label: "First"), udid: "emulator-5554", home: tmp) + try OutlineCache.write(outline: outline(label: "Second"), udid: "emulator-5554", home: tmp) + let payload = try OutlineCache.read(udid: "emulator-5554", home: tmp) + + XCTAssertEqual(payload.entries.map(\.label), ["Second"]) + } + func testReadMissingThrowsMissingError() { let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("sim-use-core-test-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/TapValidationParityTests.swift b/Tests/TapValidationParityTests.swift index d721aeaf..1ad05b1e 100644 --- a/Tests/TapValidationParityTests.swift +++ b/Tests/TapValidationParityTests.swift @@ -1,19 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 @testable import SimUse @testable import iOSSimBackend +import AndroidBackend import ArgumentParser import Foundation import SimUseCore import Testing // ArgumentParser (1.5.0) does not auto-validate nested option groups — -// each of the three tap surfaces must call the shared group validators +// each tap surface must call the shared group validators // (`TapTargetingOptions.validate(alias:)` / `TapTimingOptions.validate()` // / `TapTimingOptions.validateDuration`) explicitly from its own // `validate()`. A surface that drops a call still parses fine and loses // validation silently; that is the regression this suite exists to // catch. The same invalid argv must fail — with the same message — on -// `tap`, `long-press`, and `ios tap`. +// `tap`, `long-press`, `ios tap`, and the Android long-press implementation. @Suite("Tap validation parity across surfaces") struct TapValidationParityTests { private static let udid = ["--udid", "9CD7C6E7-45B3-4E59-BBF2-4D12A9457CD0"] @@ -40,6 +41,9 @@ struct TapValidationParityTests { (["@1", "--coordinate-space", "ui"], "--coordinate-space ui applies to explicit"), ] + /// Parse and validate targeting/timing only; deferred device resolution is + /// intentionally excluded. The iOS --udid tests flag syntax parity even + /// on Android, whose platform rejection happens during deferred resolution. /// nil when the argv parses; the rendered parser message otherwise. private func failureMessage(_ type: C.Type, _ argv: [String]) -> String? { do { @@ -56,6 +60,8 @@ struct TapValidationParityTests { let full = argv + Self.udid let tap = failureMessage(Tap.self, full) let longPress = failureMessage(LongPress.self, full) + let androidLongPress = failureMessage(AndroidLongPressCommand.self, full) + #expect(tap == androidLongPress) let iosTap = failureMessage(IOSSimTapCommand.self, full) #expect(tap != nil, "tap accepted invalid argv \(argv)") @@ -72,7 +78,7 @@ struct TapValidationParityTests { @Test("a kitchen-sink valid argv parses on every surface") func acceptedParityAcrossSurfaces() { // One selector + type filter + frame + full timing block — - // valid on all three surfaces (mirrors flagSurfaceParses, which + // valid on all surfaces (mirrors flagSurfaceParses, which // pins field values; this pins acceptance stays in sync with // the rejection table above). let argv = [ @@ -86,6 +92,7 @@ struct TapValidationParityTests { ] + Self.udid #expect(failureMessage(Tap.self, argv) == nil) #expect(failureMessage(LongPress.self, argv) == nil) + #expect(failureMessage(AndroidLongPressCommand.self, argv) == nil) #expect(failureMessage(IOSSimTapCommand.self, argv) == nil) } } diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 00000000..b63ed5cf --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,125 @@ +# Linux (Android only) + +sim-use builds on Linux with the Android backend only. The Android backend +drives the device through `adb` plus the in-device bridge APK's HTTP server and +needs none of the Apple frameworks the iOS side is built on, so `Package.swift` +declares a separate `#if os(Linux)` target graph: `SimUseCore`, +`AndroidBackend`, and the `SimUseLinux` executable. + +## What is available + +The Android verbs are registered at the top level under the same names as on +macOS — `ui` / `describe-ui`, `devices`, `tap`, `type`, `paste`, +`keyboard-state`, `swipe`, `button`, `touch`, `gesture`, `multi-touch`, +`screenshot`, `long-press`, `app-state` — plus the full +`sim-use android ` namespace and `sim-use daemon`. They take the same +flags as on macOS, except that `--device ` is always required. The +per-device daemon works as on macOS, so warm commands keep their low latency. + +Not available on Linux (running one prints an error that names the verb and +exits with status 64): + +- `sim-use ios ` and `sim-use ios-device ` +- `record-video` / `stream-video` — host-side H.264 muxing and encoding use + AVFoundation +- `viewer` +- `init` (the agent-skill installer) — copy `skills/sim-use/` into your + client's skill directory by hand + +`sim-use devices` is the Android listing (`sim-use android devices`), not the +unified cross-platform schema. + +## Build and install + +Requirements: a Swift 6 toolchain for Linux () +on `PATH`, plus JDK 17–21 and an Android SDK (`compileSdk=35`) to build the +bridge APK — see `AGENTS.md`. + +```bash +scripts/install-linux.sh # bridge APK + release build + install +scripts/install-linux.sh --skip-bridge # reuse the APK already in Sources/AndroidBackend/Resources +``` + +The script installs into `$PREFIX/lib/sim-use` (default `PREFIX=~/.local`) and +symlinks `$PREFIX/bin/sim-use`. It installs two things that must stay together: +the `sim-use` binary and the `SimUse_AndroidBackend.resources` bundle holding +the bridge APK, which `Bundle.module` looks up next to the executable. Set +`SWIFT_TOOLCHAIN` to a toolchain root if `swift` is not on `PATH`. + +The binary links the Swift runtime dynamically, so the toolchain it was built +with must stay installed. `--static-swift-stdlib` does not work: +`FoundationNetworking` needs a static libcurl that the toolchains do not ship. + +For development, `swift build` and `swift test` work directly (the `make` +targets are macOS-only). + +## Usage + +```bash +adb devices +sim-use android init --device # install the bridge APK, enable its accessibility service +sim-use ui --device +sim-use tap @5 --device +``` + +`--json` returns the same `{"ok": …, "data": …}` envelope as on macOS. + +## Remote adb server (WSL) + +`adb forward` opens its listening port on the machine that runs the **adb +server**. When `ADB_SERVER_SOCKET` points at a remote server +(`tcp::`), or, with no `ADB_SERVER_SOCKET`, when +`ANDROID_ADB_SERVER_ADDRESS` names a remote host (the same precedence adb +uses), sim-use therefore talks to the bridge on that host instead of +`127.0.0.1`. Set `SIM_USE_BRIDGE_HOST` to override the host explicitly. This +applies on macOS too. + +The forwarded port and bridge token that sim-use caches for a device belong +to the adb server that created them, so they are scoped to that connection +(`ADB_SERVER_SOCKET`, `ANDROID_ADB_SERVER_ADDRESS` / `ANDROID_ADB_SERVER_PORT` +and `SIM_USE_BRIDGE_HOST`). Pointing a device at another server restarts that +device's daemon and creates a fresh forward and token, and a cached forward is +reused only while `adb forward --list` still shows it for the device. If +`adb forward --list` itself fails, the command fails with that adb error and +the cached session is kept, rather than opening a second forward. + +Equivalent spellings of the same server (no variables, +`ANDROID_ADB_SERVER_PORT=5037`, `ADB_SERVER_SOCKET=tcp:localhost:5037`) count +as one connection, so switching between them keeps the daemon and the +forward. When only the bridge host changes, the old forward is removed from +the adb server before a new one is opened. A forward created on a +*different* adb server stays registered there, because sim-use is no longer +talking to that server; `adb forward --list` against that server shows it, +and `adb -s forward --remove tcp:` clears it. + +The common case is WSL, which has no USB access: WSL's `adb` points at the +Windows host's adb server so physical devices stay visible. + +1. On Windows, start the server listening on all interfaces: + `adb kill-server` then `adb -a -P 5037 nodaemon server`. Without `-a`, the + server — and every forward it opens — binds to Windows loopback, which WSL + cannot reach. +2. In WSL, point `adb` and sim-use at it: + `export ADB_SERVER_SOCKET=tcp:$(ip route show default | awk '{print $3}'):5037` + (under WSL's default NAT networking, the default gateway is the Windows + host). + +> **Security.** `adb -a` exposes the adb server — full shell access to every +> attached device — and the forwarded bridge port on **every** interface of the +> Windows host, and the bridge speaks plain HTTP. Only do this on a trusted +> network, and restrict inbound TCP 5037 and the forwarded ports to the WSL +> virtual network in Windows Firewall. + +A device reached with `adb connect :5555` through a local adb server in WSL +needs none of this: the forward is local and the bridge host stays +`127.0.0.1`. + +## Containers + +The per-device daemon is a child process that `sim-use` spawns and later +stops. In a container whose PID 1 does not reap children — for example +`docker run … sleep infinity` — an exited daemon stays behind as a zombie. +`kill(pid, 0)` still succeeds on a zombie, so `sim-use daemon stop` reports +`stopped=false` even though the daemon has exited. Run the container with an +init process: `docker run --init …`, or `init: true` in Compose (any init that +reaps children, such as `tini`, works). diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 00000000..593477ed --- /dev/null +++ b/scripts/install-linux.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Build and install the Linux (Android-only) sim-use CLI. +# +# Builds the Android-only Linux target graph (see docs/linux.md). The +# install is two pieces that must travel together: +# +# sim-use the executable +# SimUse_AndroidBackend.resources/ the bundled device-bridge APK +# (Bundle.module looks for it next +# to the executable) +# +# The binary links the Swift runtime dynamically with an RPATH into the +# toolchain, so the toolchain directory must stay where it is. A +# --static-swift-stdlib build is not possible without a static libcurl, +# which FoundationNetworking needs. +# +# Usage: +# scripts/install-linux.sh # build + install +# scripts/install-linux.sh --skip-bridge # reuse the existing APK +# +# Env: +# SWIFT_TOOLCHAIN toolchain root holding usr/bin/swift +# (default: the `swift` on PATH) +# PREFIX install root (default: ~/.local) +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +prefix="${PREFIX:-$HOME/.local}" +libdir="$prefix/lib/sim-use" +bindir="$prefix/bin" +skip_bridge=false +[[ "${1:-}" == "--skip-bridge" ]] && skip_bridge=true + +log() { printf '\033[1;36m▶\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; } + +# ── Swift toolchain ───────────────────────────────────────────────── +if [[ -n "${SWIFT_TOOLCHAIN:-}" ]]; then + [[ -x "$SWIFT_TOOLCHAIN/usr/bin/swift" ]] || fail "No swift at $SWIFT_TOOLCHAIN/usr/bin/swift" + export PATH="$SWIFT_TOOLCHAIN/usr/bin:$PATH" +fi +command -v swift >/dev/null || fail \ + "No Swift toolchain found. Install one from https://www.swift.org/install/linux/ and put it on PATH, or set SWIFT_TOOLCHAIN." +ok "swift: $(swift --version 2>&1 | head -1) ($(command -v swift))" + +# ── Device bridge APK ─────────────────────────────────────────────── +apk="$repo_root/Sources/AndroidBackend/Resources/sim-use-device-bridge.apk" +if [[ "$skip_bridge" == false || ! -f "$apk" ]]; then + log "Building the device-bridge APK (needs JDK 17-21 + Android SDK)..." + "$repo_root/scripts/build-bridge.sh" +fi +[[ -f "$apk" ]] || fail "Bridge APK missing: $apk" + +# ── Build ─────────────────────────────────────────────────────────── +log "Building sim-use (release)..." +(cd "$repo_root" && swift build -c release) +binary="$repo_root/.build/release/sim-use" +bundle="$repo_root/.build/release/SimUse_AndroidBackend.resources" +[[ -x "$binary" ]] || fail "Build produced no binary at $binary" +[[ -d "$bundle" ]] || fail "Build produced no resource bundle at $bundle" + +# ── Install ───────────────────────────────────────────────────────── +log "Installing to $libdir ..." +mkdir -p "$libdir" "$bindir" +install -m 0755 "$binary" "$libdir/sim-use" +rm -rf "$libdir/SimUse_AndroidBackend.resources" +cp -R "$bundle" "$libdir/SimUse_AndroidBackend.resources" +ln -sfn "$libdir/sim-use" "$bindir/sim-use" + +ok "Installed $("$bindir/sim-use" --version) -> $bindir/sim-use" +case ":$PATH:" in + *":$bindir:"*) ;; + *) printf '\033[1;33m⚠\033[0m %s\n' "$bindir is not on PATH — add it to your shell profile." >&2 ;; +esac diff --git a/skills/sim-use/SKILL.md b/skills/sim-use/SKILL.md index 45e82d69..4b410130 100644 --- a/skills/sim-use/SKILL.md +++ b/skills/sim-use/SKILL.md @@ -19,7 +19,7 @@ This verifies sim-use is installed and compatible with the skill, the device is A `WARN UI content` line still passes preflight: the read works, but the outline can miss app controls. See the *Pitfalls* index. -`--device` is optional when only one simulator is booted or one daemon is running. For Android, run `sim-use android init --device ` once to install the bridge APK. Attached physical iPhones/iPads appear in `sim-use devices` with kind `physical` and route through the top-level verbs too — but only `ui`, selector-based `tap` and `screenshot`; every other verb rejects on that target. See *Physical iOS devices* below before driving one. +`--device` is optional when only one simulator is booted or one daemon is running. For Android, run `sim-use android init --device ` once to install the bridge APK. Attached physical iPhones/iPads appear in `sim-use devices` with kind `physical` and route through the top-level verbs too — but only `ui`, selector-based `tap` and `screenshot`; every other verb rejects on that target. See *Physical iOS devices* below before driving one. A Linux build of sim-use drives Android only: `sim-use ios …`, `record-video` / `stream-video`, `viewer` and `init` are absent there, every verb needs `--device `, and `sim-use devices` lists Android devices only. ## 1. The observe-act loop