@@ -236,6 +236,9 @@ class _Point {
236236class IosSimulatorDriver extends NativeDriver {
237237 String ? _cachedUdid;
238238 String ? _cachedBridgePath;
239+ // idb availability and scale factor are detected once and cached.
240+ bool ? _idbAvailable;
241+ double ? _cachedLogicalScale;
239242
240243 @override
241244 NativePlatform get platform => NativePlatform .iosSimulator;
@@ -300,6 +303,84 @@ class IosSimulatorDriver extends NativeDriver {
300303 /// Check if HID bridge is available (preferred over osascript)
301304 Future <bool > _hasBridge () async => (await _getBridgePath ()) != null ;
302305
306+ // ========== idb (iOS Development Bridge) integration ==========
307+
308+ /// Run an idb command, using python3.13 to work around the Python 3.14
309+ /// asyncio regression in the fb-idb package.
310+ Future <ProcessResult > _runIdb (List <String > args) async {
311+ // Prefer python3.13 which is compatible with fb-idb 1.1.7.
312+ // Fall back to the idb binary in case a fixed version is installed.
313+ const py313 = '/opt/homebrew/bin/python3.13' ;
314+ final py313Exists = await File (py313).exists ();
315+ if (py313Exists) {
316+ return Process .run (py313, [
317+ '-c' ,
318+ 'import asyncio; asyncio.set_event_loop(asyncio.new_event_loop()); '
319+ 'from idb.cli.main import main; main()' ,
320+ '--' ,
321+ ...args,
322+ ]).timeout (const Duration (seconds: 10 ),
323+ onTimeout: () => ProcessResult (0 , 1 , '' , 'idb timeout' ));
324+ }
325+ return Process .run ('idb' , args).timeout (
326+ const Duration (seconds: 10 ),
327+ onTimeout: () => ProcessResult (0 , 1 , '' , 'idb timeout' ),
328+ );
329+ }
330+
331+ /// Returns true if idb is usable on this machine.
332+ Future <bool > _hasIdb () async {
333+ if (_idbAvailable != null ) return _idbAvailable! ;
334+ try {
335+ final result = await _runIdb (['list-targets' , '--json' ]);
336+ _idbAvailable = result.exitCode == 0 ;
337+ } catch (_) {
338+ _idbAvailable = false ;
339+ }
340+ return _idbAvailable! ;
341+ }
342+
343+ /// Get the logical scale factor (pixels per point) for the booted simulator.
344+ /// Used to convert device-pixel coordinates to idb logical-point coordinates.
345+ Future <double > _getLogicalScale () async {
346+ if (_cachedLogicalScale != null ) return _cachedLogicalScale! ;
347+ try {
348+ final udid = await _getBootedSimulatorUdid ();
349+ final result = await _runIdb (['describe' , '--udid' , udid]);
350+ if (result.exitCode == 0 ) {
351+ final out = result.stdout as String ;
352+ // Parse "Screen: <w> x <h> (logical)" or similar idb output.
353+ final match = RegExp (r'(\d+)\s*x\s*(\d+)\s*\(?logical' ).firstMatch (out);
354+ if (match != null ) {
355+ final logicalWidth = double .parse (match.group (1 )! );
356+ // Get pixel width from a simctl screenshot (fast, cached result).
357+ final shotPath = '${Directory .systemTemp .path }/fs_scale_probe.png' ;
358+ final shot = await Process .run (
359+ 'xcrun' , ['simctl' , 'io' , udid, 'screenshot' , shotPath]);
360+ if (shot.exitCode == 0 ) {
361+ // Use `sips` (built-in macOS tool) to read pixel dimensions.
362+ final sips = await Process .run ('sips' , [
363+ '-g' ,
364+ 'pixelWidth' ,
365+ shotPath,
366+ ]);
367+ final sipsOut = sips.stdout as String ;
368+ final pixelMatch =
369+ RegExp (r'pixelWidth:\s*(\d+)' ).firstMatch (sipsOut);
370+ if (pixelMatch != null ) {
371+ final pixelWidth = double .parse (pixelMatch.group (1 )! );
372+ _cachedLogicalScale = pixelWidth / logicalWidth;
373+ return _cachedLogicalScale! ;
374+ }
375+ }
376+ }
377+ }
378+ } catch (_) {}
379+ // Safe default: most modern iPhones are @3x.
380+ _cachedLogicalScale = 3.0 ;
381+ return _cachedLogicalScale! ;
382+ }
383+
303384 /// Get the UDID of the first booted simulator
304385 Future <String > _getBootedSimulatorUdid () async {
305386 if (_cachedUdid != null ) return _cachedUdid! ;
@@ -369,6 +450,30 @@ class IosSimulatorDriver extends NativeDriver {
369450 }
370451 }
371452
453+ // idb is more reliable than the Accessibility API: it uses XCTest under the
454+ // hood and requires no coordinate mapping (operates in logical points).
455+ if (await _hasIdb ()) {
456+ final udid = await _getBootedSimulatorUdid ();
457+ final scale = await _getLogicalScale ();
458+ final lx = (x / scale).roundToDouble ();
459+ final ly = (y / scale).roundToDouble ();
460+ final idbResult =
461+ await _runIdb (['ui' , 'tap' , '$lx ' , '$ly ' , '--udid' , udid]);
462+ if (idbResult.exitCode == 0 ) {
463+ return NativeResult (
464+ success: true ,
465+ message:
466+ 'Tapped at device (${x .round ()}, ${y .round ()}) via idb (logical: $lx , $ly )' ,
467+ metadata: {
468+ 'device_coords' : {'x' : x, 'y' : y},
469+ 'logical_coords' : {'x' : lx, 'y' : ly},
470+ 'backend' : 'idb' ,
471+ },
472+ );
473+ }
474+ // idb failed — fall through to Accessibility API.
475+ }
476+
372477 // Fallback: osascript approach
373478 // Map device pixel coordinates to screen coordinates for hit-testing
374479 final screenCoords = await _mapToScreenCoordinates (x, y);
@@ -514,6 +619,19 @@ end tell
514619 Future <NativeResult > inputText (String text) async {
515620 final udid = await _getBootedSimulatorUdid ();
516621
622+ // idb text injection is the cleanest approach: no clipboard side-effects
623+ // and no "Allow Paste" dialog to dismiss.
624+ if (await _hasIdb ()) {
625+ final idbResult = await _runIdb (['ui' , 'text' , text, '--udid' , udid]);
626+ if (idbResult.exitCode == 0 ) {
627+ return NativeResult (
628+ success: true ,
629+ message: 'Entered text via idb: "$text "' ,
630+ metadata: {'backend' : 'idb' },
631+ );
632+ }
633+ }
634+
517635 // Copy text to simulator pasteboard
518636 final process = await Process .start (
519637 'xcrun' ,
@@ -588,6 +706,37 @@ end tell
588706 }
589707 }
590708
709+ // idb swipe is more reliable (XCTest-backed, no coordinate mapping needed).
710+ if (await _hasIdb ()) {
711+ final udid = await _getBootedSimulatorUdid ();
712+ final scale = await _getLogicalScale ();
713+ final lx1 = (startX / scale).roundToDouble ();
714+ final ly1 = (startY / scale).roundToDouble ();
715+ final lx2 = (endX / scale).roundToDouble ();
716+ final ly2 = (endY / scale).roundToDouble ();
717+ final duration =
718+ (durationMs / 1000.0 ).toStringAsFixed (2 ); // idb expects seconds
719+ final idbResult = await _runIdb ([
720+ 'ui' ,
721+ 'swipe' ,
722+ '$lx1 ' ,
723+ '$ly1 ' ,
724+ '$lx2 ' ,
725+ '$ly2 ' ,
726+ '--duration' ,
727+ duration,
728+ '--udid' ,
729+ udid,
730+ ]);
731+ if (idbResult.exitCode == 0 ) {
732+ return NativeResult (
733+ success: true ,
734+ message: 'Swiped via idb' ,
735+ metadata: {'backend' : 'idb' },
736+ );
737+ }
738+ }
739+
591740 // Fallback: osascript approach
592741 // Determine scroll direction from the swipe vector
593742 final deltaY = endY - startY;
@@ -692,6 +841,36 @@ end tell
692841 return {
693842 'xcrun_simctl' : await _isCommandAvailable ('xcrun' ),
694843 'osascript' : await _isCommandAvailable ('osascript' ),
844+ 'idb' : await _hasIdb (),
845+ };
846+ }
847+
848+ /// Return device description and idb availability for the MCP `idb_describe` tool.
849+ Future <Map <String , dynamic >> describe () async {
850+ final idbOk = await _hasIdb ();
851+ if (! idbOk) {
852+ return {
853+ 'idb_available' : false ,
854+ 'error' : 'idb not available. Install with: pip3 install fb-idb\n '
855+ 'Requires Python 3.13 at /opt/homebrew/bin/python3.13' ,
856+ 'fallback' :
857+ 'native_tap/swipe/input_text will use macOS Accessibility API instead.' ,
858+ };
859+ }
860+
861+ final udid = await _getBootedSimulatorUdid ();
862+ final scale = await _getLogicalScale ();
863+ final idbResult = await _runIdb (['describe' , '--udid' , udid]);
864+ final rawDescription =
865+ idbResult.exitCode == 0 ? (idbResult.stdout as String ).trim () : null ;
866+
867+ return {
868+ 'idb_available' : true ,
869+ 'udid' : udid,
870+ 'scale_factor' : scale,
871+ 'description' : rawDescription,
872+ 'note' :
873+ 'native_tap/swipe/input_text automatically use idb when available.' ,
695874 };
696875 }
697876
0 commit comments