From b14bfbbf3a5c7f523a149ac9fa6882b580511b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20Kn=C3=B6chel?= Date: Sun, 23 Aug 2026 16:41:26 +0200 Subject: [PATCH] fix: boot and focus simulators via simctl and Device Hub URL scheme on Xcode 27+ Device Hub (the Simulator app replacement on Xcode 27+) ignores the legacy -CurrentDeviceUDID argument and does not boot any simulator on launch, so spawning it the old way neither boots nor selects the requested device. - boot the simulator via `simctl boot` and open its window in Device Hub using the (undocumented) `devices://device/open?id=` URL scheme, opened via `open -a ` so the Device Hub of the selected Xcode handles it; a failure to open the window is not fatal - focus the simulator window via the same URL scheme instead of the sim_focus script, which can only activate the Device Hub app itself - detect a running Device Hub by its executable path in the process list, since it hosts all simulators in a single instance and is not necessarily launched with a -CurrentDeviceUDID argument --- lib/simulator.js | 166 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 133 insertions(+), 33 deletions(-) diff --git a/lib/simulator.js b/lib/simulator.js index c4947f3..44df66f 100644 --- a/lib/simulator.js +++ b/lib/simulator.js @@ -91,6 +91,48 @@ function getCreateSimulatorHelpText(selectedXcode) { return '\n\nPlease open Xcode, navigate to "Window > Devices and Simulators" and create a new Simulator with your preferred configuration.'; } +/** + * Determines if the specified simulator executable is the "Device Hub" app that + * replaces the Simulator app on Xcode 27+. + * + * @param {String} exe - The path to the simulator executable. + * + * @returns {Boolean} + */ +function isDeviceHub(exe) { + return !!exe && path.basename(exe) === 'DeviceHub'; +} + +/** + * Opens and focuses the window of the specified simulator in the Xcode 27+ + * "Device Hub" app using its `devices://` URL scheme. Device Hub ignores the + * legacy `-CurrentDeviceUDID` argument, so this is the only way to show a + * specific device. This launches Device Hub if it is not already running. + * + * Note that the simulator must already be booted (via `simctl boot`) or Device + * Hub will refuse to open the window. Failures are not fatal since the + * simulator itself keeps running headless. + * + * @param {Object} handle - The simulator handle. + * @param {EventEmitter} emitter - The emitter to send debug log events to. + * @param {Function} callback() - A function to call when finished. + */ +function openDeviceHubWindow(handle, emitter, callback) { + // resolve ".../DeviceHub.app" from ".../DeviceHub.app/Contents/MacOS/DeviceHub" so + // that the URL is opened by the Device Hub matching the selected Xcode + var appPath = path.resolve(handle.simulator, '..', '..', '..'), + url = 'devices://device/open?id=' + handle.udid; + + emitter.emit('log-debug', __('Running: %s', 'open -a "' + appPath + '" "' + url + '"')); + + appc.subprocess.run('open', ['-a', appPath, url], function (code, out, err) { + if (code) { + emitter.emit('log-debug', __('Failed to open simulator window in Device Hub (code %s), continuing', code)); + } + callback(); + }); +} + /** * Detects iOS simulators. * @@ -1264,6 +1306,76 @@ function launch(simHandleOrUDID, options, callback) { return next(); } + function waitForBoot() { + // wait for the simulator to boot + async.whilst( + function (cb) { return cb(null, !booted); }, + function (cb) { + list(options, function (err, info) { + Object.keys(info.devices).some(function (type) { + return info.devices[type].some(function (sim) { + if (sim.udid === handle.udid) { + if (/^booted$/i.test(sim.state)) { + booted = true; + } + return true; + } + }); + }); + + if (booted) { + emitter.emit('log-debug', __('Simulator is booted')); + return cb(); + } + + setTimeout(function () { + cb(); + }, 250); + }); + }, + function (err) { + if (!err) { + emitter.emit('log-debug', __('%s Simulator started', handle.name)); + } + next(err); + } + ); + } + + if (isDeviceHub(handle.simulator)) { + // Xcode 27+: Device Hub ignores the -CurrentDeviceUDID argument and does not + // boot any simulator on launch, so boot the simulator via simctl, then open + // its window in Device Hub using the devices:// URL scheme + emitter.emit('log-debug', __('Running: %s', handle.simctl + ' boot ' + handle.udid)); + + simctl.boot({ simctl: handle.simctl, udid: handle.udid }, function (err) { + if (err) { + return next(err); + } + + openDeviceHubWindow(handle, emitter, function () { + // because we didn't spawn Device Hub ourselves, we have no child + // process to listen for when it exits, so we monitor it ourselves + isSimulatorRunning(handle.simulator, function (err, pid) { + if (!err && pid) { + setTimeout(function check() { + appc.subprocess.run('ps', ['-p', pid], function (code, out, err) { + if (code) { + simExited(); + } else { + setTimeout(check, 1000); + } + }); + }, 1000); + } + + waitForBoot(); + }); + }); + }); + return; + } + // not running, start the simulator emitter.emit('log-debug', __('Running: %s', handle.simulator + ' -CurrentDeviceUDID ' + handle.udid)); @@ -1271,39 +1383,7 @@ function launch(simHandleOrUDID, options, callback) { child.on('close', simExited); child.unref(); - // wait for the simulator to boot - async.whilst( - function (cb) { return cb(null, !booted); }, - function (cb) { - list(options, function (err, info) { - Object.keys(info.devices).some(function (type) { - return info.devices[type].some(function (sim) { - if (sim.udid === handle.udid) { - if (/^booted$/i.test(sim.state)) { - booted = true; - } - return true; - } - }); - }); - - if (booted) { - emitter.emit('log-debug', __('Simulator is booted')); - return cb(); - } - - setTimeout(function () { - cb(); - }, 250); - }); - }, - function (err) { - if (!err) { - emitter.emit('log-debug', __('%s Simulator started', handle.name)); - } - next(err); - } - ); + waitForBoot(); } ], function (err) { simEmitter.emit('start', err); @@ -1538,6 +1618,12 @@ function launch(simHandleOrUDID, options, callback) { // focus or hide the iOS Simulator if (options.focus !== false && !options.hide && !options.autoExit) { + if (isDeviceHub(handle.simulator)) { + // Xcode 27+: the sim_focus script can only bring the Device Hub app itself + // to the foreground, so focus the simulator's window via the URL scheme + return openDeviceHubWindow(handle, emitter, next); + } + action = ['focus', 'focused']; args = [ path.join(__dirname, 'sim_focus.scpt'), @@ -1838,6 +1924,10 @@ function launch(simHandleOrUDID, options, callback) { /** * Determines if the iOS Simulator is running by scanning the output of the `ps` command. * + * Note that on Xcode 27+ the Device Hub app hosts all simulators in a single app + * instance and is not necessarily launched with a `-CurrentDeviceUDID` argument, so + * the process is matched by its executable path and the returned udid is `null`. + * * @param {String} proc - The path of the executable to find the pid for. * @param {Function} callback - A function to call with the err, pid, and udid. */ @@ -1853,6 +1943,16 @@ function isSimulatorRunning(proc, callback) { m, procRE = /^\s*\d+\s+(\d+).* \-CurrentDeviceUDID (.+)/; + if (isDeviceHub(proc)) { + procRE = /^\s*\d+\s+(\d+)/; + for (; i < l; i++) { + if (lines[i].indexOf(proc) !== -1 && (m = lines[i].match(procRE))) { + return callback(null, parseInt(m[1]), null); + } + } + return callback(null, false); + } + for (; i < l; i++) { if (m = lines[i].match(procRE)) { return callback(null, parseInt(m[1]), m[2]);