Windshield is a development-only HTTP inspector for iOS. It captures URLSession traffic with URLProtocol and gives your debug build a native SwiftUI traffic viewer, directly on the device.
Windshield is maintained by ShareTheMeal.
Windshield keeps captured traffic in memory. It does not persist, upload, or print request data to the console. Common credential and cookie headers are redacted before they enter that in-memory log.
- iOS 15 or later
- Swift 5.9 or later
- Xcode 26 or later
- A Debug build
The interception core also builds on macOS 12 for package tests and tooling. The built-in inspector interface is available only on iOS.
Add the repository URL in Xcode under File → Add Package Dependencies and
select Up to Next Major Version starting at 0.6.0. A package manifest can
use:
.package(
url: "https://github.com/ShareTheMeal/Windshield-iOS.git",
from: "0.6.0"
)Add Windshield to the application target. Keep setup and presentation code inside #if DEBUG because the implementation is intentionally excluded from Release builds.
Capture and presentation are separate. Choose each call based on who creates the network session and how the app presents debug tools:
| Situation | Call | What it does |
|---|---|---|
| A framework creates the session | Windshield.start() |
Enables best-effort global interception for compatible sessions created after the call. |
| Your app creates the configuration | Windshield.start(intercepting:) |
Reliably inserts Windshield into a compatible default or ephemeral configuration before the session copies it. |
| A UIKit app needs the inspector UI | Windshield.installInspector(on:) |
Installs only the window-scoped gesture and presentation layer. It does not start capture. |
| A UIKit app wants global capture and UI | Windshield.start(on:) |
Combines best-effort global capture with installInspector(on:). |
Apps often need more than one row. For example, an app can call start() early
for framework-owned sessions, instrument every app-owned configuration before
creating its session, and install the UIKit inspector after its scene window is
ready:
#if DEBUG
Windshield.start() // Best effort for framework sessions
Windshield.start(intercepting: configuration) // Reliable for this configuration
Windshield.installInspector(on: window) // Presentation only
#endifGlobal registration cannot retrofit an existing session. Targeted interception does not register Windshield globally, and installing the inspector does not change networking.
The smallest Debug integration is one startup call and one root view modifier. This global setup is convenient when you do not control how every network session is created, but interception is best effort:
import SwiftUI
#if DEBUG
import Windshield
#endif
@main
struct ExampleApp: App {
init() {
#if DEBUG
Windshield.start()
#endif
}
var body: some Scene {
WindowGroup {
#if DEBUG
ContentView()
.windshieldInspector()
#else
ContentView()
#endif
}
}
}start() begins capture but never opens UI by itself. The modifier gives SwiftUI
a window-scoped presentation anchor. A one-second hold with three fingers on a
device, or one finger in Simulator, then opens the inspector. This split avoids
process-wide window lookup, first-responder takeover, and presenting over an
existing host modal.
If some requests do not appear, use the configuration-based setup below for the
sessions your app creates. Calling the global start() repeatedly will not
retrofit a session that already exists.
UIKit apps do not need to create a gesture recognizer, hosting controller, or presentation coordinator. Give Windshield the scene's window after assigning its root view controller:
import UIKit
#if DEBUG
import Windshield
#endif
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppViewController()
self.window = window
window.makeKeyAndVisible()
#if DEBUG
Windshield.start(on: window)
#endif
}start(on:) combines best-effort global capture with a passive, scene-scoped
inspector trigger. It never searches application windows and never presents over
an existing host modal. Call it once for each scene window that should expose
Windshield.
When the app owns its session configuration, keep the reliable targeted capture setup and install only the presentation layer on the window:
#if DEBUG
Windshield.start(intercepting: configuration)
Windshield.installInspector(on: window)
#endifInstrument a configuration before creating its session. This changes only the supplied configuration and does not register Windshield process-wide or install the inspector UI.
#if DEBUG
import Windshield
#endif
func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.default
#if DEBUG
Windshield.start(intercepting: configuration)
#endif
return URLSession(configuration: configuration)
}For framework-owned sessions, call the global form as early as possible during
app startup. Global URLProtocol registration is best effort: some networking
stacks do not consult globally registered protocols.
Windshield retains up to 100 transactions by default. You can choose another limit:
#if DEBUG
Windshield.start(intercepting: configuration, maximumTransactions: 250)
#endifValues below one are treated as one retained transaction.
The zero-argument Windshield.start() remains the normal setup. It now redacts
Authorization, Proxy-Authorization, Cookie, and Set-Cookie values before
they enter Windshield's store or copyable UI. Header names remain visible, with
the value shown as <redacted>. The original headers still reach the server.
Use Windshield.Options when an app needs additional privacy rules:
#if DEBUG
let options = Windshield.Options(
maximumTransactions: 250,
additionalRedactedHeaderNames: ["X-API-Key", "X-Session-Token"],
ignoredHosts: ["metrics.example.com"],
ignoredURLRules: [
.init(host: "api.example.com", pathPrefix: "/health"),
],
metadataOnlyURLRules: [
.init(
host: "api.example.com",
pathPrefix: "/payments",
httpMethods: ["POST"]
),
]
)
Windshield.start(intercepting: configuration, options: options)
#endifFor best-effort global registration, pass the same value to
Windshield.start(options: options) instead.
Policy behavior:
- Additional header names extend the secure default set and are matched case-insensitively.
- An ignored host matches that exact host, case-insensitively. It does not silently match subdomains.
- A URL rule matches an exact host plus an optional literal path prefix and an
optional set of HTTP methods. Set
includesSubdomains: truewhen that broader match is intentional. - An ignored request creates no inspector row and stores none of its URL, headers, or payload. The request and response still flow normally.
- A metadata-only request keeps its URL, method, redacted headers, status, timing, and known body sizes, but stores no request or response body bytes.
- Ignore takes precedence when the same request matches both rule sets.
Capture options are process-wide because a custom URLProtocol instance does
not receive its originating session configuration. Global setup applies its
options immediately; targeted setup applies them after the supplied
configuration is accepted. The most recently applied options supply the policy
for newly started requests. Each request takes one immutable policy snapshot,
so changing options never changes an in-flight transaction halfway through
capture. It also does not rewrite rows that are already in memory; clear the
inspector when changing a policy during a run.
Attach the inspector modifier once near the root of each window. Touch and hold anywhere for one second with three fingers on a device, or one finger in Simulator, to open the inspector as a sheet:
var body: some View {
#if DEBUG
ContentView()
.windshieldInspector()
#else
ContentView()
#endif
}The gesture does not request notification or motion permissions. Its recognizer is configured not to cancel or delay the app's touches and requests simultaneous recognition with the host app's gestures. Windshield disables it while VoiceOver or its own sheet is active, and ignores it while the containing window is already presenting another view.
For a debug-menu button or an app that already uses three-finger gestures, keep presentation under the app's control:
#if DEBUG
.windshieldInspector(isPresented: $isShowingWindshield)
#endifAn app can support both entry points with one sheet state:
#if DEBUG
.windshieldInspector(
isPresented: $isShowingWindshield,
trigger: .threeFingerLongPress
)
#endifIf your app already owns its sheet or navigation routing, it can present the inspector view directly instead of installing a Windshield presentation modifier:
#if DEBUG
.sheet(isPresented: $isShowingWindshield) {
WindshieldInspectorView()
}
#endifUse either the direct view or a Windshield presentation modifier for a given route so the app does not create two competing sheets.
Capture continues while the inspector is closed. Opening it reads the current in-memory snapshot and then receives live updates; no push notification or external service populates the interface.
The inspector provides:
- Live active, completed, failed, cancelled, and redirected requests
- Search by method, URL, host, path, status, and error
- All, Errors, Active, and Slow filters
- Request and response headers
- MIME-aware JSON, HTML source, XML, text, form, multipart-summary, and image views
- Search and highlighted matches within captured textual request and response bodies
- URLSession task timing, redirects, byte counts, per-attempt waterfalls, and contextual slow-host summaries
- Copy actions for URLs, headers, and bodies
- Clear confirmation and useful empty states
Windshield uses the captured Content-Type header to choose a safe body view:
- JSON is pretty-printed with stable key ordering when its estimated expanded form stays within the display budget. Deep or highly compact JSON is shown as bounded source with a notice.
- HTML and XML are displayed as source. Windshield never executes captured markup.
- Plain text, JavaScript, GraphQL, and URL-encoded forms are readable and searchable.
- Multipart forms show only part and file-part counts. Part values and filenames stay hidden.
- Declared
image/*bodies supported by ImageIO get a bounded, downsampled preview. Images with unsafe decoded dimensions are not rendered. - Unknown content falls back to JSON, then UTF-8 text, then an explicit binary summary.
- If
Content-Typeitself is redacted, Windshield fails closed and hides the body preview instead of guessing its format.
Text shown in the body viewer and data passed to custom decoders are capped at 128 KiB. Search highlights at most the first 500 matches so a debug screen cannot become unresponsive. These display limits are separate from the 1 MiB capture limit described below.
For an app-specific textual format, register a Debug-only decoder before opening the inspector:
#if DEBUG
Windshield.registerPayloadDecoder(
.init(
id: "my-debug-format",
contentTypes: ["application/x-my-format"]
) { input in
guard let text = MyDebugPayloadDecoder.decode(input.body) else {
return nil
}
return .init(text: text)
}
)
#endifMatching uses exact, parameter-free MIME types and is case-insensitive. A decoder
receives only a bounded copy of already-captured bytes plus the normalized MIME
type; it receives no URL, headers, response, session, or live transport. Its
text output is also bounded. Keep decoder work local and side-effect-free—do not
upload, persist, or log the input—and return promptly because Windshield cannot
force-cancel app-provided synchronous code. Remove a registration with
Windshield.removePayloadDecoder(id:) when it is no longer needed. Multipart and
image MIME types always use Windshield's built-in privacy and decode-safety paths
and cannot be overridden by a custom decoder.
For each intercepted task, Windshield displays the task duration and every available URL loading attempt. The compact waterfall can show DNS lookup, connection, TLS, request upload, waiting for the first response byte, and response transfer. It also distinguishes wire bytes from pre-encoding request bytes and post-decoding response bytes when Foundation reports them.
The Slow filter includes terminal requests whose observed duration is at least one second. The traffic screen also shows up to three hosts with the highest average observed latency, including sample count and maximum duration.
Some phase values are legitimately absent for cached responses, reused
connections, redirects, and failed requests. These metrics describe Windshield's
internal forwarding URLSession, not the originating session that invoked the
custom protocol. See Apple's documentation for
URLSessionTaskMetrics
and
URLSessionTaskTransactionMetrics.
Windshield records the URL, HTTP method, redacted headers, timestamps, response status, error details, available request and response bodies, and Foundation task metrics. Ignored and metadata-only rules can reduce what reaches the in-memory store.
Each body is capped at 1 MiB. The store keeps at most 20 MiB of captured body data in total. When the total budget is reached, Windshield discards payload bytes from older completed requests while retaining their metadata. A large body is always forwarded to the app in full.
Request streams are never consumed because reading them could change application behavior. The detail view marks those bodies as unavailable.
Windshield is designed for debug diagnostics, not production monitoring.
- Background
URLSessionconfigurations do not support custom URL protocols. WKWebView, Network.framework, and other transports outside URL Loading System are not captured.- Existing sessions cannot be modified after initialization.
- Global registration is best effort. Prefer
start(intercepting:)when you own the configuration. - The forwarding session cannot reproduce every originating session policy. Apps using custom proxies, cookie stores, or custom protocol chains should validate their integration.
- Authentication delegates, client certificates, certificate pinning, and custom server-trust decisions from the originating session are not forwarded. Windshield uses Foundation's default challenge handling. Preemptive
Authorizationheaders are preserved on the network and redacted in the inspector. Do not intercept sessions that depend on custom authentication or trust callbacks. - Streamed request bodies are reported but not read.
- HTML and XML bodies are source-only. Image previews accept only declared image MIME types and skip unsafe decoded dimensions.
- Task metrics reflect Windshield's forwarding session. They may differ from timing observed outside the custom protocol, and Foundation may omit individual phases.
- Attach the presentation modifier once per window. Windshield disables the gesture while VoiceOver is active. If another host or accessibility workflow uses three-finger long presses, use binding-based manual presentation instead.
- URLs, query strings, nonstandard headers, and payloads may still contain credentials or personal data. Add app-specific redaction, ignore, or metadata-only rules, and never enable Windshield in production.
Open Examples/WindshieldDemo/WindshieldDemo.xcodeproj and run either the
SwiftUI WindshieldDemo scheme or the storyboard-free WindshieldUIKitDemo
scheme. Both examples instrument an app-owned session configuration and consume
this repository as a local Swift package.
swift test
swift test -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors
xcodebuild -scheme Windshield -destination 'generic/platform=iOS Simulator' buildThe latest release is 0.6.0. Windshield follows
Semantic Versioning; while the package is below 1.0.0,
its public API may evolve between minor releases.
Windshield remains a read-only inspector and never changes an app's request or response payload. See CHANGELOG.md for release details.
Report vulnerabilities privately and never attach real captured traffic or credentials. See SECURITY.md for supported versions and reporting guidance.
Windshield is available under the MIT License. See LICENSE.