Skip to main content

Overview

iOS Development Bridge (idb) is a versatile tool for automating iOS Simulators & Devices. It exposes a lot of functionality that is spread over Apple's tools in a consistent and human-friendly interface.

idb is made up of these components:

  • The idb cli — a Python command line interface and client library. Because it is pure Python, it can run on any OS, not just macOS: automation can drive Simulators and Devices on a remote Mac.
  • The idb_companion — a server, written in Swift, that runs on macOS next to the Simulators and Devices it automates. The cli talks to it over gRPC. It is built on the FBSimulatorControl and FBDeviceControl frameworks, which live in the same repository and can also be used on their own.
  • idb-repl — an interactive REPL that compiles Swift code and runs it inside a live process on an iOS Simulator, built on the same companion. It has its own section of this site.

The Architecture page describes how these fit together. idb is in the middle of a transition to a pure Swift codebase: the companion is already Swift, and the frameworks are progressively migrating from Objective-C — the same page describes where that migration stands.

To get going, install idb and take the guided tour; the Commands page lists the full command surface.

See it drive a simulator

Each of these is an end-to-end test, published from a run against a real simulator. The demos page has every one.

Add a control to a running app with injected Swift, then drive it

Compile a few lines of Swift on the host and run them inside a running app, adding a button the app was never built with. idb finds the new button in the accessibility tree like any other control and taps it. Its label counts the taps, and asking the same live process how many it counted gives the same answer.

  1. 1 of 7

    Add a button to a running app with injected Swift

    idb-repl launched the host app with the REPL injected, compiled the Swift on the host and ran it inside the app. The button is plain UIKit, added while the app runs.

    Added a button to com.facebook.idb.replhost
    
    Exited 0 after 11.04s; show the command and all 5 lines it printed
    $ idb-repl app --new-session 'import UIKit
    return await MainActor.run { () -> String in
      let window = UIApplication.shared.connectedScenes
        .compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
      guard let window else { return "No window to add to" }
      let button = UIButton(configuration: .borderedProminent())
      button.configuration?.title = "Taps: 0"
      button.accessibilityIdentifier = "injected-counter"
      button.addAction(UIAction { action in
        guard let button = action.sender as? UIButton else { return }
        button.tag += 1
        button.configuration?.title = "Taps: \(button.tag)"
      }, for: .primaryActionTriggered)
      button.translatesAutoresizingMaskIntoConstraints = false
      window.addSubview(button)
      NSLayoutConstraint.activate([
        button.centerXAnchor.constraint(equalTo: window.centerXAnchor),
        button.bottomAnchor.constraint(
          equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -48),
      ])
      return "Added a button to \(Bundle.main.bundleIdentifier ?? "the app")"
    }'
    Result:
    Added a button to com.facebook.idb.replhost
    

    Errors

    idb-repl: received generated interface(s):
      IDBAPI
    idb-repl: compiling injected code for arm64-apple-ios26.5-simulator
    
  2. 2 of 7

    Find the new button by its accessibility identifier

    The button is a Button labelled 'Taps: 0', 79×34 points at (161, 758). idb reads it like any control the app shipped with.

          "identifier": "injected-counter",
          "label": "Taps: 0",
        "value": "injected-counter",
    
    Exited 0 after 0.16s; show the command and all 54 lines it printed
    $ idb ui describe injected-counter --match-key AXUniqueId --api axbridge --format complete --json
    {
      "automation": null,
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [],
          "content_required": false,
          "custom_actions": [],
          "enabled": null,
          "frame": {
            "height": 34.33333333333337,
            "width": 79.33333333333334,
            "x": 161.33333333333334,
            "y": 757.6666666666666
          },
          "help": null,
          "identifier": "injected-counter",
          "label": "Taps: 0",
          "pid": 25641,
          "role_description": null,
          "subrole": null,
          "title": null,
          "traits": [
            "Button"
          ],
          "type": "Button",
          "value": null
        }
      ],
      "frames": {
        "framed": 1,
        "total": 1,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": null,
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "marker",
        "match_key": "AXUniqueId",
        "pid": null,
        "value": "injected-counter",
        "x": null,
        "y": null
      },
      "truncated": false
    }
    
  3. 3 of 7

    Tap the new button (1 of 3)

    Exited 0 after 0.22s, printing nothing; show the command
    $ idb ui tap injected-counter --match-key AXUniqueId
  4. 4 of 7

    Tap the new button (2 of 3)

    Exited 0 after 0.24s, printing nothing; show the command
    $ idb ui tap injected-counter --match-key AXUniqueId
  5. 5 of 7

    Tap the new button (3 of 3)

    Exited 0 after 0.26s, printing nothing; show the command
    $ idb ui tap injected-counter --match-key AXUniqueId
  6. 6 of 7

    Read the button's label after 3 taps

    The label reads 'Taps: 3': each tap ran the action the injected Swift attached.

          "label": "Taps: 3",
    
    Exited 0 after 0.20s; show the command and all 54 lines it printed
    $ idb ui describe injected-counter --match-key AXUniqueId --api axbridge --format complete --json
    {
      "automation": null,
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [],
          "content_required": false,
          "custom_actions": [],
          "enabled": null,
          "frame": {
            "height": 34.33333333333337,
            "width": 79.33333333333334,
            "x": 161.33333333333334,
            "y": 757.6666666666666
          },
          "help": null,
          "identifier": "injected-counter",
          "label": "Taps: 3",
          "pid": 25641,
          "role_description": null,
          "subrole": null,
          "title": null,
          "traits": [
            "Button"
          ],
          "type": "Button",
          "value": null
        }
      ],
      "frames": {
        "framed": 1,
        "total": 1,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": null,
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "marker",
        "match_key": "AXUniqueId",
        "pid": null,
        "value": "injected-counter",
        "x": null,
        "y": null
      },
      "truncated": false
    }
    
  7. 7 of 7

    Ask the running app how many taps it counted

    idb-repl attached to the same process rather than relaunching it, so the button and its count of 3 were still there.

    The button counted 3 taps
    
    Exited 0 after 0.55s; show the command and all 5 lines it printed
    $ idb-repl app 'import UIKit
    return await MainActor.run { () -> String in
      func counter(in view: UIView) -> UIButton? {
        if view.accessibilityIdentifier == "injected-counter" { return view as? UIButton }
        return view.subviews.lazy.compactMap { counter(in: $0) }.first
      }
      let windows = UIApplication.shared.connectedScenes
        .flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
      guard let button = windows.lazy.compactMap({ counter(in: $0) }).first
      else { return "The button is gone" }
      return "The button counted \(button.tag) taps"
    }'
    Result:
    The button counted 3 taps
    

    Errors

    idb-repl: received generated interface(s):
      IDBAPI
    idb-repl: compiling injected code for arm64-apple-ios26.5-simulator
    

Spin Safari's address bar, and tap it anyway

Inject Swift into Safari, one of Apple's own apps, and set its address bar spinning with Core Animation. The spin only changes what is drawn, so the accessibility tree still reports the bar exactly where it was, and idb taps it mid-spin. Then stop it.

  1. 1 of 7

    Launch Safari with Swift injected, and ask who it is

    The Swift ran inside Safari, which answers com.apple.mobilesafari. Nothing about Safari was rebuilt or re-signed.

    com.apple.mobilesafari
    
    Exited 0 after 3.56s; show the command and all 5 lines it printed
    $ idb-repl app --bundle-id com.apple.mobilesafari --new-session 'return Bundle.main.bundleIdentifier ?? "no bundle"'
    Result:
    com.apple.mobilesafari
    

    Errors

    idb-repl: received generated interface(s):
      IDBAPI
    idb-repl: compiling injected code for arm64-apple-ios26.5-simulator
    
  2. 2 of 7

    Open a page in the same Safari

    Exited 0 after 0.41s, printing nothing; show the command
    $ idb open http://127.0.0.1:PORT/docs/idb/fbsimulatorcontrol
  3. 3 of 7

    Find the address bar

    The address bar has accessibility identifier TabBarItemTitle and is 66×20 points at (168, 806).

          "identifier": "TabBarItemTitle",
          "type": "SFUnifiedTabBarItemTitleContainerView",
        "value": "TabBarItemTitle",
    
    Exited 0 after 0.45s; show the command and all 55 lines it printed
    $ idb ui describe TabBarItemTitle --match-key AXUniqueId --api axbridge --format complete --json
    {
      "automation": null,
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [],
          "content_required": false,
          "custom_actions": [],
          "enabled": null,
          "frame": {
            "height": 20.33333333333337,
            "width": 66,
            "x": 168,
            "y": 806
          },
          "help": null,
          "identifier": "TabBarItemTitle",
          "label": "Address",
          "pid": 28763,
          "role_description": null,
          "subrole": null,
          "title": null,
          "traits": [
            "TextEntry",
            "Scrollable"
          ],
          "type": "SFUnifiedTabBarItemTitleContainerView",
          "value": "‎127.0.0.1"
        }
      ],
      "frames": {
        "framed": 1,
        "total": 1,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": null,
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "marker",
        "match_key": "AXUniqueId",
        "pid": null,
        "value": "TabBarItemTitle",
        "x": null,
        "y": null
      },
      "truncated": false
    }
    
  4. 4 of 7

    Set the address bar spinning

    The Swift climbed from the address text to the bar around it and added a rotation that repeats forever.

    Spinning a SFCapsuleView, 222×48 points at (90, 792)
    
    Exited 0 after 0.73s; show the command and all 5 lines it printed
    $ idb-repl app --bundle-id com.apple.mobilesafari 'import UIKit
    return await MainActor.run { () -> String in
      func addressTexts(in view: UIView) -> [UIView] {
        if view.accessibilityIdentifier == "TabBarItemTitle" { return [view] }
        return view.subviews.flatMap { addressTexts(in: $0) }
      }
      // Safari keeps an address bar for every tab, beside the current one and
      // outside the window, so only the one drawn inside its window is on screen.
      func isOnScreen(_ view: UIView) -> Bool {
        guard let window = view.window, !window.isHidden else { return false }
        var current: UIView? = view
        while let shown = current {
          if shown.isHidden || shown.alpha == 0 { return false }
          current = shown.superview
        }
        return window.bounds.contains(view.convert(view.bounds, to: window))
      }
      let windows = UIApplication.shared.connectedScenes
        .flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
      let shown = windows.flatMap { addressTexts(in: $0) }.filter(isOnScreen)
      guard shown.count == 1, let text = shown.first, let window = text.window
      else { return "Expected one address bar on screen, found \(shown.count)" }
      // The identified view is only the address text; the bar is the widest
      // view around it that is still inset from the window'\''s edges.
      var bar = text
      while let parent = bar.superview, parent.bounds.width < window.bounds.width {
        bar = parent
      }
      let spin = CABasicAnimation(keyPath: "transform.rotation.z")
      spin.byValue = 2 * Double.pi
      spin.duration = 4
      spin.repeatCount = .infinity
      bar.layer.add(spin, forKey: "idb-spin")
      Thread.main.threadDictionary["idb-spin"] = bar
      // Safari'\''s window fills the screen, so its coordinates are the screen'\''s.
      let frame = bar.convert(bar.bounds, to: nil)
      return "Spinning a \(type(of: bar)), \(Int(frame.width))×\(Int(frame.height)) "
        + "points at (\(Int(frame.minX)), \(Int(frame.minY)))"
    }'
    Result:
    Spinning a SFCapsuleView, 222×48 points at (90, 792)
    

    Errors

    idb-repl: received generated interface(s):
      IDBAPI
    idb-repl: compiling injected code for arm64-apple-ios26.5-simulator
    
  5. 5 of 7

    Find the address bar while it spins

    It is still 66×20 points at (168, 806). Core Animation spins what is drawn, not where UIKit lays the view out, and the accessibility tree reports the layout.

          "identifier": "TabBarItemTitle",
          "type": "SFUnifiedTabBarItemTitleContainerView",
        "value": "TabBarItemTitle",
    
    Exited 0 after 0.50s; show the command and all 55 lines it printed
    $ idb ui describe TabBarItemTitle --match-key AXUniqueId --api axbridge --format complete --json
    {
      "automation": null,
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [],
          "content_required": false,
          "custom_actions": [],
          "enabled": null,
          "frame": {
            "height": 20.33333333333337,
            "width": 66,
            "x": 168,
            "y": 806
          },
          "help": null,
          "identifier": "TabBarItemTitle",
          "label": "Address",
          "pid": 28763,
          "role_description": null,
          "subrole": null,
          "title": null,
          "traits": [
            "Scrollable",
            "TextEntry"
          ],
          "type": "SFUnifiedTabBarItemTitleContainerView",
          "value": "‎127.0.0.1"
        }
      ],
      "frames": {
        "framed": 1,
        "total": 1,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": null,
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "marker",
        "match_key": "AXUniqueId",
        "pid": null,
        "value": "TabBarItemTitle",
        "x": null,
        "y": null
      },
      "truncated": false
    }
    
  6. 6 of 7

    Tap the spinning address bar

    The tap landed where the bar is laid out, and Safari gave the address field the cursor.

    Exited 0 after 0.98s, printing nothing; show the command
    $ idb ui tap TabBarItemTitle --match-key AXUniqueId
  7. 7 of 7

    Stop the spin

    idb-repl attached to the same live Safari, so this Swift found the view the first injection left in the main thread's dictionary and removed its animation.

    Stopped the SFCapsuleView
    
    Exited 0 after 0.64s; show the command and all 5 lines it printed
    $ idb-repl app --bundle-id com.apple.mobilesafari 'import UIKit
    return await MainActor.run { () -> String in
      guard let bar = Thread.main.threadDictionary["idb-spin"] as? UIView
      else { return "Nothing is spinning" }
      Thread.main.threadDictionary.removeObject(forKey: "idb-spin")
      bar.layer.removeAnimation(forKey: "idb-spin")
      return "Stopped the \(type(of: bar))"
    }'
    Result:
    Stopped the SFCapsuleView
    

    Errors

    idb-repl: received generated interface(s):
      IDBAPI
    idb-repl: compiling injected code for arm64-apple-ios26.5-simulator
    

Read and navigate web content in Safari, including off-screen elements

Read a web page's own elements, not just Safari's toolbar, and find a heading several screens below the visible area without scrolling to it. Then type a new address into Safari's address bar, submit it with a key press, and find a label drawn inside one of the new page's diagrams.

  1. 1 of 7

    Open idb's documentation in Safari

    Exited 0 after 0.51s, printing nothing; show the command
    $ idb open https://fbidb.io/docs/idb/fbsimulatorcontrol
  2. 2 of 7

    Find a single heading in the page's accessibility tree

    idb searched 1008 elements of the page and found the heading at y=5291, about 6 screens down the page. Nothing was scrolled.

              "label": "Functionality beyond Apple's tools"
              "label": "Direct link to Functionality beyond Apple's tools"
          "label": "Functionality beyond Apple's tools Direct link to Functionality beyond Apple's tools"
        "walked": 1008
    
    Exited 0 after 3.21s; show the command and all 71 lines it printed
    $ idb ui describe-all --api axbridge --format complete --match 'Functionality beyond Apple' --key AXLabel --key AXFrame --json
    {
      "automation": {
        "asserted": false,
        "enabled": true
      },
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [
            {
              "children": [],
              "frame": {
                "height": 60,
                "width": 325,
                "x": 16,
                "y": 5291
              },
              "label": "Functionality beyond Apple's tools"
            },
            {
              "children": [],
              "frame": {
                "height": 30,
                "width": 24,
                "x": 71,
                "y": 5321
              },
              "label": "Direct link to Functionality beyond Apple's tools"
            }
          ],
          "frame": {
            "height": 61,
            "width": 370,
            "x": 16,
            "y": 5291
          },
          "label": "Functionality beyond Apple's tools Direct link to Functionality beyond Apple's tools"
        }
      ],
      "frames": {
        "framed": 3,
        "total": 3,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": {
        "filter": "all",
        "ignore_case": false,
        "match": "Functionality beyond Apple",
        "match_key": "AXLabel",
        "matched": 3,
        "walked": 1008
      },
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "frontmost",
        "match_key": null,
        "pid": null,
        "value": null,
        "x": null,
        "y": null
      },
      "truncated": false
    }
    
  3. 3 of 7

    Tap the address bar by accessibility identifier

    Safari's address bar has accessibility identifier TabBarItemTitle, so idb taps it like any native control.

    Exited 0 after 5.41s, printing nothing; show the command
    $ idb ui tap TabBarItemTitle --match-key AXUniqueId
  4. 4 of 7

    Type the address of a second page

    Exited 0 after 0.75s, printing nothing; show the command
    $ idb ui text https://fbidb.io/docs/idb/accessibility
  5. 5 of 7

    Read the address bar's value back

    The address bar reads 'https://fbidb.io/docs/idb/accessibility', exactly what was typed.

          "value": "https://fbidb.io/docs/idb/accessibility"
    
    Exited 0 after 0.33s; show the command and all 57 lines it printed
    $ idb ui describe URL --match-key AXUniqueId --api axbridge --format complete --json
    {
      "automation": null,
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [],
          "content_required": false,
          "custom_actions": [],
          "enabled": null,
          "frame": {
            "height": 48,
            "width": 222,
            "x": 46,
            "y": 792
          },
          "help": null,
          "identifier": "URL",
          "label": "Address",
          "pid": 23675,
          "role_description": null,
          "subrole": null,
          "title": null,
          "traits": [
            "Scrollable",
            "TextEntry",
            "TextOperationsAvailable",
            "IsEditing"
          ],
          "type": "TextField",
          "value": "https://fbidb.io/docs/idb/accessibility"
        }
      ],
      "frames": {
        "framed": 1,
        "total": 1,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": null,
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "marker",
        "match_key": "AXUniqueId",
        "pid": null,
        "value": "URL",
        "x": null,
        "y": null
      },
      "truncated": false
    }
    
  6. 6 of 7

    Submit the address with a key press

    Exited 0 after 0.27s, printing nothing; show the command
    $ idb ui key 40
  7. 7 of 7

    Find SimulatorFrameworkBridge on the page that loaded

    The page mentions SimulatorFrameworkBridge in 7 places. The smallest is 100×8 points at (164, 4605) on a 402×874 screen: text inside a diagram, readable like any other element.

          "label": "SimulatorFrameworkBridge"
          "label": "SimulatorFrameworkBridge"
          "label": "SimulatorFrameworkBridge"
          "label": "SimulatorFrameworkBridge (guest)"
          "label": "SimulatorFrameworkBridge (guest)"
          "label": "SimulatorFrameworkBridge"
          "label": "The SimulatorFrameworkBridge guest binary was not found in the companion Resources directory"
        "match": "SimulatorFrameworkBridge",
    
    Exited 0 after 3.69s; show the command and all 110 lines it printed
    $ idb ui describe-all --api axbridge --format complete --match SimulatorFrameworkBridge --key AXLabel --key AXFrame --json
    {
      "automation": {
        "asserted": false,
        "enabled": true
      },
      "backend": "axbridge-exclusive",
      "coverage": null,
      "elements": [
        {
          "children": [],
          "frame": {
            "height": 13,
            "width": 119,
            "x": 223,
            "y": 1615
          },
          "label": "SimulatorFrameworkBridge"
        },
        {
          "children": [],
          "frame": {
            "height": 19,
            "width": 209,
            "x": 155,
            "y": 2649
          },
          "label": "SimulatorFrameworkBridge"
        },
        {
          "children": [],
          "frame": {
            "height": 19,
            "width": 209,
            "x": 31,
            "y": 3652
          },
          "label": "SimulatorFrameworkBridge"
        },
        {
          "children": [],
          "frame": {
            "height": 8,
            "width": 100,
            "x": 164,
            "y": 4605
          },
          "label": "SimulatorFrameworkBridge (guest)"
        },
        {
          "children": [],
          "frame": {
            "height": 9,
            "width": 100,
            "x": 164,
            "y": 4434
          },
          "label": "SimulatorFrameworkBridge (guest)"
        },
        {
          "children": [],
          "frame": {
            "height": 19,
            "width": 210,
            "x": 94,
            "y": 5685
          },
          "label": "SimulatorFrameworkBridge"
        },
        {
          "children": [],
          "frame": {
            "height": 19,
            "width": 798,
            "x": 32,
            "y": 5899
          },
          "label": "The SimulatorFrameworkBridge guest binary was not found in the companion Resources directory"
        }
      ],
      "frames": {
        "framed": 7,
        "total": 7,
        "zero_frame": 0
      },
      "interaction": null,
      "modal": null,
      "narrowing": {
        "filter": "all",
        "ignore_case": false,
        "match": "SimulatorFrameworkBridge",
        "match_key": "AXLabel",
        "matched": 7,
        "walked": 1274
      },
      "profile": null,
      "screen": {
        "coordinate_space": "screen",
        "height": 874,
        "width": 402
      },
      "target": {
        "kind": "frontmost",
        "match_key": null,
        "pid": null,
        "value": null,
        "x": null,
        "y": null
      },
      "truncated": false
    }