From 6acfec144020bc8483af83e678b99228f99e5d67 Mon Sep 17 00:00:00 2001 From: AR Rahman Date: Thu, 1 Oct 2026 01:17:15 +0600 Subject: [PATCH] feat: add Device Hub compatibility --- MiniSim/Service/AppleUtils.swift | 62 +++++++++++++------ MiniSim/Service/DeviceConstants.swift | 9 +++ MiniSim/Service/DeviceService.swift | 86 +++++++++++++++------------ MiniSimTests/AppleUtilsTests.swift | 36 ++++++----- 4 files changed, 124 insertions(+), 69 deletions(-) diff --git a/MiniSim/Service/AppleUtils.swift b/MiniSim/Service/AppleUtils.swift index 523a279..078456a 100644 --- a/MiniSim/Service/AppleUtils.swift +++ b/MiniSim/Service/AppleUtils.swift @@ -24,22 +24,50 @@ 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 - } - try shell.execute( - command: "\(activeDeveloperDir)/Applications/Simulator.app/Contents/MacOS/Simulator", - arguments: ["--args", "-CurrentDeviceUDID", uuid] - ) - } + static func launchSimulatorApp(uuid: String) throws { + let isSimulatorRunning = workspace.runningApplications + .contains { $0.bundleIdentifier == "com.apple.iphonesimulator" } + + guard !isSimulatorRunning else { return } + + guard let activeDeveloperDir = try? shell.execute( + command: DeviceConstants.ProcessPaths.xcodeSelect.rawValue, + arguments: ["-p"] + ) + .trimmingCharacters(in: .whitespacesAndNewlines), + !activeDeveloperDir.isEmpty else { + throw DeviceError.xcodeError + } + + let fileManager = FileManager.default + let developerURL = URL(fileURLWithPath: activeDeveloperDir) + + // Older Xcode: Simulator.app is inside Developer/Applications. + let simulatorExecutable = developerURL + .appendingPathComponent("Applications/Simulator.app/Contents/MacOS/Simulator") + .path + + if fileManager.isExecutableFile(atPath: simulatorExecutable) { + try shell.execute( + command: simulatorExecutable, + arguments: ["--args", "-CurrentDeviceUDID", uuid] + ) + return + } + + // Newer Xcode: DeviceHub.app is alongside the Developer directory. + let deviceHubURL = developerURL + .deletingLastPathComponent() + .appendingPathComponent("Applications/DeviceHub.app") + + guard fileManager.fileExists(atPath: deviceHubURL.path) else { + throw DeviceError.xcodeError } + + try shell.execute( + command: "/usr/bin/open", + arguments: ["-a", deviceHubURL.path] + ) +} + } diff --git a/MiniSim/Service/DeviceConstants.swift b/MiniSim/Service/DeviceConstants.swift index eaebce5..a490abc 100644 --- a/MiniSim/Service/DeviceConstants.swift +++ b/MiniSim/Service/DeviceConstants.swift @@ -7,10 +7,19 @@ 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" + } + + /// Paths relative to the directory returned by `xcode-select -p`. + enum AppRelativePaths: String { + case simulatorExecutable = + "Applications/Simulator.app/Contents/MacOS/Simulator" + case deviceHub = "../Applications/DeviceHub.app" } } diff --git a/MiniSim/Service/DeviceService.swift b/MiniSim/Service/DeviceService.swift index af022f5..8db00f5 100644 --- a/MiniSim/Service/DeviceService.swift +++ b/MiniSim/Service/DeviceService.swift @@ -29,45 +29,55 @@ extension Device { } } -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) + extension DeviceServiceCommon { + func focusDevice() { + Thread.assertBackgroundThread() + + if let uuid = device.identifier, device.platform == .ios { + try? AppleUtils.launchSimulatorApp(uuid: uuid) + } + + let runningApps = NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy == .regular } + + for app in runningApps { + guard let bundleURL = app.bundleURL?.absoluteString else { + continue + } + + let isAndroid = bundleURL.contains( + DeviceConstants.BundleURL.emulator.rawValue + ) + let isAppleSimulator = bundleURL.contains( + DeviceConstants.BundleURL.simulator.rawValue + ) + let isDeviceHub = bundleURL.contains( + DeviceConstants.BundleURL.deviceHub.rawValue + ) + + guard isAndroid || isAppleSimulator || isDeviceHub else { + continue + } + + for window in AccessibilityElement.allWindowsForPID(app.processIdentifier) { + guard + let windowTitle = window.attribute(key: .title, type: String.self), + !windowTitle.isEmpty, + matchDeviceTitle(windowTitle: windowTitle, device: device) + else { + continue + } + + if isAndroid { + AccessibilityElement.forceFocus(pid: app.processIdentifier) + } else { + window.performAction(key: kAXRaiseAction) + app.activate(options: [.activateIgnoringOtherApps]) + } + } + } + } - - for app in runningApps { - guard - let bundleURL = app.bundleURL?.absoluteString, - bundleURL.contains(DeviceConstants.BundleURL.simulator.rawValue) || - bundleURL.contains(DeviceConstants.BundleURL.emulator.rawValue) else { - continue - } - let isAndroid = bundleURL.contains(DeviceConstants.BundleURL.emulator.rawValue) - - for window in AccessibilityElement.allWindowsForPID(app.processIdentifier) { - guard let windowTitle = window.attribute(key: .title, type: String.self), - !windowTitle.isEmpty else { - continue - } - - if !matchDeviceTitle(windowTitle: windowTitle, device: device) { - continue - } - - if isAndroid { - AccessibilityElement.forceFocus(pid: app.processIdentifier) - } else { - window.performAction(key: kAXRaiseAction) - app.activate(options: [.activateIgnoringOtherApps]) - } - } - } - } - private func matchDeviceTitle(windowTitle: String, device: Device) -> Bool { if device.platform == .android { let deviceName = windowTitle.match(#"(?<=- ).*?(?=:)"#).first?.first diff --git a/MiniSimTests/AppleUtilsTests.swift b/MiniSimTests/AppleUtilsTests.swift index 9becb2d..53a57de 100644 --- a/MiniSimTests/AppleUtilsTests.swift +++ b/MiniSimTests/AppleUtilsTests.swift @@ -77,24 +77,32 @@ class AppleUtilsTests: XCTestCase { waitForExpectations(timeout: 5, handler: nil) } +func testLaunchDeviceHubWhenSimulatorAppIsMissing() { + let uuid = "test-uuid" + mockWorkspace.mockRunningApplications = [] // Simulator not running - func testLaunchSimulatorAppWhenNotRunning() { - let uuid = "test-uuid" - mockWorkspace.mockRunningApplications = [] // Simulator not running - - shellStub.mockedExecute = { command, _, _ in - if command == DeviceConstants.ProcessPaths.xcodeSelect.rawValue { - return "/Applications/Xcode.app/Contents/Developer" - } - return "" + shellStub.mockedExecute = { command, _, _ in + if command == DeviceConstants.ProcessPaths.xcodeSelect.rawValue { + return "/Applications/Xcode.app/Contents/Developer" } - - XCTAssertNoThrow(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]) + return "" } + XCTAssertNoThrow(try AppleUtils.launchSimulatorApp(uuid: 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")]