A modern, drop-in Core Data inspector for iOS / iPadOS / macOS / tvOS / Mac Catalyst apps. Boots a tiny HTTP server inside your app and exposes the contents of any NSManagedObjectContext to a desktop browser on the same local network. Inspired by the original iOS-Hierarchy-Viewer, scoped to Core Data browsing only.
The bundled web UI: sidebar entity list with search, multi-tab open entities at the top, a center records grid with sortable / resizable / reorderable / hideable columns, and a right-side detail inspector that stacks attributes, relationships, and a content viewer. Pane sizes, column preferences, and open tabs persist in localStorage.
- Read-only by default; opt in to edit + delete with
Options.readOnly = false. - Manual refresh — the web UI re-queries on demand.
- Zero UI changes in your app — open the URL printed at launch in any browser.
- Pure Swift Package, one dependency (Swifter).
Highlights:
- Sidebar entity list with row counts.
- Records grid with sortable / resizable / reorderable / hideable columns; preferences persisted in
localStorageper entity. - Per-record detail inspector — every attribute, plus a relationships summary with clickable items (browser back button works).
- Relationships sub-panel — pick a relationship of the selected row from a dropdown, click an item to navigate.
- Content viewer — pick an attribute of the selected row to read its full value.
- Multiple
NSManagedObjectContextsupport — register many in one server; the top bar shows a context switcher when there's more than one. - Manual refresh; pane sizes persisted across reloads.
- Edit mode (opt-in): edit attribute values, delete records, upload binary blobs up to 10 MB. Relationships stay read-only. Disabled by default — writes return
405untilOptions.readOnly = false.
Routes: the UI is served at / (and aliased at /lab / /index.html so old links keep working).
- iOS 15+ / iPadOS 15+ / macOS 12+ / tvOS 15+ / Mac Catalyst 15+
- Swift 5.9+
- A Core Data
NSManagedObjectContext(viewContextor a private-queue context) - Local Wi-Fi between the device and the machine running the desktop browser
- File → Add Package Dependencies…
- Paste the package URL, e.g.
https://github.com/<you>/CoreDataBrowser.git, or pick Add Local… and select theCoreDataBrowser/folder. - Add the
CoreDataBrowserlibrary to your app target.
dependencies: [
.package(url: "https://github.com/<you>/CoreDataBrowser.git", from: "1.0.0")
],
targets: [
.target(name: "MyApp", dependencies: ["CoreDataBrowser"])
]Reference the package from any consuming iOS app project via an absolute or relative path. From Package.swift:
dependencies: [
.package(path: "/Users/<you>/iOS/CoreDataBrowser")
]Or in Xcode: File → Add Package Dependencies… → Add Local… and select the CoreDataBrowser folder. Xcode writes the path as an XCLocalSwiftPackageReference in the consuming app's .xcodeproj and rebuilds the package as a sibling target.
import SwiftUI
import CoreData
import CoreDataBrowser
@main
struct MyApp: App {
let persistence = PersistenceController.shared
#if DEBUG
private let browser: CoreDataBrowserServer
@State private var browserURLs: [URL] = []
init() {
browser = CoreDataBrowserServer(context: persistence.container.viewContext)
}
#endif
var body: some Scene {
WindowGroup {
ContentView()
#if DEBUG
.onAppear {
do {
let info = try browser.start()
browserURLs = info.urls
info.urls.forEach { print("CoreDataBrowser:", $0.absoluteString) }
} catch {
print("CoreDataBrowser failed to start:", error)
}
}
#endif
}
}
}Console output on launch:
CoreDataBrowser: http://192.168.1.42:8080
CoreDataBrowser: http://127.0.0.1:8080
Open either URL from a desktop browser on the same network — the LAN address from another machine, 127.0.0.1 from the same machine (which works for the iOS Simulator since it shares the host's network stack).
var opts = CoreDataBrowserServer.Options()
opts.port = 9000 // base port; +1…+10 retried if busy
opts.bindAddress = .loopback // only reachable from the device itself
let server = CoreDataBrowserServer(context: ctx, options: opts)| Option | Default | Description |
|---|---|---|
port |
8080 |
Preferred port. If busy, port+1 … port+10 is tried. |
bindAddress |
.allInterfaces |
.allInterfaces (0.0.0.0) or .loopback (127.0.0.1 only). |
readOnly |
true |
When true, PATCH/DELETE return 405. Set to false to enable edit + delete from the web UI (attributes only; relationships stay read-only). |
var opts = CoreDataBrowserServer.Options()
opts.readOnly = false
let server = CoreDataBrowserServer(context: ctx, options: opts)When readOnly = false:
- The brand chip in the top-left flips from
read-onlytoread/write. - The detail pane gains Edit and Delete buttons. Click Edit to render typed inputs per attribute (textareas for strings, date + time pickers for
Date, file picker forBinary, etc.), then Save toPATCHor Cancel to discard. - Binary uploads are capped at 10 MB; oversize files are rejected client-side and re-rejected by the server.
TransformableandObjectIDattributes are not editable (they're shown with a "not editable" tag). - Relationships are not editable from the web UI.
- Leaving the binary file picker empty preserves the existing blob — only attributes the user actually touched are written.
- Saves run through
validateForUpdate()thencontext.save()on the context's own queue; validation errors come back as400with the underlying message.
start() returns a RunningInfo with the bound port and every URL the server can be reached at. You can surface those in your app's UI — handy on physical devices when the console isn't readable.
A single CoreDataBrowserServer can browse several named NSManagedObjectContext instances side-by-side — useful when your app has more than one Core Data stack (e.g. Main + Analytics, or separate stores per user). The web UI shows a context switcher in its top bar; clients tag requests with ?ctx=<name>.
let server = CoreDataBrowserServer(contexts: [
.init(name: "Main", context: persistence.container.viewContext),
.init(name: "Analytics", context: analytics.container.viewContext),
])
try server.start()- The first context registered is the default (used when the URL has no
?ctx=). - Duplicate names are dropped (first one wins).
- The single-context init (
CoreDataBrowserServer(context:)) still works — it just registers one context namedcontext.name ?? "default". - The active context is remembered in
localStorage["cdb.context"]across reloads, and is also written into the URL (/?ctx=Main&entity=Book&id=…) so reload/share preserves state.
If you'd rather run separate servers on different ports (e.g. one per build configuration), construct multiple CoreDataBrowserServer instances with different port values — each one finds a free port on start().
For the basic case (an inbound TCP listener on a non-privileged port), no Info.plist changes are required:
NSAppTransportSecurityexceptions apply to outbound HTTP only — the iOS app receiving HTTP requests doesn't need them.NSLocalNetworkUsageDescriptionis required for outbound mDNS /.localbrowsing, not for passive listening.
Recommended to add anyway, to keep things smooth on physical devices and prepare for future Bonjour support:
<key>NSLocalNetworkUsageDescription</key>
<string>Allow this debug build to expose its Core Data store to your local network.</string>- Never ship this in production builds. Always wrap in
#if DEBUG. - The server has no authentication. Anyone on the same Wi-Fi can read the entire store.
- With
readOnly = true(the default), assume any field can be exfiltrated. WithreadOnly = false, anyone on the LAN can also modify or delete records and upload arbitrary 10 MB blobs into binary attributes — only flip the switch on disposable debug data. - For higher safety, set
bindAddress = .loopbackand forward a port from your Mac to the device/simulator when needed. This is especially worth doing wheneverreadOnly = false.
| Method | Path | Description |
|---|---|---|
GET |
/ |
The bundled web UI (aliased at /lab and /index.html). |
GET |
/lab.{css,js} |
Web UI assets. |
GET |
/api/health |
{ ok, store, readOnly, capabilities } |
GET |
/api/contexts |
[{ name, store, isDefault }] — all registered contexts. |
GET |
/api/entities?ctx= |
All entities + counts + schema, for the named (or default) context. |
GET |
/api/entities/:name?ctx=&search=&searchAttr=&sort=&order=asc|desc&limit=&offset= |
Paginated records. |
GET |
/api/object?ctx=&id=<uri> |
Full record by NSManagedObjectID URI (URL-encoded). |
GET |
/api/object/export?ctx=&id=<uri> |
Same payload, served as a JSON download. |
PATCH |
/api/object?ctx=&id=<uri> |
Body {"attrs": {<name>: <value>, ...}}. Only listed attributes are touched; relationships and Transformable/ObjectID are refused. Binary values are base64 strings (≤ 10 MB). Returns the freshly-saved record. Disabled unless readOnly = false. |
DELETE |
/api/object?ctx=&id=<uri> |
Returns {"ok": true, "id": "<uri>"}. Disabled unless readOnly = false. |
ctx is optional on every data endpoint — omit it to query the default (first-registered) context. An unknown context name returns 404 { "error": "Unknown context: <name>" }. Other errors are JSON { "error": "<message>" } with status codes 400 / 404 / 405 / 500.
- Can't reach the URL from my laptop. Both devices must be on the same network, and the network must not isolate clients (some hotel/guest Wi-Fis do). Use the IP printed for the
en0interface; ignore IPv6. - Port already in use. The server retries
port + 1up toport + 10. If all are busy,start()throws — pass a different base port. - Refreshing doesn't show my latest changes. The server reads through the context you handed it. If your app writes on a private background context that doesn't merge into
viewContext, hand the background context to the server, or fix your merge policy. - Simulator: only
127.0.0.1works. The simulator shares the Mac's network stack — that address works from the host machine. From another machine on the LAN, use the Mac's LAN IP. - VPN / iCloud Private Relay. Can route LAN traffic unexpectedly. Disable for testing.
MIT — see LICENSE.
