diff --git a/Documentation/xtool.docc/Testing.md b/Documentation/xtool.docc/Testing.md new file mode 100644 index 00000000..751e693c --- /dev/null +++ b/Documentation/xtool.docc/Testing.md @@ -0,0 +1,91 @@ +# Run XCTest/XCUITest on a device + +Build, install, and run your package's test targets on a real device -- no Xcode required. + +## Overview + +`xtool test` builds every `.testTarget` in your SwiftPM package into SwiftPM's own combined test +bundle (SwiftPM doesn't support separate per-target test products), packages it into a +`Runner.app` (matching Xcode's own `-Runner.app` convention), installs it on a connected +device, and drives it via the same `testmanagerd` protocol Xcode uses -- so you get real pass/fail +results, failure screenshots, and crash logs from an actual device, from Linux. + +```bash +xtool test +``` + +`xtool test` only ever builds and installs the test Runner -- it never builds or installs your +main app product. For XCUITest targets that drive a separate app under test, install that app +first with `xtool install` or `xtool dev`, then point `xtool test` at it: + +```bash +xtool test --test-target MyAppUITests --target-bundle-id com.example.MyApp +``` + +## Choosing what to run + +If your package has more than one `.testTarget`, `xtool test` prompts you interactively to choose +one -- only one test target actually runs per invocation, since a UI test target and a plain unit +test target typically need different session settings (notably `--target-bundle-id`) and +shouldn't run mixed together in one session anyway. Skip the prompt with `--test-target`: + +```bash +xtool test --test-target MyAppTests +``` + +To run (or exclude) specific tests within a target, use `--only`/`--skip` with a `TestClass` or +`TestClass/testMethod` identifier. Both are repeatable: + +```bash +xtool test --only LoginTests --only SettingsTests/testLogout +xtool test --skip FlakyTests +``` + +> Note: +> +> A single on-device session reliably filters to *one* class-level identifier at a time -- running +> two whole classes' worth of tests in one filtered session is a real (confirmed) limitation of the +> on-device XCTest runner, not something `xtool test` can work around within a single session. +> `--test-target` and multiple `--only` values are handled correctly by running one session per +> class internally and aggregating the results, but each of those sessions needs its own app +> relaunch -- so a target with many test classes will take noticeably longer than one with a +> single class. + +## Reports and artifacts + +Write machine-readable reports with `--junit`, `--json`, or `--html`, each taking a file path: + +```bash +xtool test --junit report.xml --json report.json +``` + +Capture additional failure diagnostics: + +- `--screenshot-on-failure` -- captures a device screenshot for every failed test case +- `--capture-syslog` -- captures the device syslog for the duration of each run +- `--capture-crash-logs` -- collects any on-device crash logs written during the run + +All three write into `--report-directory` (defaults to a timestamped directory in the current +directory). + +## Running against multiple devices or repeatedly + +```bash +xtool test --parallel # run on every currently-connected device concurrently +xtool test --repeat 5 # run the whole session 5 times sequentially, aggregating results +``` + +## Other options + +- `--triple` -- override the build target triple (defaults to the standard iOS device triple) +- `--session-timeout ` -- fail a run if no event arrives from the device for this long + (default 120s). This guards against a stalled-but-still-connected session hanging forever; it's + measured as an idle gap between events, not a cap on the whole run, so a long `--test-target` + sweep isn't penalized for legitimately taking a while. + +> Troubleshooting: +> +> A free Apple Developer account can only have a small number of apps installed on a device at +> once. If `xtool test` fails to install the Runner with a message about the maximum number of +> installed apps, uninstall one of the listed bundle IDs with `xtool uninstall ` and try +> again. diff --git a/Documentation/xtool.docc/xtool.md b/Documentation/xtool.docc/xtool.md index fea43522..4f6742f9 100644 --- a/Documentation/xtool.docc/xtool.md +++ b/Documentation/xtool.docc/xtool.md @@ -32,3 +32,4 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona - - +- diff --git a/Package.resolved b/Package.resolved index 0bcfc149..3c10e77f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "80db0cddb0e78cf6a4c79f94537cdfebd8004679161532fbae8a57ee9d1d8cd1", + "originHash" : "2f61def1e82ade5d0b132d00537a7777062aaf35fd8f4551ad15c8be072cd7d3", "pins" : [ { "identity" : "aexml", @@ -55,6 +55,15 @@ "version" : "6.2.0" } }, + { + "identity" : "opencombine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenCombine/OpenCombine.git", + "state" : { + "revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2", + "version" : "0.14.0" + } + }, { "identity" : "pathkit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 8ea98c7f..58577e86 100644 --- a/Package.swift +++ b/Package.swift @@ -74,6 +74,13 @@ let package = Package( ], targets: [ .systemLibrary(name: "XADI"), + // wraps libtatsu (Apple TSS/personalization protocol client), used for + // personalized Developer Disk Image mounting on iOS 17+. Declared locally + // (rather than in xtool-core, which only vendors libimobiledevice/plist/etc. + // as of this writing) against the system-installed library, same pattern as + // XADI above. Linux-only for now: xtool-core's macOS/Windows binaryTargets + // don't yet publish a libtatsu xcframework. + .systemLibrary(name: "CTatsu"), .target( name: "CXKit", dependencies: [ @@ -111,9 +118,15 @@ let package = Package( "CXKit", "XUtils", .byName(name: "XADI", condition: .when(platforms: [.linux])), + .byName(name: "CTatsu", condition: .when(platforms: [.linux])), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "SwiftyMobileDevice", package: "SwiftyMobileDevice"), + // for personalization calls on MobileImageMounterClient not yet + // wrapped by SwiftyMobileDevice (query nonce/identifiers/manifest, + // mount-with-options). See Installation/PersonalizedDDIMounter.swift. + .product(name: "libimobiledevice", package: "xtool-core"), + .product(name: "plist", package: "xtool-core"), .product(name: "Zupersign", package: "zsign"), .product(name: "SignerSupport", package: "xtool-core"), .product(name: "ProtoCodable", package: "xtool-core"), @@ -147,6 +160,9 @@ let package = Package( name: "XToolTests", dependencies: [ "XToolSupport", + "XKit", + "PackLib", + .product(name: "plist", package: "xtool-core"), ] ), .target( diff --git a/Sources/CTatsu/module.modulemap b/Sources/CTatsu/module.modulemap new file mode 100644 index 00000000..6547bd6e --- /dev/null +++ b/Sources/CTatsu/module.modulemap @@ -0,0 +1,4 @@ +module CTatsu [extern_c] { + header "shim.h" + link "tatsu" +} diff --git a/Sources/CTatsu/shim.h b/Sources/CTatsu/shim.h new file mode 100644 index 00000000..3e6eecbe --- /dev/null +++ b/Sources/CTatsu/shim.h @@ -0,0 +1,2 @@ +#include +#include diff --git a/Sources/CXKit/include/tun_ioctl.h b/Sources/CXKit/include/tun_ioctl.h new file mode 100644 index 00000000..d16c2916 --- /dev/null +++ b/Sources/CXKit/include/tun_ioctl.h @@ -0,0 +1,34 @@ +#ifndef XTL_TUN_IOCTL_H +#define XTL_TUN_IOCTL_H + +// Non-variadic wrappers around the Linux TUN-device ioctls (`ioctl()` itself is a variadic C +// function, which Swift can't call directly). Used by `TUNDevice.swift` to create and configure +// the kernel TUN interface the iOS 17+ RSD tunnel routes traffic through -- see that file's doc +// comment for the wire-level context (CoreDeviceProxy CDTunnel handshake -> this -> RSD lookup). + +/// Opens `/dev/net/tun` and creates (or attaches to) a TUN interface (`IFF_TUN | IFF_NO_PI`, +/// i.e. raw IP packets with no per-packet protocol header). The kernel auto-assigns a name +/// (`tunN`); on success it's written into `name_out`, which must have room for at least 16 bytes +/// (`IFNAMSIZ`). Returns the open file descriptor, or -1 on failure (`errno` set). +int xtl_tun_create(char * _Nonnull name_out); + +/// Assigns a /`prefix_len` IPv6 address (`addr6`: 16 raw bytes, network byte order) to the named +/// interface, sets its MTU, and brings it up. Returns 0 on success, -1 on failure (`errno` set +/// and `*stage_out` identifies which step failed: 0 = opening the configuration socket, +/// 1 = resolving the interface index, 2 = assigning the address, 3 = setting the MTU, +/// 4 = reading current flags, 5 = writing flags). +/// +/// Setting the MTU explicitly matters: a freshly created TUN device defaults to the kernel's +/// standard 1500-byte MTU, which can be larger than the MTU the tunnel peer actually negotiated +/// (e.g. 1280). Leaving the interface at the default risks the kernel handing back a packet +/// larger than a receive buffer sized to the *negotiated* MTU, which it silently truncates to +/// fit -- corrupting the packet stream with no error raised at all. +int xtl_tun_configure( + const char * _Nonnull name, + const unsigned char * _Nonnull addr6, + unsigned int prefix_len, + unsigned int mtu, + int * _Nonnull stage_out +); + +#endif diff --git a/Sources/CXKit/tun_ioctl.c b/Sources/CXKit/tun_ioctl.c new file mode 100644 index 00000000..a03959a2 --- /dev/null +++ b/Sources/CXKit/tun_ioctl.c @@ -0,0 +1,114 @@ +#include "tun_ioctl.h" + +#if __linux__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Deliberately does not include : it redefines `struct ifreq`/`IFF_*` and conflicts +// with if both are pulled in. (needed for `socket()`) doesn't define +// `struct ifreq` itself, so this combination is conflict-free. + +int xtl_tun_create(char *name_out) { + int fd = open("/dev/net/tun", O_RDWR); + if (fd < 0) return -1; + + struct ifreq ifr; + memset(&ifr, 0, sizeof(ifr)); + ifr.ifr_flags = IFF_TUN | IFF_NO_PI; + if (ioctl(fd, TUNSETIFF, &ifr) < 0) { + int saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; + } + memcpy(name_out, ifr.ifr_name, IFNAMSIZ); + return fd; +} + +// Mirrors the kernel's `struct in6_ifreq` (from , which can't be included directly +// alongside without further conflicts) -- just the fields actually needed here. +struct xtl_in6_ifreq { + unsigned char addr[16]; + unsigned int prefixlen; + int ifindex; +}; + +int xtl_tun_configure(const char *name, const unsigned char *addr6, unsigned int prefix_len, unsigned int mtu, int *stage_out) { + int sock = socket(AF_INET6, SOCK_DGRAM, 0); + if (sock < 0) { *stage_out = 0; return -1; } + + struct ifreq ifr; + memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, name, IFNAMSIZ - 1); + if (ioctl(sock, SIOCGIFINDEX, &ifr) < 0) { + int saved_errno = errno; + *stage_out = 1; + close(sock); + errno = saved_errno; + return -1; + } + int ifindex = ifr.ifr_ifindex; + + struct xtl_in6_ifreq ifr6; + memcpy(ifr6.addr, addr6, 16); + ifr6.prefixlen = prefix_len; + ifr6.ifindex = ifindex; + if (ioctl(sock, SIOCSIFADDR, &ifr6) < 0) { + int saved_errno = errno; + *stage_out = 2; + close(sock); + errno = saved_errno; + return -1; + } + + memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, name, IFNAMSIZ - 1); + ifr.ifr_mtu = (int)mtu; + if (ioctl(sock, SIOCSIFMTU, &ifr) < 0) { + int saved_errno = errno; + *stage_out = 3; + close(sock); + errno = saved_errno; + return -1; + } + + memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, name, IFNAMSIZ - 1); + if (ioctl(sock, SIOCGIFFLAGS, &ifr) < 0) { + int saved_errno = errno; + *stage_out = 4; + close(sock); + errno = saved_errno; + return -1; + } + ifr.ifr_flags |= IFF_UP | IFF_RUNNING; + if (ioctl(sock, SIOCSIFFLAGS, &ifr) < 0) { + int saved_errno = errno; + *stage_out = 5; + close(sock); + errno = saved_errno; + return -1; + } + + close(sock); + return 0; +} + +#else + +int xtl_tun_create(char *name_out) { (void)name_out; return -1; } +int xtl_tun_configure(const char *name, const unsigned char *addr6, unsigned int prefix_len, unsigned int mtu, int *stage_out) { + (void)name; (void)addr6; (void)prefix_len; (void)mtu; + if (stage_out) *stage_out = -1; + return -1; +} + +#endif diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index 054f65ce..f2a5fb8d 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -2,6 +2,32 @@ import Foundation import XUtils import Subprocess +public enum PackerError: Swift.Error, LocalizedError { + case missingXCTestFramework(String, searchedIn: [String]) + case missingXCTestDylib(String, searchedIn: [String]) + case staleBuildOutput(expected: String, buildDir: String) + + public var errorDescription: String? { + switch self { + case .missingXCTestFramework(let name, let searchedIn): + "Could not find \(name).framework (searched: \(searchedIn.joined(separator: ", "))). " + + "Is the installed Darwin SDK missing its iOS platform frameworks?" + case .missingXCTestDylib(let name, let searchedIn): + "Could not find \(name) (searched: \(searchedIn.joined(separator: ", "))). " + + "Is the installed Darwin SDK missing its iOS platform frameworks?" + case .staleBuildOutput(let expected, let buildDir): + """ + Expected build output not found: \(expected) + + SwiftPM reported the build as complete, but this product wasn't actually produced -- a \ + known incremental-build inconsistency (confirmed against real hardware: the same build \ + directory can report "Build complete!" for a --build-tests invocation while silently \ + skipping the actual test product). Delete \(buildDir) and try again. + """ + } + } +} + public struct Packer: Sendable { public let buildSettings: BuildSettings public let plan: Plan @@ -17,6 +43,28 @@ public struct Packer: Sendable { try? FileManager.default.removeItem(at: packageDir) try FileManager.default.createDirectory(at: packageDir, withIntermediateDirectories: true) + let runnerTargetDecl: String + if let xcTest = plan.xcTest { + // Unlike plan.allProducts, this target deliberately does NOT depend on RootPackage -- + // it's a synthesized XCTest runner, not one of the user's own products. Needs UIKit, + // not just XCTest -- see `main.swift`'s doc comment below for why. + runnerTargetDecl = """ + , + .executableTarget( + name: "\(xcTest.runnerProduct)", + linkerSettings: [ + .linkedFramework("XCTest"), + .linkedFramework("UIKit"), + .unsafeFlags([ + "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", + ]), + ] + ) + """ + } else { + runnerTargetDecl = "" + } + let packageSwift = packageDir.appendingPathComponent("Package.swift") let contents = """ // swift-tools-version: 6.0 @@ -43,7 +91,7 @@ public struct Packer: Sendable { """ } .joined(separator: ",\n") - ) + )\(runnerTargetDecl) ] )\n """ @@ -55,6 +103,65 @@ public struct Packer: Sendable { try Data().write(to: sources.appendingPathComponent("stub.c", isDirectory: false)) } + if let xcTest = plan.xcTest { + let sources = packageDir.appendingPathComponent("Sources/\(xcTest.runnerProduct)", isDirectory: true) + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + // A bare `import XCTest` with no further code was tried first and confirmed broken + // against real hardware: with nothing blocking, `main.swift`'s implicit top-level + // code runs to completion and the process exits normally in well under a second -- + // observed in the device syslog as RunningBoard/FrontBoard flagging the process + // "pending exit for reason: launch failed" and a clean "voluntary" exit, no crash + // report, right after container/bootstrap setup finishes. `XCTestConfigurationFilePath` + // triggers XCTest's test-execution machinery, but that still needs a live process (and + // a real app-launch lifecycle RunningBoard considers valid) to run in -- real Xcode's + // own `XCTRunner.app` is a genuine `UIApplicationMain`-based UIKit app, not a bare + // script, which is what actually keeps it alive. Mirrors that instead of guessing + // further: a minimal `UIApplicationDelegate` with no behavior of its own, entered via + // `UIApplicationMain` the classic (non-`@main`) way so it works from a plain + // `main.swift` without extra target configuration. + // A static "tests are running" label, not a live-updating one -- an earlier version + // of this hooked `XCTestObservationCenter` to show live suite/case progress, but real + // hardware testing (this session) showed that specific hook coinciding with an + // intermittent ~45s in-process hang right at `testSuiteWillStart`, severe enough that + // testmanagerd gave up and the OS killed the runner mid-run. Never fully root-caused + // (XCTest's observer-dispatch internals aren't visible to us), but it's the only + // non-Apple code running inside that exact notification path, so it's not worth the + // risk to core `xtool test` reliability for a cosmetic feature. This keeps *a* visible + // screen (better than blank white) without touching XCTestObservation at all. + let mainSwift = """ + import UIKit + + class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let window = UIWindow(frame: UIScreen.main.bounds) + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Running tests..." + label.textAlignment = .center + let viewController = UIViewController() + viewController.view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: viewController.view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: viewController.view.centerYAnchor), + ]) + window.rootViewController = viewController + window.makeKeyAndVisible() + self.window = window + return true + } + } + + UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(AppDelegate.self)) + + """ + try Data(mainSwift.utf8).write(to: sources.appendingPathComponent("main.swift")) + } + let buildConfig = try await buildSettings.swiftPMInvocation( forTool: "build", arguments: [ @@ -75,6 +182,36 @@ public struct Packer: Sendable { error: .currentStandardError, ) .checkSuccess() + + if plan.xcTest != nil { + // Builds every test target in the real package into one combined bundle (SwiftPM + // doesn't support per-target test products -- confirmed by reading SwiftPM's own + // BuildParameters.swift/LLBuildManifestBuilder+Product.swift, which also confirms it + // emits a real `Info.plist` for Darwin destination triples -- but in the classic + // macOS `Contents/` bundle layout regardless of target platform; `packXCTestRunner` + // flattens this to the iOS-style flat layout real `.xctest` bundles need). + let testBuildConfig = try await buildSettings.swiftPMInvocation( + forTool: "build", + arguments: [ + "--scratch-path", ".build", + "--build-tests", + ] + ) + try await Subprocess.run( + testBuildConfig, + output: .currentStandardError, + error: .currentStandardError, + ) + .checkSuccess() + } + } + + /// Compiles the package (including test targets, when `plan.xcTest` is set) without + /// packaging the main app into a `.app` bundle. `xtool test` builds+installs the test Runner + /// and, for XCUITest, expects the target app to already be installed separately (via `xtool + /// install`/`xtool dev`) -- it must never build or install the main app product itself. + public func buildOnly() async throws { + try await build() } public func pack() async throws -> URL { @@ -117,6 +254,255 @@ public struct Packer: Sendable { return dest } + /// The `XCTest`-family frameworks a Runner.app needs embedded in `Frameworks/` to actually + /// launch on a real device, in `@rpath` dependency order. Confirmed via `llvm-objdump + /// --macho --dylibs-used` against the real on-device `XCTest.framework` binary: it directly + /// (non-weak) re-exports `XCTestCore` and `XCUIAutomation`, and weakly links + /// `XCTAutomationSupport`; everything else it links against is a `/System/Library/...` + /// framework already present on-device. None of these ship in `/System/Library` on iOS -- + /// unlike on macOS, where Xcode's command-line tools install them system-wide, real Xcode + /// always embeds this same set into its own `XCTRunner.app`, which is why nothing device-side + /// provides them. `XCTestCore`/`XCUIAutomation`/`XCTAutomationSupport` each transitively + /// depend (non-weak, so required) on `XCTestSupport`; `XCTestCore` also non-weak-depends on + /// the new (this iOS version) `Testing` framework (Swift Testing's own on-device runtime), + /// which in turn requires `lib_TestingInterop.dylib` -- a raw `.dylib`, not a `.framework`, + /// so it's handled separately from `xctestFrameworkNames` below rather than added to it. Full + /// dependency set determined by running `llvm-objdump --macho --dylibs-used` against the real + /// on-device binaries and walking non-weak `@rpath` edges to a fixed point, not guessed. + private static let xctestFrameworkNames = [ + "XCTest", "XCTestCore", "XCUIAutomation", "XCTAutomationSupport", "XCTestSupport", "Testing", + ] + private static let xctestDylibNames = ["lib_TestingInterop.dylib"] + + /// Parses `llvm-objdump --macho --dylibs-used`'s output for `@rpath/Name.framework/Name` + /// entries, returning just `Name`. + private static func extraRPathFrameworkNames(inExecutable executable: URL) async throws -> [String] { + let result = try await Subprocess.run( + .path(resolveLLVMObjdumpPath()), + arguments: .init(["--macho", "--dylibs-used", executable.path]), + output: .string(limit: .max) + ).checkSuccess() + let output = result.standardOutput ?? "" + + var names: [String] = [] + for line in output.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("@rpath/") else { continue } + guard let range = trimmed.range(of: ".framework/") else { continue } + names.append(String(trimmed["@rpath/".endIndex.. FilePath { + let candidates = ["/usr/lib/swift/bin/llvm-objdump"] + for candidate in candidates where FileManager.default.fileExists(atPath: candidate) { + return FilePath(candidate) + } + return FilePath("llvm-objdump") // last resort: hope it's on PATH + } + + private static func requireBuildProduct(at url: URL, binDir: URL) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { return } + throw PackerError.staleBuildOutput(expected: url.path, buildDir: binDir.path) + } + + /// Packages the `Runner.app` synthesized for `plan.xcTest` (SwiftPM's combined `.xctest` + /// bundle for every test target in the package, embedded in `PlugIns/`). Returns `nil` if the + /// package has no test targets. + /// + /// - Important: Must be called *after* `build()` (via `buildOnly()` or `pack()`) -- it doesn't + /// build anything itself, only packages what `build()` already produced (which builds the + /// test targets as a side effect whenever `plan.xcTest != nil`). + /// + /// - Parameter platformDeveloperDirectory: the installed Darwin SDK's + /// `Developer/Platforms/iPhoneOS.platform/Developer` directory (see `DarwinSDK` in + /// `XToolSupport`, which the caller resolves and passes in -- `PackLib` doesn't depend on + /// `XToolSupport`). Required, not optional: without the frameworks found here, the runner + /// crashes at launch before any test code runs (confirmed against real hardware). + public func packXCTestRunner(platformDeveloperDirectory: URL) async throws -> URL? { + guard let xcTest = plan.xcTest else { return nil } + + let output = try TemporaryDirectory(name: "\(xcTest.runnerProduct).app") + let outputURL = output.url + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + + let binDir = URL( + fileURLWithPath: ".build/\(buildSettings.triple)/\(buildSettings.configuration.rawValue)", + isDirectory: true + ) + + // `build()` can report "Build complete!" while silently not having produced these -- + // a real SwiftPM/llbuild incremental-database inconsistency hit repeatedly against real + // hardware (this project's own history), not just a stale-directory issue (targeted + // directory deletion alone was insufficient at least once). Checking here turns that into + // a clear, actionable error instead of a confusing "file doesn't exist" further down the + // copy/codesign/install pipeline. + try Self.requireBuildProduct( + at: binDir.appendingPathComponent(xcTest.runnerProduct), + binDir: binDir + ) + try Self.requireBuildProduct( + at: binDir.appendingPathComponent("\(xcTest.testProductName).xctest"), + binDir: binDir + ) + + // the runner's own executable, built via the synthesized wrapper package (see build()). + try FileManager.default.copyItem( + at: binDir.appendingPathComponent(xcTest.runnerProduct), + to: outputURL.appendingPathComponent(xcTest.runnerProduct) + ) + + // SwiftPM's own combined .xctest bundle for every test target in the package, built + // directly from the real package (not the wrapper) -- copied wholesale. + let plugins = outputURL.appendingPathComponent("PlugIns", isDirectory: true) + try FileManager.default.createDirectory(at: plugins, withIntermediateDirectories: true) + let xctestBundle = plugins.appendingPathComponent("\(xcTest.testProductName).xctest") + try FileManager.default.copyItem( + at: binDir.appendingPathComponent("\(xcTest.testProductName).xctest"), + to: xctestBundle + ) + + // `swift build --build-tests` writes the bundle in the classic macOS layout + // (`Contents/MacOS/`, `Contents/Info.plist`) regardless of target platform -- + // confirmed by inspecting the actual build output, not assumed. iOS bundles are flat + // (executable and Info.plist directly inside the `.xctest` directory, no `Contents/` + // subdirectory); real Xcode-produced `.xctest` bundles for iOS are already flat. Without + // this, the on-device runner reports "Failed to load the test bundle" / + // "the bundle's executable couldn't be located" -- confirmed against real hardware (this + // session): the DTX/testmanagerd handshake completes fully, the runner reaches the point + // of actually dlopen-ing the bundle, and only THEN fails, because it's looking for the + // executable at the flat path this macOS-style layout doesn't have. + let macOSExecutable = xctestBundle.appendingPathComponent("Contents/MacOS/\(xcTest.testProductName)") + if FileManager.default.fileExists(atPath: macOSExecutable.path) { + try FileManager.default.moveItem( + at: macOSExecutable, + to: xctestBundle.appendingPathComponent(xcTest.testProductName) + ) + let macOSInfoPlist = xctestBundle.appendingPathComponent("Contents/Info.plist") + if FileManager.default.fileExists(atPath: macOSInfoPlist.path) { + try FileManager.default.moveItem( + at: macOSInfoPlist, + to: xctestBundle.appendingPathComponent("Info.plist") + ) + } + try? FileManager.default.removeItem(at: xctestBundle.appendingPathComponent("Contents")) + } + + // testmanagerd/XCTest need an Info.plist to identify the bundle; synthesize one if + // SwiftPM didn't emit it (or it was left behind by the flattening above not finding one). + let xctestInfoPath = xctestBundle.appendingPathComponent("Info.plist") + if !FileManager.default.fileExists(atPath: xctestInfoPath.path) { + let xctestInfo: [String: Sendable] = [ + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleDevelopmentRegion": "en", + "CFBundleVersion": "1", + "CFBundleShortVersionString": "1.0.0", + "MinimumOSVersion": xcTest.deploymentTarget, + "CFBundleIdentifier": "\(xcTest.bundleID).\(xcTest.testProductName)", + "CFBundleName": xcTest.testProductName, + "CFBundleExecutable": xcTest.testProductName, + "CFBundlePackageType": "BNDL", + "CFBundleSupportedPlatforms": ["iPhoneOS"], + ] + let encoded = try PropertyListSerialization.data(fromPropertyList: xctestInfo, format: .xml, options: 0) + try FileManager.default.createDirectory( + at: xctestInfoPath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoded.write(to: xctestInfoPath) + } + + // The runner links against `@rpath/XCTest.framework/XCTest` (see `build()`'s + // `runnerTargetDecl`) but nothing on-device provides it -- unlike macOS, iOS ships none + // of the XCTest-family frameworks in `/System/Library`. Without embedding them here, the + // runner crashes at launch before any test code runs (confirmed against real hardware via + // a pulled `.ips` crash report: "Library not loaded: @rpath/XCTest.framework/XCTest"). + let frameworksDir = outputURL.appendingPathComponent("Frameworks", isDirectory: true) + try FileManager.default.createDirectory(at: frameworksDir, withIntermediateDirectories: true) + let searchDirs = [ + platformDeveloperDirectory.appendingPathComponent("Library/Frameworks", isDirectory: true), + platformDeveloperDirectory.appendingPathComponent("Library/PrivateFrameworks", isDirectory: true), + ] + for name in Self.xctestFrameworkNames { + let frameworkDirName = "\(name).framework" + guard let source = searchDirs + .map({ $0.appendingPathComponent(frameworkDirName, isDirectory: true) }) + .first(where: { FileManager.default.fileExists(atPath: $0.path) }) + else { + throw PackerError.missingXCTestFramework(name, searchedIn: searchDirs.map(\.path)) + } + try FileManager.default.copyItem(at: source, to: frameworksDir.appendingPathComponent(frameworkDirName)) + } + let dylibSearchDirs = [platformDeveloperDirectory.appendingPathComponent("usr/lib", isDirectory: true)] + for name in Self.xctestDylibNames { + guard let source = dylibSearchDirs + .map({ $0.appendingPathComponent(name) }) + .first(where: { FileManager.default.fileExists(atPath: $0.path) }) + else { + throw PackerError.missingXCTestDylib(name, searchedIn: dylibSearchDirs.map(\.path)) + } + try FileManager.default.copyItem(at: source, to: frameworksDir.appendingPathComponent(name)) + } + + // Beyond the fixed XCTest-family set above, the test bundle can depend on the app's *own* + // binary framework dependencies too (e.g. a `.binaryTarget` like `GoogleCast.xcframework` + // linked by a target the tests `@testable import`) -- these aren't in any fixed list, so + // they're discovered by asking the linker what the built test executable actually needs + // (`llvm-objdump --macho --dylibs-used`, the same tool/flag this file's own + // `xctestFrameworkNames` doc comment already used manually to determine *that* fixed set) + // and copying whatever `@rpath/*.framework/*` entries aren't already covered. Confirmed + // necessary against real hardware (this session): a test run using a `.binaryTarget` + // dependency failed to `dlopen` with "Library not loaded: @rpath/GoogleCast.framework/ + // GoogleCast" without this, since the Runner.app is a separate `.app` bundle on disk from + // the main app and doesn't share its `Frameworks/` directory. + let testExecutableURL = xctestBundle.appendingPathComponent(xcTest.testProductName) + let alreadyEmbedded = Set(Self.xctestFrameworkNames) + for name in try await Self.extraRPathFrameworkNames(inExecutable: testExecutableURL) where !alreadyEmbedded.contains(name) { + let frameworkDirName = "\(name).framework" + let source = binDir.appendingPathComponent(frameworkDirName, isDirectory: true) + guard FileManager.default.fileExists(atPath: source.path) else { + // Not every `@rpath` entry resolves to something SwiftPM staged in `binDir` (some + // are satisfied by other embedded frameworks' own re-exports) -- only copy what's + // actually there rather than failing the whole build over the rest. + continue + } + let destination = frameworksDir.appendingPathComponent(frameworkDirName) + guard !FileManager.default.fileExists(atPath: destination.path) else { continue } + try FileManager.default.copyItem(at: source, to: destination) + } + + let info: [String: Sendable] = [ + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleDevelopmentRegion": "en", + "CFBundleVersion": "1", + "CFBundleShortVersionString": "1.0.0", + "MinimumOSVersion": xcTest.deploymentTarget, + "CFBundleIdentifier": xcTest.bundleID, + "CFBundleName": xcTest.runnerProduct, + "CFBundleExecutable": xcTest.runnerProduct, + "CFBundleDisplayName": xcTest.runnerProduct, + "CFBundlePackageType": "APPL", + "UIDeviceFamily": [1, 2], + "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], + "UILaunchScreen": [:] as [String: Sendable], + "UIRequiredDeviceCapabilities": ["arm64"], + "LSRequiresIPhoneOS": true, + "CFBundleSupportedPlatforms": ["iPhoneOS"], + ] + let encodedPlist = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try encodedPlist.write(to: outputURL.appendingPathComponent("Info.plist")) + + let dest = URL(fileURLWithPath: "xtool").appendingPathComponent(outputURL.lastPathComponent) + try? FileManager.default.removeItem(at: dest) + try output.persist(at: dest) + return dest + } + @Sendable private func pack( product: Plan.Product, binDir: URL, diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index 11501af5..6a23e253 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -111,7 +111,55 @@ public struct Planner: Sendable { extensionProducts = [] } - return Plan(app: app, extensions: extensionProducts) + let xcTest = xcTestPlan(from: graph, app: app) + + return Plan(app: app, extensions: extensionProducts, xcTest: xcTest) + } + + /// SwiftPM combines every test target in the root package into a single ` + /// PackageTests` product (confirmed by reading SwiftPM's own source -- there is no + /// per-target test product). Rather than an artificial split, this mirrors that: one + /// combined `.xctest` bundle, with per-target/per-method selection handled at runtime via + /// `XCTestConfiguration.testsToRun`/`testsToSkip` (`xcodebuild -only-testing:`'s equivalent), + /// not by building separate bundles. + /// + /// - Important: confirmed against a real Darwin SDK (`swift build --build-tests --swift-sdk + /// arm64-apple-ios`) that if a test target depends (even transitively) on the same library + /// product wrapped as the app (`schema.product`/`app.product`), and that library contains + /// the app's `@main` entry point -- which is exactly how xtool's own app-wrapping model + /// requires it to be structured, see `product(from:matching:type:...)` above -- the combined + /// test product fails to link with a `duplicate symbol: main` error, because SwiftPM + /// statically links the test product against everything the test target depends on + /// (including that `@main`) while *also* generating its own XCTest bootstrap `main`. Real + /// Xcode sidesteps this because unit tests get dlopen'd into an already-running host + /// process rather than statically linked into a fresh executable; SwiftPM's package-testing + /// model has no equivalent. Not fixed here -- needs either user-facing guidance (keep + /// `@main` in a target the test target doesn't depend on, which is Apple's own recommended + /// SwiftPM structure anyway) or a build-graph-level fix, tracked as an open item. + private func xcTestPlan(from graph: PackageGraph, app: Plan.Product) -> Plan.XCTestPlan? { + let testTargets = (graph.root.targets ?? []).filter(\.isTestTarget) + guard !testTargets.isEmpty else { return nil } + + let deploymentTarget = graph.root.platforms?.first { $0.name == "ios" }?.version + ?? Self.minSupportedIOSVersion + + return Plan.XCTestPlan( + packageName: graph.root.name, + // Deliberately *not* derived from `app.bundleID`: a free/personal Apple Developer + // account is capped at a small number of new App ID registrations per rolling 7-day + // window, and deriving this per-app would silently cost every new project tested an + // extra registration on top of the app's own. One xtool install only ever tests one + // app at a time anyway, so a single shared runner ID (still team-scoped by + // `ProvisioningIdentifiers.identifier(fromSanitized:context:)`, so it doesn't collide + // across different developers/teams) is reused across every project instead. + bundleID: "xtool.xctrunner", + deploymentTarget: deploymentTarget, + entitlementsPath: schema.entitlementsPath, + testTargetNames: testTargets.map(\.name), + testTargetPaths: Dictionary(uniqueKeysWithValues: testTargets.compactMap { target in + target.path.map { (target.name, $0) } + }) + ) } // swiftlint:disable cyclomatic_complexity function_parameter_count @@ -295,11 +343,44 @@ public struct Planner: Sendable { public struct Plan: Sendable { public var app: Product public var extensions: [Product] + /// Non-nil when the package has at least one SwiftPM test target. See `Planner.xcTestPlan` + /// for why this isn't folded into `allProducts`/`Product` -- building and packaging it is a + /// meaningfully different process (drives `swift build --build-tests` on the real package + /// rather than the synthesized per-product wrapper package `Packer` otherwise always uses). + public var xcTest: XCTestPlan? public var allProducts: [Product] { [app] + extensions } + /// Describes the combined XCTest bundle (all test targets in the package) and the `Runner.app` + /// synthesized to host it, mirroring Xcode's `-Runner.app` convention. + public struct XCTestPlan: Sendable { + public var packageName: String + public var bundleID: String + public var deploymentTarget: String + public var entitlementsPath: String? + /// Every SwiftPM `.testTarget` in the root package (e.g. `["MyAppTests", "MyAppUITests"]`) + /// -- still built into one combined `.xctest` bundle (see `testProductName`'s doc comment + /// for why SwiftPM leaves no alternative), but exposed separately so a caller (`xtool + /// test`) can let the user pick one to actually run. + public var testTargetNames: [String] + /// Resolved source directory per test target name (e.g. `["MyAppTests": + /// "Sources/MyAppTests"]`), used to scope a chosen target down to its actual `XCTestCase` + /// class names for `testsToRun` filtering. A bare module name is *not* a valid + /// `XCTTestIdentifier` on its own -- confirmed against real hardware (this session): it + /// silently matches zero tests rather than "everything in that module" (unlike + /// `xcodebuild -only-testing:ModuleName`, which is a distinct, higher-level filter xtool + /// doesn't have access to here) -- so per-target selection instead expands to every + /// `XCTestCase` subclass whose source file lives under this path. + public var testTargetPaths: [String: String] + + /// SwiftPM's own product name for the combined test bundle (`PackageTests`). + public var testProductName: String { "\(packageName)PackageTests" } + /// Name of the runner's own synthesized executable target/product. + public var runnerProduct: String { "\(packageName)XCTRunner" } + } + public enum Resource: Codable, Sendable, Hashable { case bundle(package: String, target: String) case binaryTarget(name: String) @@ -409,9 +490,18 @@ private struct PackageDump: Decodable { struct Target: Decodable { let name: String let moduleType: String + // SwiftPM's `describe --type json` reports this as "type": "test"/"library"/"executable"/etc, + // distinct from moduleType ("SwiftTarget"/"ClangTarget"/etc). Only "test" is checked today. + let type: String? let productDependencies: [String]? let targetDependencies: [String]? let resources: [Resource]? + /// Resolved source directory (e.g. `Sources/MyAppTests`) -- SwiftPM allows test targets + /// under either `Sources/` or `Tests/`, so this is read from `describe`'s own + /// resolved value rather than assumed. + let path: String? + + var isTestTarget: Bool { type == "test" } } struct Resource: Decodable { diff --git a/Sources/XKit/Installation/AutoDDIMounter.swift b/Sources/XKit/Installation/AutoDDIMounter.swift new file mode 100644 index 00000000..6aba71a3 --- /dev/null +++ b/Sources/XKit/Installation/AutoDDIMounter.swift @@ -0,0 +1,75 @@ +// +// AutoDDIMounter.swift +// XKit +// +// Ties `DDIMounter`/`PersonalizedDDIMounter` (which know how to mount a DDI given its content) +// together with `DeveloperDiskImageRepository` (which knows how to fetch that content) into a +// single "make sure a DDI is mounted, downloading one if needed" entry point, distinct from +// `xtool sdk mount-ddi` (a manual, Xcode-source-requiring command) -- without this, every command +// that needs a DDI-gated service (testmanagerd, instruments, ...) would silently depend on some +// *other* tool having already mounted one this boot. +// + +import Foundation + +public enum AutoDDIMounter { + /// No-op if a Developer/Personalized image is already mounted. Dispatches to the classic + /// (pre-iOS 17) or personalized (17+) mounter based on `productVersion`, fetching the image + /// content from `DeveloperDiskImageRepository` if it isn't already cached locally. + public static func ensureMounted( + connection: Connection, + productVersion: String, + onProgress: @escaping @Sendable (Double) -> Void = { _ in } + ) async throws { + let majorVersion = Int(productVersion.split(separator: ".").first ?? "0") ?? 0 + + if majorVersion >= 17 { + #if os(Linux) + try await ensureMountedPersonalized(connection: connection, onProgress: onProgress) + #else + throw Error("Automatic personalized-DDI mounting is only implemented on Linux; use 'xtool sdk mount-ddi' instead.") + #endif + } else { + let mounter = try await DDIMounter(connection: connection) + guard try !mounter.isMounted() else { return } + let version = productVersion.split(separator: ".").prefix(2).joined(separator: ".") + let cacheDir = try DeveloperDiskImageRepository.cacheDirectory() + .appendingPathComponent("DeveloperDiskImages/\(version)", isDirectory: true) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + try await mounter.mountIfNeeded( + local: .init( + dmg: cacheDir.appendingPathComponent("DeveloperDiskImage.dmg"), + signature: cacheDir.appendingPathComponent("DeveloperDiskImage.dmg.signature") + ), + fetchRemote: { + let urls = try DeveloperDiskImageRepository.classicImageURLs(version: version) + return .init(dmg: urls.dmg, signature: urls.signature) + }, + progress: onProgress + ) + } + } + + #if os(Linux) + private static func ensureMountedPersonalized( + connection: Connection, + onProgress: @escaping @Sendable (Double) -> Void + ) async throws { + let ecid = try await connection.client.value(ofType: UInt64.self, forDomain: nil, key: "UniqueChipID") + let mounter = try await PersonalizedDDIMounter(connection: connection, ecid: ecid) + guard try !mounter.isMounted() else { return } + let resources = try await DeveloperDiskImageRepository.fetchPersonalized(onProgress: onProgress) + try await mounter.mount(resources: .init( + image: resources.image, + buildManifest: resources.buildManifest, + trustCache: resources.trustCache + )) + } + #endif + + struct Error: Swift.Error, LocalizedError { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } + } +} diff --git a/Sources/XKit/Installation/DDIMounter.swift b/Sources/XKit/Installation/DDIMounter.swift index 8c898e2c..84f304c5 100644 --- a/Sources/XKit/Installation/DDIMounter.swift +++ b/Sources/XKit/Installation/DDIMounter.swift @@ -1,5 +1,3 @@ -#if false - // // DDIMounter.swift // XKit @@ -10,13 +8,17 @@ import Foundation import SwiftyMobileDevice -#if os(Linux) -import FoundationNetworking -#endif +import Dependencies -public final class DDIMounter { +/// Mounts the Developer Disk Image (classic, pre-iOS 17) required to expose +/// developer-only lockdown services such as `com.apple.testmanagerd.lockdown`. +/// +/// For iOS 17+ devices, use ``PersonalizedDDIMounter`` instead -- Apple replaced the +/// single shared DDI with a per-device "personalized" image starting iOS 17. Callers +/// should branch on the device's product version; see `DDIMounter.mount(on:...)`. +public final class DDIMounter: Sendable { - public struct DDILoc { + public struct DDILoc: Sendable { public let dmg: URL public let signature: URL @@ -26,67 +28,17 @@ public final class DDIMounter { } } - private class RequestDelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { - enum Verdict { - case streamData(size: Int) - case downloadFull(URL) - case failed(Swift.Error) - } - - private var verdict: Verdict? - private let cache: URL - private let fileStream: OutputStream - private let pipedStream: OutputStream - private let onVerdict: (Verdict) -> Void - init(stream: OutputStream, cache: URL, onVerdict: @escaping (Verdict) -> Void) { - self.pipedStream = stream - self.cache = cache - self.fileStream = OutputStream(url: cache, append: false)! - self.onVerdict = onVerdict - stream.open() - fileStream.open() - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - let len = response.expectedContentLength - completionHandler(.allow) - if len == -1 { - verdict = .downloadFull(cache) - } else { - let verdict: Verdict = .streamData(size: Int(len)) - self.verdict = verdict - onVerdict(verdict) - } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - data.withUnsafeBytes { bytes in - let buf = bytes.bindMemory(to: UInt8.self) - _ = pipedStream.write(buf.baseAddress!, maxLength: buf.count) - _ = fileStream.write(buf.baseAddress!, maxLength: buf.count) - } - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Swift.Error?) { - pipedStream.close() - fileStream.close() - if case let .downloadFull(url) = verdict { - onVerdict(error.map(Verdict.failed) ?? .downloadFull(url)) - } - } - } - - private enum MounterStatus: String, Decodable { + enum MounterStatus: String, Decodable { case complete = "Complete" } - private struct MountedImage: Decodable { - let status: MounterStatus + /// `mobile_image_mounter_lookup_image` returns successfully (an empty-but-valid plist) even + /// when nothing is mounted, so a bare "did this throw" check always reports "mounted" + /// regardless of actual state -- callers must decode the response and check `status` instead. + /// Shared with `PersonalizedDDIMounter.isMounted()`, since the response shape doesn't differ + /// between classic and personalized image types. + struct MountedImage: Decodable { + let status: MounterStatus? let signature: [Data]? private enum CodingKeys: String, CodingKey { @@ -95,7 +47,7 @@ public final class DDIMounter { } } - private struct MountResult: Decodable { + struct MountResult: Decodable { let status: MounterStatus? let error: String? @@ -107,92 +59,82 @@ public final class DDIMounter { public struct Error: Swift.Error, LocalizedError { public let message: String? - public var errorDescription: String? { message } + public init(message: String?) { self.message = message } + public var errorDescription: String? { message ?? "Failed to mount the Developer Disk Image" } } private let client: MobileImageMounterClient - public init(connection: Connection) throws { - self.client = try connection.startClient() + public init(connection: Connection) async throws { + self.client = try await connection.startClient() } - private func mount(file: InputStream, size: Int, signature: Data) throws { - file.open() - defer { file.close() } + /// Returns `true` if a "Developer" image is already mounted. + public func isMounted() throws -> Bool { + let mounted = try client.lookup(imageType: "Developer", resultType: MountedImage.self) + return mounted.signature?.isEmpty == false + } - try client.upload(imageType: "Developer", file: file, size: size, signature: signature) + private func mount(data: Data, signature: Data) throws { + // InputStream(data:) is a plain memory-backed stream (unlike the CFSocketStream-based + // Stream.getBoundStreams piping this used to go through), so it works identically on + // Linux, macOS, and Windows. + let stream = InputStream(data: data) + stream.open() + defer { stream.close() } + + try client.upload(imageType: "Developer", file: stream, size: data.count, signature: signature) let result = try client.mount(imageType: "Developer", signature: signature, resultType: MountResult.self) guard result.status == .complete else { throw Error(message: result.error) } } - public func mountIfNeeded(local: DDILoc, fetchRemote: () throws -> DDILoc) throws { - #warning("we can't use CFSocketStream on Linux at all") - #if os(Linux) - // maybe just tear all of this down and download first, then mount the ddi. - fatalError("DDIMounter does not yet work on Linux") - #else - let mounted = try client.lookup(imageType: "Developer", resultType: MountedImage.self) - if mounted.signature != nil { return } - - if local.dmg.exists, - local.signature.exists, - let file = InputStream(url: local.dmg), - let fileSize = try? local.dmg.resourceValues(forKeys: [.fileSizeKey]).fileSize, - let signature = try? Data(contentsOf: local.signature) { - return try mount(file: file, size: fileSize, signature: signature) - } - - let remote = try fetchRemote() - - var istream: InputStream! - var ostream: OutputStream! - Stream.getBoundStreams(withBufferSize: 1024, inputStream: &istream, outputStream: &ostream) + /// Mounts the classic Developer Disk Image, using `local` as a cache and falling back to + /// `fetchRemote` (a pair of URLs to download) if it isn't present or is invalid. + /// + /// No-op if a Developer image is already mounted. + public func mountIfNeeded( + local: DDILoc, + fetchRemote: @Sendable () async throws -> DDILoc, + progress: @escaping @Sendable (Double) -> Void = { _ in } + ) async throws { + guard try !isMounted() else { return } + + @Dependency(\.httpClient) var httpClientDependency + let httpClient = httpClientDependency + + let dmgData: Data + let signature: Data + if let cachedDMG = try? Data(contentsOf: local.dmg), + let cachedSignature = try? Data(contentsOf: local.signature) { + dmgData = cachedDMG + signature = cachedSignature + progress(1) + } else { + let remote = try await fetchRemote() + + async let dmgResult = httpClient.makeRequest(HTTPRequest(url: remote.dmg)) { downloadProgress in + // signature download is comparatively tiny, so weight the dmg download + // as ~95% of overall progress + progress((downloadProgress ?? 0) * 0.95) + } + let (_, signatureData) = try await httpClient.makeRequest(HTTPRequest(url: remote.signature)) + let (_, dmgFetchedData) = try await dmgResult - let group = DispatchGroup() + dmgData = dmgFetchedData + signature = signatureData - var mode: RequestDelegate.Verdict! - group.enter() - let delegate = RequestDelegate(stream: ostream, cache: local.dmg) { m in - mode = m - group.leave() - } + try? FileManager.default.createDirectory( + at: local.dmg.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? dmgData.write(to: local.dmg) + try? signature.write(to: local.signature) - let dmgReq = URLRequest(url: remote.dmg) - let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - session.dataTask(with: dmgReq).resume() - - let sigReq = URLRequest(url: remote.signature) - var sigRes: Result! - group.enter() - URLSession.shared.dataTask(with: sigReq) { data, _, err in - if let data = data { - sigRes = .success(data) - } else { - sigRes = .failure(err ?? Error(message: nil)) - } - group.leave() - try? data?.write(to: local.signature) - }.resume() - - group.wait() - let signature = try sigRes.get() - - switch mode! { - case .downloadFull(let url): - let file = InputStream(url: url)! - let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize! - try mount(file: file, size: size, signature: signature) - case .streamData(let size): - try mount(file: istream, size: size, signature: signature) - case .failed(let error): - throw error + progress(1) } - _ = delegate - #endif + try mount(data: dmgData, signature: signature) } } - -#endif diff --git a/Sources/XKit/Installation/DeveloperDiskImageRepository.swift b/Sources/XKit/Installation/DeveloperDiskImageRepository.swift new file mode 100644 index 00000000..a397ff39 --- /dev/null +++ b/Sources/XKit/Installation/DeveloperDiskImageRepository.swift @@ -0,0 +1,98 @@ +// +// DeveloperDiskImageRepository.swift +// XKit +// +// Apple doesn't distribute Developer Disk Images (classic or personalized) outside of Xcode -- +// `PersonalizedDDIMounter`/`DDIMounter` can mount one once they have the actual image bytes, but +// sourcing those bytes on a machine with no Xcode is a separate problem. This fetches them from +// `doronz88/DeveloperDiskImage` (MIT-licensed, publicly hosted on GitHub), the same +// community-maintained mirror pymobiledevice3's own `auto_mount_personalized`/ +// `auto_mount_developer` use for exactly this purpose -- confirmed by reading +// `developer_disk_image.repo.DeveloperDiskImageRepository` (Apache-2.0-compatible; read for the +// repository layout/file paths only, not copied). +// + +import Foundation +import Dependencies + +public enum DeveloperDiskImageRepository { + private static let rawBaseURL = "https://raw.githubusercontent.com/doronz88/DeveloperDiskImage/main" + + public struct PersonalizedResources: Sendable { + public let image: Data + public let buildManifest: Data + public let trustCache: Data + } + + struct Error: Swift.Error, LocalizedError { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } + } + + /// Fetches (with persistent local disk caching, since these are unpersonalized/device- + /// independent and only need downloading once ever) the personalized DDI content used on + /// iOS 17+ from the repository. `onProgress` only tracks the image download (by far the + /// largest of the three files, ~15MB as of writing). + public static func fetchPersonalized( + onProgress: @escaping @Sendable (Double) -> Void = { _ in } + ) async throws -> PersonalizedResources { + let dir = try cacheDirectory().appendingPathComponent( + "PersonalizedImages/Xcode_iOS_DDI_Personalized", isDirectory: true + ) + let imageURL = dir.appendingPathComponent("Image.dmg") + let manifestURL = dir.appendingPathComponent("BuildManifest.plist") + let trustCacheURL = dir.appendingPathComponent("Image.dmg.trustcache") + + if let image = try? Data(contentsOf: imageURL), + let manifest = try? Data(contentsOf: manifestURL), + let trustCache = try? Data(contentsOf: trustCacheURL) { + onProgress(1) + return PersonalizedResources(image: image, buildManifest: manifest, trustCache: trustCache) + } + + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let base = "PersonalizedImages/Xcode_iOS_DDI_Personalized" + let manifest = try await fetch("\(base)/BuildManifest.plist") + let trustCache = try await fetch("\(base)/Image.dmg.trustcache") + let image = try await fetch("\(base)/Image.dmg", onProgress: onProgress) + + try image.write(to: imageURL) + try manifest.write(to: manifestURL) + try trustCache.write(to: trustCacheURL) + + return PersonalizedResources(image: image, buildManifest: manifest, trustCache: trustCache) + } + + /// Remote (not-yet-downloaded) locations for the classic DDI matching `version` ("major.minor", + /// e.g. "16.7"), for use with `DDIMounter.mountIfNeeded(local:fetchRemote:)`'s `fetchRemote` + /// parameter -- that function already downloads + locally caches from whatever URLs this + /// returns, so this doesn't fetch anything itself. + public static func classicImageURLs(version: String) throws -> (dmg: URL, signature: URL) { + let base = "DeveloperDiskImages/\(version)/DeveloperDiskImage.dmg" + guard let dmg = URL(string: "\(rawBaseURL)/\(base)"), + let signature = URL(string: "\(rawBaseURL)/\(base).signature") + else { + throw Error("Could not construct repository URL for DDI version \(version)") + } + return (dmg, signature) + } + + private static func fetch(_ path: String, onProgress: @escaping @Sendable (Double) -> Void = { _ in }) async throws -> Data { + guard let url = URL(string: "\(rawBaseURL)/\(path)") else { + throw Error("Invalid repository URL for \(path)") + } + @Dependency(\.httpClient) var httpClient + let (_, data) = try await httpClient.makeRequest(HTTPRequest(url: url)) { progress in + onProgress(progress ?? 0) + } + return data + } + + static func cacheDirectory() throws -> URL { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return base.appendingPathComponent("xtool/DeveloperDiskImageRepository", isDirectory: true) + } +} diff --git a/Sources/XKit/Installation/PersonalizedDDIMounter.swift b/Sources/XKit/Installation/PersonalizedDDIMounter.swift new file mode 100644 index 00000000..cc68aece --- /dev/null +++ b/Sources/XKit/Installation/PersonalizedDDIMounter.swift @@ -0,0 +1,283 @@ +// +// PersonalizedDDIMounter.swift +// XKit +// + +#if os(Linux) +import Foundation +import SwiftyMobileDevice +import libimobiledevice +import plist +import CTatsu +import Crypto + +/// Mounts the "Personalized" Developer Disk Image used on iOS 17+, where Apple replaced the +/// single shared DDI with a per-device image tied to the device's ECID via a TSS ticket. +/// +/// The device-side protocol (query nonce/personalization identifiers/manifest, upload, mount +/// with options) is implemented in `libimobiledevice` (see `mobile_image_mounter.h`), and the +/// TSS request/response transport is implemented in `libtatsu`. Neither is wrapped by +/// `SwiftyMobileDevice` yet, so this type talks to both C libraries directly via the `.raw` +/// handle `MobileImageMounterClient` already exposes publicly. +/// +/// The exact TSS request field set below is transcribed (not copied verbatim -- rewritten in +/// Swift against the plist C API) from the documented, working reference implementation in +/// pymobiledevice3's `PersonalizedImageMounter.get_manifest_from_tss` (GPL-3.0; read only to +/// document the protocol, not copied, so it doesn't carry its license into this MIT-licensed +/// project). +/// +/// - Important: This has been validated to compile and exercises real `libimobiledevice`/ +/// `libtatsu` C APIs, but has **not** been exercised against a real device/TSS round trip +/// (no physical iOS 17+ device was available while writing this). Treat as unverified until +/// run against real hardware. +public final class PersonalizedDDIMounter: Sendable { + + public struct Resources: Sendable { + /// The personalized `.dmg` image data (device-independent; only the manifest/ticket is + /// device-specific). + public let image: Data + /// `BuildManifest.plist` contents describing the trusted manifest per board/chip ID. + public let buildManifest: Data + /// The image's loadable trust cache, passed to the device as `ImageTrustCache`. + public let trustCache: Data + /// Optional `ImageInfoPlist` extra. + public let infoPlist: [String: Sendable]? + + public init(image: Data, buildManifest: Data, trustCache: Data, infoPlist: [String: Sendable]? = nil) { + self.image = image + self.buildManifest = buildManifest + self.trustCache = trustCache + self.infoPlist = infoPlist + } + } + + public struct Error: Swift.Error, LocalizedError { + public let message: String + public init(_ message: String) { self.message = message } + public var errorDescription: String? { message } + } + + private static let imageType = "Personalized" + private static let personalizedImageType = "DeveloperDiskImage" + private static let tssControllerURL = "https://gs.apple.com/TSS/controller?action=2" + + private let client: MobileImageMounterClient + private let ecid: UInt64 + + /// - Parameter ecid: the target device's ECID (as reported by lockdown's `UniqueChipID`), + /// required as part of the TSS request. + public init(connection: Connection, ecid: UInt64) async throws { + self.client = try await connection.startClient() + self.ecid = ecid + } + + public func isMounted() throws -> Bool { + // Checks the lookup's actual content (a non-empty `ImageSignature`), not just whether the + // call succeeded -- see `DDIMounter.MountedImage`'s doc comment for why the latter always + // reports "mounted" regardless of real state. + guard let mounted = try? client.lookup(imageType: Self.imageType, resultType: DDIMounter.MountedImage.self) else { + return false + } + return mounted.signature?.isEmpty == false + } + + public func mount(resources: Resources) async throws { + let imageDigest = Data(Crypto.SHA384.hash(data: resources.image)) + + // Prefer a manifest the device already has cached for this exact image; only fall back + // to a fresh TSS round trip if it doesn't (mirrors pymobiledevice3's approach, which + // notes the service connection must be re-established after a MissingManifest failure). + let manifest: Data + if let cached = try? client.queryPersonalizationManifest( + imageType: Self.personalizedImageType, + signature: imageDigest + ) { + manifest = cached + } else { + manifest = try await requestManifestFromTSS(buildManifest: resources.buildManifest) + } + + try client.uploadPersonalized(imageType: Self.imageType, image: resources.image, manifest: manifest) + + try client.mountPersonalized( + imageType: Self.imageType, + signature: manifest, + trustCache: resources.trustCache, + infoPlist: resources.infoPlist + ) + } + + // MARK: - TSS + + private func requestManifestFromTSS(buildManifest: Data) async throws -> Data { + guard let manifestPlist = PlistValue.parse(xml: buildManifest), + case .dictionary(let manifestDict) = manifestPlist, + case .array(let buildIdentities)? = manifestDict["BuildIdentities"] + else { + throw Error("Could not parse BuildManifest.plist") + } + + let identifiers = try client.queryPersonalizationIdentifiers(imageType: nil) + guard case .uint(let boardID)? = identifiers["BoardId"], + case .uint(let chipID)? = identifiers["ChipID"] + else { + throw Error("Device did not report BoardId/ChipID personalization identifiers") + } + + guard let buildIdentity = buildIdentities.first(where: { identity in + guard case .dictionary(let dict) = identity, + case .string(let apBoardID)? = dict["ApBoardID"], + case .string(let apChipID)? = dict["ApChipID"], + let parsedBoardID = UInt64(apBoardID.dropFirst(2), radix: 16), + let parsedChipID = UInt64(apChipID.dropFirst(2), radix: 16) + else { return false } + return parsedBoardID == boardID && parsedChipID == chipID + }), case .dictionary(let buildIdentityDict) = buildIdentity, + case .dictionary(let manifestEntries)? = buildIdentityDict["Manifest"] + else { + throw Error("Could not find a build identity matching board 0x\(String(boardID, radix: 16)) / chip 0x\(String(chipID, radix: 16))") + } + + let nonce = try client.queryNonce(imageType: Self.personalizedImageType) + + var request: [String: PlistValue] = [:] + for (key, value) in identifiers where key.hasPrefix("Ap,") { + request[key] = value + } + request["@ApImg4Ticket"] = .bool(true) + request["@BBTicket"] = .bool(true) + request["ApBoardID"] = .uint(boardID) + request["ApChipID"] = .uint(chipID) + request["ApECID"] = .uint(ecid) + request["ApNonce"] = .data(nonce) + request["ApProductionMode"] = .bool(true) + request["ApSecurityDomain"] = .uint(1) + request["ApSecurityMode"] = .bool(true) + request["SepNonce"] = .data(Data(count: 20)) + request["UID_MODE"] = .bool(false) + + for (key, entry) in manifestEntries { + guard case .dictionary(var entryDict) = entry, entryDict["Info"] != nil else { continue } + guard case .bool(true)? = entryDict["Trusted"] else { continue } + entryDict["Info"] = nil + if entryDict["Digest"] == nil { + entryDict["Digest"] = .data(Data()) + } + request[key] = .dictionary(entryDict) + } + + let requestPlist = PlistValue.dictionary(request).toPlistT() + defer { plist_free(requestPlist) } + + guard let fullRequest = tss_request_new(requestPlist) else { + throw Error("tss_request_new failed") + } + defer { plist_free(fullRequest) } + + guard let response = tss_request_send(fullRequest, Self.tssControllerURL) else { + throw Error("TSS request failed (no response, or server rejected the request)") + } + defer { plist_free(response) } + + var ticketPtr: UnsafeMutablePointer? + var ticketLength: UInt32 = 0 + guard tss_response_get_ap_img4_ticket(response, &ticketPtr, &ticketLength) == 0, let ticketPtr else { + throw Error("TSS response did not contain an ApImg4Ticket") + } + defer { free(ticketPtr) } + return Data(bytes: ticketPtr, count: Int(ticketLength)) + } + +} + +// MARK: - MobileImageMounterClient extensions for personalization calls not yet wrapped by +// SwiftyMobileDevice. Operates on the public `.raw` handle only, so no changes to +// SwiftyMobileDevice are required. + +extension MobileImageMounterClient { + + func queryNonce(imageType: String) throws -> Data { + var noncePtr: UnsafeMutablePointer? + var nonceLength: UInt32 = 0 + let status = mobile_image_mounter_query_nonce(raw, imageType, &noncePtr, &nonceLength) + guard status == MOBILE_IMAGE_MOUNTER_E_SUCCESS, let noncePtr else { + throw Error(status) ?? Error.unknown + } + defer { free(noncePtr) } + return Data(bytes: noncePtr, count: Int(nonceLength)) + } + + func queryPersonalizationIdentifiers(imageType: String?) throws -> [String: PlistValue] { + var result: plist_t? + let status = mobile_image_mounter_query_personalization_identifiers(raw, imageType, &result) + guard status == MOBILE_IMAGE_MOUNTER_E_SUCCESS, let result else { + throw Error(status) ?? Error.unknown + } + defer { plist_free(result) } + guard case .dictionary(let dict) = PlistValue(plistT: result) else { + throw Swift.type(of: self).Error.unknown + } + return dict + } + + func queryPersonalizationManifest(imageType: String, signature: Data) throws -> Data { + var manifestPtr: UnsafeMutablePointer? + var manifestLength: UInt32 = 0 + let status = signature.withUnsafeBytes { buf -> mobile_image_mounter_error_t in + let bound = buf.bindMemory(to: UInt8.self) + return mobile_image_mounter_query_personalization_manifest( + raw, imageType, bound.baseAddress, UInt32(bound.count), &manifestPtr, &manifestLength + ) + } + guard status == MOBILE_IMAGE_MOUNTER_E_SUCCESS, let manifestPtr else { + throw Error(status) ?? Error.unknown + } + defer { free(manifestPtr) } + return Data(bytes: manifestPtr, count: Int(manifestLength)) + } + + func uploadPersonalized(imageType: String, image: Data, manifest: Data) throws { + let stream = InputStream(data: image) + stream.open() + defer { stream.close() } + try upload(imageType: imageType, file: stream, size: image.count, signature: manifest) + } + + func mountPersonalized( + imageType: String, + signature: Data, + trustCache: Data, + infoPlist: [String: Sendable]? + ) throws { + var options: [String: PlistValue] = [ + "ImageTrustCache": .data(trustCache), + ] + if let infoPlist { + let data = try PropertyListSerialization.data(fromPropertyList: infoPlist, format: .xml, options: 0) + if let parsed = PlistValue.parse(xml: data) { + options["ImageInfoPlist"] = parsed + } + } + let optionsPlist = PlistValue.dictionary(options).toPlistT() + defer { plist_free(optionsPlist) } + + var result: plist_t? + let status = signature.withUnsafeBytes { buf -> mobile_image_mounter_error_t in + let bound = buf.bindMemory(to: UInt8.self) + return mobile_image_mounter_mount_image_with_options( + raw, "", bound.baseAddress, UInt32(bound.count), imageType, optionsPlist, &result + ) + } + defer { if let result { plist_free(result) } } + guard status == MOBILE_IMAGE_MOUNTER_E_SUCCESS else { + throw Error(status) ?? Error.unknown + } + guard let result, case .dictionary(let dict) = PlistValue(plistT: result), + case .string(let statusString)? = dict["Status"], statusString == "Complete" + else { + throw type(of: self).Error.commandFailed + } + } + +} +#endif diff --git a/Sources/XKit/Testing/Artifacts/CrashLogClient.swift b/Sources/XKit/Testing/Artifacts/CrashLogClient.swift new file mode 100644 index 00000000..b856c954 --- /dev/null +++ b/Sources/XKit/Testing/Artifacts/CrashLogClient.swift @@ -0,0 +1,111 @@ +// +// CrashLogClient.swift +// XKit +// +// Wraps `com.apple.crashreportmover`/`com.apple.crashreportcopymobile` directly, same rationale +// as `ScreenshotClient`/`SyslogRelayClient`'s header comments -- neither is a typed +// `SwiftyMobileDevice` client. Protocol transcribed (not copied -- clean-room, matching the rest +// of this directory) from `libimobiledevice`'s own `idevicecrashreport.c` tool (LGPL-2.1, +// read for the two-service handshake shape only): +// +// 1. Start `com.apple.crashreportmover` and read up to 10 times (2s timeout each) for a +// 4-byte "ping" -- this tells the device to relocate crash logs from wherever they're +// staged into the location the second service can actually read. +// 2. Start `com.apple.crashreportcopymobile`, which -- unlike the two services above -- speaks +// plain AFC, so it's usable through `SwiftyMobileDevice`'s existing `AFCClient` once +// constructed from the right raw `afc_client_t` (the same way `house_arrest` vends a +// differently-scoped AFC jail elsewhere in this codebase). + +import Foundation +import SwiftyMobileDevice +import libimobiledevice + +public struct CrashLogClient: Sendable { + public struct Error: Swift.Error, LocalizedError { + public let message: String + public init(_ message: String) { self.message = message } + public var errorDescription: String? { message } + } + + private enum ServiceName { + static let mover = "com.apple.crashreportmover" + static let copyMobile = "com.apple.crashreportcopymobile" + } + + private let afc: AFCClient + + public init(connection: Connection) throws { + try Self.waitForMover(connection: connection) + + var descriptor: lockdownd_service_descriptor_t? + let status = lockdownd_start_service(connection.client.raw, ServiceName.copyMobile, &descriptor) + guard status == LOCKDOWN_E_SUCCESS, let descriptor else { + throw Error("Could not start \(ServiceName.copyMobile) (status \(status.rawValue))") + } + defer { lockdownd_service_descriptor_free(descriptor) } + + var afcRaw: afc_client_t? + let afcStatus = afc_client_new(connection.device.raw, descriptor, &afcRaw) + guard afcStatus == AFC_E_SUCCESS, let afcRaw else { + throw Error("Could not open AFC over \(ServiceName.copyMobile) (status \(afcStatus.rawValue))") + } + self.afc = AFCClient(raw: afcRaw) + } + + /// Best-effort, matching `idevicecrashreport.c`: proceeds to open the copy-mobile service + /// regardless of whether a "ping" was actually observed within 10 attempts, rather than + /// hard-failing -- the copy service works even without mover confirmation, just possibly + /// missing very recently written logs. + private static func waitForMover(connection: Connection) throws { + var descriptor: lockdownd_service_descriptor_t? + let status = lockdownd_start_service(connection.client.raw, ServiceName.mover, &descriptor) + guard status == LOCKDOWN_E_SUCCESS, let descriptor else { + throw Error("Could not start \(ServiceName.mover) (status \(status.rawValue))") + } + defer { lockdownd_service_descriptor_free(descriptor) } + + var client: service_client_t? + let clientStatus = service_client_new(connection.device.raw, descriptor, &client) + guard clientStatus == SERVICE_E_SUCCESS, let client else { + throw Error("Could not open \(ServiceName.mover) connection (status \(clientStatus.rawValue))") + } + defer { service_client_free(client) } + + let pingBytes = Data("ping".utf8) + for _ in 0..<10 { + var buffer = [UInt8](repeating: 0, count: 4) + var received: UInt32 = 0 + let recvStatus = buffer.withUnsafeMutableBytes { ptr in + service_receive_with_timeout(client, ptr.bindMemory(to: CChar.self).baseAddress, 4, &received, 2000) + } + guard recvStatus == SERVICE_E_SUCCESS || recvStatus == SERVICE_E_TIMEOUT else { + throw Error("Crash log mover connection interrupted (status \(recvStatus.rawValue))") + } + if recvStatus == SERVICE_E_SUCCESS, received == 4, Data(buffer) == pingBytes { + return + } + } + } + + /// Filenames directly under the crash-log-copy service's root -- already scoped to just crash + /// logs (this service doesn't expose the rest of the filesystem), so no path filtering needed. + public func listCrashReports() throws -> [String] { + try afc.contentsOfDirectory(at: URL(fileURLWithPath: "/")) + } + + /// AFC's standard file-info dictionary (`st_mtime`, `st_size`, etc.) for one crash log. + public func fileInfo(for name: String) throws -> [String: String] { + try afc.fileInfo(for: URL(fileURLWithPath: "/\(name)")) + } + + public func readCrashReport(_ name: String) throws -> Data { + let file = try afc.open(URL(fileURLWithPath: "/\(name)"), mode: .readOnly) + var data = Data() + while true { + let chunk = try file.read(maxLength: 1 << 16) + if chunk.isEmpty { break } + data += chunk + } + return data + } +} diff --git a/Sources/XKit/Testing/Artifacts/ScreenshotClient.swift b/Sources/XKit/Testing/Artifacts/ScreenshotClient.swift new file mode 100644 index 00000000..cadedda5 --- /dev/null +++ b/Sources/XKit/Testing/Artifacts/ScreenshotClient.swift @@ -0,0 +1,45 @@ +// +// ScreenshotClient.swift +// XKit +// +// Wraps `libimobiledevice/screenshotr.h` directly -- there's no typed `SwiftyMobileDevice` +// client for this service (same situation `TestManagerdSession`'s header comment documents for +// DTX services), so this talks to the C API the same way `PersonalizedDDIMounter` does for +// `mobile_image_mounter.h`: via the raw `idevice_t` handle `Device` already exposes publicly +// (`connection.device.raw`), not by patching the vendored `SwiftyMobileDevice` package. + +import Foundation +import SwiftyMobileDevice +import libimobiledevice + +public final class ScreenshotClient: Sendable { + public struct Error: Swift.Error, LocalizedError { + public let message: String + public init(_ message: String) { self.message = message } + public var errorDescription: String? { message } + } + + private nonisolated(unsafe) let raw: screenshotr_client_t + + public init(connection: Connection) throws { + var client: screenshotr_client_t? + let status = screenshotr_client_start_service(connection.device.raw, &client, "xtool-test-screenshot") + guard status == SCREENSHOTR_E_SUCCESS, let client else { + throw Error("Could not start the screenshotr service (status \(status.rawValue))") + } + self.raw = client + } + + deinit { screenshotr_client_free(raw) } + + public func takeScreenshot() throws -> Data { + var buffer: UnsafeMutablePointer? + var size: UInt64 = 0 + let status = screenshotr_take_screenshot(raw, &buffer, &size) + guard status == SCREENSHOTR_E_SUCCESS, let buffer else { + throw Error("Failed to capture a screenshot (status \(status.rawValue))") + } + defer { free(buffer) } + return Data(bytes: buffer, count: Int(size)) + } +} diff --git a/Sources/XKit/Testing/Artifacts/SyslogRelayClient.swift b/Sources/XKit/Testing/Artifacts/SyslogRelayClient.swift new file mode 100644 index 00000000..15be10ba --- /dev/null +++ b/Sources/XKit/Testing/Artifacts/SyslogRelayClient.swift @@ -0,0 +1,99 @@ +// +// SyslogRelayClient.swift +// XKit +// +// Wraps `libimobiledevice/syslog_relay.h` directly, same rationale as `ScreenshotClient`'s +// header comment. `syslog_relay_start_capture_raw` spawns its own internal worker thread inside +// libimobiledevice (confirmed by reading `syslog_relay.c`'s `syslog_relay_start_capture`, which +// calls `thread_new` and returns immediately) and delivers data one byte at a time via a C +// callback -- there is no dedicated `Thread`/blocking loop needed on the Swift side the way +// `DTXConnection` needs one for *its* genuinely-blocking C calls. + +import Foundation +import SwiftyMobileDevice +import libimobiledevice + +public final class SyslogRelayClient: Sendable { + public struct Error: Swift.Error, LocalizedError { + public let message: String + public init(_ message: String) { self.message = message } + public var errorDescription: String? { message } + } + + private nonisolated(unsafe) let raw: syslog_relay_client_t + private let box = LineBox() + + public init(device: Device, label: String) throws { + var client: syslog_relay_client_t? + let status = syslog_relay_client_start_service(device.raw, &client, label) + guard status == SYSLOG_RELAY_E_SUCCESS, let client else { + throw Error("Could not start the syslog_relay service (status \(status.rawValue))") + } + self.raw = client + } + + deinit { + syslog_relay_stop_capture(raw) + syslog_relay_client_free(raw) + } + + /// Yields complete lines as they arrive. The stream finishes once `stop()` is called (which + /// ends the underlying capture, causing libimobiledevice's worker thread to stop invoking the + /// callback) or the client is deallocated. + public func lines() -> AsyncStream { + AsyncStream { continuation in + box.setContinuation(continuation) + let opaque = Unmanaged.passUnretained(box).toOpaque() + let status = syslog_relay_start_capture_raw(raw, { char, userData in + guard let userData else { return } + Unmanaged.fromOpaque(userData).takeUnretainedValue().append(char) + }, opaque) + if status != SYSLOG_RELAY_E_SUCCESS { + continuation.finish() + } + } + } + + public func stop() { + syslog_relay_stop_capture(raw) + box.finish() + } +} + +/// Accumulates bytes delivered one at a time (by libimobiledevice's own worker thread, not one we +/// control) into complete lines, forwarding each to an `AsyncStream.Continuation`. A plain class +/// (not an actor) since the C callback is synchronous and can't `await` a hop onto one -- the lock +/// is what makes `buffer` safe to mutate from that background thread. +private final class LineBox: @unchecked Sendable { + private var continuation: AsyncStream.Continuation? + private var buffer = "" + private let lock = NSLock() + + func setContinuation(_ continuation: AsyncStream.Continuation) { + lock.lock() + self.continuation = continuation + lock.unlock() + } + + func append(_ char: CChar) { + let scalar = Character(UnicodeScalar(UInt8(bitPattern: char))) + lock.lock() + if scalar == "\n" { + let line = buffer + buffer = "" + let continuation = self.continuation + lock.unlock() + continuation?.yield(line) + } else { + buffer.append(scalar) + lock.unlock() + } + } + + func finish() { + lock.lock() + let continuation = self.continuation + lock.unlock() + continuation?.finish() + } +} diff --git a/Sources/XKit/Testing/DTX/DTXConnection.swift b/Sources/XKit/Testing/DTX/DTXConnection.swift new file mode 100644 index 00000000..1520a843 --- /dev/null +++ b/Sources/XKit/Testing/DTX/DTXConnection.swift @@ -0,0 +1,469 @@ +// +// DTXConnection.swift +// XKit +// +// The channel/dispatch layer on top of DTXTransport + DTXMessage: opens named "channels" over a +// single DTX socket (mirroring `DTXConnection`/`DTXChannel` in Apple's private +// DTXConnectionServices framework), correlates request/reply pairs by message identifier, and +// dispatches unsolicited device-initiated calls (e.g. `_XCT_testCaseDidFinishForTestClass:...`) +// to registered handlers. Structured after the documented, working reference in +// appium-ios-device's `lib/instrument/index.js` (Apache-2.0 -- read for the channel-open/ +// request-reply protocol, rewritten from scratch in Swift here). +// +// Channel addressing: channel 0 is the always-open "root" channel. To open a named service +// channel, the client picks an unused positive `Int32` code and calls +// `_requestChannelWithCode:identifier:` on the root channel. The device replies to further calls +// on that channel using the client's code, but sends *unsolicited* notifications back on the +// two's-complement negation of that code (e.g. if the client opened code 1, the device's +// `_XCT_...` callbacks arrive tagged with channelCode -1). + +import Foundation + +actor DTXConnection { + + enum ConnectionError: Swift.Error { + case closed + case replyTimeout + case unexpectedEmptyReply + } + + private let transport: any DTXByteTransport + /// Serializes every send/receive on **every** `DTXConnection`'s underlying + /// `idevice_connection_t`, process-wide -- not just within a single connection. + /// + /// Confirmed against real hardware (an iOS 16.7 iPhone) that per-connection locking alone is + /// necessary but not sufficient. Necessary: without any lock, a connection's background read + /// loop racing its own in-flight `call()`'s send reliably broke the SSL session + /// (`IDEVICE_E_SSL_ERROR`, since `idevice_connection_send`/`_receive` call OpenSSL's + /// `SSL_write`/`SSL_read` directly on a shared `SSL*` with no internal locking -- see + /// `libimobiledevice/src/idevice.c`). Not sufficient: `TestManagerdSession` opens three + /// `DTXConnection`s whose read threads all run concurrently for the session's whole lifetime; + /// with only a per-connection lock, runs got progressively further (past the + /// `InstallationProxyClient.lookup` crash and the `close()`/receive race, both fixed + /// separately) but then failed non-deterministically at *different* handshake steps each + /// time with a clean `ConnectionError.closed` (an unexpected EOF on whichever connection was + /// waiting for a reply) -- no crash, just cross-connection corruption/interference at the + /// shared native layer underneath (`libusbmuxd`'s client-side multiplexing is the most likely + /// candidate, per its non-reentrant-looking C API). + /// + /// A `static let ioLock = NSLock()` version of this was tried first and reverted: `NSLock` + /// gives no fairness guarantee, and with three read threads perpetually re-acquiring it in a + /// tight poll loop, a `send()` arriving on a fourth thread was starved for minutes (confirmed + /// against real hardware as a genuine hang, not just added latency). A serial + /// `DispatchQueue` fixes that: GCD guarantees FIFO execution order for work submitted to a + /// serial queue, so a `send()` can only ever wait behind operations already queued *before* + /// it, never behind ones that arrive after -- bounding worst-case wait to (queue depth at + /// submission time) x `readPollTimeout`, not indefinite. + private static let legacyIOQueue = DispatchQueue(label: "xtool.dtx.io.legacy") + /// The classic transport's shared-native-state risk (documented above) doesn't apply to the + /// iOS 17.4+ tunnel transport (`TunnelDTXTransport`/`PosixTCPSocket`): each connection owns an + /// independent POSIX file descriptor, which the kernel already serializes safely per-fd on its + /// own -- there's no shared `SSL*`/`libusbmuxd` state to protect. Forcing tunnel connections + /// through the same global queue as the legacy ones only adds contention with no safety + /// benefit, and was confirmed as the source of a real, reproducible failure on real hardware + /// (iPhone 17 Pro, this session): `TestManagerdSession` runs three DTX connections + /// concurrently, and during the traffic burst right as the runner launches, one of them could + /// be starved behind the other two's 200ms poll cycles for long enough that testmanagerd gave + /// up waiting for its reply and closed the socket from its side (`DTXSocketTransport ... + /// disconnected` in the device's own log, with no error at all on this end -- the read loop + /// was still faithfully retrying its timed-out poll, just never getting a turn). Each tunnel + /// connection gets its own private queue instead; only the legacy transport still shares one. + /// `nonisolated`: read from `receiveChunk`/`send`/`close`, which run off the actor (the + /// dedicated read `Thread` and callers awaiting `call()`) -- safe without `await` since it + /// only reads immutable `let`s (`transport`, `privateIOQueue`). + private nonisolated var ioQueue: DispatchQueue { + transport is DTXTransport ? Self.legacyIOQueue : privateIOQueue + } + private let privateIOQueue = DispatchQueue(label: "xtool.dtx.io.tunnel") + private static let readPollTimeout: TimeInterval = 0.2 + + /// Guarded by `ioQueue`, not actor isolation -- read from the dedicated read `Thread` + /// (see `start()`), which cannot `await` its way onto the actor without breaking the "never + /// block the cooperative thread pool" property this whole design exists for (see `start()`'s + /// doc comment). + private nonisolated(unsafe) var stopRequested = false + + private var nextIdentifier: UInt32 = 1 + private var nextClientChannelCode: Int32 = 1 + private var openChannels: [String: Int32] = [:] + /// Channels the *device* has requested (via its own unsolicited `_requestChannelWithCode: + /// identifier:` call on the root channel), keyed by channel name, holding the code the device + /// chose. See `waitForDeviceChannel`'s doc comment for why this exists. + private var deviceRequestedChannels: [String: Int32] = [:] + + private var pendingReplies: [UInt32: CheckedContinuation] = [:] + private var selectorHandlers: [String: [@Sendable (DTXMessage) -> Void]] = [:] + private var unhandledHandler: (@Sendable (DTXMessage) -> Void)? + /// Selectors that must reply with an actual payload instead of the default empty ack. + /// Confirmed necessary against go-ios's `proxydispatcher.go`: `_XCT_ + /// testRunnerReadyWithCapabilities:` specifically requires the reply payload to *be* the + /// archived `XCTestConfiguration`, or `XCTTargetBootstrap` on the device never observes the + /// test daemon as ready and the run hangs forever waiting on a notification that never comes. + private var selectorReplyHandlers: [String: @Sendable (DTXMessage) -> NSKeyedValue] = [:] + + private var started = false + private var closed = false + + init(transport: any DTXByteTransport) { + self.transport = transport + } + + /// Starts the background read loop. Must be called once before any `callChannel`/`makeChannel`. + /// + /// Runs on a dedicated `Thread`, **not** `Task.detached`/`Task { ... }`. Confirmed against + /// real hardware that this matters: Swift's concurrency runtime services `Task.detached` work + /// from a small, fixed-size cooperative thread pool, and this loop makes a genuinely blocking + /// synchronous C call (`idevice_connection_receive_timeout`) on every iteration for as long as + /// the connection is open. Parking a `Task.detached` on a blocking call like that starves the + /// pool of a thread indefinitely; with 2-3 such connections open at once (this session opens + /// three), the pool was exhausted and unrelated async work elsewhere in the process -- + /// including this actor's own `call()` continuations -- stopped being scheduled at all, + /// hanging the whole process. A plain `Thread` isn't drawn from that pool, so it can't starve it. + func start() { + guard !started else { return } + started = true + let thread = Thread { [transport, weak self] in + self?.readLoop(transport: transport) + } + thread.name = "xtool.dtx.read" + thread.start() + } + + func close() { + guard !closed else { return } + closed = true + // `transport.close()` must happen inside the same `ioQueue.sync` block as setting + // `stopRequested`, not after: the read thread's `receiveChunk` runs its blocking + // `transport.receive` call inside `ioQueue.sync` too (up to `readPollTimeout`), so + // closing the transport outside the queue let it race an in-flight receive on the same + // connection -- confirmed against real hardware as a reproducible use-after-free + // (SIGSEGV inside `SSL_read`, triggered by `TestManagerdSession.stop()` calling `close()` + // on all three connections while their read threads were still mid-receive). Doing both + // inside one `sync` block means `close()` waits for any in-flight receive to finish + // (bounded by `readPollTimeout`) before tearing down the transport, and the read thread's + // next `shouldStop()`/`receiveChunk` call is guaranteed to happen after the transport is + // closed, not concurrently with it. + ioQueue.sync { + stopRequested = true + transport.close() + } + for continuation in pendingReplies.values { + continuation.resume(throwing: ConnectionError.closed) + } + pendingReplies.removeAll() + } + + // MARK: - Handler registration + + /// Registers a handler for unsolicited device-initiated calls to `selector` (e.g. + /// `_XCT_testCaseDidFinishForTestClass:method:withStatus:duration:`), regardless of which + /// channel they arrive on. + func onSelector(_ selector: String, handler: @escaping @Sendable (DTXMessage) -> Void) { + selectorHandlers[selector, default: []].append(handler) + } + + func onUnhandled(_ handler: @escaping @Sendable (DTXMessage) -> Void) { + unhandledHandler = handler + } + + /// Registers a handler for an unsolicited device-initiated call to `selector` whose reply + /// must carry `handler`'s returned payload, instead of the default empty ack `handle()` sends + /// for every other message. See `selectorReplyHandlers`'s doc comment for why this exists. + func onSelectorWithReply(_ selector: String, handler: @escaping @Sendable (DTXMessage) -> NSKeyedValue) { + selectorReplyHandlers[selector] = handler + } + + // MARK: - Channels + + /// Opens (or returns the already-open) client-side channel code for `channelName`. + @discardableResult + func makeChannel(_ channelName: String) async throws -> Int32 { + if let existing = openChannels[channelName] { + return existing + } + let code = nextClientChannelCode + nextClientChannelCode += 1 + var aux = DTXAuxiliaryBuffer() + aux.append(.int32(code)) + aux.append(.object(.string(channelName))) + _ = try await call( + channelCode: 0, + selector: "_requestChannelWithCode:identifier:", + auxiliary: aux, + expectsReply: true + ) + openChannels[channelName] = code + return code + } + + /// Waits for the *device* to open **any** channel via its own unsolicited + /// `_requestChannelWithCode:identifier:` call -- used purely as a readiness signal, not to + /// address further calls to that specific channel (`_IDE_startExecutingTestPlanWithProtocolVersion:` + /// still goes out on the fixed "magic channel", `Self.magicChannel` in `TestManagerdSession`, + /// same as before -- see below for why that channel code specifically is still correct). + /// + /// Confirmed against real hardware (iOS 16.7) that sending + /// `_IDE_startExecutingTestPlanWithProtocolVersion:` right after `_IDE_ + /// authorizeTestSessionWithProcessID:` succeeds, with nothing else awaited first, reliably + /// left the on-device test runner never progressing past its own initial bootstrap -- + /// testmanagerd reported the exec-test-plan session as merely "waiting to pair" indefinitely, + /// no crash, no error, no further DTX traffic at all. go-ios's `xcuitestrunner_12.go`/ + /// `xcuitestrunner.go` (read for control flow only, not copied -- see this file's header + /// comment) resolve this by waiting for the device's own `_requestChannelWithCode:identifier:` + /// call to arrive (its own comment: "for some reason it requests the TestDriver proxy channel + /// with code 1 but sends messages on -1" -- i.e. even go-ios still hardcodes channel -1 for + /// the actual send; the device's channel-open request is used purely as a synchronization + /// gate, not to learn which channel code to use) before sending on the magic channel. + func waitForAnyDeviceChannelRequest(timeout: TimeInterval = 30) async throws { + // Polls rather than using a continuation: `handle()` (which populates + // `deviceRequestedChannels`) runs on this same actor, so each `Task.sleep` below + // suspends this method and yields the actor's executor to it between checks -- simpler + // and leak-free compared to managing continuations that might never get resumed if the + // device never opens any channel at all (a real, possible outcome this function must + // handle, not just a slow-path edge case). + let deadline = Date().addingTimeInterval(timeout) + while true { + if !deviceRequestedChannels.isEmpty { + return + } + guard Date() < deadline else { throw ConnectionError.replyTimeout } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + /// The code the device chose for whichever channel it requested via its own unsolicited + /// `_requestChannelWithCode:identifier:` (see `waitForAnyDeviceChannelRequest`'s doc comment). + /// `TestManagerdSession` uses this, when available, to address + /// `_IDE_startExecutingTestPlanWithProtocolVersion:` to the device's *actual* negotiated + /// channel instead of a hardcoded magic constant -- go-ios hardcodes -1 regardless (per its + /// own source comment, quoted in `waitForAnyDeviceChannelRequest`'s doc comment), but + /// pymobiledevice3's actively-maintained DTX implementation instead waits for and addresses + /// this exact negotiated channel (`wait_for_proxied_service(..., remote=True)` in its + /// `dtx/connection.py`) -- a real, previously-untried discrepancy between the two references, + /// worth trying given the hardcoded-`-1` approach was already confirmed not to work end-to-end + /// on two separate real devices. + func anyDeviceRequestedChannelCode() -> Int32? { + deviceRequestedChannels.values.first + } + + /// Opens `channelName` if needed, then invokes `selector` on it and awaits the reply. + @discardableResult + func callChannel( + _ channelName: String, + selector: String, + auxiliary: DTXAuxiliaryBuffer = DTXAuxiliaryBuffer(), + expectsReply: Bool = true + ) async throws -> DTXMessage { + let channelCode = try await makeChannel(channelName) + return try await call(channelCode: channelCode, selector: selector, auxiliary: auxiliary, expectsReply: expectsReply) + } + + /// Low-level send-and-wait, addressed directly by channel code (use `callChannel` for named + /// service channels; this exists for the root channel (code 0) and the "magic" broadcast + /// channel testmanagerd's `_IDE_startExecutingTestPlanWithProtocolVersion:` is sent on). + @discardableResult + func call( + channelCode: Int32, + selector: String, + auxiliary: DTXAuxiliaryBuffer = DTXAuxiliaryBuffer(), + expectsReply: Bool = true + ) async throws -> DTXMessage { + guard !closed else { throw ConnectionError.closed } + let identifier = nextIdentifier + nextIdentifier += 1 + + var message = DTXMessage( + identifier: identifier, + channelCode: channelCode, + expectsReply: expectsReply, + flags: .send, + payload: .string(selector) + ) + message.auxiliary = auxiliary + + if !expectsReply { + try send(message) + // synthesize an empty ack so callers can uniformly `await` this method + return DTXMessage(identifier: identifier, channelCode: channelCode) + } + + return try await withCheckedThrowingContinuation { continuation in + pendingReplies[identifier] = continuation + do { + try send(message) + } catch { + pendingReplies[identifier] = nil + continuation.resume(throwing: error) + } + } + } + + /// Sends an empty acknowledgment reply, as required whenever an incoming message has + /// `expectsReply` set. + private func sendAck(for message: DTXMessage) { + let ack = DTXMessage( + identifier: message.identifier, + channelCode: message.channelCode, + conversationIndex: message.conversationIndex + 1, + flags: .reply + ) + try? send(ack) + } + + /// Sends a reply carrying `payload`, for the (rare) incoming calls that require a real + /// return value instead of an empty ack -- confirmed against go-ios's `proxydispatcher.go` + /// that `_XCT_testRunnerReadyWithCapabilities:` is exactly this case: the runner's reply + /// needs to *be* the archived `XCTestConfiguration`, not an empty ack, or it never proceeds + /// past waiting for one (see `onSelectorWithReply`'s doc comment for the full story). + private func sendReply(_ payload: NSKeyedValue, for message: DTXMessage) { + let reply = DTXMessage( + identifier: message.identifier, + channelCode: message.channelCode, + conversationIndex: message.conversationIndex + 1, + flags: .reply, + payload: payload + ) + try? send(reply) + } + + private nonisolated func send(_ message: DTXMessage) throws { + if ProcessInfo.processInfo.environment["XTOOL_DTX_TRACE"] != nil { + FileHandle.standardError.write(Data( + "[dtx-trace-out] channel=\(message.channelCode) conv=\(message.conversationIndex) expectsReply=\(message.expectsReply) payload=\(String(describing: message.payload))\n".utf8 + )) + } + let data = message.encoded() + try ioQueue.sync { + _ = try transport.send(data) + } + } + + // MARK: - Read loop + + private nonisolated func shouldStop() -> Bool { + ioQueue.sync { stopRequested } + } + + /// Runs entirely on the dedicated `Thread` `start()` spins up -- never on the actor's + /// executor or the cooperative thread pool (see `start()`'s doc comment for why that matters). + /// Each fully-reassembled message is handed back to the actor via a plain (non-detached) + /// `Task { await self.handle(...) }` hop, which is cheap and doesn't itself block anything. + private nonisolated func readLoop(transport: any DTXByteTransport) { + var assembly: [Int32: (header: DTXMessage.ParsedHeader, body: Data)] = [:] + while !shouldStop() { + do { + let headerBytes = try readExact(transport, DTXMessage.headerLength) + let header = try DTXMessage.parseHeader(headerBytes) + let payload = try readExact(transport, Int(header.payloadLength)) + + if header.fragmentCount > 1 { + if header.fragmentId == 0 { + // fragment 0 of a multi-fragment message carries no body of its own in + // some DTX producers; still, treat any payload it does carry as the start + // of the accumulated body for consistency. + assembly[header.channelCode] = (header, payload) + continue + } + assembly[header.channelCode, default: (header, Data())].body += payload + guard header.fragmentId == header.fragmentCount - 1 else { continue } + guard let complete = assembly.removeValue(forKey: header.channelCode) else { continue } + let message = try DTXMessage.parseBody(complete.header, body: complete.body) + Task { await self.handle(message) } + } else { + let message = try DTXMessage.parseBody(header, body: payload) + Task { await self.handle(message) } + } + } catch { + Task { await self.handleReadLoopError(error) } + return + } + } + } + + private nonisolated func readExact(_ transport: any DTXByteTransport, _ count: Int) throws -> Data { + guard count > 0 else { return Data() } + var data = Data(capacity: count) + while data.count < count { + data += try receiveChunk(transport, maxLength: count - data.count) + } + return data + } + + /// A single bounded-time read attempt, retrying on timeout. Uses a short timeout (rather + /// than blocking indefinitely) specifically so `ioQueue` is freed up often enough for + /// concurrent sends/receives on other connections to get a turn -- see `ioQueue`'s doc comment. + private nonisolated func receiveChunk(_ transport: any DTXByteTransport, maxLength: Int) throws -> Data { + while true { + guard !shouldStop() else { throw ConnectionError.closed } + let result = ioQueue.sync { + Result { try transport.receive(maxLength: maxLength, timeout: Self.readPollTimeout) } + } + do { + let chunk = try result.get() + guard !chunk.isEmpty else { throw ConnectionError.closed } + return chunk + } catch { + guard transport.isTimeout(error) else { throw error } + continue + } + } + } + + private func handleReadLoopError(_ error: Swift.Error) { + guard !closed else { return } + closed = true + for continuation in pendingReplies.values { + continuation.resume(throwing: error) + } + pendingReplies.removeAll() + } + + private func handle(_ message: DTXMessage) { + if ProcessInfo.processInfo.environment["XTOOL_DTX_TRACE"] != nil { + FileHandle.standardError.write(Data( + "[dtx-trace] channel=\(message.channelCode) conv=\(message.conversationIndex) expectsReply=\(message.expectsReply) payload=\(String(describing: message.payload)) aux=\(message.auxiliary.values)\n".utf8 + )) + } + // a reply to one of our own requests: conversationIndex advances from 0 (our request) to + // 1 (the device's single reply). + if message.conversationIndex >= 1, let continuation = pendingReplies.removeValue(forKey: message.identifier) { + continuation.resume(returning: message) + return + } + + // the device opening its own channel on us -- the reverse of `makeChannel`. Same call the + // client uses to open a channel on the device (`_requestChannelWithCode:identifier:`, + // aux = [rawCode, boxedName]), just device-initiated. See `waitForDeviceChannel`'s doc + // comment for why this needs recognizing structurally, not just surfaced via `onUnhandled`. + if message.channelCode == 0, case .string("_requestChannelWithCode:identifier:")? = message.payload, + case .int32(let code)? = message.auxiliary.values.first, + case .object(.string(let name))? = message.auxiliary.values.dropFirst().first { + deviceRequestedChannels[name] = code + } + + // a selector whose reply must carry an actual payload (see `selectorReplyHandlers`'s doc + // comment) -- handled and replied to here, bypassing the generic dispatch/ack path below + // entirely, since the reply itself IS the response, not a followup broadcast. + if case .string(let selector)? = message.payload, let replyHandler = selectorReplyHandlers[selector] { + let payload = replyHandler(message) + if message.expectsReply { + sendReply(payload, for: message) + } + return + } + + // an unsolicited call from the device, matched by selector regardless of channel. + var dispatched = false + if case .string(let selector)? = message.payload, let handlers = selectorHandlers[selector] { + for handler in handlers { handler(message) } + dispatched = true + } + if !dispatched { + unhandledHandler?(message) + } + + if message.expectsReply { + sendAck(for: message) + } + } +} diff --git a/Sources/XKit/Testing/DTX/DTXMessage.swift b/Sources/XKit/Testing/DTX/DTXMessage.swift new file mode 100644 index 00000000..4f79a9a4 --- /dev/null +++ b/Sources/XKit/Testing/DTX/DTXMessage.swift @@ -0,0 +1,395 @@ +// +// DTXMessage.swift +// XKit +// +// DTX is the binary RPC protocol Instruments/testmanagerd speak (`com.apple.instruments. +// remoteserver[.DVTSecureSocketProxy]`, `com.apple.testmanagerd.lockdown[.secure]`). Nothing in +// xtool's dependency graph (libimobiledevice, SwiftyMobileDevice, xtool-core) implements it -- +// this is a from-scratch implementation, structured after the documented, working reference in +// appium-ios-device's `lib/instrument/transformer/headers.js` (Apache-2.0 -- read for wire +// format, rewritten from scratch in Swift here). +// +// Wire layout of a single (unfragmented) DTX message: +// +// DTXMessageHeader (32 bytes, all fields little-endian): +// u32 magic 0x1F3D5B79 +// u32 headerLength 32 (this struct's own size) +// u16 fragmentId +// u16 fragmentCount +// u32 payloadLength size of everything after this header +// u32 identifier request/response correlation id +// u32 conversationIndex 0 for a request, incremented for each reply +// u32 channel channel this message is addressed to/from (see DTXChannel) +// u32 expectsReply nonzero if the recipient must ack with an empty reply +// +// DTXMessagePayloadHeader (16 bytes): +// u32 flags +// u32 auxiliaryLength size of the auxiliary buffer below, INCLUDING its own 16-byte header +// u64 totalLength auxiliaryLength + selectorLength (i.e. everything after this header) +// +// auxiliary buffer (auxiliaryLength bytes; present even when empty -- just its own header): +// u64 magic 0x1F0 +// u64 entriesLength size of the entries below (auxiliaryLength - 16) +// entries... each: u32 entryMagic (0xa) + u32 type + type-specific payload +// +// selector/payload (totalLength - auxiliaryLength bytes): +// an NSKeyedArchiver-encoded object (see NSKeyedArchive.swift) -- usually a plain NSString +// selector name for method-invocation messages, but can be any archived object. + +import Foundation + +enum DTXMessageFlags: UInt32, Sendable { + case push = 0 + case recv = 1 + case send = 2 + case reply = 3 +} + +/// A single auxiliary argument passed alongside a selector invocation. +enum DTXAuxiliaryValue: Sendable { + case int32(Int32) + case int64(Int64) + case object(NSKeyedValue) + + fileprivate static let auxiliaryEntryMagic: UInt32 = 0xa + + fileprivate enum WireType: UInt32 { + case text = 1 + case nsKeyed = 2 + case uint32LE = 3 + case uint64LE = 4 + case int64LE = 6 + } +} + +extension DTXAuxiliaryValue: ExpressibleByIntegerLiteral { + init(integerLiteral value: Int32) { self = .int32(value) } +} + +/// The auxiliary argument buffer for a DTX message (see the file-level doc comment for layout). +struct DTXAuxiliaryBuffer: Sendable { + var values: [DTXAuxiliaryValue] = [] + + mutating func append(_ value: DTXAuxiliaryValue) { + values.append(value) + } + + /// Full wire bytes, including this buffer's own 16-byte sub-header. + /// + /// The sub-header is four little-endian `UInt32` fields -- `bufferSize`, an unused field + /// (observed always 0), `auxiliarySize` (the actual entries length -- what parsing needs), + /// and a second unused field (also observed always 0) -- not, as this code assumed until a + /// real device's `_notifyOfPublishedCapabilities:` reply (which carries a real, several-KB + /// capabilities plist, unlike the empty/trivial auxiliary buffers every earlier real-device + /// test happened to exercise) exposed the bug, an 8-byte magic constant followed by an 8-byte + /// length. There is no magic value to validate at all -- go-ios's `AuxiliaryHeader` struct + /// (MIT -- read for the wire-format field layout only, not copied; see this file's header + /// comment) documents `bufferSize` and the two unused fields as safely ignorable on decode. + /// `bufferSize` is set equal to `auxiliarySize` here since nothing in the reference material + /// suggests they need to differ for a buffer we're constructing ourselves. + func encoded() -> Data { + var entries = Data() + for value in values { + switch value { + case .int32(let int): + var entry = Data() + appendLE(&entry, DTXAuxiliaryValue.auxiliaryEntryMagic) + appendLE(&entry, DTXAuxiliaryValue.WireType.uint32LE.rawValue) + appendLE(&entry, UInt32(bitPattern: int)) + entries += entry + case .int64(let int): + var entry = Data() + appendLE(&entry, DTXAuxiliaryValue.auxiliaryEntryMagic) + appendLE(&entry, DTXAuxiliaryValue.WireType.int64LE.rawValue) + appendLE(&entry, UInt64(bitPattern: int)) + entries += entry + case .object(let object): + let archived = NSKeyedArchive.archive(object) + var entry = Data() + appendLE(&entry, DTXAuxiliaryValue.auxiliaryEntryMagic) + appendLE(&entry, DTXAuxiliaryValue.WireType.nsKeyed.rawValue) + appendLE(&entry, UInt32(archived.count)) + entry += archived + entries += entry + } + } + + var header = Data() + appendLE(&header, UInt32(entries.count)) // bufferSize + appendLE(&header, UInt32(0)) // unused + appendLE(&header, UInt32(entries.count)) // auxiliarySize + appendLE(&header, UInt32(0)) // unused + return header + entries + } + + /// Parses a full auxiliary buffer (including its 16-byte sub-header) back into typed values. + /// Object entries are decoded via `NSKeyedArchive.unarchive` and exposed as `.object` wrapping + /// a `Decoded` value. + struct Parsed { + /// All entries, in wire order, tagged by kind for callers that care about argument order. + var entries: [Entry] = [] + + enum Entry { + case int32(Int32) + case int64(Int64) + case object(NSKeyedArchive.Decoded) + } + } + + static func parse(_ data: Data) throws -> Parsed { + guard data.count >= 16 else { return Parsed() } + var cursor = data.startIndex + + // bufferSize, unused (see `encoded()`'s doc comment for the sub-header layout) -- no + // magic value to validate here. + _ = readLE(data, &cursor, as: UInt32.self) + _ = readLE(data, &cursor, as: UInt32.self) + let declaredEntriesLength = Int(readLE(data, &cursor, as: UInt32.self)) // auxiliarySize + _ = readLE(data, &cursor, as: UInt32.self) // unused + let entriesEnd = min(data.endIndex, cursor + declaredEntriesLength) + + var result = Parsed() + while cursor < entriesEnd { + guard data.distance(from: cursor, to: entriesEnd) >= 8 else { break } + let entryMagic = readLE(data, &cursor, as: UInt32.self) + guard entryMagic == DTXAuxiliaryValue.auxiliaryEntryMagic else { + throw DTXError.malformedAuxiliaryBuffer + } + let rawType = readLE(data, &cursor, as: UInt32.self) + guard let type = DTXAuxiliaryValue.WireType(rawValue: rawType) else { + throw DTXError.unknownAuxiliaryType(rawType) + } + switch type { + case .text: + let length = Int(readLE(data, &cursor, as: UInt32.self)) + let end = min(entriesEnd, cursor + length) + let bytes = data[cursor.. Data { + let selectorBytes = payload.map(NSKeyedArchive.archive) ?? Data() + let auxBytes = auxiliary.encoded() + + var payloadHeader = Data() + appendLE(&payloadHeader, flags.rawValue) + appendLE(&payloadHeader, UInt32(auxBytes.count)) + appendLE(&payloadHeader, UInt64(auxBytes.count + selectorBytes.count)) + + var header = Data() + appendLE(&header, Self.headerMagic) + appendLE(&header, UInt32(Self.headerLength)) + appendLE(&header, UInt16(0)) // fragmentId + appendLE(&header, UInt16(1)) // fragmentCount + appendLE(&header, UInt32(Self.payloadHeaderLength + auxBytes.count + selectorBytes.count)) + appendLE(&header, identifier) + appendLE(&header, conversationIndex) + appendLE(&header, UInt32(bitPattern: channelCode)) + appendLE(&header, expectsReply ? UInt32(1) : UInt32(0)) + + return header + payloadHeader + auxBytes + selectorBytes + } + + struct ParsedHeader { + var magic: UInt32 + var headerLength: UInt32 + var fragmentId: UInt16 + var fragmentCount: UInt16 + var payloadLength: UInt32 + var identifier: UInt32 + var conversationIndex: UInt32 + var channelCode: Int32 + var expectsReply: Bool + } + + static func parseHeader(_ data: Data) throws -> ParsedHeader { + guard data.count >= headerLength else { throw DTXError.notEnoughData } + var cursor = data.startIndex + let magic = readLE(data, &cursor, as: UInt32.self) + guard magic == headerMagic else { throw DTXError.badMagic } + let headerLen = readLE(data, &cursor, as: UInt32.self) + let fragmentId = readLE(data, &cursor, as: UInt16.self) + let fragmentCount = readLE(data, &cursor, as: UInt16.self) + let payloadLength = readLE(data, &cursor, as: UInt32.self) + let identifier = readLE(data, &cursor, as: UInt32.self) + let conversationIndex = readLE(data, &cursor, as: UInt32.self) + let channelCode = Int32(bitPattern: readLE(data, &cursor, as: UInt32.self)) + let expectsReply = readLE(data, &cursor, as: UInt32.self) != 0 + return ParsedHeader( + magic: magic, + headerLength: headerLen, + fragmentId: fragmentId, + fragmentCount: fragmentCount, + payloadLength: payloadLength, + identifier: identifier, + conversationIndex: conversationIndex, + channelCode: channelCode, + expectsReply: expectsReply + ) + } + + /// Parses a reassembled message body (everything after the 32-byte `DTXMessageHeader`, + /// concatenated across all fragments) into a `DTXMessage`. + static func parseBody(_ header: ParsedHeader, body: Data) throws -> DTXMessage { + var message = DTXMessage( + identifier: header.identifier, + channelCode: header.channelCode, + conversationIndex: header.conversationIndex, + expectsReply: header.expectsReply + ) + guard !body.isEmpty else { return message } + guard body.count >= payloadHeaderLength else { throw DTXError.notEnoughData } + + var cursor = body.startIndex + let flagsRaw = readLE(body, &cursor, as: UInt32.self) + message.flags = DTXMessageFlags(rawValue: flagsRaw) ?? .send + let auxLength = Int(readLE(body, &cursor, as: UInt32.self)) + guard let totalLength = Int(exactly: readLE(body, &cursor, as: UInt64.self)) else { + throw DTXError.notEnoughData + } + + guard auxLength >= 0, totalLength >= auxLength else { throw DTXError.notEnoughData } + + let auxEnd = min(body.endIndex, cursor + auxLength) + if auxLength > 0 { + message.auxiliary = try dtxAuxiliaryBuffer(from: body[cursor.. 0 { + let selectorEnd = min(body.endIndex, cursor + selectorLength) + let selectorData = Data(body[cursor.. DTXAuxiliaryBuffer { + let parsed = try DTXAuxiliaryBuffer.parse(Data(data)) + var buffer = DTXAuxiliaryBuffer() + for entry in parsed.entries { + switch entry { + case .int32(let value): + buffer.append(.int32(value)) + case .int64(let value): + buffer.append(.int64(value)) + case .object(let decoded): + if let value = decoded.asNSKeyedValue { + buffer.append(.object(value)) + } + } + } + return buffer +} + +extension NSKeyedArchive.Decoded { + /// Round-trips a decoded value back into an (encodable) `NSKeyedValue`, for the common case + /// of relaying/re-encoding a plain value. Loses fidelity for `.object` (custom Objective-C + /// classes) since those aren't generally re-encodable without knowing their exact class + /// shape; callers that need to re-send a decoded custom object should construct a fresh + /// `NSKeyedValue.object` themselves. + var asNSKeyedValue: NSKeyedValue? { + switch self { + case .string(let value): .string(value) + case .data(let value): .data(value) + case .int(let value): .int(value) + case .double(let value): .double(value) + case .bool(let value): .bool(value) + case .array(let value): .array(value.compactMap(\.asNSKeyedValue)) + case .dictionary(let value): .dictionary(value.compactMapValues(\.asNSKeyedValue)) + case .object: nil + case .null: .null + } + } +} + +// MARK: - Little-endian read/write helpers + +private func appendLE(_ data: inout Data, _ value: T) { + withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } +} + +private func readLE(_ data: Data, _ cursor: inout Data.Index, as type: T.Type) -> T { + let size = MemoryLayout.size + let end = min(data.endIndex, cursor + size) + var value: T = 0 + let bytes = data[cursor.. Int + /// A per-attempt bounded read; implementations should return quickly (throwing an error + /// `isTimeout` accepts) rather than blocking indefinitely, so `DTXConnection`'s read loop can + /// keep checking whether it's been asked to stop. + func receive(maxLength: Int, timeout: TimeInterval) throws -> Data + func close() + /// Whether `error` represents "no data arrived within the timeout" (retry) as opposed to a + /// real connection failure (give up). + func isTimeout(_ error: Swift.Error) -> Bool +} + +struct IdeviceError: CAPIError { + let raw: idevice_error_t + init?(_ raw: idevice_error_t) { + guard raw != IDEVICE_E_SUCCESS else { return nil } + self.raw = raw + } +} + +func checkIdevice(_ raw: idevice_error_t) throws { + if let error = IdeviceError(raw) { throw error } +} + +/// A raw `idevice_connection_t` socket, conforming to `StreamingConnection` so it can reuse +/// `send(_:)`/`receive(maxLength:timeout:)`/`receiveAll(...)`. +/// +/// `nonisolated(unsafe)` on the stored properties below matches the pattern every other +/// `LockdownService`-conforming client in SwiftyMobileDevice uses for its raw C handle/function +/// pointers (e.g. `MobileImageMounterClient.raw`) -- C pointers/function pointers aren't checked +/// for `Sendable` by the compiler, but concurrent access to a single `idevice_connection_t` is +/// already externally synchronized here (all sends/receives happen on `DTXConnection`, an actor). +struct DTXTransport: StreamingConnection { + typealias Error = IdeviceError + typealias Raw = idevice_connection_t + + nonisolated(unsafe) let raw: idevice_connection_t + nonisolated(unsafe) let sendFunc: SendFunc = idevice_connection_send + nonisolated(unsafe) let receiveFunc: ReceiveFunc = idevice_connection_receive + nonisolated(unsafe) let receiveTimeoutFunc: ReceiveTimeoutFunc = idevice_connection_receive_timeout + + /// Opens a DTX-capable service (instruments or testmanagerd) and returns a connected + /// transport. Enables SSL when the service descriptor reports it's required (both + /// `com.apple.instruments.remoteserver.DVTSecureSocketProxy` and + /// `com.apple.testmanagerd.lockdown.secure` do on modern iOS versions). + static func connect(device: Device, service: LockdownClient.ServiceDescriptor) throws -> DTXTransport { + var raw: idevice_connection_t? + try checkIdevice(idevice_connect(device.raw, service.port, &raw)) + guard let raw else { throw CAPIGenericError.unexpectedNil } + if service.isSSLEnabled { + try checkIdevice(idevice_connection_enable_ssl(raw)) + } + return DTXTransport(raw: raw) + } + + func close() { + idevice_disconnect(raw) + } +} + +extension DTXTransport: DTXByteTransport { + func receive(maxLength: Int, timeout: TimeInterval) throws -> Data { + try receive(maxLength: maxLength, timeout: TimeInterval?.some(timeout)) + } + + func isTimeout(_ error: Swift.Error) -> Bool { + (error as? IdeviceError)?.raw == IDEVICE_E_TIMEOUT + } +} diff --git a/Sources/XKit/Testing/NSKeyedArchive.swift b/Sources/XKit/Testing/NSKeyedArchive.swift new file mode 100644 index 00000000..064ed4f0 --- /dev/null +++ b/Sources/XKit/Testing/NSKeyedArchive.swift @@ -0,0 +1,353 @@ +// +// NSKeyedArchive.swift +// XKit +// +// A from-scratch, minimal implementation of Apple's NSKeyedArchiver wire format, needed because +// DTX (see Testing/DTX/DTXMessage.swift) serializes selector names and method-call arguments +// using it, and Foundation's own NSKeyedArchiver on Linux does not correctly emit custom +// `$classname` values for user-defined NSCoding types (verified empirically: `setClassName(_: +// for:)`, both the static and archiver-instance overloads, had no effect on the emitted +// `$objects` entry when tested against this toolchain's swift-corelibs-foundation) -- and DTX +// payloads like `XCTestConfiguration` specifically need to be tagged with Apple's real +// Objective-C class name for testmanagerd to recognize them. +// +// The archive format itself (a `bplist00` binary property list containing `$archiver`/ +// `$version`/`$top`/`$objects`, with cross-references via the plist `UID` primitive) was +// confirmed against this toolchain's real `NSKeyedArchiver` output for standard Foundation +// types (NSURL, NSUUID, NSDictionary, NSData all matched exactly), and the specific +// inline-vs-boxed encoding rules below follow the documented, working reference implementation +// in appium-ios-device's `lib/instrument/transformer/nskeyed.js` (Apache-2.0 -- read for +// protocol structure, rewritten from scratch here). + +import Foundation +import plist + +/// A value to be archived in NSKeyedArchiver's wire format. +public indirect enum NSKeyedValue: Sendable { + case string(String) + case data(Data) + case int(Int64) + case double(Double) + case bool(Bool) + case array([NSKeyedValue]) + /// Archives as `NSSet` (`$class` name + `NS.objects`), not `NSArray` -- distinct cases because + /// the two aren't interchangeable on the wire: `XCTestConfiguration.testsToRun`/`testsToSkip` + /// are declared as `NSSet` on the real Apple type, and archiving them as `.array` + /// instead (i.e. tagging the archived object `$class` as `NSArray`) is silently ignored by the + /// on-device runner when unarchiving -- confirmed against real hardware (this session): `--only`/ + /// `--test-target` filters had no effect at all before this fix, with no error surfaced + /// anywhere in the runner's own logs. + case set([NSKeyedValue]) + /// Archives as `NSMutableArray`, not `NSArray` -- needed for + /// `XCTTestIdentifierSet.identifiers`, which is typed `NSMutableArray` + /// on the real Apple type (confirmed against pymobiledevice3's `xctest_types.py`, which + /// explicitly calls this out: "Must be NSMutableArray"). Same class-of-archived-object + /// mismatch problem `.set`'s doc comment describes for `NSArray` vs `NSSet` -- confirmed + /// against real hardware (this session): passing multiple `--only`/expanded `--test-target` + /// identifiers with this array archived as plain `NSArray` silently kept only the *last* + /// identifier in effect, discarding the rest, rather than erroring outright. + case mutableArray([NSKeyedValue]) + case dictionary([String: NSKeyedValue]) + /// A reference to a custom Objective-C class (e.g. `NSURL`, `NSUUID`, `XCTestConfiguration`). + /// `properties` are encoded in the given order; primitive values (`int`/`double`/`bool`) are + /// inlined by default unless wrapped in `.boxed`, matching how NSKeyedArchiver only indirects + /// through an `$objects` entry for values encoded via `encodeObject:forKey:` (as opposed to + /// primitive coder methods like `encodeInt64:forKey:`). + case object(className: String, extraClasses: [String] = ["NSObject"], properties: [(String, NSKeyedValue)]) + /// Forces a primitive value to be boxed as its own `$objects` entry rather than inlined. + /// `XCTestConfiguration.formatVersion` is the one documented case that needs this. + case boxed(NSKeyedValue) + case null +} + +enum NSKeyedArchive { + private static let archiverName = "NSKeyedArchiver" + private static let archiveVersion: UInt64 = 100_000 + + // MARK: - Encoding + + private final class Archiver { + var objects: [PlistValue] = [.string("$null")] + + /// Always creates a new `$objects` entry and returns a UID reference to it. + func archive(_ value: NSKeyedValue) -> PlistValue { + switch value { + case .null: + return .uid(0) + case .string(let string): + return push(.string(string)) + case .data(let data): + return push(.data(data)) + case .int(let int): + return push(.uint(UInt64(bitPattern: int))) + case .double(let double): + return push(.real(double)) + case .bool(let bool): + return push(.bool(bool)) + case .array(let array): + return archiveList(className: "NSArray", items: array) + case .set(let set): + return archiveList(className: "NSSet", items: set) + case .mutableArray(let array): + return archiveList(className: "NSMutableArray", items: array) + case .dictionary(let dict): + return archiveDictionary(dict) + case .object(let className, let extraClasses, let properties): + return archiveObject(className: className, extraClasses: extraClasses, properties: properties) + case .boxed(let inner): + return archive(inner) + } + } + + /// Inlines primitives directly; boxes (archives + returns a UID for) everything else. + /// Mirrors NSKeyedArchiver's distinction between primitive coder methods + /// (`encodeInt64:forKey:`, `encodeBytes:length:forKey:`) and object coder methods + /// (`encodeObject:forKey:`). + /// + /// `.data` is inlined, not boxed, confirmed by byte-for-byte comparison against this + /// toolchain's real `NSKeyedArchiver` archiving a real `NSUUID`: `NS.uuidbytes` holds the + /// raw 16 bytes directly in the object's own dictionary, not a UID reference to a boxed + /// `NSData` entry. Boxing it (this code's original behavior) produced a structurally + /// different archive that `testmanagerd` silently failed to decode as an NSUUID -- + /// confirmed against real hardware: `_IDE_initiateSessionWithIdentifier:forClient: + /// atPath:protocolVersion:`'s `sessionIdentifier` argument (encoded exactly this way) was + /// logged device-side as "(null)", and testmanagerd closed the session with "Can't accept + /// request from client without a session identifier" -- silently breaking every `xtool + /// test` run's ability to ever receive `_XCT_testBundleReadyWithProtocolVersion:` on that + /// connection, with no error surfaced back to the client. + func encode(_ value: NSKeyedValue) -> PlistValue { + switch value { + case .int(let int): + return .uint(UInt64(bitPattern: int)) + case .bool(let bool): + return .bool(bool) + case .double(let double): + return .real(double) + case .data(let data): + return .data(data) + case .boxed(let inner): + return archive(inner) + default: + return archive(value) + } + } + + private func push(_ value: PlistValue) -> PlistValue { + let index = objects.count + objects.append(value) + return .uid(UInt64(index)) + } + + private func classReference(_ className: String, extraClasses: [String]) -> PlistValue { + push(.dictionary([ + "$classname": .string(className), + "$classes": .array(([className] + extraClasses).map(PlistValue.string)), + ])) + } + + private func archiveList(className: String, items: [NSKeyedValue]) -> PlistValue { + let index = objects.count + objects.append(.bool(false)) // placeholder reserved so nested archiving doesn't reuse this index + let itemRefs = items.map { archive($0) } + let classRef = classReference(className, extraClasses: ["NSObject"]) + objects[index] = .dictionary([ + "NS.objects": .array(itemRefs), + "$class": classRef, + ]) + return .uid(UInt64(index)) + } + + private func archiveDictionary(_ dict: [String: NSKeyedValue]) -> PlistValue { + let index = objects.count + objects.append(.bool(false)) // placeholder reserved so nested archiving doesn't reuse this index + // sort keys for deterministic, testable output; wire format doesn't require an order + let sortedKeys = dict.keys.sorted() + let keyRefs = sortedKeys.map { archive(.string($0)) } + let valueRefs = sortedKeys.map { archive(dict[$0]!) } + let classRef = classReference("NSDictionary", extraClasses: ["NSObject"]) + objects[index] = .dictionary([ + "NS.keys": .array(keyRefs), + "NS.objects": .array(valueRefs), + "$class": classRef, + ]) + return .uid(UInt64(index)) + } + + private func archiveObject( + className: String, + extraClasses: [String], + properties: [(String, NSKeyedValue)] + ) -> PlistValue { + let index = objects.count + objects.append(.bool(false)) // placeholder reserved so nested archiving doesn't reuse this index + var fields: [String: PlistValue] = [:] + for (key, value) in properties { + fields[key] = encode(value) + } + fields["$class"] = classReference(className, extraClasses: extraClasses) + objects[index] = .dictionary(fields) + return .uid(UInt64(index)) + } + } + + /// Serializes `value` into an NSKeyedArchiver-compatible `bplist00` buffer. + static func archive(_ value: NSKeyedValue) -> Data { + let archiver = Archiver() + let rootRef = archiver.archive(value) + let plist = PlistValue.dictionary([ + "$version": .uint(archiveVersion), + "$archiver": .string(archiverName), + "$top": .dictionary(["root": rootRef]), + "$objects": .array(archiver.objects), + ]) + return plist.toBinaryData() + } + + // MARK: - Decoding + + /// A decoded NSKeyedArchive object graph. Doesn't attempt to fully recreate specific + /// Objective-C classes (unlike the Swift-side `NSKeyedValue` used for encoding) -- callers + /// pattern-match `.object` for the specific classes they care about (test result callbacks + /// mostly carry plain strings/dictionaries/numbers). + indirect enum Decoded: Sendable { + case string(String) + case data(Data) + case int(Int64) + case double(Double) + case bool(Bool) + case array([Decoded]) + case dictionary([String: Decoded]) + case object(className: String, fields: [String: Decoded]) + case null + } + + enum DecodeError: Swift.Error { + case notBplist + case unsupportedArchiver(String?) + case unsupportedVersion(UInt64?) + case missingRoot + case cycle + } + + /// Parses an NSKeyedArchiver-compatible `bplist00` buffer (as produced by `archive(_:)`, or + /// received from a device) back into a `Decoded` value tree. + static func unarchive(_ data: Data) throws -> Decoded { + guard let root = PlistValue.parse(binary: data), case .dictionary(let plist) = root else { + throw DecodeError.notBplist + } + guard case .string(let archiverName)? = plist["$archiver"], archiverName == Self.archiverName else { + if case .string(let name)? = plist["$archiver"] { + throw DecodeError.unsupportedArchiver(name) + } + throw DecodeError.unsupportedArchiver(nil) + } + guard case .array(let objects)? = plist["$objects"] else { + throw DecodeError.missingRoot + } + guard case .dictionary(let top)? = plist["$top"], case .uid(let rootUID)? = top["root"] else { + throw DecodeError.missingRoot + } + + var cache: [Int: Decoded] = [:] + var inProgress: Set = [] + + func decodeValue(_ value: PlistValue) throws -> Decoded { + switch value { + case .uid(let uid): + guard let intUID = Int(exactly: uid) else { return .null } + return try decodeUID(intUID) + case .string(let string): + return .string(string) + case .data(let data): + return .data(data) + case .uint(let uint): + return .int(Int64(bitPattern: uint)) + case .real(let real): + return .double(real) + case .bool(let bool): + return .bool(bool) + case .array(let array): + return .array(try array.map(decodeValue)) + case .dictionary(let dict): + return .dictionary(try dict.mapValues(decodeValue)) + } + } + + func decodeUID(_ uid: Int) throws -> Decoded { + if uid == 0 { return .null } + if let cached = cache[uid] { return cached } + guard uid >= 0, uid < objects.count else { return .null } + guard !inProgress.contains(uid) else { throw DecodeError.cycle } + inProgress.insert(uid) + defer { inProgress.remove(uid) } + + let raw = objects[uid] + let decoded: Decoded + switch raw { + case .string(let string): + decoded = .string(string) + case .uint(let uint): + decoded = .int(Int64(bitPattern: uint)) + case .real(let real): + decoded = .double(real) + case .bool(let bool): + decoded = .bool(bool) + case .data(let data): + decoded = .data(data) + case .dictionary(let fields): + decoded = try decodeClassedObject(fields) + default: + decoded = .null + } + cache[uid] = decoded + return decoded + } + + func decodeClassedObject(_ fields: [String: PlistValue]) throws -> Decoded { + guard case .uid(let classUID)? = fields["$class"], + classUID >= 0, Int(classUID) < objects.count, + case .dictionary(let classInfo) = objects[Int(classUID)], + case .string(let className)? = classInfo["$classname"] + else { + // not a classed object (e.g. a plain aux-buffer dictionary payload); decode as-is + var decodedFields: [String: Decoded] = [:] + for (key, value) in fields where key != "$class" { + decodedFields[key] = try decodeValue(value) + } + return .dictionary(decodedFields) + } + + switch className { + case "NSDictionary", "NSMutableDictionary": + guard case .array(let keyRefs)? = fields["NS.keys"], case .array(let valueRefs)? = fields["NS.objects"] else { + return .object(className: className, fields: [:]) + } + var dict: [String: Decoded] = [:] + for (keyRef, valueRef) in zip(keyRefs, valueRefs) { + guard case .string(let key) = try decodeValue(keyRef) else { continue } + dict[key] = try decodeValue(valueRef) + } + return .dictionary(dict) + case "NSArray", "NSMutableArray", "NSSet", "NSMutableSet": + guard case .array(let itemRefs)? = fields["NS.objects"] else { + return .array([]) + } + return .array(try itemRefs.map(decodeValue)) + case "NSString", "NSMutableString": + if case .string(let value)? = fields["NS.string"] { + return .string(value) + } + return .string("") + default: + var decodedFields: [String: Decoded] = [:] + for (key, value) in fields where key != "$class" { + decodedFields[key] = try decodeValue(value) + } + return .object(className: className, fields: decodedFields) + } + } + + guard let rootIntUID = Int(exactly: rootUID) else { throw DecodeError.missingRoot } + return try decodeUID(rootIntUID) + } +} diff --git a/Sources/XKit/Testing/TestManagerdSession.swift b/Sources/XKit/Testing/TestManagerdSession.swift new file mode 100644 index 00000000..3362f1b6 --- /dev/null +++ b/Sources/XKit/Testing/TestManagerdSession.swift @@ -0,0 +1,951 @@ +// +// TestManagerdSession.swift +// XKit +// +// Orchestrates the full handshake Xcode performs to run an installed XCTest bundle without +// Xcode: two `testmanagerd` DTX sessions (one for authorization, one to drive test execution) +// plus a third `instruments` DTX session to actually launch the Runner process, tied together +// by writing an `XCTestConfiguration` plist into the Runner app's sandbox over house_arrest/AFC. +// Selector names, channel names, and call sequence transcribed from the documented, working +// reference implementation in appium-ios-device's `lib/xctest.js` (Apache-2.0 -- read for the +// handshake sequence, rewritten from scratch in Swift here). +// +// Requires the Developer Disk Image to already be mounted (see DDIMounter/PersonalizedDDIMounter) +// -- testmanagerd/instruments are developer-only services exposed only once it is. + +import Foundation +import SwiftyMobileDevice +import libimobiledevice +import plist + +public enum TestManagerdEvent: Sendable { + /// A `_XCT_...` callback from the device that isn't specifically interpreted below, exposed + /// for callers (e.g. a future CLI reporter) that want to inspect raw test-progress calls. + case raw(selector: String, arguments: [NSKeyedValue]) + case testBundleReady + case logDebugMessage(String) + /// `_XCT_testSuiteWithIdentifier:didFinishAt:runCount:skipCount:failureCount: + /// expectedFailureCount:uncaughtExceptionCount:testDuration:totalDuration:` -- fires once per + /// nesting level of the suite hierarchy (method's class, module, "All tests" root all report + /// the same final tally once every test has finished, confirmed against real hardware), not + /// once overall. There's no separate "run finished" signal to wait for instead: real hardware + /// testing (this session) showed the runner logging "Creating future for 'confirming end of + /// session with the harness' with timeout 1800.00" after results are already fully in, and + /// no further callback (a `_XCT_didFinishExecutingTestPlan` was hypothesized and searched for + /// specifically -- never observed) arrives to resolve that wait; it just sits there for up to + /// 30 minutes. Also reachable via `TestOutputParser`'s stdout-parsed fallback (see + /// `.testCaseResult`'s doc comment) for real-device cases where this structured callback never + /// arrives at all even though the run completed -- callers should trust whichever arrives + /// first as authoritative, not wait to corroborate it against accumulated `.testCaseResult` + /// counts. + case testSuiteFinished(runCount: Int, failureCount: Int) + /// Parsed from the runner's own console output (`outputReceived:fromProcess:atTime:`), not a + /// structured DTX callback -- see `TestOutputParser`'s header comment for why this is the + /// reliable source of human-readable test names. `failureMessages` is always empty for + /// `.passed`; for `.failed` it's the `:: error: ...` lines that preceded this + /// test case's "failed" line, if any arrived. + case testCaseResult( + testClass: String, + testMethod: String, + status: TestCaseStatus, + duration: Double, + failureMessages: [String] + ) +} + +public actor TestManagerdSession { + + public enum SessionError: Swift.Error, LocalizedError { + case noServiceAvailable(String) + case invalidRunnerBundle(String) + case launchFailed(String) + case missingLaunchedProcessID + + public var errorDescription: String? { + switch self { + case .noServiceAvailable(let name): "Could not start service '\(name)' (is the DDI mounted?)" + case .invalidRunnerBundle(let reason): "Invalid XCTest runner bundle: \(reason)" + case .launchFailed(let reason): "Failed to launch the test runner: \(reason)" + case .missingLaunchedProcessID: "Device did not return a process ID for the launched runner" + } + } + } + + private enum ServiceName { + static let testmanagerdSecure = "com.apple.testmanagerd.lockdown.secure" + static let testmanagerdLegacy = "com.apple.testmanagerd.lockdown" + static let instrumentsSecure = "com.apple.instruments.remoteserver.DVTSecureSocketProxy" + static let instrumentsLegacy = "com.apple.instruments.remoteserver" + } + + private enum Channel { + static let daemonConnectionInterface = + "dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface" + static let processControl = "com.apple.instruments.server.services.processcontrol" + } + + /// `_IDE_startExecutingTestPlanWithProtocolVersion:` is sent to this literal channel code + /// (0xFFFFFFFF as a two's-complement `Int32`), not the code `makeChannel` returns for + /// `daemonConnectionInterface` -- confirmed against appium-ios-device's `MAGIC_CHANNEL` + /// constant. This assumes `daemonConnectionInterface` is the only (and therefore first, + /// code-1) channel opened on the exec-plan connection, which this type always does. + private static let magicChannel: Int32 = -1 + private static let xcodeVersion: Int32 = 36 + private static let xcodeBuildPathMarker = "/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild" + + private let connection: Connection + private let device: Device + private let majorOSVersion: Int + private let tunnelContext: TunnelContext? + + private var initialControlSession: DTXConnection? + private var execTestPlanSession: DTXConnection? + private var instrumentSession: DTXConnection? + private var didStartExecutingTestPlan = false + /// The runner process `launchRunner` started, so `stop()` can kill it -- otherwise it's left + /// running on the device after every `xtool test` invocation (there's no completion detection + /// yet, so every run ends via cancellation, not a natural exit), forcing a manual force-quit + /// before the next run. + private var launchedRunnerPID: pid_t? + /// Set by `start()` when driving a separate target app (XCUITest), so `stop()` can terminate + /// it too -- otherwise the target app is left running/foregrounded on the device after every + /// `xtool test` invocation, exactly like `launchedRunnerPID`'s doc comment describes for the + /// runner itself. Confirmed necessary against real hardware (this session): killing only the + /// runner process leaves the target app sitting open on-screen, requiring a manual force-quit. + private var targetApplicationBundleID: String? + /// Passively captured from `_XCT_applicationDidUpdateState:` text that already flows through + /// `_XCT_logDebugMessage:` during a normal run (e.g. "XTL-....MyApp@/private/var/... + /// processName: MyApp pid: 82587 state: running foreground") -- used by `stop()` as a fast + /// path instead of a live `processIdentifierForBundleIdentifier:` lookup. Confirmed against + /// real hardware (this session): that lookup call consistently takes ~18s to reply (unrelated + /// to whether the runner has already been killed -- an earlier fix reordering the calls to + /// query before killing the runner made no difference), which is the actual source of the + /// "target app closes, but with a big delay" symptom, since the kill can't be sent until the + /// pid is known. This is best-effort: if the text was never observed (e.g. `stop()` is called + /// very early, before the target app ever reported a state update), `stop()` falls back to + /// the slow live lookup rather than skipping the kill entirely. + private var lastKnownTargetApplicationPID: pid_t? + /// `:: error: ...` lines parsed from console output, buffered per "Class method" + /// key until the matching `.failed` test-case-finished line arrives to attach them to -- + /// see `TestOutputParser`'s header comment for why this parsing exists at all. + private var pendingFailureMessages: [String: [String]] = [:] + + private var eventContinuation: AsyncStream.Continuation? + public let events: AsyncStream + + /// iOS 17.4+'s RSD-discovered equivalents of the classic lockdown service names -- both + /// `testmanagerd` connections (control + exec) go to the same service, matching the classic + /// path's own `ServiceName.testmanagerdSecure`/`Legacy` reuse across both. + private enum RSDServiceName { + static let testmanagerd = "com.apple.dt.testmanagerd.remote" + static let instruments = "com.apple.instruments.dtservicehub" + static let appservice = "com.apple.coredevice.appservice" + } + + /// Bundles the tunnel + its RSD service directory, when opening DTX connections via the + /// iOS 17.4+ tunnel path (`RSDServiceName`) instead of classic lockdown (`ServiceName`). + public struct TunnelContext: Sendable { + let tunnel: CoreDeviceProxyTunnel + let rsd: RSDHandshakeResponse + + public init(tunnel: CoreDeviceProxyTunnel, rsd: RSDHandshakeResponse) { + self.tunnel = tunnel + self.rsd = rsd + } + } + + /// - Parameter tunnel: when non-nil, DTX connections (testmanagerd, instruments) are opened + /// over this iOS 17.4+ RSD tunnel instead of classic lockdown -- everything else (house_arrest/ + /// AFC for `XCTestConfiguration`, process control) still goes through `connection`/`device` + /// as before, since those aren't DTX and classic lockdown reaches them on every iOS version. + public init(connection: Connection, productVersion: String, tunnel: TunnelContext? = nil) { + self.connection = connection + self.device = connection.device + self.majorOSVersion = Int(productVersion.split(separator: ".").first ?? "0") ?? 0 + self.tunnelContext = tunnel + var continuation: AsyncStream.Continuation! + self.events = AsyncStream { continuation = $0 } + self.eventContinuation = continuation + } + + deinit { + eventContinuation?.finish() + } + + public func stop() async { + // Each of these three is independent (a different DTX connection/service), so they run + // concurrently rather than sequentially -- confirmed against real hardware (this session) + // that two of them are each independently slow (the `processIdentifierForBundleIdentifier:` + // lookup below consistently takes ~15-18s to reply regardless of call ordering, and + // `execTestPlanSession.close()` separately takes ~13-15s), and running them one after + // another was the real source of the "target app closes, but with a big delay" symptom -- + // concurrently, the total is bounded by the slower of the two instead of their sum. + async let instrumentCleanup: Void = cleanUpInstrumentSession() + async let closedInitial: Void = closeIfPresent(initialControlSession) + async let closedExecPlan: Void = closeIfPresent(execTestPlanSession) + _ = await (instrumentCleanup, closedInitial, closedExecPlan) + eventContinuation?.finish() + } + + private func closeIfPresent(_ session: DTXConnection?) async { + await session?.close() + } + + private func cleanUpInstrumentSession() async { + guard let instrumentSession else { return } + // Best-effort fast path: if a `_XCT_applicationDidUpdateState:` line for the target app + // happened to arrive during the run (see `lastKnownTargetApplicationPID`'s doc comment), + // this avoids the slow live lookup entirely -- not relied upon, since it's not guaranteed + // to have arrived (confirmed against real hardware, this session: some runs simply never + // report a state update for the target app specifically, only the runner's own). + var targetApplicationPID = lastKnownTargetApplicationPID + if targetApplicationPID == nil, let targetApplicationBundleID { + var lookupAux = DTXAuxiliaryBuffer() + lookupAux.append(.object(.string(targetApplicationBundleID))) + if let reply = try? await instrumentSession.callChannel( + Channel.processControl, + selector: "processIdentifierForBundleIdentifier:", + auxiliary: lookupAux + ), case .int(let pid64)? = reply.payload, pid64 > 0 { + targetApplicationPID = pid_t(pid64) + } + } + if let targetApplicationPID { + await kill(pid: targetApplicationPID, on: instrumentSession) + } + if let launchedRunnerPID { + await kill(pid: launchedRunnerPID, on: instrumentSession) + } + await instrumentSession.close() + } + + private func kill(pid: pid_t, on session: DTXConnection) async { + var aux = DTXAuxiliaryBuffer() + aux.append(.object(.int(Int64(pid)))) + _ = try? await session.callChannel( + Channel.processControl, + selector: "killPid:", + auxiliary: aux, + expectsReply: false + ) + } + + /// Starts a lockdown service by raw string identifier. `SwiftyMobileDevice` (as pinned by + /// this project) only exposes a generic `startService(...)` overload + /// keyed by a typed client -- there's no typed client for DTX services (see this file's + /// header comment), so this calls the underlying `lockdownd_start_service`/ + /// `lockdownd_start_service_with_escrow_bag` C functions directly, exactly like + /// `LockdownClient.ServiceDescriptor`'s generic initializer does internally, then wraps the + /// result in the same public `ServiceDescriptor` type. + private func startService(named identifier: String, sendEscrowBag: Bool = false) throws -> LockdownClient.ServiceDescriptor { + var descriptor: lockdownd_service_descriptor_t? + let fn = sendEscrowBag ? lockdownd_start_service_with_escrow_bag : lockdownd_start_service + let status = fn(connection.client.raw, identifier, &descriptor) + guard status == LOCKDOWN_E_SUCCESS, let descriptor else { + throw SessionError.noServiceAvailable(identifier) + } + return LockdownClient.ServiceDescriptor(raw: descriptor) + } + + /// Opens a service by its modern ("secure") name, falling back to the legacy name for older + /// iOS versions that don't have the `DVTSecureSocketProxy`/`.secure` variant -- or, when + /// `tunnelContext` is set, opens `rsdServiceName` over the iOS 17.4+ RSD tunnel instead. Either + /// way the result is a `DTXConnection`; the DTX wire protocol above it doesn't know or care + /// which transport it's running over. + private func openDTXConnection(secureName: String, legacyName: String, rsdServiceName: String) throws -> DTXConnection { + if let tunnelContext { + guard let port = tunnelContext.rsd.port(for: rsdServiceName) else { + throw SessionError.noServiceAvailable(rsdServiceName) + } + let transport = try TunnelDTXTransport(tunnel: tunnelContext.tunnel, port: port) + return DTXConnection(transport: transport) + } + + let descriptor: LockdownClient.ServiceDescriptor + do { + descriptor = try startService(named: secureName) + } catch { + do { + descriptor = try startService(named: legacyName) + } catch { + throw SessionError.noServiceAvailable(legacyName) + } + } + let transport = try DTXTransport.connect(device: device, service: descriptor) + return DTXConnection(transport: transport) + } + + // MARK: - Handshake + + /// Runs the full handshake and launches `runnerBundleID` (a pre-installed `Runner.app` + /// containing the `.xctest` bundle to run). `targetApplicationBundleID` should be set for + /// XCUITest runs that drive a separate app-under-test; leave `nil` for plain XCTest bundles + /// hosted directly inside the runner. + /// + /// - Parameter testBundleName: the `.xctest` bundle's name (without extension) inside the + /// runner's `PlugIns/` directory, e.g. `Plan.XCTestPlan.testProductName`. Real Xcode-built + /// runners derive this from their own executable name by stripping a `-Runner` suffix + /// (`MyAppTests-Runner` -> `MyAppTests`); xtool's synthesized runners don't follow that + /// naming convention (confirmed against a real device that assuming they did was a real + /// bug), so this must be passed explicitly instead of derived from `runnerBundleID`'s + /// executable name. + @discardableResult + public func start( + runnerBundleID: String, + testBundleName: String, + targetApplicationBundleID: String? = nil, + testsToRun: [String]? = nil, + testsToSkip: [String]? = nil + ) async throws -> pid_t { + let sessionIdentifier = UUID() + + try await startInitialControlSession() + try await startExecTestPlanSession(sessionIdentifier: sessionIdentifier) + + let pid = try await launchRunner( + bundleID: runnerBundleID, + testBundleName: testBundleName, + targetApplicationBundleID: targetApplicationBundleID, + sessionIdentifier: sessionIdentifier, + testsToRun: testsToRun, + testsToSkip: testsToSkip + ) + launchedRunnerPID = pid + self.targetApplicationBundleID = targetApplicationBundleID + + try await authorizeTestSession(processID: pid) + + // Real hardware testing (iOS 16.7, this session) showed the runner process staying alive + // indefinitely, past its own initial `XCTTargetBootstrap` checks, with neither + // `_XCT_testBundleReadyWithProtocolVersion:minimumVersion:` nor a matching + // `_XCT_logDebugMessage:` (this file's two existing triggers, transcribed from + // appium-ios-device's `_startExecSession`) ever arriving to fire + // `startExecutingTestPlanIfNeeded` -- no crash, no error, just silence. pymobiledevice3's + // `XCUITestService.run` (a separately-maintained, actively-used reference; read for + // control flow only, not copied -- see this file's header comment) does not gate sending + // `_IDE_startExecutingTestPlanWithProtocolVersion:` on any `_XCT_...` callback from the + // runner at all -- it sends it as soon as authorization succeeds (immediately after + // opening the runner's reverse DTX channel, which this implementation doesn't track + // separately since `_IDE_startExecutingTestPlanWithProtocolVersion:` already goes out on + // the fixed "magic channel", not a negotiated one). Calling this here proactively, in + // addition to (not instead of) the existing selector-triggered calls: harmless if the + // runner does send one of those triggers later (`startExecutingTestPlanIfNeeded` is + // idempotent, guarded by `didStartExecutingTestPlan`), and unblocks the case -- not yet + // confirmed but consistent with everything observed -- where the runner is itself waiting + // for this call before doing anything else observable. + if let execTestPlanSession { + await startExecutingTestPlanIfNeeded(on: execTestPlanSession) + } + + return pid + } + + /// Builds an `XCTCapabilities` archive object: `{"capabilities-dictionary": }`, class + /// `XCTCapabilities`/`NSObject`. Wire shape confirmed against go-ios's `nskeyedarchiver. + /// XCTCapabilities`/`archiveXCTCapabilities` (MIT -- read for the field layout only, not + /// copied; see this file's header comment). + private static func capabilities(_ dict: [String: NSKeyedValue] = [:]) -> NSKeyedValue { + .object(className: "XCTCapabilities", properties: [ + ("capabilities-dictionary", .dictionary(dict)), + ]) + } + + private func startInitialControlSession() async throws { + let dtx = try openDTXConnection(secureName: ServiceName.testmanagerdSecure, legacyName: ServiceName.testmanagerdLegacy, rsdServiceName: RSDServiceName.testmanagerd) + await dtx.start() + initialControlSession = dtx + + guard majorOSVersion >= 11 else { return } + var aux = DTXAuxiliaryBuffer() + // `_IDE_initiateControlSessionWithCapabilities:` for iOS 14+, matching go-ios's + // `runXUITestWithBundleIdsXcode12Ctx` (its actual working iOS 14-16 implementation, not + // just the iOS 17+ path) -- confirmed necessary against real hardware (iOS 16.7): the + // legacy `_IDE_initiateControlSessionWithProtocolVersion:` this code originally sent + // (transcribed from appium-ios-device) got a normal-looking reply and let the exec-plan + // session reach "waiting to pair", but the on-device test runner then never opened its + // own channel or sent any further DTX traffic at all -- consistent with testmanagerd + // internally treating a capabilities-initiated session differently from a + // protocol-version-initiated one, not just accepting either as equivalent. iOS 11-13 keep + // the protocol-version selector (pymobiledevice3 confirms that range still uses it; no + // go-ios reference or real device available to verify differently for that older range). + if majorOSVersion >= 14 { + aux.append(.object(Self.capabilities())) + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateControlSessionWithCapabilities:", + auxiliary: aux + ) + } else { + aux.append(.object(.int(Int64(Self.xcodeVersion)))) + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateControlSessionWithProtocolVersion:", + auxiliary: aux + ) + } + } + + private func startExecTestPlanSession(sessionIdentifier: UUID) async throws { + let dtx = try openDTXConnection(secureName: ServiceName.testmanagerdSecure, legacyName: ServiceName.testmanagerdLegacy, rsdServiceName: RSDServiceName.testmanagerd) + await dtx.start() + execTestPlanSession = dtx + + // ensures daemonConnectionInterface is channel code 1, matching Self.magicChannel's + // hardcoded assumption (see its doc comment). + _ = try await dtx.makeChannel(Channel.daemonConnectionInterface) + + await dtx.onSelector("_XCT_testBundleReadyWithProtocolVersion:minimumVersion:") { [weak self] _ in + Task { + await self?.emit(.testBundleReady) + await self?.startExecutingTestPlanIfNeeded(on: dtx) + } + } + await dtx.onSelector("_XCT_logDebugMessage:") { [weak self] message in + // `message.payload` is the selector name itself ("_XCT_logDebugMessage:"), not the + // logged text -- that's in the first auxiliary argument. Confirmed as a real, + // previously-unnoticed bug once real callback traffic finally arrived on real + // hardware (this session): every `.logDebugMessage` event was showing the literal + // string "_XCT_logDebugMessage:" instead of the runner's actual log line. + guard case .object(.string(let text))? = message.auxiliary.values.first else { return } + Task { + await self?.emit(.logDebugMessage(text)) + if text.contains("Received test runner ready reply with error: (null") { + await self?.startExecutingTestPlanIfNeeded(on: dtx) + } + await self?.captureTargetApplicationPID(fromLogText: text) + } + } + await dtx.onSelector( + "_XCT_testSuiteWithIdentifier:didFinishAt:runCount:skipCount:failureCount:" + + "expectedFailureCount:uncaughtExceptionCount:testDuration:totalDuration:" + ) { [weak self] message in + // Positional: [runCount, skipCount, failureCount, expectedFailureCount, + // uncaughtExceptionCount] -- the leading `didFinishAt` date string and trailing + // duration doubles aren't ints, so filtering to just `.int` values conveniently + // lines them up at indices 0/1/2 regardless of whether the leading identifier + // argument (a numeric/opaque value in this protocol version, not a human-readable + // name) is present in this buffer. + let ints: [Int64] = message.auxiliary.values.compactMap { + if case .object(.int(let i)) = $0 { return i } + return nil + } + guard ints.count >= 3 else { return } + let runCount = Int(ints[0]) + let failureCount = Int(ints[2]) + Task { await self?.emit(.testSuiteFinished(runCount: runCount, failureCount: failureCount)) } + } + await dtx.onUnhandled { [weak self] message in + // catch-all: any other `_XCT_...` selector not specifically handled above. + guard case .string(let selector)? = message.payload else { return } + let arguments: [NSKeyedValue] = message.auxiliary.values.compactMap { + if case .object(let value) = $0 { return value } + return nil + } + Task { await self?.emit(.raw(selector: selector, arguments: arguments)) } + } + + let sessionUUID = NSKeyedValue.object(className: "NSUUID", properties: [ + ("NS.uuidbytes", .data(sessionIdentifier.dtxUUIDBytes)), + ]) + var aux = DTXAuxiliaryBuffer() + // `_IDE_initiateSessionWithIdentifier:capabilities:` for iOS 14+ -- see + // `startInitialControlSession`'s doc comment for why (same real-hardware finding, same + // go-ios reference). Capability keys match go-ios's `runXUITestWithBundleIdsXcode12Ctx` + // (iOS 14-16) exactly for that range; iOS 17+ gets go-ios's larger + // `runXUITestWithBundleIdsXcode15Ctx` set instead -- notably including "daemon container + // sandbox extension", missing from the 14-16 set. Real-hardware testing (iPhone 17 Pro, + // iOS 26, this session) showed testmanagerd accepting this call and then hanging forever + // at "Waiting for harness to handle response to session initiation before notifying + // delegate" with the smaller (14-16) capability set -- consistent with testmanagerd + // requiring the caller to have declared a capability it isn't otherwise willing to grant. + if majorOSVersion >= 17 { + aux.append(.object(sessionUUID)) + aux.append(.object(Self.capabilities([ + "XCTIssue capability": .int(1), + "daemon container sandbox extension": .int(1), + "delayed attachment transfer": .int(1), + "expected failure test capability": .int(1), + "request diagnostics for specific devices": .int(1), + "skipped test capability": .int(1), + "test case run configurations": .int(1), + "test iterations": .int(1), + "test timeout capability": .int(1), + "ubiquitous test identifiers": .int(1), + ]))) + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateSessionWithIdentifier:capabilities:", + auxiliary: aux + ) + } else if majorOSVersion >= 14 { + aux.append(.object(sessionUUID)) + aux.append(.object(Self.capabilities([ + "XCTIssue capability": .int(1), + "skipped test capability": .int(1), + "test timeout capability": .int(1), + ]))) + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateSessionWithIdentifier:capabilities:", + auxiliary: aux + ) + } else { + aux.append(.object(sessionUUID)) + aux.append(.object(.string("\(sessionIdentifier.uuidString)-746F-006D726964646C79"))) + aux.append(.object(.string(Self.xcodeBuildPathMarker))) + aux.append(.object(.int(Int64(Self.xcodeVersion)))) + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateSessionWithIdentifier:forClient:atPath:protocolVersion:", + auxiliary: aux + ) + } + } + + private struct AppLookupInfo { + let container: String + let path: String + } + + /// Looks up `Container`/`Path` for an installed app via a direct + /// `instproxy_lookup` call, bypassing `InstallationProxyClient.lookup`'s generic + /// `Decodable`-based path entirely. + /// + /// - Important: `InstallationProxyClient.lookup` (pinned `SwiftyMobileDevice` 1.5.0) crashes + /// natively (a real SIGSEGV inside libplist's decode path, not a Swift-level error) when + /// called from this file -- confirmed reproducible against a real device across multiple + /// independent runs, with two different bundle IDs, and unaffected by narrowing + /// `returnAttributes`. Root cause not isolated (no symbols in the crashing native frames). + /// This works around it by using the same lower-level pattern already used elsewhere in + /// this file for calls `SwiftyMobileDevice` doesn't wrap (raw C call + the `PlistValue` + /// bridge from Phase 0's `PersonalizedDDIMounter` work), rather than the generic decoder. + private func lookupApp(bundleID: String, client: InstallationProxyClient) throws -> AppLookupInfo { + let optionsPlist = PlistValue.dictionary([ + "ReturnAttributes": .array([.string("Container"), .string("Path")]), + ]).toPlistT() + defer { plist_free(optionsPlist) } + + var appIDs: [UnsafeMutablePointer?] = [strdup(bundleID), nil] + defer { appIDs.forEach { $0.map { free($0) } } } + + var result: plist_t? + let status = appIDs.withUnsafeMutableBufferPointer { buf -> instproxy_error_t in + buf.withMemoryRebound(to: UnsafePointer?.self) { rebound in + instproxy_lookup(client.raw, rebound.baseAddress, optionsPlist, &result) + } + } + guard status == INSTPROXY_E_SUCCESS, let result else { + throw SessionError.invalidRunnerBundle("'\(bundleID)' is not installed") + } + defer { plist_free(result) } + + guard case .dictionary(let apps) = PlistValue(plistT: result), + case .dictionary(let info)? = apps[bundleID], + case .string(let container)? = info["Container"], + case .string(let path)? = info["Path"] + else { + throw SessionError.invalidRunnerBundle("'\(bundleID)' is not installed") + } + return AppLookupInfo(container: container, path: path) + } + + /// Finds the actual installed bundle ID matching `baseBundleID`, accounting for the team-ID + /// prefix (`XTL-.`) xtool's own free-tier signing adds -- a package's + /// configured bundle ID (from `xtool.yml`/`Package.swift`) is never what's actually installed + /// on a real device signed with a free account, only what it was derived from. Looks up every + /// installed app's `CFBundleIdentifier` (same raw `instproxy_lookup` pattern as `lookupApp` + /// above, for the same crash-avoidance reason) and returns the first exact or suffix match. + public static func resolveInstalledBundleID( + matching baseBundleID: String, + client: InstallationProxyClient + ) throws -> String? { + let optionsPlist = PlistValue.dictionary([ + "ReturnAttributes": .array([.string("CFBundleIdentifier")]), + ]).toPlistT() + defer { plist_free(optionsPlist) } + + var result: plist_t? + let status = instproxy_lookup(client.raw, nil, optionsPlist, &result) + guard status == INSTPROXY_E_SUCCESS, let result else { return nil } + defer { plist_free(result) } + + guard case .dictionary(let apps) = PlistValue(plistT: result) else { return nil } + if apps[baseBundleID] != nil { return baseBundleID } + + let suffix = ".\(baseBundleID)" + return apps.keys.first { $0.hasSuffix(suffix) } + } + + private func launchRunner( + bundleID: String, + testBundleName: String, + targetApplicationBundleID: String?, + sessionIdentifier: UUID, + testsToRun: [String]?, + testsToSkip: [String]? + ) async throws -> pid_t { + let installProxy: InstallationProxyClient = try await connection.startClient() + let appInfo = try lookupApp(bundleID: bundleID, client: installProxy) + // For an XCUITest run, the runner needs the target app's *actual* install path to + // correlate against -- left at `XCTestConfiguration`'s placeholder default (confirmed + // against real hardware, this session), the runner never fails outright, it just never + // recognizes the target app's process among the (mostly unrelated) accessibility + // notifications it observes, so nothing ever progresses past "authorized, waiting." + let targetApplicationPath = try targetApplicationBundleID.map { + try lookupApp(bundleID: $0, client: installProxy).path + } + + let sessionToken = sessionIdentifier.uuidString.uppercased() + let xctestConfigRelativePath = "\(testBundleName)-\(sessionToken).xctestconfiguration" + + var config = XCTestConfiguration( + testBundleURL: "file://\(appInfo.path)/PlugIns/\(testBundleName).xctest", + sessionIdentifier: sessionIdentifier, + productModuleName: testBundleName, + targetApplicationBundleID: targetApplicationBundleID, + targetApplicationPath: targetApplicationPath ?? "/KEEP-THIS-NOT-EMPTY/KEEP-THIS-NOT-EMPTY", + testsToRun: testsToRun, + testsToSkip: testsToSkip + ) + // Only an actual XCUITest run (driving a separate target app) needs the automation/ + // accessibility bootstrap this triggers -- confirmed against real hardware (this session): + // left at the struct's default `true` for a plain hosted XCTest run (no target app), the + // runner spun forever repeating "getting screen bounds" against an automation session that + // was never actually established, since there's no target app to automate. + config.initializeForUITesting = targetApplicationBundleID != nil + // Confirmed against pymobiledevice3's `to_xctestconfiguration`, which switches on this + // same >= 17 threshold -- real-device evidence (this session): leaving this at the + // pre-17 default on iOS 26 let the actual test case run and pass, but the runner's + // post-test-case automation-session re-acquisition then failed with "No bundle at path + // /Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework", stalling the + // session instead of completing. + if majorOSVersion >= 17 { + config.automationFrameworkPath = "/System/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework" + } + let finalConfig = config + try await writeConfiguration(finalConfig, relativePath: xctestConfigRelativePath, toSandboxOf: bundleID) + + // The on-device runner calls `_XCT_testRunnerReadyWithCapabilities:` back on the exec-plan + // session once it's up, expecting the reply payload to actually *be* this configuration -- + // confirmed against go-ios's `proxydispatcher.go`, which suppresses the default ack for + // this selector and replies with the archived `XCTestConfiguration` instead. Without this, + // `XCTTargetBootstrap` never observes the test daemon as ready and the run hangs forever. + // Registered here (not in `startExecTestPlanSession`) so the closure can capture `config` + // directly -- it's a plain `Sendable` value, unlike this actor's other state, which would + // need an `await` hop the synchronous reply-handler contract doesn't allow. + if let execTestPlanSession { + await execTestPlanSession.onSelectorWithReply("_XCT_testRunnerReadyWithCapabilities:") { _ in + finalConfig.keyedValue + } + } + + let dtx = try openDTXConnection(secureName: ServiceName.instrumentsSecure, legacyName: ServiceName.instrumentsLegacy, rsdServiceName: RSDServiceName.instruments) + await dtx.start() + instrumentSession = dtx + + // Fallback completion signal: confirmed against real hardware (this session, XCUITest + // against a real target app) that the exec-plan session's structured + // `_XCT_testSuiteWithIdentifier:didFinishAt:...` callback can simply never arrive even + // though the on-device run completes successfully -- the runner's stdout (relayed here + // over this *separate* instruments connection, which keeps flowing even when the + // exec-plan session goes silent) still prints XCTest's own human-readable summary line + // ("Test Suite 'All tests' passed/failed at ...\n\t Executed N tests, with M failures + // ..."), so this is parsed as a backstop in case the structured signal never shows up. + await dtx.onSelector("outputReceived:fromProcess:atTime:") { [weak self] message in + guard case .object(.string(let text))? = message.auxiliary.values.first else { return } + Task { await self?.handleOutputText(text) } + } + + var lookupAux = DTXAuxiliaryBuffer() + lookupAux.append(.object(.string(bundleID))) + _ = try? await dtx.callChannel( + Channel.processControl, + selector: "processIdentifierForBundleIdentifier:", + auxiliary: lookupAux + ) + + let xctestConfigurationPath = appInfo.container + "/tmp/" + xctestConfigRelativePath + var envStrings: [String: String] = [ + "CA_ASSERT_MAIN_THREAD_TRANSACTIONS": "0", + "CA_DEBUG_TRANSACTIONS": "0", + "DYLD_FRAMEWORK_PATH": "\(appInfo.path)/Frameworks:", + "DYLD_LIBRARY_PATH": "\(appInfo.path)/Frameworks", + "NSUnbufferedIO": "YES", + "SQLITE_ENABLE_THREAD_ASSERTIONS": "1", + "XCTestConfigurationFilePath": xctestConfigurationPath, + "XCODE_DBG_XPC_EXCLUSIONS": "com.apple.dt.xctestSymbolicator", + "LLVM_PROFILE_FILE": "\(appInfo.container)/tmp/%p.profraw", + // Confirmed present in pymobiledevice3's `_generate_launch_args` (read for the + // env-key set only, not copied -- see this file's header comment) and missing here + // until now: without these two, the runner process launches and stays alive (past + // the framework-embedding and UIApplicationMain fixes) but its XCTTargetBootstrap + // subsystem never proceeds past its initial "test daemon not ready" checks -- it + // never reads `XCTestConfigurationFilePath` at all without also being told the + // bundle path and session identifier directly via these two keys. + "XCTestBundlePath": "\(appInfo.path)/PlugIns/\(testBundleName).xctest", + "XCTestSessionIdentifier": sessionToken, + ] + if majorOSVersion >= 11 { + envStrings["DYLD_INSERT_LIBRARIES"] = "/Developer/usr/lib/libMainThreadChecker.dylib" + envStrings["OS_ACTIVITY_DT_MODE"] = "YES" + } + let env = envStrings.mapValues { NSKeyedValue.string($0) } + + let argStrings = ["-NSTreatUnknownArgumentsAsOpen", "NO", "-ApplePersistenceIgnoreState", "YES"] + let args: [NSKeyedValue] = argStrings.map { .string($0) } + // `KillExisting: true` was tried (matching go-ios's `ProcessControl`'s use of the key) to + // chase what was, at the time, misdiagnosed as a stale-process rejection -- the real cause + // turned out to be the DTX auxiliary-buffer format bug documented on + // `DTXAuxiliaryBuffer.encoded()` in `DTXMessage.swift`, unrelated to this. Reverted: + // confirmed against real hardware that `true` here makes the on-device process-control + // service perform a *separate* kill-then-relaunch (a distinct pid, launched by + // DTServiceHub itself as "Terminating any existing instance before DTServiceHub app + // launch" in the device syslog) rather than simply reusing/suppressing a duplicate -- + // and the freshly-relaunched instance never went on to log any `XCTTargetBootstrap` + // activity the way a plain, single `KillExisting: false` launch reliably does. Matches + // go-ios's own default (`uint64(0)`, i.e. false) for a plain app launch. + // `ActivateSuspended` (foreground/activate the app once launched) is only sent for + // XCUITest runs, matching go-ios's `startTestRunner17` exactly: it builds an *empty* + // options dict for plain XCTest and only populates `ActivateSuspended`/ + // `__ActivateSuspended`/`StartSuspendedKey` when `!isXCTest` (i.e. XCUITest, which needs a + // foregrounded, visible app to drive). This code previously sent `ActivateSuspended: true` + // unconditionally for every run on iOS >= 12 -- untested previously whether forcing + // foreground-activation on a plain XCTest run (which isn't supposed to need it at all) + // could itself be why the runner never signals readiness back to testmanagerd. + var options: [String: NSKeyedValue] + if targetApplicationBundleID != nil && majorOSVersion >= 12 { + options = [ + "ActivateSuspended": .int(1), + "StartSuspendedKey": .int(0), + "__ActivateSuspended": .int(1), + ] + } else { + options = [ + "StartSuspendedKey": .bool(false), + "KillExisting": .bool(false), + ] + } + + let pid: pid_t + // On the iOS 17.4+ tunnel path, launch via `com.apple.coredevice.appservice` (RemoteXPC, + // not DTX) instead of the classic DTX `processcontrol` channel below -- pymobiledevice3 + // (an independent implementation) hits the exact same "runner never signals readiness" + // failure the DTX call produces, and go-ios's only working iOS 17+ path launches via this + // appservice instead. A first attempt at this reused `envStrings` (the DTX path's env + // dict) and got total silence back from the device -- go-ios's `startTestRunner17` + // (finally read in full, not just skimmed) revealed the DDI/appservice launch path needs + // an entirely different environment, not the DTX one: `DYLD_FRAMEWORK_PATH`/ + // `DYLD_LIBRARY_PATH` point at the *DDI's* system paths, not the app bundle; + // `XCTestConfigurationFilePath` is left empty; `XCTestManagerVariant: "DDI"` is set (xtool + // had none of this); and -- likely the actual missing piece for plain XCTest -- + // `DYLD_INSERT_LIBRARIES` must additionally include `libXCTestBundleInject.dylib`, the + // actual dylib that triggers the on-device runtime to dlopen the `.xctest` bundle at all. + // Always injected, not just for plain (hosted) XCTest: the runner always needs its + // *own* `.xctest` bundle loaded regardless of whether it's testing itself or driving + // a separate target app -- confirmed against real hardware (this session): an XCUITest + // run (targetApplicationBundleID != nil) reproduced the exact same "authorizes, starts + // executing the plan, then total silence" symptom plain XCTest had before this dylib + // was added, with this one condition being the only difference. go-ios's own `isXCTest` + // conditional for this (read as "plain XCTest only" when this was first written) may + // reflect something else about go-ios's flow, not an actual iOS-side requirement to + // omit it for XCUITest. + // + // On iOS 17+, launch via the *classic* DTX `processcontrol` channel (below, tunneled + // through the same `dtx` connection already used for everything else in this method), + // not `com.apple.coredevice.appservice` -- confirmed against pymobiledevice3's current + // (actively maintained) `xcuitest.py`, which uses `launch_suspended_process` over the + // DVT/instruments channel for *every* iOS version including 17+/26+, with no appservice + // branch at all. This directly contradicts this file's prior assumption (that appservice + // is the only working iOS 17+ path, based on a much older reading of go-ios) -- the + // earlier real-device attempt at the classic path that motivated switching to appservice + // predates both the `libXCTestBundleInject.dylib` and `targetApplicationPath` fixes above, + // so its failure was likely caused by those missing pieces, not the launch mechanism + // itself. + if tunnelContext != nil { + let ddiLibraries = "/Developer/usr/lib/libMainThreadChecker.dylib:/System/Developer/usr/lib/libXCTestBundleInject.dylib" + let ddiEnvStrings: [String: String] = [ + "CA_ASSERT_MAIN_THREAD_TRANSACTIONS": "0", + "CA_DEBUG_TRANSACTIONS": "0", + "DYLD_INSERT_LIBRARIES": ddiLibraries, + "DYLD_FRAMEWORK_PATH": "/System/Developer/Library/Frameworks", + "DYLD_LIBRARY_PATH": "/System/Developer/usr/lib", + "MTC_CRASH_ON_REPORT": "1", + "NSUnbufferedIO": "YES", + "OS_ACTIVITY_DT_MODE": "YES", + "SQLITE_ENABLE_THREAD_ASSERTIONS": "1", + "XCTestBundlePath": "\(appInfo.path)/PlugIns/\(testBundleName).xctest", + "XCTestConfigurationFilePath": "", + "XCTestManagerVariant": "DDI", + "XCTestSessionIdentifier": sessionToken, + ] + let ddiEnv = ddiEnvStrings.mapValues { NSKeyedValue.string($0) } + + var launchAux = DTXAuxiliaryBuffer() + launchAux.append(.object(.string(appInfo.path))) + launchAux.append(.object(.string(bundleID))) + launchAux.append(.object(.dictionary(ddiEnv))) + launchAux.append(.object(.array(args))) + launchAux.append(.object(.dictionary(options))) + let launchReply = try await dtx.callChannel( + Channel.processControl, + selector: "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:", + auxiliary: launchAux + ) + guard case .int(let pid64)? = launchReply.payload else { + throw SessionError.launchFailed("expected a process ID, got \(String(describing: launchReply.payload))") + } + pid = pid_t(pid64) + } else { + var launchAux = DTXAuxiliaryBuffer() + launchAux.append(.object(.string(appInfo.path))) + launchAux.append(.object(.string(bundleID))) + launchAux.append(.object(.dictionary(env))) + launchAux.append(.object(.array(args))) + launchAux.append(.object(.dictionary(options))) + let launchReply = try await dtx.callChannel( + Channel.processControl, + selector: "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:", + auxiliary: launchAux + ) + guard case .int(let pid64)? = launchReply.payload else { + throw SessionError.launchFailed("expected a process ID, got \(String(describing: launchReply.payload))") + } + pid = pid_t(pid64) + } + + var observeAux = DTXAuxiliaryBuffer() + observeAux.append(.object(.int(Int64(pid)))) + _ = try? await dtx.callChannel(Channel.processControl, selector: "startObservingPid:", auxiliary: observeAux) + + return pid + } + + private func authorizeTestSession(processID: pid_t) async throws { + guard let dtx = initialControlSession else { throw SessionError.launchFailed("no control session") } + var aux = DTXAuxiliaryBuffer() + aux.append(.object(.int(Int64(processID)))) + if majorOSVersion >= 12 { + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_authorizeTestSessionWithProcessID:", + auxiliary: aux + ) + } else if majorOSVersion <= 9 { + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateControlSessionForTestProcessID:", + auxiliary: aux + ) + } else { + aux.append(.object(.int(Int64(Self.xcodeVersion)))) + _ = try await dtx.callChannel( + Channel.daemonConnectionInterface, + selector: "_IDE_initiateControlSessionForTestProcessID:protocolVersion:", + auxiliary: aux + ) + } + } + + private func writeConfiguration(_ config: XCTestConfiguration, relativePath: String, toSandboxOf bundleID: String) async throws { + let houseArrest: HouseArrestClient = try await connection.startClient() + let afc = try houseArrest.vend(.container, forApp: bundleID) + + for existing in (try? afc.contentsOfDirectory(at: URL(fileURLWithPath: "/tmp"))) ?? [] { + guard existing.hasSuffix(".xctestconfiguration") else { continue } + try? afc.removeItem(at: URL(fileURLWithPath: "/tmp").appendingPathComponent(existing)) + } + + let destination = URL(fileURLWithPath: "/tmp").appendingPathComponent(relativePath) + let file = try afc.open(destination, mode: .writeOnly) + _ = try file.write(config.archived()) + } + + private func emit(_ event: TestManagerdEvent) { + eventContinuation?.yield(event) + } + + /// See `lastKnownTargetApplicationPID`'s doc comment. Matches lines of the shape + /// "@ processName: pid: state: ..." -- only the pid immediately + /// following "pid: " after confirming this line is actually about the target app (not some + /// unrelated process the runner also happens to report on, e.g. the runner itself). + private func captureTargetApplicationPID(fromLogText text: String) { + guard let targetApplicationBundleID, text.contains("\(targetApplicationBundleID)@") else { return } + guard let marker = text.range(of: " pid: ") else { return } + let digits = text[marker.upperBound...].prefix { $0.isNumber } + guard let pid = pid_t(digits) else { return } + lastKnownTargetApplicationPID = pid + } + + private func handleOutputText(_ text: String) { + for outputEvent in TestOutputParser.parse(text) { + switch outputEvent { + case .testCaseStarted: + break + case .failureDetail(let testClass, let testMethod, let file, let line, let message): + let key = "\(testClass) \(testMethod)" + let location = [file, line.map(String.init)].compactMap { $0 }.joined(separator: ":") + let full = location.isEmpty ? message : "\(location): \(message)" + pendingFailureMessages[key, default: []].append(full) + case .testCaseFinished(let testClass, let testMethod, let status, let duration): + let key = "\(testClass) \(testMethod)" + let failureMessages = pendingFailureMessages.removeValue(forKey: key) ?? [] + emit(.testCaseResult( + testClass: testClass, + testMethod: testMethod, + status: status, + duration: duration, + failureMessages: failureMessages + )) + case .suiteFinished(_, let runCount, let failureCount): + emit(.testSuiteFinished(runCount: runCount, failureCount: failureCount)) + } + } + } + + /// Fires `_IDE_startExecutingTestPlanWithProtocolVersion:`, at most once + /// (`didStartExecutingTestPlan` makes every call site idempotent). Three call sites feed into + /// this: `_XCT_testBundleReadyWithProtocolVersion:minimumVersion:` or a matching + /// `_XCT_logDebugMessage:` (both from `startExecTestPlanSession`'s handler registrations, + /// transcribed from appium-ios-device), and unconditionally right after + /// `_IDE_authorizeTestSessionWithProcessID:` succeeds in `start()` (matching + /// pymobiledevice3's flow, which never waits on an `_XCT_...` callback at all -- see `start()`'s + /// doc comment for why both are wired up). + private func startExecutingTestPlanIfNeeded(on dtx: DTXConnection) async { + guard !didStartExecutingTestPlan else { return } + didStartExecutingTestPlan = true + + // Wait for the device to open its own channel first -- confirmed against real hardware + // to be required (see `DTXConnection.waitForAnyDeviceChannelRequest`'s doc comment for + // the full story); falls through and sends anyway on timeout rather than giving up + // silently, in case this doesn't hold for some OS version/trigger path this wasn't tested + // against. + try? await dtx.waitForAnyDeviceChannelRequest() + + // Address the device's *actual* negotiated channel when we have it, not the hardcoded + // `Self.magicChannel` -- see `DTXConnection.anyDeviceRequestedChannelCode`'s doc comment + // for why. Negated: the device's own request (`_requestChannelWithCode:identifier:`) + // reports its *local* view of the code (confirmed on real hardware to be `1`, matching + // `daemonConnectionInterface`'s own client-opened channel code); addressing messages *to* + // that channel from this side requires the negated value, per this same file's existing + // `channelHandlers[-message.channelCode]` convention for the reverse direction. Sending on + // the un-negated code silently went nowhere -- the runner logged "entering wait loop ... + // requesting ready for testing" and never proceeded, even though the call itself + // succeeded with no error. Un-negated, this coincidentally reproduces `Self.magicChannel` + // (`-1`) whenever the device requests code `1`, which is presumably why go-ios's hardcoded + // `-1` already worked without ever needing to look up the device's own requested code. + let channelCode = await dtx.anyDeviceRequestedChannelCode().map { -$0 } ?? Self.magicChannel + + var aux = DTXAuxiliaryBuffer() + aux.append(.object(.int(Int64(Self.xcodeVersion)))) + // A short delay before firing is documented as necessary on some devices/iOS versions + // (appium-ios-device's comment: "if not using a delay this would fail on iPhone7 iOS + // 13.6.1") -- kept here for the same reason. + try? await Task.sleep(nanoseconds: 1_000_000_000) + _ = try? await dtx.call( + channelCode: channelCode, + selector: "_IDE_startExecutingTestPlanWithProtocolVersion:", + auxiliary: aux, + expectsReply: false + ) + } +} diff --git a/Sources/XKit/Testing/TestOutputParser.swift b/Sources/XKit/Testing/TestOutputParser.swift new file mode 100644 index 00000000..77c6638f --- /dev/null +++ b/Sources/XKit/Testing/TestOutputParser.swift @@ -0,0 +1,104 @@ +// +// TestOutputParser.swift +// XKit +// +// Parses the on-device XCTest runner's own human-readable console output (relayed to us via +// `outputReceived:fromProcess:atTime:` on the instruments DTX connection) -- the same +// "Test Case '-[Class method]' started/passed/failed (D seconds)." / ":: error: +// -[Class method] : message" / "Test Suite 'X' passed/failed at ...\n\t Executed N tests, with M +// failures (K unexpected) in ..." format `xcodebuild`/xcpretty parse -- since it's the one +// reliable source of per-test-case names: the structured `_XCT_testCaseWithIdentifier:...` +// callbacks carry an opaque numeric identifier in this protocol version, not a class/method name +// (confirmed against real hardware, see `TestManagerdEvent.testCaseFinished`'s doc comment), and +// (also confirmed against real hardware, this session) the structured callbacks can go missing +// entirely for a run that otherwise completed successfully on-device. This text format is the +// same one Xcode's own `xcodebuild` has relied on for CI consumption for over a decade, so +// parsing it isn't a fragile workaround -- it's the de facto stable interface. + +import Foundation + +public enum TestCaseStatus: String, Sendable, Equatable, Codable { + case passed + case failed +} + +public enum TestOutputEvent: Sendable, Equatable { + case testCaseStarted(testClass: String, testMethod: String) + case testCaseFinished(testClass: String, testMethod: String, status: TestCaseStatus, duration: Double) + /// A `:: error: -[Class method] : message` line -- always immediately precedes the + /// corresponding `.testCaseFinished(..., status: .failed, ...)` line, so callers should buffer + /// these and attach them to the next finished event for the same class/method. + case failureDetail(testClass: String, testMethod: String, file: String?, line: Int?, message: String) + case suiteFinished(suiteName: String, runCount: Int, failureCount: Int) +} + +public enum TestOutputParser { + private static let startedRegex = try! NSRegularExpression( + pattern: #"^Test Case '-\[(\S+) (\S+)\]' started\.$"# + ) + private static let finishedRegex = try! NSRegularExpression( + pattern: #"^Test Case '-\[(\S+) (\S+)\]' (passed|failed) \(([\d.]+) seconds\)\.$"# + ) + private static let failureDetailRegex = try! NSRegularExpression( + pattern: #"^(?:(.+?):(\d+): )?error: -\[(\S+) (\S+)\] : (.*)$"# + ) + // The suite summary is two physical lines within one `outputReceived` chunk: the "Test Suite + // '...' passed/failed at ..." header and a tab-indented "Executed N tests..." line -- matched + // together (`.dotMatchesLineSeparators`) since they always arrive as a single unit. + private static let suiteFinishedRegex = try! NSRegularExpression( + pattern: #"Test Suite '([^']+)' (?:passed|failed) at [^\n]+\n\s*Executed (\d+) tests?, with (\d+) failures?"#, + options: [.dotMatchesLineSeparators] + ) + + /// Parses every event found in `text`, which may contain one or more lines (an + /// `outputReceived:fromProcess:atTime:` chunk is not guaranteed to be exactly one line). + public static func parse(_ text: String) -> [TestOutputEvent] { + var events: [TestOutputEvent] = [] + + if let match = firstMatch(suiteFinishedRegex, in: text), + let suiteName = group(match, 1, in: text), + let runCount = group(match, 2, in: text).flatMap(Int.init), + let failureCount = group(match, 3, in: text).flatMap(Int.init) { + events.append(.suiteFinished(suiteName: suiteName, runCount: runCount, failureCount: failureCount)) + } + + for line in text.split(separator: "\n", omittingEmptySubsequences: true) { + let line = String(line) + if let match = firstMatch(startedRegex, in: line), + let testClass = group(match, 1, in: line), + let testMethod = group(match, 2, in: line) { + events.append(.testCaseStarted(testClass: testClass, testMethod: testMethod)) + continue + } + if let match = firstMatch(finishedRegex, in: line), + let testClass = group(match, 1, in: line), + let testMethod = group(match, 2, in: line), + let statusString = group(match, 3, in: line), + let status = TestCaseStatus(rawValue: statusString), + let duration = group(match, 4, in: line).flatMap(Double.init) { + events.append(.testCaseFinished(testClass: testClass, testMethod: testMethod, status: status, duration: duration)) + continue + } + if let match = firstMatch(failureDetailRegex, in: line), + let testClass = group(match, 3, in: line), + let testMethod = group(match, 4, in: line), + let message = group(match, 5, in: line) { + let file = group(match, 1, in: line) + let lineNumber = group(match, 2, in: line).flatMap(Int.init) + events.append(.failureDetail(testClass: testClass, testMethod: testMethod, file: file, line: lineNumber, message: message)) + continue + } + } + + return events + } + + private static func firstMatch(_ regex: NSRegularExpression, in text: String) -> NSTextCheckingResult? { + regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) + } + + private static func group(_ match: NSTextCheckingResult, _ index: Int, in text: String) -> String? { + guard index < match.numberOfRanges, let range = Range(match.range(at: index), in: text) else { return nil } + return String(text[range]) + } +} diff --git a/Sources/XKit/Testing/TestReport.swift b/Sources/XKit/Testing/TestReport.swift new file mode 100644 index 00000000..9223d4d5 --- /dev/null +++ b/Sources/XKit/Testing/TestReport.swift @@ -0,0 +1,106 @@ +// +// TestReport.swift +// XKit +// +// A single internal result model every reporter (console, JUnit, JSON, HTML) derives from, so +// adding a reporter doesn't touch the execution path -- `TestCommand` builds one of these while +// consuming `TestManagerdSession.events`, then hands it to whichever reporters were requested. + +import Foundation + +public struct TestCaseReport: Sendable, Codable, Equatable { + public var testClass: String + public var testMethod: String + public var status: TestCaseStatus + public var duration: Double + public var failureMessages: [String] + /// Set only for a `.failed` case, and only when `--screenshot-on-failure`-equivalent capture + /// succeeded -- relative to the report directory, not an absolute path, so a report directory + /// stays portable when copied off the machine that produced it. + public var screenshotPath: String? + + public init( + testClass: String, + testMethod: String, + status: TestCaseStatus, + duration: Double, + failureMessages: [String] = [], + screenshotPath: String? = nil + ) { + self.testClass = testClass + self.testMethod = testMethod + self.status = status + self.duration = duration + self.failureMessages = failureMessages + self.screenshotPath = screenshotPath + } + + public var identifier: String { "\(testClass)/\(testMethod)" } +} + +/// One full `xtool test` invocation against one device. With `--repeat`, each repetition produces +/// its own `TestRunReport`; with `--parallel`, one per device -- `TestReport` (below) aggregates +/// across both. +public struct TestRunReport: Sendable, Codable, Equatable { + public var deviceName: String + public var deviceUDID: String + public var productVersion: String + public var testBundleName: String + public var startedAt: Date + public var finishedAt: Date + public var testCases: [TestCaseReport] + /// Relative path (see `TestCaseReport.screenshotPath`) to a captured device syslog window + /// covering this run, if log capture was requested and succeeded. + public var syslogPath: String? + /// Relative paths (see `TestCaseReport.screenshotPath`) to `.ips`/`.crash` files pulled from + /// the device's crash log store that were written during this run, if crash log collection + /// was requested -- empty (not necessarily an error) whenever nothing actually crashed. + public var crashLogPaths: [String] + /// Set if the run itself failed before any test cases could be collected (DDI mount failure, + /// connection drop, etc.) -- distinct from a test case failing, which is `TestCaseReport`. + public var infrastructureError: String? + + public init( + deviceName: String, + deviceUDID: String, + productVersion: String, + testBundleName: String, + startedAt: Date, + finishedAt: Date, + testCases: [TestCaseReport], + syslogPath: String? = nil, + crashLogPaths: [String] = [], + infrastructureError: String? = nil + ) { + self.deviceName = deviceName + self.deviceUDID = deviceUDID + self.productVersion = productVersion + self.testBundleName = testBundleName + self.startedAt = startedAt + self.finishedAt = finishedAt + self.testCases = testCases + self.syslogPath = syslogPath + self.crashLogPaths = crashLogPaths + self.infrastructureError = infrastructureError + } + + public var passCount: Int { testCases.count { $0.status == .passed } } + public var failCount: Int { testCases.count { $0.status == .failed } } + public var duration: TimeInterval { finishedAt.timeIntervalSince(startedAt) } +} + +/// The top-level result of an `xtool test` invocation, spanning every device x repetition +/// combination that ran. +public struct TestReport: Sendable, Codable, Equatable { + public var runs: [TestRunReport] + + public init(runs: [TestRunReport]) { + self.runs = runs + } + + public var passCount: Int { runs.reduce(0) { $0 + $1.passCount } } + public var failCount: Int { runs.reduce(0) { $0 + $1.failCount } } + /// `true` if every run finished infra-cleanly (whether or not individual test cases failed) + /// and at least one run happened at all. + public var allRunsCompleted: Bool { !runs.isEmpty && runs.allSatisfy { $0.infrastructureError == nil } } +} diff --git a/Sources/XKit/Testing/Tunnel/CoreDeviceProxyTunnel.swift b/Sources/XKit/Testing/Tunnel/CoreDeviceProxyTunnel.swift new file mode 100644 index 00000000..03088c4b --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/CoreDeviceProxyTunnel.swift @@ -0,0 +1,319 @@ +// +// CoreDeviceProxyTunnel.swift +// XKit +// +// Establishes the iOS 17.4+ RSD tunnel via the lockdown-exposed `CoreDeviceProxy` service -- +// the simple path that reuses the *existing* trusted USB pairing (already handled by +// `SwiftyMobileDevice`/lockdown elsewhere in this project) instead of the older, much larger +// SRP6a/RemoteXPC pairing handshake iOS 17.0-17.3 required. Structured after the documented, +// working reference in go-ios's `ios/tunnel/tunnel_lockdown.go` (MIT -- read for the handshake +// and packet-forwarding shape, rewritten from scratch in Swift here; same clean-room approach as +// the rest of this directory). +// +// Sequence: +// 1. Open the `com.apple.internal.devicecompute.CoreDeviceProxy` lockdown service. +// 2. Exchange CDTunnel parameters over it (`"CDTunnel\0"` + 1-byte length + JSON), getting +// back the tunnel's IPv6 addressing plus the address/port to reach RSD at once the tunnel +// is up. +// 3. Create and configure a kernel TUN device with the client-side address (`TUNDevice.swift`). +// 4. Pump raw IPv6 packets bidirectionally between the lockdown connection (which frames +// packets as `[40-byte IPv6 header][payload]` back-to-back on a plain byte stream) and the +// TUN device (which is already packet-boundary-preserving, one packet per read/write). +// + +import Foundation +import SwiftyMobileDevice +import libimobiledevice + +struct CoreDeviceProxyTransport: StreamingConnection { + typealias Error = IdeviceError + typealias Raw = idevice_connection_t + + nonisolated(unsafe) let raw: idevice_connection_t + nonisolated(unsafe) let sendFunc: SendFunc = idevice_connection_send + nonisolated(unsafe) let receiveFunc: ReceiveFunc = idevice_connection_receive + nonisolated(unsafe) let receiveTimeoutFunc: ReceiveTimeoutFunc = idevice_connection_receive_timeout + /// One per `CoreDeviceProxyTransport` instance, not shared -- see `CoreDeviceProxyIOState`'s + /// doc comment for why this needs to exist at all, and this property's own doc comment on + /// why it must NOT be a single process-wide global (which is how it was originally written). + let ioState = CoreDeviceProxyIOState() + + static func connect(device: Device, service: LockdownClient.ServiceDescriptor) throws -> CoreDeviceProxyTransport { + var raw: idevice_connection_t? + try checkIdevice(idevice_connect(device.raw, service.port, &raw)) + guard let raw else { throw CAPIGenericError.unexpectedNil } + if service.isSSLEnabled { + try checkIdevice(idevice_connection_enable_ssl(raw)) + } + return CoreDeviceProxyTransport(raw: raw) + } + + func close() { + idevice_disconnect(raw) + } +} + +struct CDTunnelParameters: Sendable { + let serverAddress: String + let serverRSDPort: UInt64 + let clientAddress: String + let clientMTU: UInt64 +} + +public enum CDTunnelError: Swift.Error { + case serviceUnavailable + case malformedResponse + case malformedPacketStream + case stopped +} + +private let cdTunnelPollTimeout: TimeInterval = 0.2 + +/// Serializes every send/receive/close on the raw `idevice_connection_t` `CoreDeviceProxyTransport` +/// wraps, *and* tracks whether it's been closed -- both checked and mutated atomically inside the +/// same `queue.sync` block as the actual I/O. This is the piece a first pass at this fix (checking +/// a `shouldStop` closure *before* entering the queue) got wrong: `CoreDeviceProxyTransport` is a +/// bare struct with no closed-tracking of its own, so a `sendSerialized`/`receiveExact` call could +/// pass its pre-queue `shouldStop()` check, then lose a race to `close()` for the queue itself -- +/// `close()` runs first, `idevice_disconnect`s the connection, and the pending send/receive then +/// executes on a freed connection. Confirmed via a real heap-corruption crash (`free(): chunks in +/// smallbin corrupted`, `double free or corruption`, and a SIGSEGV, non-deterministically across +/// runs -- the hallmark of a genuine data race) during real-device testing, and pinned down via +/// fine-grained per-operation logging showing the crash landing immediately after `close()` and an +/// in-flight `sendSerialized` were both active. Mirrors `TUNDevice`'s `ioQueue`, which already got +/// this right (its `isClosed` check lives inside the same `ioQueue.sync` block as its I/O). +/// +/// - Important: one instance per `CoreDeviceProxyTransport` (see that type's `ioState` property), +/// *not* a single process-wide singleton -- this was originally a `private let` at file scope, +/// which meant every tunnel in the process shared one `closed` flag: closing the first tunnel +/// opened (e.g. after `--repeat`'s first iteration) permanently poisoned every subsequent +/// tunnel's I/O with `CDTunnelError.stopped`, even freshly-connected ones. Confirmed against +/// real hardware (this session): `xtool test --repeat 2` worked on iteration 1 and failed +/// immediately on iteration 2 with exactly that error. +final class CoreDeviceProxyIOState: @unchecked Sendable { + let queue = DispatchQueue(label: "xtool.tunnel.io") + /// Guarded by `queue` -- only ever read/written from inside a `queue.sync` block. + var closed = false +} + +extension CoreDeviceProxyTransport { + /// Blocks until exactly `count` bytes are read, retrying on the timeout error + /// `DTXConnection`'s own read loop already established the convention for (see + /// `DTXConnection.receiveChunk`'s doc comment) -- a short per-attempt timeout so this can't + /// block forever on a connection that's silently gone away. Each individual receive attempt + /// (not the whole retry loop) runs inside the shared queue so a concurrent `send` on the same + /// connection can interleave between attempts rather than being blocked out entirely. + fileprivate func receiveExact(_ count: Int) throws -> Data { + guard count > 0 else { return Data() } + var data = Data(capacity: count) + while data.count < count { + let result = ioState.queue.sync { () -> Result in + guard !ioState.closed else { return .failure(CDTunnelError.stopped) } + return Result { try receive(maxLength: count - data.count, timeout: cdTunnelPollTimeout) } + } + do { + let chunk = try result.get() + guard !chunk.isEmpty else { throw CDTunnelError.malformedPacketStream } + data += chunk + } catch let error as IdeviceError where error.raw == IDEVICE_E_TIMEOUT { + continue + } + } + return data + } + + /// Serialized counterpart to `receiveExact` for the write side. + fileprivate func sendSerialized(_ data: Data) throws { + try ioState.queue.sync { + guard !ioState.closed else { throw CDTunnelError.stopped } + _ = try send(data) + } + } + + /// Marks this transport's state closed and disconnects, both inside the same `queue.sync` + /// block so no `receiveExact`/`sendSerialized` call already past its `closed` check can still + /// be in-flight when `close()` (the `StreamingConnection` requirement, e.g. + /// `idevice_disconnect`) actually runs. + fileprivate func closeSerialized(_ close: () -> Void) { + ioState.queue.sync { + guard !ioState.closed else { return } + ioState.closed = true + close() + } + } +} + +enum CDTunnelHandshake { + private static let magicPrefix = Data("CDTunnel\0".utf8) + + static func exchange(over connection: CoreDeviceProxyTransport) throws -> CDTunnelParameters { + let request: [String: Any] = ["type": "clientHandshakeRequest", "mtu": 1280] + let requestBody = try JSONSerialization.data(withJSONObject: request) + guard requestBody.count <= 255 else { throw CDTunnelError.malformedResponse } + + var frame = magicPrefix + frame.append(UInt8(requestBody.count)) + frame += requestBody + _ = try connection.send(frame) + + let header = try connection.receiveExact(magicPrefix.count + 1) + guard header.starts(with: magicPrefix) else { throw CDTunnelError.malformedResponse } + let bodyLength = Int(header[header.index(before: header.endIndex)]) + let body = try connection.receiveExact(bodyLength) + + guard + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any], + let serverAddress = json["serverAddress"] as? String, + let serverRSDPort = json["serverRSDPort"] as? NSNumber, + let clientParameters = json["clientParameters"] as? [String: Any], + let clientAddress = clientParameters["address"] as? String, + let clientMTU = clientParameters["mtu"] as? NSNumber + else { + throw CDTunnelError.malformedResponse + } + + return CDTunnelParameters( + serverAddress: serverAddress, + serverRSDPort: serverRSDPort.uint64Value, + clientAddress: clientAddress, + clientMTU: clientMTU.uint64Value + ) + } +} + +/// A live tunnel to a device: `address`/`rsdPort` are where RSD (and, after an RSD lookup, +/// `com.apple.dt.testmanagerd.remote`) are reachable over the TUN interface this creates. Call +/// `close()` when done to tear down the TUN device and the underlying lockdown connection. +public final class CoreDeviceProxyTunnel: @unchecked Sendable { + static let serviceName = "com.apple.internal.devicecompute.CoreDeviceProxy" + + public let address: String + public let rsdPort: Int + + private let transport: CoreDeviceProxyTransport + private let tun: TUNDevice + private let stopLock = NSLock() + private var stopped = false + + private init(address: String, rsdPort: Int, transport: CoreDeviceProxyTransport, tun: TUNDevice) { + self.address = address + self.rsdPort = rsdPort + self.transport = transport + self.tun = tun + } + + /// Opens the tunnel end to end: lockdown service -> CDTunnel handshake -> TUN device -> + /// background forwarding threads. `connection.client` must already have an active, + /// established pairing with the device (the same one every other lockdown service in this + /// project relies on) -- no separate SRP/RemoteXPC pairing is performed. + public static func connect(connection: Connection) throws -> CoreDeviceProxyTunnel { + var descriptor: lockdownd_service_descriptor_t? + let status = lockdownd_start_service(connection.client.raw, serviceName, &descriptor) + guard status == LOCKDOWN_E_SUCCESS, let descriptor else { + // e.g. iOS < 17.4, which doesn't expose this service at all -- this device needs the + // classic testmanagerd.lockdown path (`TestManagerdSession`), not this tunnel. + throw CDTunnelError.serviceUnavailable + } + let serviceDescriptor = LockdownClient.ServiceDescriptor(raw: descriptor) + let transport = try CoreDeviceProxyTransport.connect(device: connection.device, service: serviceDescriptor) + + let parameters: CDTunnelParameters + do { + parameters = try CDTunnelHandshake.exchange(over: transport) + } catch { + transport.closeSerialized { transport.close() } + throw error + } + + let tun: TUNDevice + do { + // matches go-ios's own admitted simplification (its comment: "TODO: could be derived + // from the netmask provided by the device") -- the device always hands back a /64. + tun = try TUNDevice( + address: parameters.clientAddress, + prefixLength: 64, + mtu: UInt32(parameters.clientMTU) + ) + } catch { + transport.closeSerialized { transport.close() } + throw error + } + + let tunnel = CoreDeviceProxyTunnel( + address: parameters.serverAddress, + rsdPort: Int(parameters.serverRSDPort), + transport: transport, + tun: tun + ) + tunnel.startForwarding(mtu: Int(parameters.clientMTU)) + return tunnel + } + + private func shouldStop() -> Bool { + stopLock.withLock { stopped } + } + + public func close() { + stopLock.withLock { + guard !stopped else { return } + stopped = true + } + tun.close() + transport.closeSerialized { transport.close() } + } + + deinit { + close() + } + + /// Spins up the two forwarding directions on dedicated `Thread`s, not `Task.detached` -- + /// same reasoning as `DTXConnection.start()`'s doc comment: these loops block on synchronous + /// I/O (a lockdown `idevice_connection_t` receive and a TUN `read(2)`) for as long as the + /// tunnel is open, and parking that on the cooperative thread pool would starve unrelated + /// async work elsewhere in the process. + private func startForwarding(mtu: Int) { + let deviceToTUN = Thread { [weak self] in self?.forwardDeviceToTUN(mtu: mtu) } + deviceToTUN.name = "xtool.tunnel.deviceToTUN" + deviceToTUN.start() + + let tunToDevice = Thread { [weak self] in self?.forwardTUNToDevice(mtu: mtu) } + tunToDevice.name = "xtool.tunnel.tunToDevice" + tunToDevice.start() + } + + /// The lockdown connection is a plain byte stream with no packet framing of its own, so each + /// IPv6 packet has to be pulled off by reading its fixed 40-byte header, checking the version + /// nibble, then reading exactly `payloadLength` (from the header's byte 4-5, big-endian) more + /// bytes -- mirrors go-ios's `forwardTCPToInterface`. + private func forwardDeviceToTUN(mtu: Int) { + let ipv6HeaderLength = 40 + while !shouldStop() { + do { + let header = try transport.receiveExact(ipv6HeaderLength) + guard header[header.startIndex] >> 4 == 6 else { throw CDTunnelError.malformedPacketStream } + let payloadLength = Int(header[header.index(header.startIndex, offsetBy: 4)]) << 8 + | Int(header[header.index(header.startIndex, offsetBy: 5)]) + let payload = try transport.receiveExact(payloadLength) + try tun.send(header + payload) + } catch let error as IdeviceError where error.raw == IDEVICE_E_TIMEOUT { + continue + } catch { + return + } + } + } + + /// The TUN device already hands back one complete packet per read, so no reframing is needed + /// on this side -- mirrors go-ios's `forwardTUNToDevice`. + private func forwardTUNToDevice(mtu: Int) { + while !shouldStop() { + do { + guard let packet = try tun.receive(maxLength: mtu, pollTimeoutMs: 200) else { continue } + try transport.sendSerialized(packet) + } catch { + return + } + } + } +} + diff --git a/Sources/XKit/Testing/Tunnel/MinimalHTTP2Framer.swift b/Sources/XKit/Testing/Tunnel/MinimalHTTP2Framer.swift new file mode 100644 index 00000000..369ce35f --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/MinimalHTTP2Framer.swift @@ -0,0 +1,247 @@ +// +// MinimalHTTP2Framer.swift +// XKit +// +// iOS 17+'s RemoteXPC services are carried over HTTP/2, but only as a framing/multiplexing +// layer -- there's no real HTTP semantics (no headers, no methods, no status codes) involved. +// Two fixed streams are used: id 1 ("client-server") for host-to-device messages, id 3 +// ("server-client") for device-to-host. This is a from-scratch implementation of exactly the +// subset of HTTP/2 framing that requires -- connection preface, SETTINGS, WINDOW_UPDATE, and +// DATA frames on those two streams -- structured after the documented, working reference in +// go-ios's `ios/http/http.go` (MIT -- read for the exact handshake byte sequence, rewritten from +// scratch in Swift here; same clean-room approach as `DTXMessage.swift`/`RemoteXPCCodec.swift`). +// A full HTTP/2 client (HPACK, flow control, real header blocks) is unnecessary and not +// implemented. +// + +import Foundation + +/// A minimal blocking byte-stream abstraction, implemented by whatever the RemoteXPC connection +/// actually rides on (a raw TCP socket to the device's RSD port over the TUN interface -- see +/// `TUNDevice.swift`). +public protocol ByteStream: Sendable { + func send(_ data: Data) throws + /// Blocking read of at most `maxLength` bytes. Returns an empty `Data` only at EOF. + func receive(maxLength: Int) throws -> Data +} + +enum ByteStreamError: Swift.Error { + case unexpectedEOF +} + +extension ByteStream { + /// Blocks until exactly `count` bytes have been read. + func readExact(_ count: Int) throws -> Data { + guard count > 0 else { return Data() } + var result = Data() + result.reserveCapacity(count) + while result.count < count { + let chunk = try receive(maxLength: count - result.count) + guard !chunk.isEmpty else { throw ByteStreamError.unexpectedEOF } + result += chunk + } + return result + } +} + +enum HTTP2FrameType: UInt8 { + case data = 0x0 + case headers = 0x1 + case rstStream = 0x3 + case settings = 0x4 + case goAway = 0x7 + case windowUpdate = 0x8 +} + +struct HTTP2Frame { + var type: HTTP2FrameType + var flags: UInt8 + var streamId: UInt32 + var payload: Data +} + +enum HTTP2Error: Swift.Error { + case unknownFrameType(UInt8) + case unexpectedFrame(HTTP2FrameType) + case goAway + case rstStream + case unknownStream(UInt32) +} + +private let http2ClientPreface = Data("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".utf8) +private let settingsMaxConcurrentStreams: UInt16 = 0x3 +private let settingsInitialWindowSize: UInt16 = 0x4 +private let settingsAckFlag: UInt8 = 0x1 +private let headersEndHeadersFlag: UInt8 = 0x4 + +enum HTTP2FrameIO { + static func writeFrame( + _ stream: ByteStream, type: HTTP2FrameType, flags: UInt8 = 0, streamId: UInt32, payload: Data = Data() + ) throws { + var out = Data() + let length = payload.count + out.append(UInt8((length >> 16) & 0xFF)) + out.append(UInt8((length >> 8) & 0xFF)) + out.append(UInt8(length & 0xFF)) + out.append(type.rawValue) + out.append(flags) + let sid = streamId & 0x7FFF_FFFF + out.append(UInt8((sid >> 24) & 0xFF)) + out.append(UInt8((sid >> 16) & 0xFF)) + out.append(UInt8((sid >> 8) & 0xFF)) + out.append(UInt8(sid & 0xFF)) + out += payload + try stream.send(out) + } + + static func readFrame(_ stream: ByteStream) throws -> HTTP2Frame { + let header = Array(try stream.readExact(9)) + let length = (Int(header[0]) << 16) | (Int(header[1]) << 8) | Int(header[2]) + guard let type = HTTP2FrameType(rawValue: header[3]) else { + throw HTTP2Error.unknownFrameType(header[3]) + } + let flags = header[4] + let streamId = (UInt32(header[5]) << 24 | UInt32(header[6]) << 16 + | UInt32(header[7]) << 8 | UInt32(header[8])) & 0x7FFF_FFFF + let payload = length > 0 ? try stream.readExact(length) : Data() + return HTTP2Frame(type: type, flags: flags, streamId: streamId, payload: payload) + } +} + +/// A connection carrying exactly two logical byte-streams (client-server = stream 1, +/// server-client = stream 3) multiplexed over minimal HTTP/2 DATA framing. Not an actor: every +/// call blocks synchronously on the underlying `ByteStream`, and (matching go-ios's usage of this +/// layer) nothing here needs concurrent access from multiple tasks -- callers that need +/// background reads build that on top, same as `DTXConnection` builds its read loop on top of the +/// synchronous `DTXTransport`. +final class MinimalHTTP2Connection: @unchecked Sendable { + enum StreamID: UInt32 { + case clientServer = 1 + case serverClient = 3 + } + + private let stream: ByteStream + private var clientServerBuffer = Data() + private var serverClientBuffer = Data() + private var clientServerHeadersSent = false + private var serverClientHeadersSent = false + + /// Performs the connection preface + SETTINGS/WINDOW_UPDATE exchange. Mirrors go-ios's + /// `NewHttpConnection` exactly (including the specific setting values and the single + /// expected-SETTINGS-frame read), since these are just the bytes real `remoted` expects, not + /// values with independent meaning to this implementation. + init(stream: ByteStream) throws { + self.stream = stream + + try stream.send(http2ClientPreface) + + var settingsPayload = Data() + appendSetting(&settingsPayload, id: settingsMaxConcurrentStreams, value: 100) + appendSetting(&settingsPayload, id: settingsInitialWindowSize, value: 1_048_576) + try HTTP2FrameIO.writeFrame(stream, type: .settings, streamId: 0, payload: settingsPayload) + + var windowUpdatePayload = Data() + appendBE32(&windowUpdatePayload, 983_041) + try HTTP2FrameIO.writeFrame(stream, type: .windowUpdate, streamId: 0, payload: windowUpdatePayload) + + let frame = try HTTP2FrameIO.readFrame(stream) + if frame.type == .settings, frame.flags & settingsAckFlag == 0 { + try HTTP2FrameIO.writeFrame(stream, type: .settings, flags: settingsAckFlag, streamId: 0) + } + } + + func writeClientServerStream(_ data: Data) throws { + try write(data, stream: .clientServer, headersSent: &clientServerHeadersSent) + } + + func writeServerClientStream(_ data: Data) throws { + try write(data, stream: .serverClient, headersSent: &serverClientHeadersSent) + } + + private func write(_ data: Data, stream streamID: StreamID, headersSent: inout Bool) throws { + if !headersSent { + try HTTP2FrameIO.writeFrame(stream, type: .headers, flags: headersEndHeadersFlag, streamId: streamID.rawValue) + headersSent = true + } + try HTTP2FrameIO.writeFrame(stream, type: .data, streamId: streamID.rawValue, payload: data) + } + + func readClientServerStream(exactly count: Int) throws -> Data { + try read(exactly: count, from: .clientServer) + } + + func readServerClientStream(exactly count: Int) throws -> Data { + try read(exactly: count, from: .serverClient) + } + + /// Deliberately doesn't take the buffer as an `inout` parameter: an `inout` access to + /// `clientServerBuffer`/`serverClientBuffer` held for this whole function's duration would + /// overlap with `readDataFrame()`'s own direct mutation of that same property (it appends to + /// `self.clientServerBuffer`/`self.serverClientBuffer` while called from here) -- a genuine + /// Swift exclusivity violation, confirmed via a real crash ("Fatal access conflict detected") + /// during real-device testing. Re-reading `self..count`/re-slicing on each iteration + /// instead keeps every access short-lived and non-overlapping. + private func read(exactly count: Int, from streamID: StreamID) throws -> Data { + while bufferedCount(for: streamID) < count { + try readDataFrame() + } + switch streamID { + case .clientServer: + let result = clientServerBuffer.prefix(count) + clientServerBuffer.removeFirst(count) + return Data(result) + case .serverClient: + let result = serverClientBuffer.prefix(count) + serverClientBuffer.removeFirst(count) + return Data(result) + } + } + + private func bufferedCount(for streamID: StreamID) -> Int { + switch streamID { + case .clientServer: return clientServerBuffer.count + case .serverClient: return serverClientBuffer.count + } + } + + /// Reads and dispatches frames until a DATA frame has been appended to the relevant buffer. + /// SETTINGS frames are ACKed inline (the device sends periodic heartbeat-adjacent SETTINGS + /// updates); GOAWAY/RST_STREAM are treated as fatal, matching go-ios. + private func readDataFrame() throws { + while true { + let frame = try HTTP2FrameIO.readFrame(stream) + switch frame.type { + case .data: + switch frame.streamId { + case StreamID.clientServer.rawValue: clientServerBuffer += frame.payload + case StreamID.serverClient.rawValue: serverClientBuffer += frame.payload + default: throw HTTP2Error.unknownStream(frame.streamId) + } + return + case .goAway: + throw HTTP2Error.goAway + case .settings: + if frame.flags & settingsAckFlag == 0 { + try HTTP2FrameIO.writeFrame(stream, type: .settings, flags: settingsAckFlag, streamId: 0) + } + case .rstStream: + throw HTTP2Error.rstStream + case .headers, .windowUpdate: + break + } + } + } +} + +private func appendSetting(_ data: inout Data, id: UInt16, value: UInt32) { + data.append(UInt8((id >> 8) & 0xFF)) + data.append(UInt8(id & 0xFF)) + appendBE32(&data, value) +} + +private func appendBE32(_ data: inout Data, _ value: UInt32) { + data.append(UInt8((value >> 24) & 0xFF)) + data.append(UInt8((value >> 16) & 0xFF)) + data.append(UInt8((value >> 8) & 0xFF)) + data.append(UInt8(value & 0xFF)) +} diff --git a/Sources/XKit/Testing/Tunnel/PosixTCPSocket.swift b/Sources/XKit/Testing/Tunnel/PosixTCPSocket.swift new file mode 100644 index 00000000..48832678 --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/PosixTCPSocket.swift @@ -0,0 +1,112 @@ +// +// PosixTCPSocket.swift +// XKit +// +// A raw IPv6 TCP `ByteStream`, used to reach services (RSD, then +// `com.apple.dt.testmanagerd.remote`) over the TUN interface `CoreDeviceProxyTunnel` sets up -- +// once the tunnel's /64 is routed through the TUN device, connecting to an address inside it is +// a plain kernel-routed TCP connection like any other, no lockdown/usbmux involvement. +// + +import Foundation +#if canImport(Glibc) +import Glibc +#endif + +enum PosixTCPSocketError: Swift.Error, Equatable { + case invalidAddress(String) + case socketCreateFailed(errno: Int32) + case connectFailed(errno: Int32) + case ioFailed(errno: Int32) + case closed + case timeout +} + +public final class PosixTCPSocket: ByteStream, @unchecked Sendable { + private let fd: Int32 + private let lock = NSLock() + private var isClosed = false + + public init(address: String, port: Int) throws { + var addr = sockaddr_in6() + addr.sin6_family = sa_family_t(AF_INET6) + addr.sin6_port = UInt16(port).bigEndian + let parsed = address.withCString { inet_pton(AF_INET6, $0, &addr.sin6_addr) } + guard parsed == 1 else { throw PosixTCPSocketError.invalidAddress(address) } + + let sock = socket(AF_INET6, Int32(SOCK_STREAM.rawValue), 0) + guard sock >= 0 else { throw PosixTCPSocketError.socketCreateFailed(errno: errno) } + + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(sock, __CONST_SOCKADDR_ARG(__sockaddr__: sockPtr), socklen_t(MemoryLayout.size)) + } + } + guard connectResult == 0 else { + let savedErrno = errno + Glibc.close(sock) + throw PosixTCPSocketError.connectFailed(errno: savedErrno) + } + self.fd = sock + } + + public func send(_ data: Data) throws { + try checkNotClosed() + var remaining = data + while !remaining.isEmpty { + let written = remaining.withUnsafeBytes { raw -> Int in + write(fd, raw.baseAddress, raw.count) + } + guard written > 0 else { throw PosixTCPSocketError.ioFailed(errno: errno) } + remaining.removeFirst(written) + } + } + + public func receive(maxLength: Int) throws -> Data { + try checkNotClosed() + var buffer = [UInt8](repeating: 0, count: maxLength) + let count = buffer.withUnsafeMutableBytes { raw in + read(fd, raw.baseAddress, raw.count) + } + guard count >= 0 else { throw PosixTCPSocketError.ioFailed(errno: errno) } + return Data(buffer[0.. Data { + try checkNotClosed() + var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll(&pfd, 1, timeoutMs) + guard ready >= 0 || errno == EINTR else { throw PosixTCPSocketError.ioFailed(errno: errno) } + guard ready > 0 else { throw PosixTCPSocketError.timeout } + return try receive(maxLength: maxLength) + } + + private func checkNotClosed() throws { + try lock.withLock { + guard !isClosed else { throw PosixTCPSocketError.closed } + } + } + + func close() { + lock.withLock { + guard !isClosed else { return } + isClosed = true + Glibc.close(fd) + } + } + + deinit { + close() + } +} + +extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Sources/XKit/Testing/Tunnel/RSDHandshake.swift b/Sources/XKit/Testing/Tunnel/RSDHandshake.swift new file mode 100644 index 00000000..2ff57621 --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/RSDHandshake.swift @@ -0,0 +1,75 @@ +// +// RSDHandshake.swift +// XKit +// +// Remote Service Discovery (RSD): iOS 17+'s replacement for classic lockdown service lookup. +// Once a `RemoteXPCConnection` is up (either pre-tunnel, over the device's untrusted USB network +// interface, or post-tunnel, over the TUN-routed interface -- this type doesn't care which), the +// device pushes a single unsolicited "Handshake" message listing every available service and its +// port. Structured after the documented, working reference in go-ios's `ios/rsd.go` (MIT -- read +// for the message shape, rewritten from scratch in Swift here; same clean-room approach as the +// rest of this directory). +// + +import Foundation + +public struct RSDServiceEntry: Sendable { + public let port: UInt32 +} + +public struct RSDHandshakeResponse: Sendable { + public let udid: String + public let services: [String: RSDServiceEntry] + + public func port(for service: String) -> Int? { + guard let entry = services[service] else { return nil } + return Int(entry.port) + } +} + +enum RSDError: Swift.Error { + case missingUDID + case unexpectedMessageType + case malformedServiceEntry(String) +} + +public enum RSDHandshake { + /// Blocks until the device's `Handshake` message arrives and parses it. Must be called + /// immediately after `RemoteXPCConnection` is constructed -- the device sends this + /// unprompted, as the very first thing on the connection. + public static func perform(over connection: RemoteXPCConnection) throws -> RSDHandshakeResponse { + guard let message = try connection.receiveOnClientServerStream() else { + throw RSDError.unexpectedMessageType + } + guard case .string(let udid)? = dig(message, "Properties", "UniqueDeviceID") else { + throw RSDError.missingUDID + } + guard case .string("Handshake")? = message["MessageType"] else { + throw RSDError.unexpectedMessageType + } + guard case .dictionary(let servicesRaw)? = message["Services"] else { + throw RSDError.unexpectedMessageType + } + var services: [String: RSDServiceEntry] = [:] + for (name, entry) in servicesRaw { + guard + case .dictionary(let entryDict) = entry, + case .string(let portString)? = entryDict["Port"], + let port = UInt32(portString) + else { + throw RSDError.malformedServiceEntry(name) + } + services[name] = RSDServiceEntry(port: port) + } + return RSDHandshakeResponse(udid: udid, services: services) + } + + private static func dig(_ dict: [String: RemoteXPCValue], _ path: String...) -> RemoteXPCValue? { + var current: RemoteXPCValue = .dictionary(dict) + for key in path { + guard case .dictionary(let d) = current, let next = d[key] else { return nil } + current = next + } + return current + } +} diff --git a/Sources/XKit/Testing/Tunnel/RemoteXPCCodec.swift b/Sources/XKit/Testing/Tunnel/RemoteXPCCodec.swift new file mode 100644 index 00000000..5c5fda66 --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/RemoteXPCCodec.swift @@ -0,0 +1,347 @@ +// +// RemoteXPCCodec.swift +// XKit +// +// RemoteXPC is the binary message format iOS 17+ uses for the RSD/pairing/tunnel control-plane +// protocols (carried as HTTP/2 DATA frames -- see `MinimalHTTP2Framer.swift`). Nothing in xtool's +// dependency graph implements it -- this is a from-scratch implementation, structured after the +// documented, working reference in go-ios's `ios/xpc/encoding.go` (MIT -- read for wire format, +// rewritten from scratch in Swift here; same clean-room approach as `DTXMessage.swift`). +// +// Wire layout of a full RemoteXPC message: +// +// wrapper header (24 bytes, little-endian): +// u32 magic 0x29b00b92 +// u32 flags +// u64 bodyLen 0 if there's no body (nothing follows) +// u64 msgId +// +// body (bodyLen bytes, omitted entirely when bodyLen == 0): +// u32 magic 0x42133742 +// u32 version 5 +// always a dictionary in practice, encoded per the `RemoteXPCValue` cases +// +// object encoding: every value is prefixed by a u32 type tag, then type-specific payload. +// Strings/dictionary keys are NUL-terminated and the whole field (tag-relative for strings, +// key-relative for dictionary keys) is padded to a multiple of 4 bytes. data/string/array/ +// dictionary payloads are additionally prefixed by their own u32 byte length. +// + +import Foundation + +enum RemoteXPCValue: Sendable, Equatable { + case null + case bool(Bool) + case int64(Int64) + case uint64(UInt64) + case double(Double) + case date(Date) + case data(Data) + case string(String) + case uuid(UUID) + case array([RemoteXPCValue]) + case dictionary([String: RemoteXPCValue]) +} + +enum RemoteXPCFlag { + static let alwaysSet: UInt32 = 0x0000_0001 + static let data: UInt32 = 0x0000_0100 + /// `0x0001_0000` is the generic "this message expects a reply" bit -- go-ios/pymobiledevice3 + /// both use it for plain request/response RPCs (e.g. `com.apple.coredevice.appservice` + /// invocations), not just heartbeats. `heartbeatRequest` below is the same numeric value, + /// kept as-is since it's already used that way elsewhere; this is just the correctly-named + /// alias for non-heartbeat callers. + static let wantsReply: UInt32 = 0x0001_0000 + static let heartbeatRequest: UInt32 = 0x0001_0000 + static let heartbeatReply: UInt32 = 0x0002_0000 + static let fileOpen: UInt32 = 0x0010_0000 + static let initHandshake: UInt32 = 0x0040_0000 +} + +struct RemoteXPCMessage: Sendable { + var flags: UInt32 + /// `nil` means "no body at all" (an empty wrapper, `bodyLen == 0`), distinct from an empty + /// dictionary body -- mirrors go-ios's `Message.Body == nil` special case. + var body: [String: RemoteXPCValue]? + var id: UInt64 = 0 +} + +enum RemoteXPCError: Swift.Error { + case badMagic + case badVersion + case notEnoughData + case unknownType(UInt32) + case bodyNotADictionary +} + +enum RemoteXPCCodec { + private static let wrapperMagic: UInt32 = 0x29b0_0b92 + private static let objectMagic: UInt32 = 0x4213_3742 + private static let bodyVersion: UInt32 = 0x0000_0005 + + /// Size of the fixed wrapper header (magic + flags + bodyLen + msgId), i.e. how many bytes a + /// stream-based reader must pull before it can determine how many further bytes (`bodyLen`, + /// per `bodyLength(fromHeader:)`) make up the rest of the message. + static let wrapperHeaderLength = 24 + + /// Reads the `bodyLen` field out of a message's first `wrapperHeaderLength` bytes, for + /// callers (like `RemoteXPCConnection`) that read a message off a byte stream in two passes: + /// header first, then exactly `bodyLength` more bytes, before handing the whole thing to + /// `decode(_:)`. + static func bodyLength(fromHeader header: Data) throws -> UInt64 { + var cursor = header.startIndex + guard try readLE(header, &cursor, as: UInt32.self) == wrapperMagic else { + throw RemoteXPCError.badMagic + } + _ = try readLE(header, &cursor, as: UInt32.self) // flags + return try readLE(header, &cursor, as: UInt64.self) + } + + private enum WireType: UInt32 { + case null = 0x0000_1000 + case bool = 0x0000_2000 + case int64 = 0x0000_3000 + case uint64 = 0x0000_4000 + case double = 0x0000_5000 + case date = 0x0000_7000 + case data = 0x0000_8000 + case string = 0x0000_9000 + case uuid = 0x0000_a000 + case array = 0x0000_e000 + case dictionary = 0x0000_f000 + } + + // MARK: - Encoding + + static func encode(_ message: RemoteXPCMessage) -> Data { + var out = Data() + appendLE(&out, wrapperMagic) + appendLE(&out, message.flags) + guard let body = message.body else { + appendLE(&out, UInt64(0)) // bodyLen + appendLE(&out, message.id) + return out + } + var bodyData = Data() + appendLE(&bodyData, objectMagic) + appendLE(&bodyData, bodyVersion) + encodeDictionary(&bodyData, body) + + appendLE(&out, UInt64(bodyData.count)) + appendLE(&out, message.id) + out += bodyData + return out + } + + private static func encodeValue(_ out: inout Data, _ value: RemoteXPCValue) { + switch value { + case .null: + appendLE(&out, WireType.null.rawValue) + case .bool(let b): + appendLE(&out, WireType.bool.rawValue) + out.append(b ? 1 : 0) + out.append(contentsOf: [0, 0, 0]) // padding to 4 bytes, matching Go's `pad [3]byte` + case .int64(let i): + appendLE(&out, WireType.int64.rawValue) + appendLE(&out, UInt64(bitPattern: i)) + case .uint64(let u): + appendLE(&out, WireType.uint64.rawValue) + appendLE(&out, u) + case .double(let d): + appendLE(&out, WireType.double.rawValue) + appendLE(&out, d.bitPattern) + case .date(let date): + appendLE(&out, WireType.date.rawValue) + let nanos = Int64(date.timeIntervalSince1970 * 1_000_000_000) + appendLE(&out, UInt64(bitPattern: nanos)) + case .data(let data): + appendLE(&out, WireType.data.rawValue) + appendLE(&out, UInt32(data.count)) + out += data + out += Data(repeating: 0, count: Int(calcPadding(UInt32(data.count)))) + case .string(let s): + let utf8 = Array(s.utf8) + appendLE(&out, WireType.string.rawValue) + appendLE(&out, UInt32(utf8.count + 1)) // +1 for NUL terminator + let padded = Int(calcPadding(UInt32(utf8.count + 1))) + out += Data(utf8) + out += Data(repeating: 0, count: 1 + padded) + case .uuid(let uuid): + appendLE(&out, WireType.uuid.rawValue) + withUnsafeBytes(of: uuid.uuid) { out.append(contentsOf: $0) } + case .array(let values): + var inner = Data() + for v in values { encodeValue(&inner, v) } + appendLE(&out, WireType.array.rawValue) + appendLE(&out, UInt32(inner.count)) + appendLE(&out, UInt32(values.count)) + out += inner + case .dictionary(let dict): + encodeDictionary(&out, dict) + } + } + + private static func encodeDictionary(_ out: inout Data, _ dict: [String: RemoteXPCValue]) { + var inner = Data() + appendLE(&inner, UInt32(dict.count)) + for (key, value) in dict { + encodeDictionaryKey(&inner, key) + encodeValue(&inner, value) + } + appendLE(&out, WireType.dictionary.rawValue) + appendLE(&out, UInt32(inner.count)) + out += inner + } + + private static func encodeDictionaryKey(_ out: inout Data, _ key: String) { + let utf8 = Array(key.utf8) + let strLen = utf8.count + 1 + let padding = Int(calcPadding(UInt32(strLen))) + out += Data(utf8) + out += Data(repeating: 0, count: 1 + padding) + } + + /// Rounds `l` up to the next multiple of 4 and returns the number of padding bytes needed. + private static func calcPadding(_ l: UInt32) -> UInt32 { + let rounded = ((l + 3) / 4) * 4 + return rounded - l + } + + // MARK: - Decoding + + static func decode(_ data: Data) throws -> RemoteXPCMessage { + var cursor = data.startIndex + guard try readLE(data, &cursor, as: UInt32.self) == wrapperMagic else { + throw RemoteXPCError.badMagic + } + let flags = try readLE(data, &cursor, as: UInt32.self) + let bodyLen = try readLE(data, &cursor, as: UInt64.self) + let msgId = try readLE(data, &cursor, as: UInt64.self) + guard bodyLen > 0 else { + return RemoteXPCMessage(flags: flags, body: nil, id: msgId) + } + guard try readLE(data, &cursor, as: UInt32.self) == objectMagic else { + throw RemoteXPCError.badMagic + } + guard try readLE(data, &cursor, as: UInt32.self) == bodyVersion else { + throw RemoteXPCError.badVersion + } + guard case .dictionary(let dict) = try decodeValue(data, &cursor) else { + throw RemoteXPCError.bodyNotADictionary + } + return RemoteXPCMessage(flags: flags, body: dict, id: msgId) + } + + private static func decodeValue(_ data: Data, _ cursor: inout Data.Index) throws -> RemoteXPCValue { + let rawType = try readLE(data, &cursor, as: UInt32.self) + guard let type = WireType(rawValue: rawType) else { + throw RemoteXPCError.unknownType(rawType) + } + switch type { + case .null: + return .null + case .bool: + guard data.distance(from: cursor, to: data.endIndex) >= 4 else { throw RemoteXPCError.notEnoughData } + let byte = data[cursor] + cursor = data.index(cursor, offsetBy: 4) + return .bool(byte != 0) + case .int64: + return .int64(Int64(bitPattern: try readLE(data, &cursor, as: UInt64.self))) + case .uint64: + return .uint64(try readLE(data, &cursor, as: UInt64.self)) + case .double: + return .double(Double(bitPattern: try readLE(data, &cursor, as: UInt64.self))) + case .date: + let nanos = Int64(bitPattern: try readLE(data, &cursor, as: UInt64.self)) + return .date(Date(timeIntervalSince1970: Double(nanos) / 1_000_000_000)) + case .data: + let length = Int(try readLE(data, &cursor, as: UInt32.self)) + let bytes = try readBytes(data, &cursor, count: length) + skip(data, &cursor, count: Int(calcPadding(UInt32(length)))) + return .data(bytes) + case .string: + let length = Int(try readLE(data, &cursor, as: UInt32.self)) + let bytes = try readBytes(data, &cursor, count: length) + skip(data, &cursor, count: Int(calcPadding(UInt32(length)))) + let trimmed = bytes.prefix(while: { $0 != 0 }) + return .string(String(decoding: trimmed, as: UTF8.self)) + case .uuid: + let bytes = try readBytes(data, &cursor, count: 16) + let uuid = bytes.withUnsafeBytes { raw -> UUID in + let tuple = raw.loadUnaligned(as: uuid_t.self) + return UUID(uuid: tuple) + } + return .uuid(uuid) + case .array: + _ = try readLE(data, &cursor, as: UInt32.self) // payload byte length, unused on decode + let count = Int(try readLE(data, &cursor, as: UInt32.self)) + var values: [RemoteXPCValue] = [] + // `count` is an unvalidated wire value (up to ~4 billion); clamp the up-front + // allocation to the remaining buffer size, since every element needs at least one + // byte to decode -- the loop below still fails fast via a bounds check if `count` + // doesn't match what's actually there. + values.reserveCapacity(min(count, data.distance(from: cursor, to: data.endIndex))) + for _ in 0.. String { + var bytes: [UInt8] = [] + while true { + guard cursor < data.endIndex else { throw RemoteXPCError.notEnoughData } + let byte = data[cursor] + cursor = data.index(after: cursor) + if byte == 0 { break } + bytes.append(byte) + } + skip(data, &cursor, count: Int(calcPadding(UInt32(bytes.count + 1)))) + return String(decoding: bytes, as: UTF8.self) + } +} + +// MARK: - Little-endian read/write helpers + +private func appendLE(_ data: inout Data, _ value: T) { + withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } +} + +private func readLE( + _ data: Data, _ cursor: inout Data.Index, as type: T.Type +) throws -> T { + let size = MemoryLayout.size + guard data.distance(from: cursor, to: data.endIndex) >= size else { throw RemoteXPCError.notEnoughData } + let end = data.index(cursor, offsetBy: size) + var value: T = 0 + let bytes = data[cursor.. Data { + guard data.distance(from: cursor, to: data.endIndex) >= count else { throw RemoteXPCError.notEnoughData } + let end = data.index(cursor, offsetBy: count) + let result = Data(data[cursor..device) and server-client (device->host) streams. +// Structured after the documented, working reference in go-ios's `ios/connect.go` +// (`initializeXpcConnection`/`CreateXpcConnection`) and `ios/xpc/xpc.go` (MIT -- read for the +// handshake message sequence, rewritten from scratch in Swift here; same clean-room approach as +// the rest of this directory). +// + +import Foundation + +public final class RemoteXPCConnection: @unchecked Sendable { + private let http2: MinimalHTTP2Connection + private var nextMsgId: UInt64 = 1 + + public init(stream: ByteStream) throws { + self.http2 = try MinimalHTTP2Connection(stream: stream) + try Self.performHandshake(http2) + } + + /// The exact 3-message exchange go-ios's `initializeXpcConnection` performs. The specific + /// flag values (`0x201` on the third message) aren't independently meaningful here -- they're + /// just the bytes real `remoted` expects to see before it starts accepting normal messages. + private static func performHandshake(_ http2: MinimalHTTP2Connection) throws { + try writeMessage(http2, on: .clientServer, RemoteXPCMessage(flags: RemoteXPCFlag.alwaysSet, body: [:], id: 0)) + _ = try readMessage(http2, on: .clientServer) + + try writeMessage( + http2, on: .serverClient, + RemoteXPCMessage(flags: RemoteXPCFlag.initHandshake | RemoteXPCFlag.alwaysSet, body: nil, id: 0) + ) + _ = try readMessage(http2, on: .serverClient) + + try writeMessage(http2, on: .clientServer, RemoteXPCMessage(flags: 0x201, body: nil, id: 0)) + _ = try readMessage(http2, on: .clientServer) + } + + /// Sends `data` as a RemoteXPC message on the client-server (host->device) stream. + func send(_ data: [String: RemoteXPCValue]?, extraFlags: UInt32 = 0) throws { + var flags = RemoteXPCFlag.alwaysSet | extraFlags + if data != nil { flags |= RemoteXPCFlag.data } + let message = RemoteXPCMessage(flags: flags, body: data, id: nextMsgId) + if ProcessInfo.processInfo.environment["XTOOL_XPC_TRACE"] != nil { + FileHandle.standardError.write(Data("[xpc-trace-out] flags=\(flags) id=\(nextMsgId) body=\(String(describing: data))\n".utf8)) + } + try Self.writeMessage(http2, on: .clientServer, message) + } + + /// Blocks until a full message has been received on the client-server stream (used by RSD's + /// handshake, which -- despite the naming -- is a message *from* the device delivered on the + /// stream this host reads inbound traffic from; see `MinimalHTTP2Connection`'s doc comment for + /// why both directions share stream id 1 in this specific usage. Matches go-ios's + /// `ReceiveOnClientServerStream`, which reads the same stream regardless of direction). + func receiveOnClientServerStream() throws -> [String: RemoteXPCValue]? { + let body = try Self.readMessage(http2, on: .clientServer).body + if ProcessInfo.processInfo.environment["XTOOL_XPC_TRACE"] != nil { + FileHandle.standardError.write(Data("[xpc-trace-in] body=\(String(describing: body))\n".utf8)) + } + return body + } + + private static func writeMessage(_ http2: MinimalHTTP2Connection, on streamID: MinimalHTTP2Connection.StreamID, _ message: RemoteXPCMessage) throws { + let encoded = RemoteXPCCodec.encode(message) + switch streamID { + case .clientServer: try http2.writeClientServerStream(encoded) + case .serverClient: try http2.writeServerClientStream(encoded) + } + } + + private static func readMessage(_ http2: MinimalHTTP2Connection, on streamID: MinimalHTTP2Connection.StreamID) throws -> RemoteXPCMessage { + let header: Data + switch streamID { + case .clientServer: header = try http2.readClientServerStream(exactly: RemoteXPCCodec.wrapperHeaderLength) + case .serverClient: header = try http2.readServerClientStream(exactly: RemoteXPCCodec.wrapperHeaderLength) + } + let bodyLength = try Int(RemoteXPCCodec.bodyLength(fromHeader: header)) + guard bodyLength > 0 else { + return try RemoteXPCCodec.decode(header) + } + let body: Data + switch streamID { + case .clientServer: body = try http2.readClientServerStream(exactly: bodyLength) + case .serverClient: body = try http2.readServerClientStream(exactly: bodyLength) + } + return try RemoteXPCCodec.decode(header + body) + } +} diff --git a/Sources/XKit/Testing/Tunnel/TUNDevice.swift b/Sources/XKit/Testing/Tunnel/TUNDevice.swift new file mode 100644 index 00000000..a9f4c562 --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/TUNDevice.swift @@ -0,0 +1,143 @@ +// +// TUNDevice.swift +// XKit +// +// Wraps the `xtl_tun_*` ioctl shim (`CXKit/tun_ioctl.c`) into a Swift `ByteStream`: creates a +// kernel TUN interface, assigns it the IPv6 address the device handed back in the CDTunnel +// handshake (`CoreDeviceProxyTunnel.swift`), and brings it up. Requires `CAP_NET_ADMIN` (granted +// to the built `xtool` binary via `setcap`, per the project's tunnel setup notes) -- there is no +// userspace-network-stack fallback (go-ios falls back to a gVisor-based userspace stack; no +// Swift equivalent exists, so this project requires the real kernel TUN device). +// +// Reading/writing the fd directly gives raw IPv6 packets (no framing) since the interface was +// created with `IFF_NO_PI`. +// + +import Foundation +import CXKit +#if canImport(Glibc) +import Glibc +#endif + +enum TUNDeviceError: Swift.Error { + case openFailed(errno: Int32) + case configureFailed(stage: Int32, errno: Int32) + case invalidAddress(String) + case ioFailed(errno: Int32) + case closed +} + +final class TUNDevice: ByteStream, @unchecked Sendable { + let name: String + private let fd: Int32 + /// Serializes every `send`/`receive`/`close` on this device's fd. Originally `send`/`receive` + /// only took a lock around the `isClosed` *check*, not the actual syscall, to let a blocking + /// `receive` and a concurrent `send` interleave freely -- but that left a real close-during- + /// syscall race (confirmed via a real SIGSEGV during real-device testing: `close()` closing + /// the fd out from under a `read(2)`/`write(2)` already in flight on another thread, the same + /// class of bug `DTXConnection.close()`'s doc comment describes for its own transport). + /// Routing `close()` through this same queue means it can only run between syscalls, never + /// during one. The cost -- `send` occasionally waiting behind a `receive`'s bounded poll -- + /// is minor (`receive(maxLength:pollTimeoutMs:)`'s poll is capped at 200ms) and worth the + /// safety. + private let ioQueue = DispatchQueue(label: "xtool.tun.io") + private var isClosed = false + + /// Creates a TUN interface and configures it with `address`/`prefixLength`/`mtu`, matching + /// go-ios's Linux `setupTunnelInterface` (netlink-based there; ioctl-based here -- both + /// reach the same kernel state, see `tun_ioctl.c`'s doc comment for why ioctls were chosen). + /// `mtu` must match the tunnel peer's negotiated MTU (from the CDTunnel handshake) -- a + /// freshly created TUN device otherwise defaults to 1500, which can silently truncate packets + /// read into a buffer sized to the (smaller) negotiated MTU; see `tun_ioctl.h`'s doc comment. + init(address: String, prefixLength: UInt32, mtu: UInt32) throws { + var nameBuf = [CChar](repeating: 0, count: 16) + let fd = xtl_tun_create(&nameBuf) + guard fd >= 0 else { throw TUNDeviceError.openFailed(errno: errno) } + self.fd = fd + self.name = nameBuf.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + + guard let addr6 = Self.parseIPv6(address) else { + Glibc.close(fd) + throw TUNDeviceError.invalidAddress(address) + } + + var stage: Int32 = -1 + let result = nameBuf.withUnsafeBufferPointer { namePtr -> Int32 in + addr6.withUnsafeBufferPointer { addrPtr in + xtl_tun_configure(namePtr.baseAddress!, addrPtr.baseAddress!, prefixLength, mtu, &stage) + } + } + guard result == 0 else { + let savedErrno = errno + Glibc.close(fd) + throw TUNDeviceError.configureFailed(stage: stage, errno: savedErrno) + } + } + + private static func parseIPv6(_ string: String) -> [UInt8]? { + var buf = [UInt8](repeating: 0, count: 16) + let result = string.withCString { cstr in + buf.withUnsafeMutableBytes { rawBuf in + inet_pton(AF_INET6, cstr, rawBuf.baseAddress) + } + } + return result == 1 ? buf : nil + } + + func send(_ data: Data) throws { + try ioQueue.sync { + guard !isClosed else { throw TUNDeviceError.closed } + let written = data.withUnsafeBytes { raw -> Int in + write(fd, raw.baseAddress, raw.count) + } + guard written == data.count else { throw TUNDeviceError.ioFailed(errno: errno) } + } + } + + /// Blocking read of a single packet (or up to `maxLength` bytes of one, for a caller-owned + /// buffer sized to the negotiated MTU -- `Read`/`ReadWriteCloser` on a TUN fd always returns + /// exactly one packet per call, never a partial one or multiple coalesced together, so no + /// extra framing is needed here unlike the lockdown-connection side of the tunnel). + func receive(maxLength: Int) throws -> Data { + try ioQueue.sync { + guard !isClosed else { throw TUNDeviceError.closed } + var buffer = [UInt8](repeating: 0, count: maxLength) + let count = buffer.withUnsafeMutableBytes { raw in + read(fd, raw.baseAddress, raw.count) + } + guard count >= 0 else { throw TUNDeviceError.ioFailed(errno: errno) } + return Data(buffer[0.. Data? { + let ready = try ioQueue.sync { () -> Bool in + guard !isClosed else { throw TUNDeviceError.closed } + var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let result = poll(&pfd, 1, pollTimeoutMs) + guard result >= 0 || errno == EINTR else { throw TUNDeviceError.ioFailed(errno: errno) } + return result > 0 + } + guard ready else { return nil } + return try receive(maxLength: maxLength) + } + + func close() { + ioQueue.sync { + guard !isClosed else { return } + isClosed = true + Glibc.close(fd) + } + } + + deinit { + close() + } +} diff --git a/Sources/XKit/Testing/Tunnel/TunnelDTXTransport.swift b/Sources/XKit/Testing/Tunnel/TunnelDTXTransport.swift new file mode 100644 index 00000000..d0f44eea --- /dev/null +++ b/Sources/XKit/Testing/Tunnel/TunnelDTXTransport.swift @@ -0,0 +1,40 @@ +// +// TunnelDTXTransport.swift +// XKit +// +// Adapts `PosixTCPSocket` to `DTXByteTransport`, so `DTXConnection` (built for the classic +// lockdown `idevice_connection_t` path in `DTXTransport.swift`) can run unmodified over an +// RSD-discovered service reached through `CoreDeviceProxyTunnel` -- e.g. +// `com.apple.dt.testmanagerd.remote` on iOS 17.4+, in place of `com.apple.testmanagerd.lockdown +// [.secure]`. The DTX wire protocol itself (message framing, NSKeyedArchiver payloads, channel +// semantics) is identical either way; only the byte transport underneath differs. +// + +import Foundation + +struct TunnelDTXTransport: DTXByteTransport { + let socket: PosixTCPSocket + + /// Connects to an RSD-discovered service's port at the tunnel's server address. `port` comes + /// from `RSDHandshakeResponse.port(for:)` (e.g. `"com.apple.dt.testmanagerd.remote"`). + init(tunnel: CoreDeviceProxyTunnel, port: Int) throws { + socket = try PosixTCPSocket(address: tunnel.address, port: port) + } + + func send(_ data: Data) throws -> Int { + try socket.send(data) + return data.count + } + + func receive(maxLength: Int, timeout: TimeInterval) throws -> Data { + try socket.receive(maxLength: maxLength, timeoutMs: Int32(timeout * 1000)) + } + + func close() { + socket.close() + } + + func isTimeout(_ error: Swift.Error) -> Bool { + (error as? PosixTCPSocketError) == .timeout + } +} \ No newline at end of file diff --git a/Sources/XKit/Testing/XCTestConfiguration.swift b/Sources/XKit/Testing/XCTestConfiguration.swift new file mode 100644 index 00000000..780ba641 --- /dev/null +++ b/Sources/XKit/Testing/XCTestConfiguration.swift @@ -0,0 +1,150 @@ +// +// XCTestConfiguration.swift +// XKit +// +// The plist Xcode writes into the Runner app's sandbox (as `-.xctestconfiguration`) +// to tell the on-device XCTest runner which bundle to load and how to report results. Field set +// and defaults transcribed from appium-ios-device's `XCTestConfiguration` class in +// `lib/instrument/transformer/nskeyed.js` (Apache-2.0 -- read for the required keys/defaults, +// rewritten from scratch in Swift here). + +import Foundation + +public struct XCTestConfiguration: Sendable { + /// `file://` URL to the `.xctest` bundle inside the Runner app, e.g. + /// `file:///path/to/Runner.app/PlugIns/MyTests.xctest`. + public var testBundleURL: String + /// Uniquely identifies this test run; also used to name the `.xctestconfiguration` file. + public var sessionIdentifier: UUID + public var productModuleName: String + /// Bundle ID of the app under test, for UI tests that drive a separate app. `nil` for tests + /// hosted directly inside the Runner (plain XCTest unit/integration tests). + public var targetApplicationBundleID: String? + public var targetApplicationPath: String + public var testsToRun: [String]? + public var testsToSkip: [String]? + public var reportResultsToIDE: Bool = true + public var reportActivities: Bool = true + public var testsDrivenByIDE: Bool = false + public var initializeForUITesting: Bool = true + /// `/Developer/Library/...` pre-17, `/System/Developer/Library/...` on 17+ -- confirmed + /// against pymobiledevice3's `to_xctestconfiguration`, which switches on this same threshold. + /// Left at the pre-17 default here; on real iOS 26 hardware (this session) leaving this at the + /// pre-17 path didn't block the run outright, but the runner's post-test-case automation + /// session re-acquisition failed with "No bundle at path /Developer/Library/PrivateFrameworks/ + /// XCTAutomationSupport.framework", which then stalled the session instead of completing + /// cleanly -- callers on 17+ devices must override this. + public var automationFrameworkPath = "/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework" + + public init( + testBundleURL: String, + sessionIdentifier: UUID, + productModuleName: String, + targetApplicationBundleID: String? = nil, + targetApplicationPath: String = "/KEEP-THIS-NOT-EMPTY/KEEP-THIS-NOT-EMPTY", + testsToRun: [String]? = nil, + testsToSkip: [String]? = nil + ) { + self.testBundleURL = testBundleURL + self.sessionIdentifier = sessionIdentifier + self.productModuleName = productModuleName + self.targetApplicationBundleID = targetApplicationBundleID + self.targetApplicationPath = targetApplicationPath + self.testsToRun = testsToRun + self.testsToSkip = testsToSkip + } + + /// Encodes to the NSKeyedArchiver-compatible `bplist00` xtool writes to the device. + func archived() -> Data { + NSKeyedArchive.archive(keyedValue) + } + + /// The un-archived `NSKeyedValue` form, needed as-is (not re-archived) when this + /// configuration must be sent as a DTX reply payload -- `DTXMessage.encoded()` already + /// archives its `payload` itself, so nesting `archived()`'s bytes there would double-archive. + /// See `TestManagerdSession`'s `_XCT_testRunnerReadyWithCapabilities:` handler. + var keyedValue: NSKeyedValue { + var properties: [(String, NSKeyedValue)] = [ + ("aggregateStatisticsBeforeCrash", .dictionary(["XCSuiteRecordsKey": .dictionary([:])])), + ("automationFrameworkPath", .string(automationFrameworkPath)), + ("disablePerformanceMetrics", .bool(false)), + ("emitOSLogs", .bool(false)), + ("formatVersion", .boxed(.int(2))), + ("gatherLocalizableStringsData", .bool(false)), + ("initializeForUITesting", .bool(initializeForUITesting)), + ("productModuleName", .string(productModuleName)), + ("randomExecutionOrderingSeed", .null), + ("reportActivities", .bool(reportActivities)), + ("reportResultsToIDE", .bool(reportResultsToIDE)), + ("systemAttachmentLifetime", .int(2)), + ("targetApplicationArguments", .array([])), + ("targetApplicationBundleID", targetApplicationBundleID.map(NSKeyedValue.string) ?? .null), + ("targetApplicationEnvironment", .null), + ("targetApplicationPath", .string(targetApplicationPath)), + ("testApplicationDependencies", .dictionary([:])), + ( + "testBundleURL", + .object(className: "NSURL", properties: [ + ("NS.base", .null), + ("NS.relative", .string(testBundleURL)), + ]) + ), + ("testExecutionOrdering", .int(0)), + ("testTimeoutsEnabled", .bool(false)), + ("testsDrivenByIDE", .bool(testsDrivenByIDE)), + ("testsMustRunOnMainThread", .bool(true)), + // Legacy `NSSet` form, kept for pre-17 runners. + ("testsToRun", testsToRun.map { .set($0.map(NSKeyedValue.string)) } ?? .null), + ("testsToSkip", testsToSkip.map { .set($0.map(NSKeyedValue.string)) } ?? .null), + // iOS 17+ form -- confirmed against pymobiledevice3's `xctest_types.py` (read for the + // wire shape only, not copied): "the legacy testsToRun/testsToSkip NSSet + // keys are ignored by modern runners" once `testIdentifiersToRun`/ + // `testIdentifiersToSkip` (`XCTTestIdentifierSet` objects) are present at all -- this + // was the actual, previously-unnoticed reason `--only`/`--skip` never had any effect + // on real iOS 17+ hardware (this session): the legacy fields were being sent, and even + // once correctly NSSet-encoded (see `NSKeyedValue.set`'s doc comment), the runner never + // looked at them. + ("testIdentifiersToRun", testsToRun.map(Self.testIdentifierSet) ?? .null), + ("testIdentifiersToSkip", testsToSkip.map(Self.testIdentifierSet) ?? .null), + ("treatMissingBaselinesAsFailures", .bool(false)), + ("userAttachmentLifetime", .int(1)), + ( + "sessionIdentifier", + .object(className: "NSUUID", properties: [ + ("NS.uuidbytes", .data(sessionIdentifier.dtxUUIDBytes)), + ]) + ), + ] + // stable order makes archived output deterministic/testable; wire format doesn't care + properties.sort { $0.0 < $1.0 } + + return .object(className: "XCTestConfiguration", properties: properties) + } + + /// Parses `xtool test`'s `--only`/`--skip`/`--test-target` identifier syntax ("TestClass", + /// "TestClass/testMethod", or a bare module/target name) into an `XCTTestIdentifierSet`. + /// `options` follows XCTest's own convention (confirmed against pymobiledevice3's + /// `XCTTestIdentifier.from_string`): `3` for a single-component identifier (matches everything + /// nested under it -- a whole module or a whole class), `2` for a two-component leaf + /// (class + method). + private static func testIdentifierSet(_ specs: [String]) -> NSKeyedValue { + let identifiers: [NSKeyedValue] = specs.map { spec in + let components = spec.split(separator: "/").map(String.init) + let options = components.count <= 1 ? 3 : 2 + return .object(className: "XCTTestIdentifier", properties: [ + ("c", .array(components.map(NSKeyedValue.string))), + ("o", .int(Int64(options))), + ]) + } + return .object(className: "XCTTestIdentifierSet", properties: [ + ("identifiers", .mutableArray(identifiers)), + ]) + } +} + +extension UUID { + /// The raw 16-byte representation NSKeyedArchiver's `NSUUID` uses for `NS.uuidbytes`. + var dtxUUIDBytes: Data { + withUnsafeBytes(of: uuid) { Data($0) } + } +} diff --git a/Sources/XKit/Utilities/PlistValue.swift b/Sources/XKit/Utilities/PlistValue.swift new file mode 100644 index 00000000..8bf27bb0 --- /dev/null +++ b/Sources/XKit/Utilities/PlistValue.swift @@ -0,0 +1,162 @@ +// +// PlistValue.swift +// XKit +// + +import Foundation +import libimobiledevice +import plist + +/// A small, self-contained bridge between `plist_t` (from `libplist`, used by +/// `libimobiledevice`, `libtatsu`, and the NSKeyedArchiver-compatible encoding in +/// `Testing/NSKeyedArchive.swift`) and a Swift value, covering only the node types xtool needs +/// (dict/array/string/data/uint/bool/uid). Intentionally not a general-purpose PropertyList +/// implementation -- `PropertyListSerialization` already covers that for the XML/binary formats +/// used elsewhere in xtool; this exists because some C APIs (`mobile_image_mounter`, `libtatsu`) +/// hand back/expect raw `plist_t` graphs directly, and because NSKeyedArchiver's wire format +/// relies on the `UID` plist primitive, which `PropertyListSerialization` doesn't expose. +/// +/// `libimobiledevice` and `plist` (both from xtool-core) are available as direct XKit +/// dependencies on every platform xtool supports (Linux via system `.systemLibrary`, macOS/ +/// Windows via xtool-core's prebuilt xcframeworks), so this type is not platform-gated. +enum PlistValue { + case dictionary([String: PlistValue]) + case array([PlistValue]) + case string(String) + case data(Data) + case uint(UInt64) + case real(Double) + case bool(Bool) + /// The `UID` plist primitive used by NSKeyedArchiver's `$objects` back-references. Not a + /// general-purpose plist type outside of that context. + case uid(UInt64) + + init?(plistT node: plist_t) { + switch plist_get_node_type(node) { + case PLIST_DICT: + var iter: plist_dict_iter? + plist_dict_new_iter(node, &iter) + var dict: [String: PlistValue] = [:] + while true { + var keyPtr: UnsafeMutablePointer? + var value: plist_t? + plist_dict_next_item(node, iter, &keyPtr, &value) + guard let keyPtr, let value else { break } + defer { free(keyPtr) } + dict[String(cString: keyPtr)] = PlistValue(plistT: value) + } + self = .dictionary(dict) + case PLIST_ARRAY: + let count = plist_array_get_size(node) + var array: [PlistValue] = [] + array.reserveCapacity(Int(count)) + for i in 0.. PlistValue? { + var node: plist_t? + data.withUnsafeBytes { buf in + let bound = buf.bindMemory(to: Int8.self) + plist_from_memory(bound.baseAddress, UInt32(bound.count), &node, nil) + } + guard let node else { return nil } + defer { plist_free(node) } + return PlistValue(plistT: node) + } + + /// Parses a binary property list (`bplist00`, the format NSKeyedArchiver payloads and some + /// DTX auxiliary values use) into a `PlistValue` tree via `plist_from_bin`. + static func parse(binary data: Data) -> PlistValue? { + var node: plist_t? + data.withUnsafeBytes { buf in + let bound = buf.bindMemory(to: Int8.self) + plist_from_bin(bound.baseAddress, UInt32(bound.count), &node) + } + guard let node else { return nil } + defer { plist_free(node) } + return PlistValue(plistT: node) + } + + func toPlistT() -> plist_t { + switch self { + case .dictionary(let dict): + // swiftlint:disable:next force_unwrapping + let node = plist_new_dict()! + for (key, value) in dict { + plist_dict_set_item(node, key, value.toPlistT()) + } + return node + case .array(let array): + // swiftlint:disable:next force_unwrapping + let node = plist_new_array()! + for value in array { + plist_array_append_item(node, value.toPlistT()) + } + return node + case .string(let string): + // swiftlint:disable:next force_unwrapping + return plist_new_string(string)! + case .data(let data): + return data.withUnsafeBytes { buf in + // swiftlint:disable:next force_unwrapping + plist_new_data(buf.bindMemory(to: Int8.self).baseAddress, UInt64(buf.count))! + } + case .uint(let value): + // swiftlint:disable:next force_unwrapping + return plist_new_uint(value)! + case .real(let value): + // swiftlint:disable:next force_unwrapping + return plist_new_real(value)! + case .bool(let value): + // swiftlint:disable:next force_unwrapping + return plist_new_bool(value ? 1 : 0)! + case .uid(let value): + // swiftlint:disable:next force_unwrapping + return plist_new_uid(value)! + } + } + + /// Serializes to the binary property list (`bplist00`) wire format via `plist_to_bin`. + func toBinaryData() -> Data { + let node = toPlistT() + defer { plist_free(node) } + var bytes: UnsafeMutablePointer? + var length: UInt32 = 0 + plist_to_bin(node, &bytes, &length) + guard let bytes else { return Data() } + defer { free(bytes) } + return bytes.withMemoryRebound(to: UInt8.self, capacity: Int(length)) { Data(bytes: $0, count: Int(length)) } + } +} diff --git a/Sources/XToolSupport/DDIExtractor.swift b/Sources/XToolSupport/DDIExtractor.swift new file mode 100644 index 00000000..e223a64f --- /dev/null +++ b/Sources/XToolSupport/DDIExtractor.swift @@ -0,0 +1,80 @@ +import Foundation +import XUtils + +/// Extracts the classic (pre-iOS-17) Developer Disk Image for a specific iOS version out of an +/// Xcode.xip or Xcode.app, reusing `extractXIPRaw` (shared with `SDKBuilder`) for the xip case. +/// +/// Real Xcode ships these under `Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/ +/// /DeveloperDiskImage.dmg(+.signature)` -- a sibling of the `Developer` SDK directory +/// `SDKBuilder` extracts, not something `SDKBuilder`'s own allowlist (`SDKEntry.wanted`) collects. +enum DDIExtractor { + struct Result { + let dmg: URL + let signature: URL + } + + /// - Parameter versionPrefix: matched against `DeviceSupport` subfolder names via + /// `hasPrefix`, e.g. `"16.7"` matches a `"16.7"` folder (Xcode ships DeviceSupport per + /// minor version, not per exact patch build). + static func extract(xcodePath: String, versionPrefix: String, outputDir: URL) async throws -> Result { + let input = try SDKBuilder.Input(path: xcodePath) + + let appDir: URL + switch input { + case .xip(let inputPath): + let stage = try TemporaryDirectory(name: "DDIExtractStage") + // unxip doesn't like cooperative cancellation atm so shield it, same as SDKBuilder. + try await Task { + try await extractXIPRaw(inputPath: inputPath, outDir: stage.url.path) + }.value + try Task.checkCancellation() + let contents = try FileManager.default.contentsOfDirectory( + at: stage.url, + includingPropertiesForKeys: nil + ) + guard let app = contents.first(where: { $0.pathExtension == "app" }) else { + throw Console.Error("Unrecognized xip layout (Xcode.app not found)") + } + appDir = app + let result = try locate(appDir: appDir, versionPrefix: versionPrefix, outputDir: outputDir) + withExtendedLifetime(stage) {} + return result + case .app(let appPath): + appDir = URL(fileURLWithPath: appPath) + return try locate(appDir: appDir, versionPrefix: versionPrefix, outputDir: outputDir) + } + } + + private static func locate(appDir: URL, versionPrefix: String, outputDir: URL) throws -> Result { + let deviceSupportDir = appDir.appendingPathComponent( + "Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport" + ) + let versions = try FileManager.default.contentsOfDirectory( + at: deviceSupportDir, + includingPropertiesForKeys: nil + ) + guard let match = versions.first(where: { $0.lastPathComponent.hasPrefix(versionPrefix) }) else { + let available = versions.map(\.lastPathComponent).sorted().joined(separator: ", ") + throw Console.Error(""" + No DeviceSupport folder matching '\(versionPrefix)' found in this Xcode. \ + Available versions: \(available.isEmpty ? "(none)" : available) + """) + } + + let dmgSrc = match.appendingPathComponent("DeveloperDiskImage.dmg") + let sigSrc = match.appendingPathComponent("DeveloperDiskImage.dmg.signature") + guard FileManager.default.fileExists(atPath: dmgSrc.path) else { + throw Console.Error("'\(match.lastPathComponent)' does not contain a DeveloperDiskImage.dmg") + } + + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + let dmgDest = outputDir.appendingPathComponent("DeveloperDiskImage.dmg") + let sigDest = outputDir.appendingPathComponent("DeveloperDiskImage.dmg.signature") + try? FileManager.default.removeItem(at: dmgDest) + try? FileManager.default.removeItem(at: sigDest) + try FileManager.default.copyItem(at: dmgSrc, to: dmgDest) + try FileManager.default.copyItem(at: sigSrc, to: sigDest) + + return Result(dmg: dmgDest, signature: sigDest) + } +} diff --git a/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift b/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift index 38150ffc..ed9c30ac 100644 --- a/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift @@ -9,11 +9,28 @@ struct DSIdentifiersCommand: AsyncParsableCommand { abstract: "Interact with bundle identifiers", subcommands: [ DSIdentifiersListCommand.self, + DSIdentifiersDeleteCommand.self, ], defaultSubcommand: DSIdentifiersListCommand.self ) } +struct DSIdentifiersDeleteCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a bundle identifier" + ) + + @Argument(help: "The Developer Services id of the bundle identifier to delete (see `xtool ds identifiers list`)") + var id: String + + func run() async throws { + let client = DeveloperAPIClient(auth: try AuthToken.saved().authData()) + _ = try await client.bundleIdsDeleteInstance(path: .init(id: id)).noContent + print("Deleted \(id)") + } +} + struct DSIdentifiersListCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "list", diff --git a/Sources/XToolSupport/Reporters/HTMLReporter.swift b/Sources/XToolSupport/Reporters/HTMLReporter.swift new file mode 100644 index 00000000..ee7af701 --- /dev/null +++ b/Sources/XToolSupport/Reporters/HTMLReporter.swift @@ -0,0 +1,65 @@ +import Foundation +import XKit + +/// Simple, dependency-free HTML report -- optional per the plan, so kept minimal: one page, +/// inline CSS, no JS. Screenshot/syslog artifacts are referenced by their relative path (written +/// alongside the report directory), not embedded, so the report stays small even with many +/// failures. +enum HTMLReporter { + static func write(_ report: TestReport, to url: URL) throws { + var html = """ + + xtool test report + """ + html += "

xtool test report

\n" + html += "
\(report.passCount) passed, " + + "\(report.failCount) failed across \(report.runs.count) run(s)
\n" + + for (index, run) in report.runs.enumerated() { + html += "

Run \(index + 1): \(escape(run.deviceName)) (iOS \(escape(run.productVersion)))

\n" + if let error = run.infrastructureError { + html += "

Infrastructure error: \(escape(error))

\n" + } + html += "\n" + for testCase in run.testCases { + let statusClass = testCase.status == .passed ? "pass" : "fail" + html += "" + html += "" + html += "\n" + } + html += "
TestStatusDurationDetails
\(escape(testCase.identifier))\(testCase.status.rawValue)\(String(format: "%.3f", testCase.duration))s" + if !testCase.failureMessages.isEmpty { + html += "
\(escape(testCase.failureMessages.joined(separator: "\n")))
" + } + if let screenshotPath = testCase.screenshotPath { + html += "" + } + html += "
\n" + if let syslogPath = run.syslogPath { + html += "

Device syslog for this run

\n" + } + } + + html += "\n" + try html.write(to: url, atomically: true, encoding: .utf8) + } + + private static func escape(_ string: String) -> String { + string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } +} diff --git a/Sources/XToolSupport/Reporters/JSONReporter.swift b/Sources/XToolSupport/Reporters/JSONReporter.swift new file mode 100644 index 00000000..6981b9af --- /dev/null +++ b/Sources/XToolSupport/Reporters/JSONReporter.swift @@ -0,0 +1,12 @@ +import Foundation +import XKit + +enum JSONReporter { + static func write(_ report: TestReport, to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(report) + try data.write(to: url) + } +} diff --git a/Sources/XToolSupport/Reporters/JUnitReporter.swift b/Sources/XToolSupport/Reporters/JUnitReporter.swift new file mode 100644 index 00000000..c40ea27f --- /dev/null +++ b/Sources/XToolSupport/Reporters/JUnitReporter.swift @@ -0,0 +1,49 @@ +import Foundation +import XKit + +/// Standard JUnit XML (``/``/``) -- the same shape `xcodebuild` +/// and virtually every CI system consume, so no schema is invented here. +enum JUnitReporter { + static func write(_ report: TestReport, to url: URL) throws { + var xml = #""# + "\n" + xml += #""# + "\n" + for (index, run) in report.runs.enumerated() { + let suiteName = escape("\(run.testBundleName) - \(run.deviceName) (run \(index + 1))") + xml += " \n" + for testCase in run.testCases { + xml += " " + xml += escape(testCase.failureMessages.joined(separator: "\n")) + xml += "\n \n" + } else { + xml += " />\n" + } + } + if let error = run.infrastructureError { + xml += " \(escape(error))\n" + } + xml += " \n" + } + xml += "\n" + try xml.write(to: url, atomically: true, encoding: .utf8) + } + + private static func escape(_ string: String) -> String { + string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } + + private static func iso8601(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + return formatter.string(from: date) + } +} diff --git a/Sources/XToolSupport/SDKBuilder.swift b/Sources/XToolSupport/SDKBuilder.swift index 96e7f6f6..860edf1b 100644 --- a/Sources/XToolSupport/SDKBuilder.swift +++ b/Sources/XToolSupport/SDKBuilder.swift @@ -360,67 +360,10 @@ struct SDKBuilder { // returns the number of files we actually want to keep, // useful for computing progress % during fs traversal private func extractXIP(inputPath: String, outDir: String) async throws -> Int { - let fd = try FileDescriptor.open(inputPath, .readOnly) - defer { try? fd.close() } - - let length = try fd.seek(offset: 0, from: .end) - try fd.seek(offset: 0, from: .start) - - // global state, ah well - let oldDirectory = FileManager.default.currentDirectoryPath - guard FileManager.default.changeCurrentDirectoryPath(outDir) else { - throw Console.Error("Could not change directory to '\(outDir)'") - } - defer { _ = FileManager.default.changeCurrentDirectoryPath(oldDirectory) } - - let inputStream = DataReader.data(readingFrom: fd.rawValue) - - let (observer, source) = inputStream.lockstepSplit() - - async let readTask: Void = { - var read = 0 - for try await chunk in observer { - read += chunk.count - let progress = Int(Double(read) / Double(length) * 100) - print("\r[Extracting XIP] \(progress)%", terminator: "") - fflush(stdoutSafe) - if read == length { break } - } - }() - - let xipToChunks = XIP.transform( - DataReader(data: source), - options: nil - ) - - // ideally we would filter out the files we don't want at this stage, - // speeding up extraction AND entirely avoiding the "Installing SDKs" - // post-processing step. However, files in xip archives may be hardlinks - // to one another and so we need to handle the case where a file inside - // our filter() points to a file outside of it. We might be able to do this - // with a double-pass system. - - let chunksToFiles = Chunks.transform( - xipToChunks, - options: nil - ) - - let filesToDisk = Files.transform( - chunksToFiles, - options: .init( - compress: false, - dryRun: false - ) - ) - var wanted = 0 - for try await file in filesToDisk { - wanted += Self.isWanted(file.name[...]) ? 1 : 0 + try await extractXIPRaw(inputPath: inputPath, outDir: outDir) { name in + wanted += Self.isWanted(name) ? 1 : 0 } - _ = try await readTask - - print() - return wanted } @@ -511,3 +454,70 @@ struct SDKEntry { }), ]) } + +/// Streams `inputPath` (an Xcode.xip) into `outDir`, unfiltered -- every file in the archive gets +/// written to disk, regardless of whether the caller actually wants it. Filtering during +/// extraction isn't safe: xip archives can contain hardlinks pointing at files outside of any +/// single filter's matches (see `SDKBuilder.extractXIP`'s original comment, preserved on the +/// caller in `SDKBuilder.isWanted`/`extractXIP` above). `onFile` is invoked once per extracted +/// file with its archive-relative path, for callers that want progress counting or +/// post-extraction filtering without a second directory walk -- `SDKBuilder.extractXIP` and +/// `DDIExtractor.extract(xcodePath:versionPrefix:outputDir:)` both build on this shared core. +func extractXIPRaw( + inputPath: String, + outDir: String, + onFile: (Substring) -> Void = { _ in } +) async throws { + let fd = try FileDescriptor.open(inputPath, .readOnly) + defer { try? fd.close() } + + let length = try fd.seek(offset: 0, from: .end) + try fd.seek(offset: 0, from: .start) + + // global state, ah well + let oldDirectory = FileManager.default.currentDirectoryPath + guard FileManager.default.changeCurrentDirectoryPath(outDir) else { + throw Console.Error("Could not change directory to '\(outDir)'") + } + defer { _ = FileManager.default.changeCurrentDirectoryPath(oldDirectory) } + + let inputStream = DataReader.data(readingFrom: fd.rawValue) + + let (observer, source) = inputStream.lockstepSplit() + + async let readTask: Void = { + var read = 0 + for try await chunk in observer { + read += chunk.count + let progress = Int(Double(read) / Double(length) * 100) + print("\r[Extracting XIP] \(progress)%", terminator: "") + fflush(stdoutSafe) + if read == length { break } + } + }() + + let xipToChunks = XIP.transform( + DataReader(data: source), + options: nil + ) + + let chunksToFiles = Chunks.transform( + xipToChunks, + options: nil + ) + + let filesToDisk = Files.transform( + chunksToFiles, + options: .init( + compress: false, + dryRun: false + ) + ) + + for try await file in filesToDisk { + onFile(file.name[...]) + } + _ = try await readTask + + print() +} diff --git a/Sources/XToolSupport/SDKCommand.swift b/Sources/XToolSupport/SDKCommand.swift index cdb0bfcf..0f5bf858 100644 --- a/Sources/XToolSupport/SDKCommand.swift +++ b/Sources/XToolSupport/SDKCommand.swift @@ -1,8 +1,6 @@ import Foundation import XKit -import Version import ArgumentParser -import Dependencies import PackLib import XUtils import Subprocess @@ -16,11 +14,101 @@ struct SDKCommand: AsyncParsableCommand { DevSDKRemoveCommand.self, DevSDKBuildCommand.self, DevSDKStatusCommand.self, + DevSDKMountDDICommand.self, ], defaultSubcommand: DevSDKInstallCommand.self ) } +struct DevSDKMountDDICommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mount-ddi", + abstract: "Extract a Developer Disk Image from Xcode.xip/Xcode.app and mount it on a connected device", + discussion: """ + Locates the DeviceSupport folder matching the connected device's iOS version inside the \ + given Xcode.xip/Xcode.app (real Xcode ships these under Contents/Developer/Platforms/\ + iPhoneOS.platform/DeviceSupport//), extracts DeveloperDiskImage.dmg + its \ + signature, and mounts it -- unmounting whatever Developer image is currently mounted \ + first, if any, so this can be used to replace a stale or mismatched DDI. + """ + ) + + @Argument( + help: "Path to Xcode.xip or Xcode.app", + completion: .file(extensions: ["xip", "app"]) + ) + var path: String + + @Option( + help: ArgumentHelp( + "DeviceSupport version to use instead of the connected device's own version", + discussion: """ + Newer Xcode releases don't ship a DeviceSupport folder for every historical iOS \ + version -- pass e.g. '16.4' to use the closest available one if there's no exact \ + match for the device's own version. + """ + ) + ) + var version: String? + + @OptionGroup var connectionOptions: ConnectionOptions + + func run() async throws { + let client = try await connectionOptions.client() + let connection = try await Connection.connection( + forUDID: client.udid, + preferences: .init(lookupMode: .only(client.connectionType)) + ) { _ in } + let versionPrefix: String + if let version { + versionPrefix = version + } else { + let productVersion = try await connection.client.value( + ofType: String.self, forDomain: nil, key: "ProductVersion" + ) + versionPrefix = productVersion.split(separator: ".").prefix(2).joined(separator: ".") + } + + let cacheDir = try TemporaryDirectory(name: "DDIMount-\(versionPrefix)") + + print("Extracting Developer Disk Image for iOS \(versionPrefix) from '\(path)'...") + let result = try await DDIExtractor.extract( + xcodePath: path, + versionPrefix: versionPrefix, + outputDir: cacheDir.url + ) + print("Extracted to \(result.dmg.path)") + + let mounter = try await DDIMounter(connection: connection) + if try mounter.isMounted() { + print("Unmounting existing Developer Disk Image...") + // MobileImageMounterClient doesn't wrap unmount (mobile_image_mounter_unmount_image); + // ideviceimagemounter (same libimobiledevice suite already relied on elsewhere for + // interactive debugging this session) does, and is the more battle-tested path here. + try await Subprocess.run( + .name("ideviceimagemounter"), + arguments: ["-u", client.udid, "unmount", "/Developer"], + output: .discarded + ) + .checkSuccess() + } + + print("Mounting new Developer Disk Image...") + try await mounter.mountIfNeeded( + local: .init(dmg: result.dmg, signature: result.signature), + fetchRemote: { + throw Console.Error("Internal error: extracted DDI not found locally") + } + ) { progress in + print("\r[Mounting] \(Int(progress * 100))%", terminator: "") + fflush(stdoutSafe) + } + print("\nMounted Developer Disk Image for iOS \(versionPrefix) on \(client.deviceName).") + + withExtendedLifetime(cacheDir) {} + } +} + struct DevSDKBuildCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "build", @@ -217,40 +305,6 @@ struct DarwinSDK { } } -private enum SwiftVersion {} -extension SwiftVersion { - static func current() async throws -> Version { - let outputString: String? - do { - outputString = try await Subprocess.run( - .name("swift"), - arguments: ["--version"], - output: .string(limit: .max) - ) - .checkSuccess() - .standardOutput - } catch { - throw Console.Error("Failed to obtain Swift version") - } - var output = outputString?[...] ?? "" - if output.hasPrefix("Apple ") { - output = output.dropFirst("Apple ".count) - } - guard output.hasPrefix("Swift version ") else { - throw Console.Error("Could not parse Swift version: '\(output)'") - } - output = output.dropFirst("Swift version ".count) - guard let space = output.firstIndex(of: " ") else { - throw Console.Error("Could not parse Swift version: '\(output)'") - } - output = output[.. 1 { + runReports = try await withThrowingTaskGroup(of: [TestRunReport].self) { group in + for client in clients { + group.addTask { + try await self.runOnDevice( + client: client, + runnerURL: runnerURL, + xcTest: xcTest, + token: token, + reportDir: reportDir, + testsToRun: effectiveTestsToRun, + autoDetectAppBundleID: autoDetectAppBundleID + ) + } + } + return try await group.reduce(into: []) { $0.append(contentsOf: $1) } + } + } else { + runReports = try await runOnDevice( + client: clients[0], + runnerURL: runnerURL, + xcTest: xcTest, + token: token, + reportDir: reportDir, + testsToRun: effectiveTestsToRun, + autoDetectAppBundleID: autoDetectAppBundleID + ) + } + + let report = TestReport(runs: runReports) + + if let junit { + let url = URL(fileURLWithPath: junit) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JUnitReporter.write(report, to: url) + print("Wrote JUnit report to \(junit)") + } + if let json { + let url = URL(fileURLWithPath: json) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONReporter.write(report, to: url) + print("Wrote JSON report to \(json)") + } + if let html { + let url = URL(fileURLWithPath: html) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try HTMLReporter.write(report, to: url) + print("Wrote HTML report to \(html)") + } + + let runDescription = [ + repeatCount > 1 ? "\(repeatCount) repeats" : nil, + clients.count > 1 ? "\(clients.count) devices" : nil, + ].compactMap { $0 }.joined(separator: ", ") + print("\n\(report.passCount) passed, \(report.failCount) failed" + (runDescription.isEmpty ? "" : " (\(runDescription))")) + + guard report.allRunsCompleted else { + throw Console.Error("One or more runs did not complete (see above).") + } + if report.failCount > 0 { + throw ExitCode.failure + } + } + + /// Resolves which test identifiers to actually run: explicit `--only` wins outright (the + /// caller presumably already knows exactly what they want, so no target name is resolved in + /// that case); otherwise, if the package has more than one `.testTarget`, prompts for one (or + /// takes `--test-target` directly) and scopes the whole run to it via Apple's own + /// `ModuleName`-as-identifier convention. A single-test-target package never prompts -- + /// `Console.choose` already special-cases exactly one element. The chosen target's name is + /// also returned so the caller can default `--target-bundle-id` for a UI test target. + private func resolveTestsToRun(xcTest: Plan.XCTestPlan) async throws -> (testsToRun: [String], chosenTarget: String?) { + guard testsToRun.isEmpty else { return (testsToRun, nil) } + + let chosen: String + if let testTarget { + guard xcTest.testTargetNames.contains(testTarget) else { + throw Console.Error(""" + No test target named '\(testTarget)' in this package. Available: \ + \(xcTest.testTargetNames.joined(separator: ", ")) + """) + } + chosen = testTarget + } else { + chosen = try await Console.choose( + from: xcTest.testTargetNames, + onNoElement: { throw Console.Error("No XCTest targets were found in this package.") }, + multiPrompt: "Multiple test targets found -- choose one to run (or pass --test-target):", + formatter: { $0 } + ) + } + + guard let path = xcTest.testTargetPaths[chosen] else { return ([chosen], chosen) } + let classes = Self.xcTestCaseClassNames(inDirectory: URL(fileURLWithPath: path)) + guard !classes.isEmpty else { + throw Console.Error("No XCTestCase subclasses found under '\(path)' for test target '\(chosen)'.") + } + return (classes, chosen) + } + + /// Scans every `.swift` file under `directory` for `class Foo: XCTestCase` / `final class + /// Foo: XCTestCase` declarations -- see `Plan.XCTestPlan.testTargetPaths`'s doc comment for + /// why a bare module name can't be used as the `testsToRun` filter directly. A simple text + /// scan, not full parsing: good enough for XCTest's own convention (one class per top-level + /// declaration line), and avoids needing a second, separately-configured build just to + /// enumerate tests (`swift test list` builds for the *host* triple, which fails outright for + /// an iOS-only package like a UIKit/SwiftUI app -- confirmed against real hardware, this + /// session). + /// `applicationVerificationFailed` covers several unrelated provisioning failures too, but the + /// free-tier-account app-count cap has a distinctive `details` message and was hit repeatedly + /// against real hardware (this project's own history) with no actionable next step surfaced -- + /// just a raw `StatusError` dump naming the already-installed bundle IDs without saying what to + /// do about them. + private static func describeInstallFailure(_ error: InstallationProxyClient.StatusError) -> String { + guard let details = error.details, let message = Self.installCapacityMessage(fromDetails: details) else { + return "Failed to install the test runner: \(error)" + } + return message + } + + /// Parses the already-installed bundle IDs back out of the device's raw `details` message and + /// suggests `xtool uninstall` on one. Returns `nil` if `details` isn't this specific error. + static func installCapacityMessage(fromDetails details: String) -> String? { + guard details.contains("maximum number of installed apps") else { return nil } + + let quotedStringPattern = try! NSRegularExpression(pattern: #""([^"]+)""#) + let ns = details as NSString + let installedIDs = quotedStringPattern.matches(in: details, range: NSRange(location: 0, length: ns.length)) + .map { ns.substring(with: $0.range(at: 1)) } + + // Installed bundle IDs are reported team-ID-prefixed (e.g. "ABCDE12345.XTL-...actual-id"), + // but `xtool uninstall` expects just the identifier after that prefix. + let suggestion = installedIDs.first.map { id -> String in + let strippedID = id.split(separator: ".", maxSplits: 1).count > 1 + ? String(id.split(separator: ".", maxSplits: 1)[1]) + : id + return "\n\nFree up a slot first, e.g.: xtool uninstall \(strippedID)" + } ?? "" + + return """ + This device has reached the maximum number of apps a free Apple Developer account can \ + have installed at once\(installedIDs.isEmpty ? "" : " (\(installedIDs.joined(separator: ", ")))").\ + \(suggestion) + """ + } + + /// Scans every `.swift` file under `directory` for `XCTestCase` subclasses, resolving + /// indirect inheritance (e.g. a shared `class BaseUITestCase: XCTestCase` with concrete + /// `class FooUITests: BaseUITestCase` subclasses that hold the actual `test...` methods -- + /// confirmed against real hardware (this session) as a case a plain "inherits from + /// XCTestCase directly" regex misses entirely: filtering on the base class name alone + /// resolves to zero test methods, since XCTest addresses tests by their concrete class, not + /// an ancestor's). Only classes that declare at least one `test...` method of their own are + /// returned -- an abstract base with no directly-declared tests would otherwise waste a + /// whole filter/session round-trip for zero results. + static func xcTestCaseClassNames(inDirectory directory: URL) -> [String] { + guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { + return [] + } + let classPattern = try! NSRegularExpression(pattern: #"\bclass\s+(\w+)\s*:\s*([^{]+?)\{"#) + let testMethodPattern = try! NSRegularExpression(pattern: #"\bfunc\s+test\w*\s*\("#) + + var superclassByName: [String: String] = [:] + var hasOwnTestMethod: Set = [] + + for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" { + guard let contents = try? String(contentsOf: fileURL, encoding: .utf8) else { continue } + let ns = contents as NSString + for match in classPattern.matches(in: contents, range: NSRange(location: 0, length: ns.length)) { + guard let nameRange = Range(match.range(at: 1), in: contents), + let superListRange = Range(match.range(at: 2), in: contents) + else { continue } + let className = String(contents[nameRange]) + // Swift requires the superclass (if any) to be listed before any protocols, so + // the first entry is either the real superclass or a protocol -- either way it's + // the only entry worth treating as a potential superclass link. + guard let firstSuper = String(contents[superListRange]) + .split(separator: ",").first?.trimmingCharacters(in: .whitespacesAndNewlines), + !firstSuper.isEmpty + else { continue } + superclassByName[className] = firstSuper + + // Scope the test-method search to this class's own body via brace matching -- + // otherwise a `func test...` in a later sibling class would be misattributed here. + var depth = 1 + var index = match.range.location + match.range.length + while depth > 0, index < ns.length { + switch ns.character(at: index) { + case UInt16(UnicodeScalar("{").value): depth += 1 + case UInt16(UnicodeScalar("}").value): depth -= 1 + default: break + } + index += 1 + } + let bodyStart = match.range.location + match.range.length + let body = ns.substring(with: NSRange(location: bodyStart, length: max(0, index - 1 - bodyStart))) + if testMethodPattern.firstMatch(in: body, range: NSRange(location: 0, length: (body as NSString).length)) != nil { + hasOwnTestMethod.insert(className) + } + } + } + + func isXCTestCaseDescendant(_ name: String) -> Bool { + var current = name + var visited: Set = [] + while let next = superclassByName[current], visited.insert(current).inserted { + if next == "XCTestCase" { return true } + current = next + } + return false + } + + return superclassByName.keys + .filter { hasOwnTestMethod.contains($0) && isXCTestCaseDescendant($0) } + .sorted() + } + + private func firstNonEmptyBatch(searchMode: ClientDevice.SearchMode) async throws -> [ClientDevice] { + let stream = try await ClientDevice.search(mode: searchMode) + for await devices in stream where !devices.isEmpty { + return devices + } + throw CancellationError() + } + + /// Installs the already-built runner, mounts the DDI, and runs the whole `--repeat` loop + /// against one device -- extracted so `--parallel` can call this once per connected device + /// concurrently via a `TaskGroup`, and the single-device path (the common case) can call it + /// directly with one client. + private func runOnDevice( + client: ClientDevice, + runnerURL: URL, + xcTest: Plan.XCTestPlan, + token: AuthToken, + reportDir: URL?, + testsToRun: [String], + autoDetectAppBundleID: String? + ) async throws -> [TestRunReport] { + print("Installing \(xcTest.runnerProduct) to device: \(client.deviceName) (udid: \(client.udid))") + + let installDelegate = XToolInstallerDelegate() + let installer = IntegratedInstaller(auth: token.authData(), delegate: installDelegate) + let runnerBundleID: String + do { + runnerBundleID = try await installer.install( + app: runnerURL, + udid: client.udid, + lookupMode: .only(client.connectionType), + configureDevice: false + ) + print("\nInstalled \(runnerBundleID)") + } catch let error as CancellationError { + throw error + } catch let error as InstallationProxyClient.StatusError where error.type == .applicationVerificationFailed { + throw Console.Error(Self.describeInstallFailure(error)) + } catch { + throw Console.Error("Failed to install the test runner: \(error)") + } + + // `targetBundleID` (the --target-bundle-id CLI option) wins outright; otherwise, if the + // chosen test target looked like a UI test target, look up what this package's own app is + // actually installed as on *this* device -- `autoDetectAppBundleID` is only the raw, + // unprefixed value from `xtool.yml`/`Package.swift`, not the team-ID-prefixed form a + // free-tier account's signing actually installs it under. + let targetBundleID: String? + if let explicit = self.targetBundleID { + targetBundleID = explicit + } else if let autoDetectAppBundleID { + let installProxy = try InstallationProxyClient(device: client.device, label: "xtool-test") + if let resolved = try TestManagerdSession.resolveInstalledBundleID( + matching: autoDetectAppBundleID, + client: installProxy + ) { + print("No --target-bundle-id given; defaulting to this package's own app (\(resolved)), since the chosen test target looks like a UI test target.") + targetBundleID = resolved + } else { + throw Console.Error(""" + Could not find '\(autoDetectAppBundleID)' installed on \(client.deviceName) to drive as \ + the UI test target. Install it first (e.g. `xtool install`/`xtool dev`), or pass \ + --target-bundle-id explicitly if it's under a different bundle ID. + """) + } + } else { + targetBundleID = nil + } + + let connection = try await Connection.connection( + forUDID: client.udid, + preferences: .init(lookupMode: .only(client.connectionType)) + ) { _ in } + let productVersion = try await connection.client.value( + ofType: String.self, forDomain: nil, key: "ProductVersion" + ) + + // testmanagerd/instruments are developer-only services exposed only once a Developer + // Disk Image is mounted -- without this, `xtool test` only worked so far as some *other* + // tool had already mounted one this boot (confirmed during this session: real-device + // testing relied on pymobiledevice3, used for diagnostics, having done exactly that). + do { + print("Ensuring Developer Disk Image is mounted...") + try await AutoDDIMounter.ensureMounted(connection: connection, productVersion: productVersion) { progress in + print("\r[Mounting DDI] \(Int(progress * 100))%", terminator: "") + fflush(stdoutSafe) + } + print() + } catch { + throw Console.Error("Failed to mount the Developer Disk Image: \(error)") + } + + // iOS 17.4+ doesn't expose testmanagerd/instruments over classic lockdown at all -- try + // the CoreDeviceProxy tunnel first and fall back to classic on `serviceUnavailable` + // (the device itself is the source of truth here, not a version-string parse) rather than + // requiring the caller to know which path applies. + // + // Re-opened fresh on *every* `--repeat` iteration below, not just once before the loop -- + // confirmed against real hardware (this session): reusing one tunnel across multiple + // sequential `TestManagerdSession`s made every run after the first fail immediately with + // `ioFailed(errno: 104)` (ECONNRESET). Something about a session's `stop()` (which closes + // its three DTX connections and kills the runner process) leaves the underlying tunnel + // socket unusable for opening new connections afterward, even though the tunnel object + // itself doesn't report being closed -- not fully root-caused, but a fresh tunnel per + // iteration reliably avoids it, matching the low relative cost of the RSD handshake. + func openTunnel() throws -> (CoreDeviceProxyTunnel?, TestManagerdSession.TunnelContext?) { + do { + let t = try CoreDeviceProxyTunnel.connect(connection: connection) + let socket = try PosixTCPSocket(address: t.address, port: t.rsdPort) + let xpc = try RemoteXPCConnection(stream: socket) + let handshake = try RSDHandshake.perform(over: xpc) + return (t, .init(tunnel: t, rsd: handshake)) + } catch CDTunnelError.serviceUnavailable { + return (nil, nil) // pre-17.4 device -- classic lockdown path. + } + } + + var runReports: [TestRunReport] = [] + for iteration in 1...max(1, repeatCount) { + if repeatCount > 1 { + print("\n=== \(client.deviceName): run \(iteration)/\(repeatCount) ===") + } + let startedAt = Date() + + var syslogCapture: SyslogCapture? + if captureSyslog { + syslogCapture = try? await SyslogCapture(connection: connection) + } + + print("\nRunning \(xcTest.testProductName) on \(client.deviceName)...") + // One session per filter entry, not one session filtered to all of them -- confirmed + // against real hardware (this session): a single `XCTTestIdentifierSet` containing + // more than one class-level (whole-class) identifier reliably runs only *one* of + // them, and empirically not "first" or "last" but something else entirely (tested + // multiple orderings/pairings) -- a genuine on-device XCTest limitation, not an + // encoding bug (single-identifier filtering, including a class with 41 test methods, + // is completely reliable). This mainly matters for `--test-target`, which expands a + // chosen target into every `XCTestCase` class under it (`resolveTestsToRun`) -- a + // real target routinely has more than one. + let filters: [String?] = testsToRun.isEmpty ? [nil] : testsToRun + var combinedTestCases: [TestCaseReport] = [] + var anyTimedOut = false + var startFailure: Swift.Error? + for filter in filters { + // Fresh tunnel per session, not shared across the whole loop -- same reasoning + // as `--repeat`'s own fix (see `openTunnel`'s doc comment): a tunnel that already + // had a `TestManagerdSession` opened and stopped on it fails every subsequent + // session with `ioFailed(errno: 104)`, confirmed against real hardware (this + // session) once this per-filter loop started opening more than one session per + // tunnel. + let (tunnel, tunnelContext) = try openTunnel() + defer { tunnel?.close() } + if filter == filters.first, tunnel != nil { + print("Using iOS 17+ RSD tunnel for testmanagerd/instruments.") + } + let session = TestManagerdSession(connection: connection, productVersion: productVersion, tunnel: tunnelContext) + do { + _ = try await session.start( + runnerBundleID: runnerBundleID, + testBundleName: xcTest.testProductName, + targetApplicationBundleID: targetBundleID, + testsToRun: filter.map { [$0] }, + testsToSkip: testsToSkip.isEmpty ? nil : testsToSkip + ) + } catch { + await session.stop() + startFailure = error + break + } + + do { + let outcome = try await consume(session: session, connection: connection, reportDir: reportDir) + combinedTestCases.append(contentsOf: outcome.testCases) + if outcome.timedOutWithoutResult { anyTimedOut = true } + } catch { + // Ctrl-C (or any other cancellation) lands here -- without this, the runner + // process this session launched is left running on the device after every + // `xtool test` invocation, forcing a manual force-quit before the next run. + await session.stop() + await syslogCapture?.stop() + throw error + } + await session.stop() + } + + if let startFailure { + await syslogCapture?.stop() + if repeatCount > 1 || parallel { + runReports.append(TestRunReport( + deviceName: client.deviceName, + deviceUDID: client.udid, + productVersion: productVersion, + testBundleName: xcTest.testProductName, + startedAt: startedAt, + finishedAt: Date(), + testCases: combinedTestCases, + infrastructureError: "\(startFailure)" + )) + continue + } + throw Console.Error("Failed to start the test session: \(startFailure)") + } + let outcome = RunOutcome(testCases: combinedTestCases, timedOutWithoutResult: anyTimedOut) + + let syslogPath = await syslogCapture?.stop(into: reportDir, runLabel: "\(client.udid)-run\(iteration)") + + var crashLogPaths: [String] = [] + if captureCrashLogs, let reportDir { + crashLogPaths = collectCrashLogs( + connection: connection, + reportDir: reportDir, + runLabel: "\(client.udid)-run\(iteration)", + since: startedAt, + processNames: [xcTest.runnerProduct, targetBundleID?.components(separatedBy: ".").last].compactMap { $0 } + ) + } + + runReports.append(TestRunReport( + deviceName: client.deviceName, + deviceUDID: client.udid, + productVersion: productVersion, + testBundleName: xcTest.testProductName, + startedAt: startedAt, + finishedAt: Date(), + testCases: outcome.testCases, + syslogPath: syslogPath, + crashLogPaths: crashLogPaths, + infrastructureError: outcome.timedOutWithoutResult + ? "Connection to the device was lost before the test run finished." + : nil + )) + } + return runReports + } + + private func prepareReportDirectory() throws -> URL { + let dir: URL + if let reportDirectory { + dir = URL(fileURLWithPath: reportDirectory) + } else { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + dir = URL(fileURLWithPath: "xtool-test-report-\(formatter.string(from: Date()))") + } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private struct RunOutcome { + var testCases: [TestCaseReport] + /// The event stream ended (connection dropped) before a `.testSuiteFinished` event + /// arrived -- distinct from a clean run where every test case is simply accounted for. + var timedOutWithoutResult: Bool + } + + /// Tracks how long it's been since the last event from `session.events`, polled by + /// `consume`'s watchdog task -- an `actor` (rather than a plain mutable var) since it's + /// written from the event-consuming task and read from the watchdog task concurrently. + private actor IdleWatchdog { + private var lastEventAt = Date() + func poke() { lastEventAt = Date() } + func secondsSinceLastEvent() -> TimeInterval { Date().timeIntervalSince(lastEventAt) } + } + + /// Races the event-consuming loop (which returns once a `.testSuiteFinished` event arrives) + /// against two things that exist purely to make this cancellable/boundable -- a bare `for + /// await` over `session.events` (a plain `AsyncStream`) does not itself respond to `Task` + /// cancellation or the passage of time: + /// 1. A plain `Task.sleep` so Ctrl-C unwinds to the caller (where `session.stop()`, and the + /// app-kill it performs, actually runs) instead of hanging. + /// 2. An idle watchdog (`sessionTimeout`) that fails the run if no event arrives for that long + /// -- a session that goes silent without the underlying connection actually dropping + /// previously had no way to give up short of the caller hitting Ctrl-C themselves. Idle + /// *gap*, not a cap on the whole run, so a long `--test-target` sweep isn't penalized for + /// legitimately taking a while between individual events. + private func consume( + session: TestManagerdSession, + connection: Connection, + reportDir: URL? + ) async throws -> RunOutcome { + let watchdog = IdleWatchdog() + return try await withThrowingTaskGroup(of: RunOutcome.self) { group in + group.addTask { + var testCases: [TestCaseReport] = [] + var suiteFinished: (runCount: Int, failureCount: Int)? + for await event in await session.events { + await watchdog.poke() + switch event { + case .testBundleReady: + print("Test bundle ready, starting execution...") + case .logDebugMessage(let message): + print("[debug] \(message)") + case .testCaseResult(let testClass, let testMethod, let status, let duration, let failureMessages): + print("[\(status.rawValue)] \(testClass)/\(testMethod) (\(String(format: "%.3f", duration))s)") + var screenshotPath: String? + if status == .failed, screenshotOnFailure, let reportDir { + // Best-effort: confirmed against real hardware (this session) that + // `com.apple.mobile.screenshotr` can fail with "Invalid service" on a + // device using a *personalized* iOS 17+ DDI fetched from this + // project's current source (`doronz88/DeveloperDiskImage`) -- the + // same failure reproduces with the stock `idevicescreenshot` CLI + // against the same device, and the identical code path succeeds + // against a classic (pre-17) DDI, so this is a DDI-content gap in the + // fetched personalized image, not a bug in `ScreenshotClient`. Not + // worth failing the whole run over. + screenshotPath = try? await captureScreenshot( + connection: connection, + reportDir: reportDir, + testClass: testClass, + testMethod: testMethod + ) + } + testCases.append(TestCaseReport( + testClass: testClass, + testMethod: testMethod, + status: status, + duration: duration, + failureMessages: failureMessages, + screenshotPath: screenshotPath + )) + case .testSuiteFinished(let runCount, let failureCount): + // See `TestManagerdEvent.testSuiteFinished`'s doc comment: this is + // authoritative the first time it fires, regardless of how many + // `.testCaseResult` events we've collected so far -- trusted as-is rather + // than waited on to corroborate our own tally. + suiteFinished = (runCount, failureCount) + return RunOutcome(testCases: testCases, timedOutWithoutResult: false) + case .raw(let selector, let arguments): + // Anything not specifically handled above surfaces here so it's at least + // visible. + print("[\(selector)] \(arguments)") + } + } + _ = suiteFinished // silence unused-when-loop-exits-early warning; see above + return RunOutcome(testCases: testCases, timedOutWithoutResult: true) + } + group.addTask { + try await Task.sleep(nanoseconds: .max) + return RunOutcome(testCases: [], timedOutWithoutResult: true) + } + group.addTask { + let pollInterval = 5.0 + while true { + try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000)) + if await watchdog.secondsSinceLastEvent() > Double(self.sessionTimeout) { + return RunOutcome(testCases: [], timedOutWithoutResult: true) + } + } + } + defer { group.cancelAll() } + return try await group.next()! + } + } + + private func captureScreenshot( + connection: Connection, + reportDir: URL, + testClass: String, + testMethod: String + ) async throws -> String { + let screenshotr = try ScreenshotClient(connection: connection) + let data = try screenshotr.takeScreenshot() + let filename = "\(testClass)-\(testMethod)-\(Int(Date().timeIntervalSince1970)).png" + let sanitized = filename.replacingOccurrences(of: "/", with: "_") + try data.write(to: reportDir.appendingPathComponent(sanitized)) + return sanitized + } + + /// Best-effort: `CrashLogClient` itself can fail to start (e.g. no Developer Disk Image, or + /// simply no crash log service on this iOS version) without failing the run over it -- crash + /// logs are a bonus artifact, not something `xtool test`'s pass/fail result should depend on. + /// Filters to files whose name contains one of `processNames` (the runner product name and/or + /// the target app's last bundle-ID component) and whose modification time (`st_mtime`, if AFC + /// reports one) is no earlier than `since` -- best-effort on both counts, since a crash log + /// worth surfacing but failing either heuristic is a worse outcome than one falsely included. + private func collectCrashLogs( + connection: Connection, + reportDir: URL, + runLabel: String, + since: Date, + processNames: [String] + ) -> [String] { + guard !processNames.isEmpty, let client = try? CrashLogClient(connection: connection) else { return [] } + guard let names = try? client.listCrashReports() else { return [] } + + var collected: [String] = [] + for name in names { + guard processNames.contains(where: { name.contains($0) }) else { continue } + if let info = try? client.fileInfo(for: name), + let mtimeString = info["st_mtime"], + let mtimeNanos = Double(mtimeString) { + let mtime = Date(timeIntervalSince1970: mtimeNanos / 1_000_000_000) + guard mtime >= since else { continue } + } + guard let data = try? client.readCrashReport(name) else { continue } + let sanitized = "\(runLabel)-\(name)".replacingOccurrences(of: "/", with: "_") + guard (try? data.write(to: reportDir.appendingPathComponent(sanitized))) != nil else { continue } + collected.append(sanitized) + } + return collected + } +} + +/// Captures the device syslog for the duration of a run via `syslog_relay`, writing it to the +/// report directory on `stop()`. Best-effort: a failure to start capture (e.g. service +/// unavailable) shouldn't fail the test run itself, so `TestCommand` only ever uses `try?` around +/// construction. +private actor SyslogCapture { + private let client: SyslogRelayClient + private var lines: [String] = [] + private var task: Task? + + init(connection: Connection) async throws { + let device = await connection.device + self.client = try SyslogRelayClient(device: device, label: "xtool-test-syslog") + task = Task { [weak self] in + guard let self else { return } + for await line in self.client.lines() { + await self.append(line) + } + } + } + + private func append(_ line: String) { + lines.append(line) + } + + func stop() { + task?.cancel() + client.stop() + } + + /// Writes accumulated lines to `/-syslog.txt`, returning the filename + /// (relative to `reportDir`) if a directory was provided and the write succeeded. + func stop(into reportDir: URL?, runLabel: String) -> String? { + stop() + guard let reportDir else { return nil } + let filename = "\(runLabel)-syslog.txt" + let text = lines.joined(separator: "\n") + guard (try? text.write(to: reportDir.appendingPathComponent(filename), atomically: true, encoding: .utf8)) != nil else { + return nil + } + return filename + } +} diff --git a/Sources/XToolSupport/TunnelCommand.swift b/Sources/XToolSupport/TunnelCommand.swift new file mode 100644 index 00000000..03675189 --- /dev/null +++ b/Sources/XToolSupport/TunnelCommand.swift @@ -0,0 +1,72 @@ +import Foundation +import XKit +import ArgumentParser + +/// Diagnostic command for the iOS 17.4+ RSD tunnel work (`Sources/XKit/Testing/Tunnel/`): opens +/// the CoreDeviceProxy tunnel, creates the TUN device, and prints the RSD service directory +/// reachable over it. Not yet wired to `xtool test` -- this is the real-hardware checkpoint for +/// the tunnel/RSD layers before connecting them to the existing DTX/testmanagerd code. +struct TunnelRSDTestCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tunnel-rsd-test", + abstract: "Diagnostic: establish the iOS 17+ CoreDeviceProxy tunnel and print the RSD service list", + discussion: """ + Opens the lockdown-exposed CoreDeviceProxy service, performs the CDTunnel parameter \ + exchange, creates a kernel TUN device (requires CAP_NET_ADMIN -- run \ + `sudo setcap cap_net_admin+ep ` first), and looks up the device's \ + RSD service directory over it. + """ + ) + + @OptionGroup var connectionOptions: ConnectionOptions + + func run() async throws { + let client = try await connectionOptions.client() + let connection = try await Connection.connection( + forUDID: client.udid, + preferences: .init(lookupMode: .only(client.connectionType)) + ) { _ in } + + print("Opening CoreDeviceProxy tunnel...") + let tunnel = try CoreDeviceProxyTunnel.connect(connection: connection) + defer { tunnel.close() } + print("Tunnel up: address=\(tunnel.address) rsdPort=\(tunnel.rsdPort)") + + print("Connecting to RSD over the tunnel...") + let socket = try PosixTCPSocket(address: tunnel.address, port: tunnel.rsdPort) + let xpc = try RemoteXPCConnection(stream: socket) + let handshake = try RSDHandshake.perform(over: xpc) + + print("Device UDID (via RSD): \(handshake.udid)") + print("Services (\(handshake.services.count)):") + for (name, entry) in handshake.services.sorted(by: { $0.key < $1.key }) { + print(" \(name): \(entry.port)") + } + if let port = handshake.port(for: "com.apple.dt.testmanagerd.remote") { + print("\nFound com.apple.dt.testmanagerd.remote at port \(port)") + } else { + print("\ncom.apple.dt.testmanagerd.remote not found in RSD service list") + } + + // Exercise the actual DTX/testmanagerd handshake over the tunnel -- a bogus bundle ID + // means this will fail at the launchRunner step (no such app installed), but by then both + // testmanagerd DTX sessions (control + exec) will have already completed for real. This + // is the real-hardware checkpoint for whether the classic-path "runner never opens its + // channel" mystery is code-level or device/iOS-16.7-specific -- if it reproduces here too + // (a fully-installed runner needed to see that far), that's still one step further than + // this diagnostic goes today. + print("\nExercising testmanagerd DTX handshake over the tunnel...") + let productVersion = try await connection.client.value(ofType: String.self, forDomain: nil, key: "ProductVersion") + let session = TestManagerdSession( + connection: connection, + productVersion: productVersion, + tunnel: .init(tunnel: tunnel, rsd: handshake) + ) + do { + _ = try await session.start(runnerBundleID: "com.xtool.tunnel-diagnostic-nonexistent", testBundleName: "Dummy") + } catch { + print("start() ended (expected, no such app installed): \(error)") + } + await session.stop() + } +} diff --git a/Sources/XToolSupport/XTool.swift b/Sources/XToolSupport/XTool.swift index 2cec336e..1fa10f2e 100644 --- a/Sources/XToolSupport/XTool.swift +++ b/Sources/XToolSupport/XTool.swift @@ -39,6 +39,8 @@ private struct XToolCommand: AsyncParsableCommand { InstallCommand.self, UninstallCommand.self, LaunchCommand.self, + TestCommand.self, + TunnelRSDTestCommand.self, ] ) ] diff --git a/Sources/xtool/XTool.swift b/Sources/xtool/XTool.swift index 2968919a..e7e590b6 100644 --- a/Sources/xtool/XTool.swift +++ b/Sources/xtool/XTool.swift @@ -7,6 +7,14 @@ import Dependencies @main enum XToolMain { static func main() async throws { + // `stdout` is fully block-buffered (not line-buffered) whenever it isn't a TTY -- e.g. + // whenever output is redirected to a file/pipe, which is exactly when a hung or killed + // process's *last* lines matter most for diagnosis. Without this, those buffered lines + // are silently lost on a SIGTERM/crash, and concurrent writers can interleave mid-line + // (confirmed against real hardware, this session: a killed `xtool test --repeat` run + // produced a log with two lines spliced together character-by-character). + setvbuf(stdoutSafe, nil, _IOLBF, 0) + prepareDependencies { _ in #warning("Improve persistence mechanism") // for Windows, we could use dpapi.h or wincred.h. diff --git a/Tests/XToolTests/DTXMessageTests.swift b/Tests/XToolTests/DTXMessageTests.swift new file mode 100644 index 00000000..1a89cb07 --- /dev/null +++ b/Tests/XToolTests/DTXMessageTests.swift @@ -0,0 +1,82 @@ +import Testing +import Foundation +@testable import XKit + +@Test func testDTXMessageHeaderRoundTrips() throws { + var message = DTXMessage( + identifier: 7, + channelCode: -3, + conversationIndex: 1, + expectsReply: true, + flags: .send, + payload: .string("_IDE_initiateControlSessionWithProtocolVersion:") + ) + message.auxiliary.append(.int32(36)) + + let encoded = message.encoded() + + // header is always the first 32 bytes, unfragmented + let header = try DTXMessage.parseHeader(encoded.prefix(DTXMessage.headerLength)) + #expect(header.magic == DTXMessage.headerMagic) + #expect(header.headerLength == UInt32(DTXMessage.headerLength)) + #expect(header.fragmentId == 0) + #expect(header.fragmentCount == 1) + #expect(header.identifier == 7) + #expect(header.conversationIndex == 1) + #expect(header.channelCode == -3) + #expect(header.expectsReply == true) + #expect(Int(header.payloadLength) == encoded.count - DTXMessage.headerLength) + + let body = encoded.suffix(from: DTXMessage.headerLength) + let parsed = try DTXMessage.parseBody(header, body: Data(body)) + #expect(parsed.identifier == 7) + #expect(parsed.channelCode == -3) + #expect(parsed.conversationIndex == 1) + #expect(parsed.expectsReply == true) + guard case .string(let selector)? = parsed.payload else { + Issue.record("expected a string payload") + return + } + #expect(selector == "_IDE_initiateControlSessionWithProtocolVersion:") + #expect(parsed.auxiliary.values.count == 1) + guard case .int32(let value) = parsed.auxiliary.values[0] else { + Issue.record("expected an int32 auxiliary value") + return + } + #expect(value == 36) +} + +@Test func testDTXMessageWithoutPayloadRoundTrips() throws { + // matches the empty-ack reply pattern used to satisfy expectsReply + let message = DTXMessage(identifier: 1, channelCode: 0, conversationIndex: 1, flags: .reply) + let encoded = message.encoded() + let header = try DTXMessage.parseHeader(encoded.prefix(DTXMessage.headerLength)) + let body = Data(encoded.suffix(from: DTXMessage.headerLength)) + let parsed = try DTXMessage.parseBody(header, body: body) + #expect(parsed.payload == nil) + #expect(parsed.auxiliary.values.isEmpty) +} + +@Test func testDTXAuxiliaryBufferRoundTripsMixedTypes() throws { + var aux = DTXAuxiliaryBuffer() + aux.append(.int32(42)) + aux.append(.int64(-9_000_000_000)) + aux.append(.object(.string("hello"))) + aux.append(.object(.dictionary(["a": .int(1)]))) + + let encoded = aux.encoded() + let parsed = try DTXAuxiliaryBuffer.parse(encoded) + #expect(parsed.entries.count == 4) + + guard case .int32(let i32) = parsed.entries[0] else { Issue.record("expected int32"); return } + #expect(i32 == 42) + + guard case .int64(let i64) = parsed.entries[1] else { Issue.record("expected int64"); return } + #expect(i64 == -9_000_000_000) + + guard case .object(.string(let str)) = parsed.entries[2] else { Issue.record("expected string object"); return } + #expect(str == "hello") + + guard case .object(.dictionary(let dict)) = parsed.entries[3] else { Issue.record("expected dict object"); return } + #expect(dict["a"] == .int(1)) +} diff --git a/Tests/XToolTests/NSKeyedArchiveTests.swift b/Tests/XToolTests/NSKeyedArchiveTests.swift new file mode 100644 index 00000000..247a3a25 --- /dev/null +++ b/Tests/XToolTests/NSKeyedArchiveTests.swift @@ -0,0 +1,119 @@ +import Testing +import Foundation +@testable import XKit + +@Test func testNSKeyedArchiveRoundTripsPrimitives() throws { + let value = NSKeyedValue.dictionary([ + "aString": .string("hello"), + "aNumber": .int(42), + "aBool": .bool(true), + "anArray": .array([.string("a"), .int(1), .bool(false)]), + ]) + + let data = NSKeyedArchive.archive(value) + #expect(data.starts(with: Data("bplist00".utf8))) + + let decoded = try NSKeyedArchive.unarchive(data) + guard case .dictionary(let dict) = decoded else { + Issue.record("expected a dictionary") + return + } + #expect(dict["aString"] == .string("hello")) + #expect(dict["aNumber"] == .int(42)) + #expect(dict["aBool"] == .bool(true)) + guard case .array(let array)? = dict["anArray"] else { + Issue.record("expected an array") + return + } + #expect(array == [.string("a"), .int(1), .bool(false)]) +} + +@Test func testNSKeyedArchiveEncodesCustomClassName() throws { + // mirrors how NSURL is archived by Foundation's own NSKeyedArchiver: NS.base/NS.relative + // fields, $classname "NSURL" -- verified separately against a real NSKeyedArchiver run on + // this toolchain to confirm the wire shape. + let value = NSKeyedValue.object( + className: "NSURL", + extraClasses: ["NSObject"], + properties: [ + ("NS.base", .null), + ("NS.relative", .string("file:///tmp/foo.xctest")), + ] + ) + + let data = NSKeyedArchive.archive(value) + let decoded = try NSKeyedArchive.unarchive(data) + guard case .object(let className, let fields) = decoded else { + Issue.record("expected a classed object") + return + } + #expect(className == "NSURL") + #expect(fields["NS.relative"] == .string("file:///tmp/foo.xctest")) + #expect(fields["NS.base"] == .null) +} + +@Test func testNSKeyedArchiveBoxedPrimitiveGetsOwnObjectsEntry() throws { + // XCTestConfiguration.formatVersion is documented (via appium-ios-device) to be archived as + // a boxed reference rather than an inline value; verify our .boxed wrapper actually produces + // a UID-referenced $objects entry rather than an inline one. + let value = NSKeyedValue.object( + className: "XCTestConfiguration", + properties: [ + ("formatVersion", .boxed(.int(2))), + ("reportResultsToIDE", .bool(true)), + ] + ) + let data = NSKeyedArchive.archive(value) + let decoded = try NSKeyedArchive.unarchive(data) + guard case .object(_, let fields) = decoded else { + Issue.record("expected a classed object") + return + } + #expect(fields["formatVersion"] == .int(2)) + #expect(fields["reportResultsToIDE"] == .bool(true)) +} + +@Test func testNSKeyedArchiveInlinesDataMatchingFoundation() throws { + // Regression test for a real bug found via real-device testing: `NS.uuidbytes` (an NSUUID's + // only field) must be archived as raw inline bytes, not boxed as a UID reference to a + // separate `$objects` entry -- boxing it produced a structurally different archive that + // testmanagerd silently failed to decode as an NSUUID (logged the session identifier as + // "(null)" and closed the session). Verified byte-for-byte against this toolchain's real + // `NSKeyedArchiver` archiving a real `NSUUID` -- see the doc comment on `Archiver.encode()`. + let uuid = UUID(uuidString: "12345678-1234-5678-1234-567812345678")! + let foundationData = try NSKeyedArchiver.archivedData(withRootObject: uuid as NSUUID, requiringSecureCoding: false) + let fPlist = try PropertyListSerialization.propertyList(from: foundationData, format: nil) as! [String: Any] + let fObjects = fPlist["$objects"] as! [Any] + let fUUIDEntry = fObjects[1] as! [String: Any] + // Foundation inlines the bytes directly (an NSData value), not a UID reference. + #expect(fUUIDEntry["NS.uuidbytes"] is Data) + + let ourValue = NSKeyedValue.object(className: "NSUUID", properties: [ + ("NS.uuidbytes", .data(uuid.dtxUUIDBytes)), + ]) + let ourData = NSKeyedArchive.archive(ourValue) + let decoded = try NSKeyedArchive.unarchive(ourData) + guard case .object(let className, let fields) = decoded else { + Issue.record("expected a classed object") + return + } + #expect(className == "NSUUID") + #expect(fields["NS.uuidbytes"] == .data(uuid.dtxUUIDBytes)) +} + +extension NSKeyedArchive.Decoded: Equatable { + public static func == (lhs: NSKeyedArchive.Decoded, rhs: NSKeyedArchive.Decoded) -> Bool { + switch (lhs, rhs) { + case (.string(let l), .string(let r)): l == r + case (.data(let l), .data(let r)): l == r + case (.int(let l), .int(let r)): l == r + case (.double(let l), .double(let r)): l == r + case (.bool(let l), .bool(let r)): l == r + case (.array(let l), .array(let r)): l == r + case (.dictionary(let l), .dictionary(let r)): l == r + case (.object(let lc, let lf), .object(let rc, let rf)): lc == rc && lf == rf + case (.null, .null): true + default: false + } + } +} diff --git a/Tests/XToolTests/PlannerXCTestDetectionTests.swift b/Tests/XToolTests/PlannerXCTestDetectionTests.swift new file mode 100644 index 00000000..e76a6765 --- /dev/null +++ b/Tests/XToolTests/PlannerXCTestDetectionTests.swift @@ -0,0 +1,83 @@ +import Testing +import Foundation +import PackLib + +/// Builds a real, minimal SwiftPM package fixture (no external dependencies, so this runs +/// offline) and exercises `Planner.createPlan()` against it for real via `swift package +/// describe`/`show-dependencies` subprocesses. Verifies the SwiftPM test-target detection added +/// for xtool test support; doesn't (and can't, without a Darwin SDK configured) exercise the +/// actual `--build-tests`/packaging step `Packer` performs once a plan says a test target exists. +@Test func testPlannerDetectsSwiftPMTestTargets() async throws { + let fixture = try makeFixturePackage(name: "PlannerFixtureWithTests", includeTestTarget: true) + defer { try? FileManager.default.removeItem(at: fixture) } + + let buildSettings = try await BuildSettings( + configuration: .debug, + triple: "arm64-apple-ios", + packagePath: fixture.path + ) + let planner = Planner(buildSettings: buildSettings, schema: .default) + let plan = try await planner.createPlan() + + let xcTest = try #require(plan.xcTest) + #expect(xcTest.packageName == "PlannerFixtureWithTests") + #expect(xcTest.testProductName == "PlannerFixtureWithTestsPackageTests") + #expect(xcTest.runnerProduct == "PlannerFixtureWithTestsXCTRunner") + // Deliberately not derived from the app's own bundle ID -- see the doc comment on + // `Planner.xcTestPlan(from:app:)`'s `bundleID` argument. + #expect(xcTest.bundleID == "xtool.xctrunner") + #expect(xcTest.testTargetNames == ["PlannerFixtureWithTestsTests"]) +} + +@Test func testPlannerSkipsXCTestPlanWhenNoTestTargetsExist() async throws { + let fixture = try makeFixturePackage(name: "PlannerFixtureWithoutTests", includeTestTarget: false) + defer { try? FileManager.default.removeItem(at: fixture) } + + let buildSettings = try await BuildSettings( + configuration: .debug, + triple: "arm64-apple-ios", + packagePath: fixture.path + ) + let planner = Planner(buildSettings: buildSettings, schema: .default) + let plan = try await planner.createPlan() + + #expect(plan.xcTest == nil) +} + +private func makeFixturePackage(name: String, includeTestTarget: Bool) throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("\(name)-\(UUID().uuidString)") + let sources = root.appendingPathComponent("Sources/\(name)") + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + try Data("public func hello() -> String { \"hello\" }\n".utf8) + .write(to: sources.appendingPathComponent("\(name).swift")) + + let testTargetDecl: String + if includeTestTarget { + let tests = root.appendingPathComponent("Tests/\(name)Tests") + try FileManager.default.createDirectory(at: tests, withIntermediateDirectories: true) + try Data("import XCTest\nfinal class SmokeTests: XCTestCase { func testHello() {} }\n".utf8) + .write(to: tests.appendingPathComponent("SmokeTests.swift")) + testTargetDecl = """ + , + .testTarget(name: "\(name)Tests", dependencies: ["\(name)"]) + """ + } else { + testTargetDecl = "" + } + + let packageSwift = """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "\(name)", + platforms: [.iOS(.v16)], + products: [.library(name: "\(name)", targets: ["\(name)"])], + targets: [ + .target(name: "\(name)")\(testTargetDecl) + ] + ) + """ + try Data(packageSwift.utf8).write(to: root.appendingPathComponent("Package.swift")) + + return root +} diff --git a/Tests/XToolTests/PlistValueTests.swift b/Tests/XToolTests/PlistValueTests.swift new file mode 100644 index 00000000..8b403fad --- /dev/null +++ b/Tests/XToolTests/PlistValueTests.swift @@ -0,0 +1,67 @@ +import Testing +import Foundation +import plist +@testable import XKit + +@Test func testPlistValueRoundTripsThroughPlistT() { + let value = PlistValue.dictionary([ + "aString": .string("hello"), + "aData": .data(Data([0x01, 0x02, 0x03])), + "aUInt": .uint(42), + "aBoolTrue": .bool(true), + "aBoolFalse": .bool(false), + "aUID": .uid(7), + "anArray": .array([.string("a"), .string("b"), .uint(3)]), + "nested": .dictionary(["inner": .string("value")]), + ]) + + let node = value.toPlistT() + defer { plist_free(node) } + + let roundTripped = PlistValue(plistT: node) + #expect(roundTripped == value) +} + +@Test func testPlistValueRoundTripsThroughBinaryData() { + let value = PlistValue.dictionary([ + "aString": .string("hello"), + "aData": .data(Data([0x01, 0x02, 0x03])), + ]) + + let binary = value.toBinaryData() + #expect(binary.starts(with: Data("bplist00".utf8))) + + let roundTripped = PlistValue.parse(binary: binary) + #expect(roundTripped == value) +} + +@Test func testPlistValueParsesXMLPropertyList() throws { + let dict: [String: Any] = ["ApBoardID": "0x8", "ApChipID": "0x8110", "Trusted": true] + let xml = try PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) + + let parsed = PlistValue.parse(xml: xml) + guard case .dictionary(let result) = parsed else { + Issue.record("Expected a dictionary") + return + } + + #expect(result["ApBoardID"] == .string("0x8")) + #expect(result["ApChipID"] == .string("0x8110")) + #expect(result["Trusted"] == .bool(true)) +} + +extension PlistValue: Equatable { + public static func == (lhs: PlistValue, rhs: PlistValue) -> Bool { + switch (lhs, rhs) { + case (.dictionary(let l), .dictionary(let r)): l == r + case (.array(let l), .array(let r)): l == r + case (.string(let l), .string(let r)): l == r + case (.data(let l), .data(let r)): l == r + case (.uint(let l), .uint(let r)): l == r + case (.real(let l), .real(let r)): l == r + case (.bool(let l), .bool(let r)): l == r + case (.uid(let l), .uid(let r)): l == r + default: false + } + } +} diff --git a/Tests/XToolTests/RemoteXPCCodecTests.swift b/Tests/XToolTests/RemoteXPCCodecTests.swift new file mode 100644 index 00000000..c80f4858 --- /dev/null +++ b/Tests/XToolTests/RemoteXPCCodecTests.swift @@ -0,0 +1,79 @@ +import Testing +import Foundation +@testable import XKit + +/// Reference bytes for `Message{Flags: AlwaysSetFlag, Body: map[string]interface{}{}}` captured +/// from go-ios's `ios/xpc/xpc_empty_dict.bin` test fixture (MIT) -- a real, wire-captured +/// RemoteXPC message, not just self-consistency between this file's encode/decode. Matches the +/// byte-for-byte Foundation-comparison convention used in `NSKeyedArchiveTests.swift`. +private let emptyDictFixture: [UInt8] = [ + 0x92, 0x0b, 0xb0, 0x29, 0x01, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x42, 0x37, 0x13, 0x42, 0x05, 0x00, 0x00, 0x00, + 0x00, 0xf0, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +] + +@Test func testRemoteXPCDecodesRealEmptyDictFixture() throws { + let decoded = try RemoteXPCCodec.decode(Data(emptyDictFixture)) + #expect(decoded.flags == RemoteXPCFlag.alwaysSet) + #expect(decoded.body == [:]) +} + +@Test func testRemoteXPCEncodesMatchingRealEmptyDictFixture() throws { + let message = RemoteXPCMessage(flags: RemoteXPCFlag.alwaysSet, body: [:], id: 0) + let encoded = RemoteXPCCodec.encode(message) + #expect(Array(encoded) == emptyDictFixture) +} + +@Test func testRemoteXPCEmptyWrapperRoundTrips() throws { + let message = RemoteXPCMessage(flags: RemoteXPCFlag.initHandshake | RemoteXPCFlag.alwaysSet, body: nil, id: 0) + let encoded = RemoteXPCCodec.encode(message) + #expect(encoded.count == 24) // magic(4) + flags(4) + bodyLen(8) + msgId(8), no body bytes + + let decoded = try RemoteXPCCodec.decode(encoded) + #expect(decoded.flags == message.flags) + #expect(decoded.body == nil) +} + +@Test func testRemoteXPCDictionaryRoundTripsAllValueTypes() throws { + let uuid = UUID() + let date = Date(timeIntervalSince1970: 1_700_000_000) + let body: [String: RemoteXPCValue] = [ + "aString": .string("hello world"), + "aBool": .bool(true), + "anInt": .int64(-42), + "aUInt": .uint64(42), + "aDouble": .double(3.5), + "aData": .data(Data([0x01, 0x02, 0x03])), + "aUUID": .uuid(uuid), + "aDate": .date(date), + "aNull": .null, + "anArray": .array([.int64(1), .string("two"), .bool(false)]), + "aDict": .dictionary(["nested": .string("value")]), + ] + let message = RemoteXPCMessage(flags: RemoteXPCFlag.alwaysSet | RemoteXPCFlag.data, body: body, id: 7) + + let encoded = RemoteXPCCodec.encode(message) + let decoded = try RemoteXPCCodec.decode(encoded) + + #expect(decoded.flags == message.flags) + #expect(decoded.id == 7) + guard let decodedBody = decoded.body else { + Issue.record("expected a body") + return + } + #expect(decodedBody["aString"] == .string("hello world")) + #expect(decodedBody["aBool"] == .bool(true)) + #expect(decodedBody["anInt"] == .int64(-42)) + #expect(decodedBody["aUInt"] == .uint64(42)) + #expect(decodedBody["aDouble"] == .double(3.5)) + #expect(decodedBody["aData"] == .data(Data([0x01, 0x02, 0x03]))) + #expect(decodedBody["aUUID"] == .uuid(uuid)) + if case .date(let decodedDate)? = decodedBody["aDate"] { + #expect(abs(decodedDate.timeIntervalSince1970 - date.timeIntervalSince1970) < 0.001) + } else { + Issue.record("expected a date") + } + #expect(decodedBody["aNull"] == .null) + #expect(decodedBody["anArray"] == .array([.int64(1), .string("two"), .bool(false)])) + #expect(decodedBody["aDict"] == .dictionary(["nested": .string("value")])) +} diff --git a/Tests/XToolTests/TestCommandXCTestCaseDiscoveryTests.swift b/Tests/XToolTests/TestCommandXCTestCaseDiscoveryTests.swift new file mode 100644 index 00000000..1b391ef0 --- /dev/null +++ b/Tests/XToolTests/TestCommandXCTestCaseDiscoveryTests.swift @@ -0,0 +1,118 @@ +import Testing +import Foundation +@testable import XToolSupport + +/// Covers `TestCommand.xcTestCaseClassNames`'s regex/closure-based `XCTestCase` discovery -- +/// added after a real-hardware run of `--test-target` against a real-world package (not a crafted +/// fixture) returned 0 tests despite the target having many concrete UI test classes. Root cause: +/// every one of those classes inherited from a shared, project-local base class (itself with no +/// test methods of its own) rather than `XCTestCase` directly, which the original +/// direct-subclass-only regex missed entirely. +@Test func testDiscoversDirectXCTestCaseSubclass() throws { + let dir = try makeSwiftFilesFixture([ + "Direct.swift": """ + import XCTest + final class DirectTests: XCTestCase { + func testSomething() {} + } + """, + ]) + defer { try? FileManager.default.removeItem(at: dir) } + + #expect(TestCommand.xcTestCaseClassNames(inDirectory: dir) == ["DirectTests"]) +} + +@Test func testDiscoversIndirectXCTestCaseSubclassThroughSharedBase() throws { + let dir = try makeSwiftFilesFixture([ + "Base.swift": """ + import XCTest + class BaseUITestCase: XCTestCase { + func helper() {} + } + """, + "Concrete.swift": """ + final class LoginUITests: BaseUITestCase { + func testLoginScreenAppears() {} + } + final class SettingsUITests: BaseUITestCase { + func testSettingsScreenAppears() {} + } + """, + ]) + defer { try? FileManager.default.removeItem(at: dir) } + + #expect( + TestCommand.xcTestCaseClassNames(inDirectory: dir).sorted() + == ["LoginUITests", "SettingsUITests"] + ) +} + +@Test func testExcludesAbstractBaseWithNoOwnTestMethods() throws { + let dir = try makeSwiftFilesFixture([ + "Base.swift": """ + import XCTest + class BaseUITestCase: XCTestCase { + func notATestMethod() {} + } + """, + "Concrete.swift": """ + final class ConcreteUITests: BaseUITestCase { + func testReal() {} + } + """, + ]) + defer { try? FileManager.default.removeItem(at: dir) } + + // `BaseUITestCase` must not appear -- filtering on it alone would match zero actual tests, + // reproducing the exact bug this whole test file exists to guard against. + #expect(TestCommand.xcTestCaseClassNames(inDirectory: dir) == ["ConcreteUITests"]) +} + +@Test func testExcludesUnrelatedClasses() throws { + let dir = try makeSwiftFilesFixture([ + "Model.swift": """ + final class SomeModel { + func testIsNotATestMethodContextButMatchesPrefix() {} + } + """, + "Real.swift": """ + import XCTest + final class RealTests: XCTestCase { + func testReal() {} + } + """, + ]) + defer { try? FileManager.default.removeItem(at: dir) } + + #expect(TestCommand.xcTestCaseClassNames(inDirectory: dir) == ["RealTests"]) +} + +/// `details` message shape observed from a device's `InstallationProxyClient.StatusError` when a +/// free Apple Developer account's 3-app install cap is hit. +@Test func testInstallCapacityMessageParsesInstalledBundleIDsAndSuggestsUninstall() throws { + let details = """ + This device has reached the maximum number of installed apps using a free developer profile: {( + "ABCDE12345.XTL-ABCDE12345.com.example.MyApp.xctrunner", + "ABCDE12345.XTL-ABCDE12345.com.example.OtherApp", + "ABCDE12345.XTL-ABCDE12345.com.example.MyApp" + )} + """ + + let message = try #require(TestCommand.installCapacityMessage(fromDetails: details)) + #expect(message.contains("XTL-ABCDE12345.com.example.MyApp.xctrunner")) + #expect(message.contains("xtool uninstall XTL-ABCDE12345.com.example.MyApp.xctrunner")) +} + +@Test func testInstallCapacityMessageReturnsNilForUnrelatedDetails() { + #expect(TestCommand.installCapacityMessage(fromDetails: "Some other install failure entirely") == nil) +} + +private func makeSwiftFilesFixture(_ files: [String: String]) throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("xctest-discovery-fixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + for (name, contents) in files { + try contents.write(to: dir.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + return dir +}