diff --git a/MiniSim/Service/AppleUtils.swift b/MiniSim/Service/AppleUtils.swift index 523a279..4083fe2 100644 --- a/MiniSim/Service/AppleUtils.swift +++ b/MiniSim/Service/AppleUtils.swift @@ -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, @@ -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 } } diff --git a/MiniSim/Service/DeviceConstants.swift b/MiniSim/Service/DeviceConstants.swift index eaebce5..c57019e 100644 --- a/MiniSim/Service/DeviceConstants.swift +++ b/MiniSim/Service/DeviceConstants.swift @@ -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" } } diff --git a/MiniSim/Service/DeviceService.swift b/MiniSim/Service/DeviceService.swift index af022f5..e1ee66e 100644 --- a/MiniSim/Service/DeviceService.swift +++ b/MiniSim/Service/DeviceService.swift @@ -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, diff --git a/MiniSimTests/AppleUtilsTests.swift b/MiniSimTests/AppleUtilsTests.swift index 9becb2d..5253d8e 100644 --- a/MiniSimTests/AppleUtilsTests.swift +++ b/MiniSimTests/AppleUtilsTests.swift @@ -37,6 +37,7 @@ class AppleUtilsTests: XCTestCase { override func tearDown() { shellStub.tearDown() + AppleUtils.fileExists = { FileManager.default.fileExists(atPath: $0) } super.tearDown() } @@ -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() {