├── .gitignore ├── Package.swift ├── README.md ├── Sources ├── Intramodular │ └── SimulatorDevice.swift └── module.swift └── Tests └── Tests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm/ 3 | .swiftpm/* 4 | /*.xcodeproj 5 | /.build 6 | /Packages 7 | xcuserdata/ 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SimulatorKit", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9) 12 | ], 13 | products: [ 14 | .library( 15 | name: "SimulatorKit", 16 | targets: [ 17 | "SimulatorKit" 18 | ] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/vmanot/CorePersistence.git", branch: "main"), 23 | .package(url: "https://github.com/vmanot/Merge.git", branch: "master"), 24 | .package(url: "https://github.com/vmanot/Swallow.git", branch: "master"), 25 | ], 26 | targets: [ 27 | .target( 28 | name: "SimulatorKit", 29 | dependencies: [ 30 | "CorePersistence", 31 | "Merge", 32 | "Swallow" 33 | ], 34 | path: "Sources" 35 | ), 36 | .testTarget( 37 | name: "SimulatorKitTests", 38 | dependencies: [ 39 | "SimulatorKit" 40 | ], 41 | path: "Tests" 42 | ) 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SimulatorKit is a framework that wraps `CoreSimulator` and `simctl` to offer programmatic access to the Simulator app bundled with Xcode. 2 | 3 | - macOS 11 4 | - Swift 5.3 5 | - Xcode 12 6 | 7 | # Installation 8 | 9 | The preferred way of installing SimulatorKit is via the [Swift Package Manager](https://swift.org/package-manager/). 10 | 11 | 1. In Xcode, open your project and navigate to **File** → **Swift Packages** → **Add Package Dependency...** 12 | 2. Paste the repository URL (`https://github.com/vmanot/SimulatorKit`) and click **Next**. 13 | 3. For **Rules**, select **Branch** (with branch set to `master`). 14 | 4. Click **Finish**. 15 | 16 | # Why 17 | 18 | The goal of this framework is to provide a safe and idiomatic way to control the Simulator app. 19 | 20 | # Usage 21 | 22 | Almost all functions on `SimulatorDevice` are synchronous, blocking and throwing. 23 | 24 | #### List all available simulators 25 | ```swift 26 | import SimulatorKit 27 | 28 | print(try! SimulatorDevice.all()) 29 | ``` 30 | 31 | #### Boot a simulator 32 | ```swift 33 | import SimulatorKit 34 | 35 | let iphoneSimulator = try SimulatorDevice.all().first(where: { $0.name.contains("iPhone") })! 36 | 37 | try iphoneSimulator.boot() 38 | ``` 39 | 40 | #### Take a screenshot of a simulator 41 | ```swift 42 | import SimulatorKit 43 | 44 | let iphoneSimulator = try SimulatorDevice.all().first(where: { $0.name.contains("iPhone") })! 45 | 46 | let jpgDataOfScreenshot: Data = try iphoneSimulator.screenshot() 47 | ``` 48 | -------------------------------------------------------------------------------- /Sources/Intramodular/SimulatorDevice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | #if os(macOS) 6 | 7 | import CorePersistence 8 | import Merge 9 | import POSIX 10 | import Swallow 11 | import System 12 | 13 | /// A struct representing a simulator device. 14 | public struct SimulatorDevice: Identifiable, Hashable { 15 | public typealias State = SimDeviceState 16 | 17 | /// A unique, persistent identifier that can be used to identify this device. 18 | public let id: UUID 19 | /// The name of the device. 20 | public let name: String 21 | /// The runtime version of this device. 22 | public let runtimeVersion: String? 23 | /// The state of the device. 24 | public let state: State 25 | 26 | private init(device: SimDeviceProtocol) { 27 | self.id = device.UDID 28 | self.name = device.name 29 | self.runtimeVersion = device.runtime?.versionString 30 | self.state = device.state 31 | } 32 | } 33 | 34 | extension SimulatorDevice { 35 | private static var handle: POSIXDynamicLibraryHandle? 36 | 37 | /// Get a list of all available simulator devices. 38 | public static func all() async throws -> [Self] { 39 | if handle == nil { 40 | handle = try POSIXDynamicLibraryHandle.open(at: "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator") 41 | } 42 | 43 | let task = Process.Task(executableURL: URL(fileURLWithPath: "/usr/bin/xcode-select"), arguments: ["-p"]) 44 | 45 | let output = try await task 46 | .standardOutputAndErrorLinesPublisher 47 | .tryMap({ try $0.leftValue.unwrap() }) 48 | .timeout(5.0, scheduler: DispatchQueue.global(qos: .userInitiated)) 49 | .first() 50 | .output() 51 | 52 | return unsafeBitCast(NSClassFromString("SimServiceContext"), to: SimServiceContextProtocol.Type.self) 53 | .sharedServiceContext(forDeveloperDir: output, error: nil) 54 | .defaultDeviceSetWithError(nil) 55 | .devices 56 | .map({ SimulatorDevice(device: unsafeBitCast($0, to: SimDeviceProtocol.self)) }) 57 | } 58 | } 59 | 60 | extension SimulatorDevice { 61 | /// Boots up the simulator, if not booted already. 62 | public func boot() async throws { 63 | guard state != .booted else { 64 | return 65 | } 66 | 67 | let task = Process.Task( 68 | executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), 69 | arguments: ["simctl", "boot", id.uuidString] 70 | ) 71 | 72 | task.start() 73 | 74 | try await task.value 75 | } 76 | 77 | /// Bring this simulator device into the foreground. 78 | /// 79 | /// This launches the Simulator app that is bundled with Xcode, with this simulator device being set as the current active device. 80 | public func foreground() async throws { 81 | let task = Process.Task( 82 | executableURL: URL(fileURLWithPath: "/usr/bin/open"), 83 | arguments: ["-a", "Simulator", "--args", "-CurrentDeviceUDID", id.uuidString] 84 | ) 85 | 86 | task.start() 87 | 88 | try await task.value 89 | } 90 | 91 | /// Copy and install an ".app" from a given location. 92 | public func installApp(from url: URL) async throws { 93 | let task = Process.Task( 94 | executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), 95 | arguments: ["simctl", "install", id.uuidString, url.path] 96 | ) 97 | 98 | task.start() 99 | 100 | try await task.value 101 | } 102 | 103 | /// Screenshot this simulator device. 104 | /// 105 | /// Note: The screenshot is taken immediately, which may be undesirable if called right after an app is launched via `Simulator/launchApp(withIdentifier:)` because it would capture the home screen -> app animation rather than a screenshot of the app fully launched and loaded. 106 | public func screenshot() async throws -> Data { 107 | let temporaryDirectoryPath = FilePath.temporaryDirectory() 108 | let temporaryFilePath = temporaryDirectoryPath + "screenshot.jpg" 109 | 110 | let task = Process.Task( 111 | executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), 112 | arguments: ["simctl", "io", id.uuidString, "screenshot", temporaryFilePath.stringValue] 113 | ) 114 | 115 | task.start() 116 | 117 | try await task.value 118 | 119 | let data = try Data(contentsOf: URL(temporaryFilePath).unwrap()) 120 | 121 | try FileManager.default.removeItem(at: temporaryFilePath) 122 | 123 | return data 124 | } 125 | 126 | /// Launches an installed app, identified by the given app identifier. 127 | public func launchApp(withIdentifier appIdentifier: String) async throws { 128 | let task = Process.Task( 129 | executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), 130 | arguments: ["simctl", "launch", id.uuidString, appIdentifier] 131 | ) 132 | 133 | task.start() 134 | 135 | try await task.value 136 | } 137 | } 138 | 139 | // MARK: - Conformances 140 | 141 | extension SimulatorDevice { 142 | public var description: String { 143 | guard let runtimeVersion = runtimeVersion else { 144 | return name 145 | } 146 | 147 | return "\(name) (\(runtimeVersion))" 148 | } 149 | } 150 | 151 | #endif 152 | -------------------------------------------------------------------------------- /Sources/module.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Foundation 6 | import Swift 7 | 8 | @objc public enum SimDeviceState: Int { 9 | case creating 10 | case shutdown 11 | case booting 12 | case booted 13 | case shuttingDown 14 | } 15 | 16 | @objc protocol SimDeviceRuntimeProtocol: NSObjectProtocol { 17 | var versionString: String? { get } 18 | } 19 | 20 | @objc protocol SimDeviceProtocol: NSObjectProtocol { 21 | var UDID: UUID { get set } 22 | var name: String { get } 23 | var runtime: SimDeviceRuntimeProtocol? { get set } 24 | var state: SimDeviceState { get set } 25 | 26 | func sendPushNotification(forBundleID bundleID: String, jsonPayload json: [AnyHashable : Any]) throws 27 | } 28 | 29 | @objc protocol SimDeviceSetProtocol: NSObjectProtocol { 30 | var devices: [AnyObject] { get } 31 | 32 | func registerNotificationHandler(_ handler: @escaping ([AnyHashable: Any]) -> Void) -> UInt64 33 | } 34 | 35 | @objc protocol SimServiceContextProtocol: NSObjectProtocol { 36 | static func sharedServiceContext(forDeveloperDir developerDirectory: String, error: NSErrorPointer) -> Self 37 | 38 | func defaultDeviceSetWithError(_ error: NSErrorPointer) -> SimDeviceSetProtocol 39 | } 40 | -------------------------------------------------------------------------------- /Tests/Tests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | @testable import SimulatorKit 6 | 7 | import XCTest 8 | 9 | final class SimulatorKitTests: XCTestCase { 10 | func testListingAllSimulatorDevices() throws { 11 | _ = try SimulatorDevice.all().first(where: { $0.name.contains("iPhone") })! 12 | } 13 | 14 | func testScreenshot() throws { 15 | let iphoneSimulator = try SimulatorDevice.all().first(where: { $0.name.contains("iPhone") })! 16 | 17 | try iphoneSimulator.boot() 18 | let jpgDataOfScreenshot: Data = try iphoneSimulator.screenshot() 19 | 20 | XCTAssert(!jpgDataOfScreenshot.isEmpty) 21 | } 22 | } 23 | --------------------------------------------------------------------------------