A Flipper-class UI debugging platform for iOS, in one SPM package.
QA reports: "The Pay button is visible but doesn't respond."
The screenshot looks fine. isHidden is false, alpha is 1. On device, taps go nowhere.
SpaceZ answers the question a screenshot can't: what objects is this screen actually made of, and what state is each one in?
🔴 invisible-interaction
LoadingOverlay is nearly invisible (alpha=0.01) but still receives
touches over 393×852pt.
→ Set isUserInteractionEnabled = false while invisible, or remove
the view instead of fading it out.
The browser inspector, one URL away — live tree, frame canvas, properties, and the planted bug caught by the rule engine:
In-app inspector (presentInspector() or the floating Z button) |
Browser selection highlighting live on device |
|---|---|
![]() |
![]() |
- In-app inspector — floating button → live hierarchy tree, property
inspector, search (
PayButton,alpha=0,accessibilityLabel=nil), tap-to-select, on-screen highlight. - Remote inspector — your app serves a web inspector; open a URL in any browser on the same network. Live tree with incremental updates, 2D frame canvas, property editing, device↔browser two-way selection. No desktop app to install — the "developer tool" is one embedded HTML file.
- Automated diagnostics — a rule engine that flags invisible touch-eaters, missing accessibility labels, clipped content, wrapper-view creep, sibling floods, and hidden-but-alive subtrees. Add your own rules (deprecated design-system components, experiment misconfigurations, …).
- SwiftUI semantic tree — best-effort reflection shows
ProfileCard → VStack → Text/Buttonalongside the real UIKit render tree. - Zero dependencies. DEBUG-only by construction.
Xcode: File → Add Package Dependencies → https://github.com/snailer-team/SpaceZ.git
Or in Package.swift:
.package(url: "https://github.com/snailer-team/SpaceZ.git", from: "1.0.0")Then one call:
#if DEBUG
import SpaceZ
// In application(_:didFinishLaunchingWithOptions:) or App.init:
SpaceZDebugger.start()
// Console: [SpaceZ] Inspector → http://192.168.0.42:9394/?token=a1b2c3…
#endif- Tap the floating Z button for the on-device inspector (long-press = tap-to-select).
- Open the printed URL in a browser on the same Wi-Fi for the remote
inspector. On the simulator,
localhostworks from the same Mac.
Options:
SpaceZDebugger.start(configuration: .overlayOnly) // no network server
var config = SpaceZConfiguration.default
config.captureThrottle = 0.5 // fewer captures
config.remoteRedaction = .none // ⚠️ raw text over the network
config.writablePropertyKeys = ["alpha"] // shrink the write API
SpaceZDebugger.start(configuration: config)
SpaceZDebugger.register(rule: MyDeprecatedComponentRule()) // custom diagnostics
SpaceZDebugger.register(descriptor: MyFrameworkDescriptor()) // custom UI framework
SpaceZDebugger.presentInspector() // open the panel from your own trigger
// (debug menu, shake gesture, UI test)Try it immediately: open Examples/DemoApp/DemoApp.xcodeproj — a demo app with
every bug class planted on purpose.
The library is the lecture-series design
(DFS → immutable snapshot → normalized node store → partial fetch/invalidation → adapter → diagnostics platform) implemented end to end:
┌───────────────────────── DEVICE / APP ─────────────────────────┐
│ UIKit / SwiftUI / your framework │
│ │ │
│ ┌─────▼──────────┐ NodeDescriptor adapters — core never │
│ │ Descriptors │ imports a UI framework │
│ └─────┬──────────┘ │
│ MAIN │ value copy only · p50 < 2ms for 5,000 nodes │
│ ┌─────▼──────────┐ │
│ │ CaptureEngine │──► immutable Snapshot (NodeID → node, │
│ └─────┬──────────┘ children as [NodeID]) │
│ ═══════╪═════════════ thread boundary ═══════════════════════ │
│ BACKGROUND │
│ ┌─────▼──────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Pipeline │─►│ Diff │ │ Rule Engine │ │
│ │ (latest wins) │ │ →invalidate│ │ →issues │ │
│ └─────┬──────────┘ └─────┬──────┘ └──────┬───────┘ │
│ │ ┌─────▼───────────────▼───────┐ │
│ ┌─────▼─────────┐ │ Redactor → WebSocket server │ │
│ │ Overlay (UI) │ │ (HTTP serves web client) │ │
│ └───────────────┘ └─────────────┬───────────────┘ │
└────────────────────────────────────┼───────────────────────────┘
│ getRoot / getNodes(ids) /
│ invalidate / search /
│ highlight / setProperty
┌──────────▼──────────┐
│ Browser inspector │
│ tree·canvas·props │
└─────────────────────┘
- Main thread copies values; everything else runs behind it. UIKit is main-thread-only (correctness), and the debugger must not distort what it measures (performance). Budget: p50 < 2 ms / p95 < 5 ms / hard 8 ms per 5,000-node capture. Measured on CI every run; captures that exceed the budget log a warning at runtime.
- Normalized node store, not a nested tree.
nodes[id]is O(1); children are[NodeID]. That's what makes partial fetch, search indexes, and subtree invalidation cheap — the same shape Flipper's protocol used. - Identity IDs + invalidation, not tree diffing. A full snapshot is ~1.5 MB (5,000 × 300 B); at 10 Hz that's 15 MB/s. An invalidation is tens of bytes, and the client refetches only what it shows. Stable NodeIDs (object-identity-keyed, weak) turn "diff two trees" into "which IDs changed" — a cache-invalidation problem, solved in O(N) dictionary walks.
- Latest state wins. If the UI mutates faster than a consumer drains,
intermediate snapshots are dropped (
bufferingNewest(1)). A debugger wants now, not a replay. (A future recording mode is a different consistency policy — see roadmap.) - Adapters own framework knowledge. Supporting a new UI framework is one
NodeDescriptorconformance, registered at runtime. The SwiftUI semantic tree is "just another descriptor" — proof the abstraction holds.
Measured by CapturePerformanceTests on a 5,551-node synthetic tree
(iPhone 15 Pro simulator):
| Build | p50 | p95 | vs budget (2 / 5 / 8 ms) |
|---|---|---|---|
Debug -Onone (what you debug in) |
~10.5 ms | ~12 ms | over — 250 ms throttle keeps main-thread usage at ~4% |
Optimized -O |
3.44 ms | 3.80 ms | p95 ✅, hard ✅; p50 misses the 2 ms target by 1.7× |
The runtime logs a warning whenever a capture exceeds the hard budget, so regressions surface where they happen. CI fails any PR whose p95 crosses 50 ms on shared runners, and capture-path PRs must include before/after numbers (see the PR template).
A UI inspector is a data-exfiltration surface if you're careless. SpaceZ isn't:
| Layer | Guarantee |
|---|---|
| Build | SpaceZDebugger.start() compiles to a no-op outside DEBUG — the server can't ship on by accident |
| Session | Per-launch random 64-bit token required on every HTTP route and as the first WebSocket message |
| Data | Redaction on by default for anything leaving the device: text, titles, placeholders, accessibility values → [REDACTED]. accessibilityIdentifier survives (developer-authored, needed for search) |
| Writes | setProperty accepts an explicit allowlist only (alpha, isHidden, backgroundColor by default) |
| On-device | The overlay shows raw values — the developer already sees the screen |
The semantic tree comes from Mirror reflection over the hosted view values —
no private symbols are linked. App-defined views are expanded through body
(outside SwiftUI's render context, @Environment yields defaults; structure is
accurate, resolved values may not be). When extraction fails on some OS
release, the hosting view's real UIKit subtree is still complete and the node
carries swiftUISemantics: unavailable on this OS version. Breakage on new OS
versions is treated as a normal state with a graceful fallback, not a crash.
- iOS 16.0+
- Swift 6 toolchain (Xcode 16+)
- Snapshot diffing across app versions (UI regression detection)
- Recording mode (every-event consistency, for animation debugging)
- Swizzle-based dirty tracking as opt-in instrumentation
- Accessibility audit rule pack
- Historical snapshots attached to bug reports
See CONTRIBUTING.md — including the three invariants every PR must hold (main-thread budget, redaction-by-default, zero dependencies) and how to add framework adapters and diagnostic rules.
MIT © SnailerLab


