Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 84 additions & 15 deletions MiniSim/Service/AppleUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AppKit
class AppleUtils {
static var shell: ShellProtocol = Shell()
static var workspace: NSWorkspace = .shared
static var fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }

static func clearDerivedData(
completionQueue: DispatchQueue = .main,
Expand All @@ -24,22 +25,90 @@ class AppleUtils {
}
}

static func launchSimulatorApp(uuid: String) throws {
let isSimulatorRunning = workspace.runningApplications
.contains { $0.bundleIdentifier == "com.apple.iphonesimulator" }

if !isSimulatorRunning {
guard let activeDeveloperDir = try? shell.execute(
command: DeviceConstants.ProcessPaths.xcodeSelect.rawValue,
arguments: ["-p"]
)
.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw DeviceError.xcodeError
/// Xcode 27 removed `Contents/Developer/Applications` and replaced Simulator.app
/// with Device Hub, which ships next to Instruments in `Contents/Applications`.
/// Returns the GUI app to launch, or nil when neither is installed.
static func simulatorApp(developerDir: String) -> (path: String, isDeviceHub: Bool)? {
let simulator = "\(developerDir)/Applications/\(DeviceConstants.BundleURL.simulator.rawValue)"
if fileExists(simulator) {
return (simulator, false)
}

let deviceHub = URL(fileURLWithPath: developerDir)
.deletingLastPathComponent()
.appendingPathComponent("Applications")
.appendingPathComponent(DeviceConstants.BundleURL.deviceHub.rawValue)
.path
if fileExists(deviceHub) {
return (deviceHub, true)
}

return nil
}

/// Device Hub registers the `devices://` URL scheme. Opening this link launches it
/// if needed and shows that device's own window, which is how a specific device is
/// targeted on Xcode 27+ (`-CurrentDeviceUDID` is ignored by Device Hub).
/// The call is idempotent: repeating it re-focuses the existing window.
static func deviceHubDeepLink(uuid: String) -> String {
"devices://device/open?id=\(uuid)"
}

/// Ensures the simulator GUI is running and showing `uuid`.
/// Returns true when Device Hub handled it, meaning the device window is already
/// focused and no accessibility fallback is needed.
@discardableResult
static func launchSimulatorApp(uuid: String) throws -> Bool {
guard let activeDeveloperDir = try? shell.execute(
command: DeviceConstants.ProcessPaths.xcodeSelect.rawValue,
arguments: ["-p"]
)
.trimmingCharacters(in: .whitespacesAndNewlines),
let app = simulatorApp(developerDir: activeDeveloperDir) else {
throw DeviceError.xcodeError
}

if app.isDeviceHub {
// Always fired, even when Device Hub is already up: it is what switches the
// focused device, so an early return here would strand the user on the
// previously opened one. An unknown uuid is a safe no-op.
if uuid.isEmpty {
try shell.execute(
command: DeviceConstants.ProcessPaths.open.rawValue,
arguments: ["-a", app.path]
)
} else {
do {
try shell.execute(
command: DeviceConstants.ProcessPaths.open.rawValue,
arguments: [deviceHubDeepLink(uuid: uuid)]
)
} catch {
// LaunchServices may not know Device Hub's devices:// scheme yet on
// fresh Xcode installs, which makes `open devices://…` fail. Opening
// the app itself still works; the user just picks the device manually.
try shell.execute(
command: DeviceConstants.ProcessPaths.open.rawValue,
arguments: ["-a", app.path]
)
}
}
try shell.execute(
command: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator",
arguments: ["--args", "-CurrentDeviceUDID", uuid]
)
return true
}

// Simulator.app ignores -CurrentDeviceUDID once running, so only launch it once.
// Deliberately not checking SimulatorTrampoline here: it lingers after the GUI
// quits, so treating it as "running" would suppress the launch entirely.
let isSimulatorRunning = workspace.runningApplications
.contains { $0.bundleIdentifier == DeviceConstants.BundleID.simulator.rawValue }

guard !isSimulatorRunning else { return false }

// Launch through LaunchServices rather than exec'ing the binary.
try shell.execute(
command: DeviceConstants.ProcessPaths.open.rawValue,
arguments: ["-a", app.path, "--args", "-CurrentDeviceUDID", uuid]
)
return false
}
}
7 changes: 7 additions & 0 deletions MiniSim/Service/DeviceConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ enum DeviceConstants {
enum ProcessPaths: String {
case xcrun = "/usr/bin/xcrun"
case xcodeSelect = "/usr/bin/xcode-select"
case open = "/usr/bin/open"
}

enum BundleURL: String {
case emulator = "qemu-system-aarch64"
case simulator = "Simulator.app"
case deviceHub = "DeviceHub.app"
}

enum BundleID: String {
case simulator = "com.apple.iphonesimulator"
case deviceHub = "com.apple.dt.Devices"
}
}
10 changes: 7 additions & 3 deletions MiniSim/Service/DeviceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ extension DeviceServiceCommon {
func focusDevice() {
Thread.assertBackgroundThread()

let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular }

if let uuid = device.identifier, device.platform == .ios {
try? AppleUtils.launchSimulatorApp(uuid: uuid)
// On Xcode 27+ the devices:// deep link opens and raises that device's own
// Device Hub window, so the accessibility pass below has nothing left to do.
if (try? AppleUtils.launchSimulatorApp(uuid: uuid)) == true {
return
}
}

let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular }

for app in runningApps {
guard
let bundleURL = app.bundleURL?.absoluteString,
Expand Down
135 changes: 126 additions & 9 deletions MiniSimTests/AppleUtilsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class AppleUtilsTests: XCTestCase {

override func tearDown() {
shellStub.tearDown()
AppleUtils.fileExists = { FileManager.default.fileExists(atPath: $0) }
super.tearDown()
}

Expand Down Expand Up @@ -78,30 +79,146 @@ class AppleUtilsTests: XCTestCase {
waitForExpectations(timeout: 5, handler: nil)
}

private func stubXcodeSelect(developerDir: String = "/Applications/Xcode.app/Contents/Developer") {
shellStub.mockedExecute = { command, _, _ in
if command == DeviceConstants.ProcessPaths.xcodeSelect.rawValue {
return developerDir
}
return ""
}
}

func testLaunchSimulatorAppWhenNotRunning() {
let uuid = "test-uuid"
mockWorkspace.mockRunningApplications = [] // Simulator not running
stubXcodeSelect()
// Xcode 26 and earlier: Simulator.app is present.
AppleUtils.fileExists = { $0.hasSuffix("Developer/Applications/Simulator.app") }

XCTAssertFalse(try AppleUtils.launchSimulatorApp(uuid: uuid))

XCTAssertEqual(shellStub.lastExecutedCommand, DeviceConstants.ProcessPaths.open.rawValue)
XCTAssertEqual(shellStub.lastPassedArguments, [
"-a",
"/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app",
"--args",
"-CurrentDeviceUDID",
uuid
])
}

shellStub.mockedExecute = { command, _, _ in
func testLaunchSimulatorAppIgnoresLingeringTrampolineOnLegacyXcode() {
let uuid = "test-uuid"
// SimulatorTrampoline keeps running after Simulator.app quits, so it must not
// count as "the simulator app is already running".
mockWorkspace.mockRunningApplications = [
MockNSRunningApplication(bundleIdentifier: "com.apple.CoreSimulator.SimulatorTrampoline")
]
stubXcodeSelect()
AppleUtils.fileExists = { $0.hasSuffix("Developer/Applications/Simulator.app") }

XCTAssertFalse(try AppleUtils.launchSimulatorApp(uuid: uuid))

XCTAssertEqual(shellStub.lastExecutedCommand, DeviceConstants.ProcessPaths.open.rawValue)
}

private func stubDeviceHubOnly() {
// Xcode 27 removed Simulator.app; only Device Hub exists.
AppleUtils.fileExists = { $0.hasSuffix("Contents/Applications/DeviceHub.app") }
}

func testLaunchSimulatorAppUsesDeepLinkOnXcode27() {
let uuid = "test-uuid"
mockWorkspace.mockRunningApplications = []
stubXcodeSelect()
stubDeviceHubOnly()

XCTAssertTrue(try AppleUtils.launchSimulatorApp(uuid: uuid))

// Device Hub ignores -CurrentDeviceUDID; the devices:// link targets the device.
XCTAssertEqual(shellStub.lastExecutedCommand, DeviceConstants.ProcessPaths.open.rawValue)
XCTAssertEqual(shellStub.lastPassedArguments, ["devices://device/open?id=\(uuid)"])
}

func testLaunchSimulatorAppFallsBackToOpeningDeviceHubWhenDeepLinkFails() {
let uuid = "test-uuid"
mockWorkspace.mockRunningApplications = []
stubDeviceHubOnly()
// Fresh Xcode installs may not have Device Hub's devices:// scheme in the
// LaunchServices database yet, so `open devices://…` exits non-zero.
shellStub.mockedExecute = { command, arguments, _ in
if command == DeviceConstants.ProcessPaths.xcodeSelect.rawValue {
return "/Applications/Xcode.app/Contents/Developer"
}
if command == DeviceConstants.ProcessPaths.open.rawValue,
arguments.first?.hasPrefix("devices://") == true {
throw NSError(domain: "TestError", code: 1)
}
return ""
}

XCTAssertNoThrow(try AppleUtils.launchSimulatorApp(uuid: uuid))
XCTAssertTrue(try AppleUtils.launchSimulatorApp(uuid: uuid))

XCTAssertEqual(shellStub.lastExecutedCommand, "/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator")
XCTAssertEqual(shellStub.lastPassedArguments, ["--args", "-CurrentDeviceUDID", uuid])
XCTAssertEqual(shellStub.lastExecutedCommand, DeviceConstants.ProcessPaths.open.rawValue)
XCTAssertEqual(shellStub.lastPassedArguments, [
"-a",
"/Applications/Xcode.app/Contents/Applications/DeviceHub.app"
])
}

func testLaunchSimulatorAppWhenAlreadyRunning() {
let uuid = "test-uuid"
mockWorkspace.mockRunningApplications = [MockNSRunningApplication(bundleIdentifier: "com.apple.iphonesimulator")]
func testLaunchSimulatorAppDeepLinksEvenWhenDeviceHubAlreadyRunning() {
let uuid = "other-uuid"
// Device Hub already showing some other device: the deep link is what switches
// focus, so it must still fire rather than returning early.
mockWorkspace.mockRunningApplications = [
MockNSRunningApplication(bundleIdentifier: DeviceConstants.BundleID.deviceHub.rawValue)
]
stubXcodeSelect()
stubDeviceHubOnly()

XCTAssertNoThrow(try AppleUtils.launchSimulatorApp(uuid: uuid))
XCTAssertTrue(try AppleUtils.launchSimulatorApp(uuid: uuid))

XCTAssertTrue(shellStub.lastExecutedCommand.isEmpty, "Should not execute any command when simulator is already running")
XCTAssertEqual(shellStub.lastPassedArguments, ["devices://device/open?id=\(uuid)"])
}

func testLaunchSimulatorAppOpensDeviceHubWithoutDeepLinkWhenUuidIsEmpty() {
mockWorkspace.mockRunningApplications = []
stubXcodeSelect()
stubDeviceHubOnly()

XCTAssertTrue(try AppleUtils.launchSimulatorApp(uuid: ""))

XCTAssertEqual(shellStub.lastPassedArguments, [
"-a",
"/Applications/Xcode.app/Contents/Applications/DeviceHub.app"
])
}

func testLaunchSimulatorAppThrowsWhenNoSimulatorAppInstalled() {
mockWorkspace.mockRunningApplications = []
stubXcodeSelect()
AppleUtils.fileExists = { _ in false }

XCTAssertThrowsError(try AppleUtils.launchSimulatorApp(uuid: "test-uuid")) { error in
XCTAssertEqual(error as? DeviceError, DeviceError.xcodeError)
}
}

func testLaunchSimulatorAppWhenAlreadyRunning() {
let uuid = "test-uuid"
mockWorkspace.mockRunningApplications = [
MockNSRunningApplication(bundleIdentifier: DeviceConstants.BundleID.simulator.rawValue)
]
stubXcodeSelect()
AppleUtils.fileExists = { $0.hasSuffix("Developer/Applications/Simulator.app") }

XCTAssertFalse(try AppleUtils.launchSimulatorApp(uuid: uuid))

XCTAssertEqual(
shellStub.lastExecutedCommand,
DeviceConstants.ProcessPaths.xcodeSelect.rawValue,
"Should resolve the app but never re-open Simulator.app when it is already running"
)
}

func testLaunchSimulatorAppWithXcodeError() {
Expand Down