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
idbcli — 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 theFBSimulatorControlandFBDeviceControlframeworks, 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 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.replhostExited 0 after 15.09s; 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.replhostErrors
idb-repl: received generated interface(s): IDBAPI idb-repl: compiling injected code for arm64-apple-ios26.5-simulator - 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.19s; 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": 7548, "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 of 7
Tap the new button (1 of 3)
Exited 0 after 0.28s, printing nothing; show the command
$ idb ui tap injected-counter --match-key AXUniqueId - 4 of 7
Tap the new button (2 of 3)
Exited 0 after 0.26s, printing nothing; show the command
$ idb ui tap injected-counter --match-key AXUniqueId - 5 of 7
Tap the new button (3 of 3)
Exited 0 after 0.42s, printing nothing; show the command
$ idb ui tap injected-counter --match-key AXUniqueId - 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.28s; 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": 7548, "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 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 tapsExited 0 after 1.26s; 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 tapsErrors
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 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.mobilesafariExited 0 after 4.47s; 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.mobilesafariErrors
idb-repl: received generated interface(s): IDBAPI idb-repl: compiling injected code for arm64-apple-ios26.5-simulator - 2 of 7
Open a page in the same Safari
Exited 0 after 0.46s, printing nothing; show the command
$ idb open http://127.0.0.1:PORT/docs/idb/fbsimulatorcontrol - 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.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": 10163, "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 } - 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.94s; 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 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.59s; 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": 10163, "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 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 1.04s, printing nothing; show the command
$ idb ui tap TabBarItemTitle --match-key AXUniqueId - 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 SFCapsuleViewExited 0 after 0.56s; 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 SFCapsuleViewErrors
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 of 7
Open idb's documentation in Safari
Exited 0 after 0.57s, printing nothing; show the command
$ idb open https://fbidb.io/docs/idb/fbsimulatorcontrol - 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": 1008Exited 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 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 4.99s, printing nothing; show the command
$ idb ui tap TabBarItemTitle --match-key AXUniqueId - 4 of 7
Type the address of a second page
Exited 0 after 2.42s, printing nothing; show the command
$ idb ui text https://fbidb.io/docs/idb/accessibility - 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.30s; 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": 5634, "role_description": null, "subrole": null, "title": null, "traits": [ "IsEditing", "TextEntry", "TextOperationsAvailable", "Scrollable" ], "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 of 7
Submit the address with a key press
Exited 0 after 0.21s, printing nothing; show the command
$ idb ui key 40 - 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 4.11s; 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 }