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
78 changes: 65 additions & 13 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,73 @@ class AppleUtils {
}
}

static func launchSimulatorApp(uuid: String) throws {
let isSimulatorRunning = workspace.runningApplications
.contains { $0.bundleIdentifier == "com.apple.iphonesimulator" }
/// 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)
}

if !isSimulatorRunning {
guard let activeDeveloperDir = try? shell.execute(
command: DeviceConstants.ProcessPaths.xcodeSelect.rawValue,
arguments: ["-p"]
)
.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw DeviceError.xcodeError
}
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.
try shell.execute(
command: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator",
arguments: ["--args", "-CurrentDeviceUDID", uuid]
command: DeviceConstants.ProcessPaths.open.rawValue,
arguments: uuid.isEmpty ? ["-a", app.path] : [deviceHubDeepLink(uuid: 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
115 changes: 103 additions & 12 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,120 @@ class AppleUtilsTests: XCTestCase {
waitForExpectations(timeout: 5, handler: nil)
}

func testLaunchSimulatorAppWhenNotRunning() {
let uuid = "test-uuid"
mockWorkspace.mockRunningApplications = [] // Simulator not running

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

XCTAssertNoThrow(try AppleUtils.launchSimulatorApp(uuid: uuid))
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
])
}

XCTAssertEqual(shellStub.lastExecutedCommand, "/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator")
XCTAssertEqual(shellStub.lastPassedArguments, ["--args", "-CurrentDeviceUDID", uuid])
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)
}

func testLaunchSimulatorAppWhenAlreadyRunning() {
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 = [MockNSRunningApplication(bundleIdentifier: "com.apple.iphonesimulator")]
mockWorkspace.mockRunningApplications = []
stubXcodeSelect()
stubDeviceHubOnly()

XCTAssertNoThrow(try AppleUtils.launchSimulatorApp(uuid: uuid))
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)"])
}

XCTAssertTrue(shellStub.lastExecutedCommand.isEmpty, "Should not execute any command when simulator is already running")
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()

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

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