CLAUDE.md documents architecture, build flow, proxy pipeline, IPC/logging conventions, and gotchas for future Claude sessions. README gives a user-facing overview, setup steps, and stack.
7.7 KiB
CLAUDE.md
Guidance for Claude Code when working in this repository.
Project
ProxyApp — on-device iOS HTTP/HTTPS proxy with MITM decryption, inspired by Proxyman/Charles. Runs a SwiftNIO proxy inside a NEPacketTunnelProvider so the app can intercept its own device's traffic without a computer.
- Min iOS: 17.0
- Swift: 6.0 (PacketTunnel target is Swift 5 — NEPacketTunnelProvider Sendable issues)
- Strict concurrency: complete
- Device families: iPhone + iPad
Build
The Xcode project is generated by xcodegen from project.yml. Do not hand-edit ProxyApp.xcodeproj/project.pbxproj.
xcodegen generate # regenerate project after editing project.yml or adding files
xcodebuild -scheme ProxyApp -destination 'generic/platform=iOS Simulator' build
Scheme: ProxyApp (builds ProxyApp + PacketTunnel + ProxyCore).
Targets
| Target | Type | Notes |
|---|---|---|
ProxyApp |
iOS app | SwiftUI UI, hosts the PacketTunnel extension |
PacketTunnel |
App extension | NEPacketTunnelProvider — sets up proxy network settings, runs ProxyServer |
ProxyCore |
Static framework (MACH_O_TYPE: staticlib) |
Shared code used by app + extension |
Both processes share group.com.treyt.proxyapp App Group for the SQLite DB, CA material, and IPC.
Layout
App/ ProxyApp target (SwiftUI entry + app state)
ContentView.swift Adaptive: TabView on iPhone, NavigationSplitView on iPad
AppState.swift VPN control, lock state, top-level observable
PacketTunnel/ Packet tunnel extension (just wires up ProxyServer)
ProxyCore/Sources/
ProxyEngine/ SwiftNIO pipeline
ProxyServer.swift ServerBootstrap on 127.0.0.1, HTTP/CONNECT parser
ConnectHandler.swift CONNECT tunnel + plain HTTP forwarding + auto-pin fallback
MITMHandler.swift SNI sniff → leaf cert → NIOSSLServerHandler → decrypted pipeline
HTTPCaptureHandler.swift Parses decrypted HTTP, writes to TrafficRepository
GlueHandler.swift Bidirectional channel glue (local <-> remote)
CertificateManager.swift Root CA + leaf cert generation/LRU cache
RulesEngine.swift Block / SSL-proxy / map-local / DNS spoof / breakpoint matching
DataLayer/
Database/DatabaseManager.swift GRDB pool in App Group container
Repositories/*Repository.swift All DB access goes through these
Models/*.swift GRDB record structs
Shared/
IPCManager.swift Darwin notifications between app & extension
NotificationThrottle.swift Rate-limits Darwin notifications (0.5s floor)
AppGroupPaths.swift Centralized container URLs
ProxyLogger.swift os.Logger per subsystem (tunnel/proxy/mitm/cert/db/ipc/ui…)
HTTPBodyDecoder.swift gzip/br body decoding
CURLParser.swift, WildcardMatcher.swift, SystemTrafficFilter.swift
UI/
Home/ Live traffic list (GRDB ValueObservation + Darwin refresh)
Pin/ Pinned domains view
Compose/ Request composer (curl import, editor, history)
More/ Cert install, rule lists, settings, setup guide
SharedComponents/ JSONTreeView, HexView, FilterChipsView, badges, etc.
How the proxy works
- User enables VPN →
PacketTunnelProvider.startTunnelis called. - Provider sets
NEProxySettingsto route all traffic to127.0.0.1:<port>. No DNS settings — adding DNS breaks the tunnel. - Provider spins up
ProxyServer(SwiftNIOServerBootstrap) on that port. - Each incoming connection goes through:
- HTTP parser → plain HTTP: forward via
ConnectHandler, capture viaHTTPCaptureHandler - CONNECT → check rules: passthrough (tunnel bytes) or MITM (decrypt)
- HTTP parser → plain HTTP: forward via
- MITM path: sniff SNI from ClientHello →
CertificateManager.tlsServerContext(for:)→NIOSSLServerHandler→ decrypted HTTP parser →HTTPCaptureHandler→ upstream via a second NIO client withNIOSSLClientHandler. - Auto-pinning: If TLS handshake fails on a domain being MITM'd,
TLSErrorLogger(insideMITMHandler) records the domain inpinned_domainsand next time it goes to passthrough. This is how SSL-pinned apps stop breaking.
Certificate install flow
CertificateView runs a tiny Network.framework NWListener on localhost serving the root CA as application/x-x509-ca-cert, then opens Safari to http://localhost:PORT/ProxyCA.cer. This triggers the iOS profile installation flow. The CA lives in the App Group container, not Keychain.
Data flow / IPC
- App and extension both open the same GRDB pool at
AppGroupPaths.databaseURL. - Extension writes traffic →
IPCManager.shared.post(.newTrafficCaptured)(throttled) → app'sHomeViewobserves viaIPCManager.observe(...)and refreshes its GRDBValueObservation. - Config changes from the app (rules, SSL list, block list) → post
.configurationChanged→ extension reloadsRulesEnginesnapshot. - Wrap all Darwin notification callbacks in
DispatchQueue.main.asyncbefore touching@State. Not doing this crashes the app.
Logging
Use ProxyLogger (os.Logger wrapper) — never print. Pick the right subsystem:
ProxyLogger.tunnel.info("...") // PacketTunnelProvider
ProxyLogger.proxy.info("...") // ProxyServer setup
ProxyLogger.connect.info("...") // ConnectHandler
ProxyLogger.mitm.info("...") // MITMHandler
ProxyLogger.capture.info("...") // HTTPCaptureHandler
ProxyLogger.cert.info("...") // CertificateManager
ProxyLogger.rules.info("...") // RulesEngine
ProxyLogger.db.info("...") // DB layer
ProxyLogger.ipc.info("...") // IPC
ProxyLogger.ui.info("...") // SwiftUI
View extension logs: /axiom:console (xclog) or Console.app filtering on subsystem com.treyt.proxyapp.
Conventions
- All DB access goes through a repository. No
dbPool.readin UI code. - Never
try!/!force-unwrap in proxy pipeline code — crashes the extension and kills the whole VPN. - Use
Color.accentColor/Color.primary, not.accentColor(ShapeStyle ambiguity errors). - iPad adaptation: check
horizontalSizeClass == .regularinContentView— do not create iPad-only views, adapt existing ones. - No force-adding features without being asked. This codebase already has half-stubbed features (breakpoints, WebSocket capture, image preview toggle) — leave them alone unless the request is about them.
- No print/NSLog. Use
ProxyLogger. - No new files unless needed. Prefer extending existing repositories/views.
When adding a source file
Add it under the appropriate target's source directory and regenerate:
xcodegen generate
project.yml uses directory globs — you don't edit it, you just drop the file in App/, UI/, ProxyCore/Sources/..., or PacketTunnel/.
Gotchas
- Clean builds fail with "No such module NIOCore" → derived data is stale. Delete
~/Library/Developer/Xcode/DerivedData/ProxyApp-*and rebuild. - dyld "Library not loaded: ProxyCore.framework" → ProxyCore must stay
staticlib; if you changeMACH_O_TYPE, update the app/extension embed settings. - VPN starts but no traffic → check for DNS settings in
PacketTunnelProvider. There should be none. - Swift 6 concurrency errors in PacketTunnel → that target is pinned to Swift 5 on purpose. Don't "upgrade" it.
xcuserstatemodified in every commit → it's now gitignored; if it shows up again, re-add the pattern.
Airline research project
There is a separate project at ~/Desktop/code/flights that uses Frida hooks to capture airline API traffic. It is not part of this repo. The proxy app is the user-facing version of the same idea (MITM instead of Frida).