Remote debugging for native Apple apps: one Swift Package that puts your iOS or macOS build under your AI tool's control on a real device or simulator.
The Apple SDK is a single Swift Package (OmniDebugLink) that adapts to the platform it compiles for:
drawHierarchy, and public-API activation for clicks.NSView tree traversal and real synthesized NSEvent input.Each build announces its own capability list on connect, so your AI tool automatically sees only the tasks the connected device can actually run. Protocol semantics align with the Flutter and Android clients: coordinates are normalized 0–1 with a top-left origin, and screenshots travel as JPEG in the __odl_file envelope.
// Package.swift .package(url: "https://github.com/omnidebuglink/omnidebuglink_apple.git", from: "0.2.0")
In Xcode this is File → Add Package Dependencies…, or a local package reference if you prefer to develop against a checkout.
import OmniDebugLink
// In AppDelegate.application(_:didFinishLaunching:) / App.init():
OmniDebugLink.start("<clientToken>")
The relay URL is built in and the app version is read from the bundle. Connection, heartbeat and reconnect with exponential backoff are handled for you.
OmniDebugLink.recordLog("order placed", level: .info)
OmniDebugLink.recordError(error)
OmniDebugLink.tasks.register("my_task", { req in ["ok": true] }, description: "...")
Handlers run on the main actor, so a custom task can touch UIKit/AppKit directly. Registered tasks re-announce the capability list automatically.
claude mcp add --transport http odl \ "https://api.omnidebuglink.dev/mcp"
Sign in once in the browser; the AI tool then drives every device under your account, including this one.
OmniDebugLink.actionsEnabled (default true) is the master switch for write operations. Set it to false for a read-only observation mode — the change is announced on connect, and toggling it re-sends the announcement automatically (or call announce() yourself).recordLog(_:level:) and recordError(_) feed the read_logs buffer. Apple platforms have no historical-log API, so only content you forward is captured — plus uncaught exceptions, which are trapped best-effort before the process exits.Available on both platform lines:
| Task | What it does |
|---|---|
echo / ping / get_stats | Connectivity basics and runtime stats. |
read_logs | 1000-entry ring buffer of forwarded logs and uncaught exceptions (nothing before the SDK started). |
prefs | NSUserDefaults / UserDefaults get / set / delete / list with valueType coercion. |
get_perf | FPS and frame-time percentiles, memory, device snapshot. |
get_state | App/version state, screen metrics, keyboard and VoiceOver status. |
The UIKit and AppKit lines share one UI task set — introspection first, then actions:
| Task | What it does |
|---|---|
ui_traverse | View tree snapshot as a flat list (3000-node cap); SwiftUI controls are flattened in as addressable pseudo-nodes. |
find_objects | Search by key (accessibilityIdentifier, recommended) / text / view_type substring plus index. |
view_component | One node in depth: Mirror-reflected properties with KVC guards and crashing getters skipped. |
wait_for | Polls every 200 ms until a match appears; timeout returns found: false, not an error. |
screenshot | JPEG via drawHierarchy (UIKit) / cacheDisplay (AppKit), with a quality-then-downsample size budget. |
ui_click | Nearest UIControl gets sendActions(.touchUpInside), otherwise accessibilityActivate(); segments and sliders infer the intended value from the click x. |
tap_screen | Activates the element at a point on iOS; on macOS a synthesized NSEvent click queued through NSApp.postEvent. |
swipe | Programmatic UIScrollView scrolling on iOS; a real NSEvent drag on macOS. |
long_press | Activation-style hold on iOS; real NSEvent press-hold-release on macOS. |
input_text | Writes into the first responder's field via the responder-chain sendAction(to: nil) trick — no private API. |
send_key | iOS: UIKeyInput soft dispatch (enter/tab/space/del/escape). macOS: real NSEvent key codes. |
set_component | Mutates text, segment_index, slider_value, switch state and similar targeted properties. |
For more on what these return and how to chain them, see UI & scene introspection and real input injection.
Targets are addressed by key / text / view_type substring with an index for disambiguation, and path as an exact fallback. Find and act happen atomically inside one task, so the tree cannot change between the two steps.
SwiftUI controls do not appear in the view subtree — they live in the host view's accessibilityElements, which the snapshot flattens into addressable pseudo-nodes. In practice .accessibilityIdentifier() does not land on those elements, so set .accessibilityLabel() on SwiftUI controls and locate them by text; ui_click then activates them through accessibilityActivate(), which drives SwiftUI Buttons and Toggles reliably. When the accessibility runtime is not active, results say so and hint at how to enable it.
iOS has no public touch-synthesis API — UITouch cannot be configured and UIEvent cannot be created, and private API would risk App Store rejection. The UIKit line therefore activates elements instead: ui_click and tap_screen use sendActions plus accessibilityActivate(), swipe performs programmatic scrolling on UIScrollView, and long_press approximates an activation hold. Free-form gesture injection is not possible there with public API, and each task's return value states plainly what was actually done.
macOS injects real events: NSEvents are synthesized in-process and queued through NSApp.postEvent, so clicks, drags, press-holds and key codes are routed exactly like user input — and no accessibility permission prompt is needed. (An earlier CGEvent.postToPid path was measured as ignored by AppKit, which is why the final design uses NSApp delivery.) AppKit's bottom-left coordinate space is converted to the protocol's top-left origin on the way out, so coordinates mean the same thing on both lines.
| Line | Status |
|---|---|
| iOS / iPadOS (UIKit) | Verified end-to-end on Xcode 14 + iOS 16.2 simulator: connection, heartbeat, replacement stop on close code 4000, all tasks, SwiftUI activation, screenshot budget. |
| macOS (AppKit) | Verified on Xcode 14 + macOS 12.5 Intel: connection and heartbeat, coordinate/screenshot consistency, NSEvent injection across all tasks, real SwiftUI control clicks, reflection guards. |
| tvOS / Mac Catalyst | Runs the UIKit-line code; not separately verified. |
cacheDisplay path.ui_click infers the segment or slider value from the click x coordinate and matches segment titles; if neither is available the task refuses honestly and points at set_component.input_text makes the target field first responder first, otherwise a following send_key has nowhere to land.