A comprehensive iOS debugging toolkit that helps you cut through bugs in your iOS app. Scyther provides tools for developers, QA testers, UI/UX teams, and backend developers. Made with love in Sydney, Australia.
- Features
- Requirements
- Preferences Storage
- Installation
- Quick Start
- Detailed Usage Guide
- Feature Flags
- Server Configuration
- Network Logging
- Traffic Stats
- Request Overrides
- Scyther's Own Traffic
- Request Replay
- Breakpoints
- Network Conditioning
- Console Logging
- Crash Logging
- Database Browser
- Data Browsers
- Location Spoofing
- Push Notification Testing
- UI Debugging Tools
- Custom Developer Options
- Environment Variables
- Menu Invocation
- API Reference
- FAQ
- Contributing
- License
- Display device model, OS version, and hardware details
- Show bundle identifier, app version, and build number
- Display process ID and release type (Debug/TestFlight/App Store)
- Show build date and app ID prefix
- Network Logging: Automatically intercept and log all HTTP requests/responses
- Request Details: View headers, body, timing, and response codes
- cURL Export: Generate cURL commands for any captured request
- Log Export: Share the captured requests as a zip containing a HAR 1.2 file, raw bodies, and a cURL command per request, with best-effort redaction and a sensitivity warning
- Filter Chips: Narrow the network log by method, status class, host, content type, API kind, GraphQL operation, duration, exact status code, or recency from glass chips pinned above the list, or edit every filter at once from the all-filters sheet
- Traffic Stats: A chart button on Network Logs opens the figures for whatever the list is showing — failure rate, median and 95th percentile duration, bytes received, the slowest endpoints, a per-host breakdown, and a waterfall preview zoomed to the most recent handful of requests with a tappable row per one of them beneath it — use the header's See all link to open the full page, anchored on the newest traffic at half the log's span (the whole span instead, on a log short enough that the two are the same thing), where the same strip now carries a zoomable, draggable window over a detail list: pinch to zoom, drag the strip to move the window, an accessibility-adjustable action for VoiceOver and Switch Control, and a tappable row per request that opens its details — tapping the strip itself never does, there it only moves the window
- Request Overrides: Mock responses, serve local files, rewrite headers, and add latency, throttling or random failures to matching requests — combined on one override — from the menu or from code
- Save as Mock: Turn any captured response into a disabled mock override in one tap, and import a HAR file as a whole set of them
- Request Replay: Reopen any captured request in an editor, change its method, URL, headers or body, and send it again — the resent request is logged with a
REPLAYbadge and listed on the original with its status, duration and size deltas - Breakpoints: Hold a matching request before it is sent, or a matching response before the app sees it, edit method, URL, headers, status or body in place, then continue or fail it with an error of your choosing — every hold has a timeout, and none of it ever runs during a test
- Network Conditioning: Degrade every intercepted request at once — latency, a bandwidth ceiling and a failure rate, with the presets Network Link Conditioner made familiar
- Server Configuration: Switch between development, staging, and production environments
- IP Address: Display the device's public IP address
- Feature Flags: Register and override feature flags at runtime
- UserDefaults Browser: View and modify UserDefaults values
- Cookie Browser: Inspect and manage HTTP cookies
- Keychain Browser: View keychain items (read-only for security)
- File Browser: Browse app sandbox (Documents, Library, Caches, tmp)
- Database Browser: Browse SQLite, CoreData, and SwiftData databases with full CRUD support
- Location Spoofing: Fake GPS coordinates for testing location-based features
- Preset Locations: 20+ major cities worldwide
- Custom Locations: Set any coordinate manually
- Route Simulation: Simulate movement along predefined routes
- Deep Link Tester: Test custom URL schemes and universal links with QR scanner
- Crash Logging: Capture and view uncaught exceptions on subsequent app launches
- Push Notification Tester: Schedule local test notifications
- Notification Logger: View received notification payloads
- Token Display: View APNS and FCM device tokens
- Grid Overlay: Display alignment grid over your UI
- Layout Guides: Draw the key window's safe-area insets and layout margins over your UI
- Layout Ruler: Drag to measure between two points, snapping each end to the nearest view edge — it takes over every touch on screen while active, so tap Done to get the app back
- FPS Counter: Real-time frame rate overlay with color-coded performance indicators
- Touch Visualizer: Show touch points for demos and recordings
- Accessibility Audit: Walk the live accessibility tree for missing VoiceOver labels, undersized touch targets, and low-contrast text, with a live on-screen overlay for the two checks that cost nothing
- View Frames: Highlight view boundaries with colored borders
- View Sizes: Display view dimensions as labels
- View Hierarchy: Browse a snapshot of the key window's view hierarchy — every view's class name, size, and badges for hidden, zero-size, and off-screen — searchable by class name or the text a view carries, with each view's geometry, appearance, owning controller, and responder-chain context a tap away. It is read-only and only ever shows what pulling to refresh last captured, not the app as it is right now, and it skips Scyther's own views, so the tree is the host app's hierarchy and nothing else. Text search reaches UIKit-backed text only: SwiftUI draws a
Textrather than holding it in a label, so a navigation bar title matches and body text does not - Slow Animations: Reduce animation speed for debugging
- Appearance Overrides: Force dark/light mode, high contrast, and Dynamic Type sizes
- Font Browser: View all available system fonts
- Interface Previews: Browse registered UI components
- Language: Force the app's language from the debug menu (applies on next launch; Scyther's own menu switches immediately)
- Pseudo-localisation: Accent, lengthen, flip to RTL, or show catalog keys, to find layout problems before a translator is briefed — see Pseudo-localisation for exactly which strings it can and cannot reach, and when RTL takes effect
- Console Logger: Capture and view stdout/stderr output
- Custom Options: Add your own debug options to the menu
- iOS 16.0+
- Xcode 16+
- Swift 6.0+
Scyther is fully compatible with Swift 6 strict concurrency checking. The library uses modern Swift concurrency patterns throughout:
| Component | Isolation | Notes |
|---|---|---|
Scyther |
@MainActor |
Main entry point, UI presentation |
Scyther.servers |
actor |
Thread-safe server configuration |
NetworkLogger |
actor |
Thread-safe request logging with AsyncStream |
Scyther.featureFlags |
@MainActor |
Feature flag management |
Scyther.network |
@MainActor |
Network facade |
Scyther.console |
@MainActor |
Console capture facade |
Scyther.crashes |
@MainActor |
Crash logging facade |
Scyther.interface |
@MainActor |
UI tools facade |
Scyther.location |
@MainActor |
Location spoofing facade |
Scyther.localization |
@unchecked Sendable |
LanguageOverride, lock-guarded language override facade |
The Servers subsystem is an actor, requiring await for all access:
// Register servers (requires await)
await Scyther.servers.register(id: "dev", variables: ["API_URL": "https://dev.api.com"])
// Access current configuration (requires await)
let currentServer = await Scyther.servers.currentId
let apiURL = await Scyther.servers.variables["API_URL"]Key public types conform to Sendable for safe cross-actor usage:
ServerConfiguration- Server environment configurationLocation- GPS coordinate dataRoute- Location simulation routesConsoleLogEntry- Captured console outputCrashLogEntry- Captured crash data
Scyther uses nonisolated properties for UserDefaults-backed settings to avoid actor hop overhead in hot paths. This ensures the debugging tools don't impact your app's UI performance.
Two settings are read often enough to be worth more than that, and both are held in memory rather
than read from UserDefaults per use:
- The pseudo-localisation switches. Every string Scyther resolves asks whether a text mode is
on, which is about 1,500 questions for one render of the main menu. They are cached and
invalidated whenever anything in the process writes to
UserDefaults, so the cache cannot disagree with what is persisted — including after Reset all Scyther settings, which removes the whole suite. In the Simulator each of those reads costs about 2 ms, because it goes tocfprefsd, which checks the app's entitlements against the code signature on disk. - The menu's own layout. A row's section tint is a dictionary lookup, the section list and the search index are resolved once and held, and the fixed device and application facts a value row shows — including the App ID Prefix, a keychain query — are snapshotted once per menu rather than read inside every row's body.
Scyther's own interface is available in twelve languages besides English: French, German, Spanish, Italian, Brazilian Portuguese, Dutch, Japanese, Simplified and Traditional Chinese, Korean, Russian, and Arabic. It picks one the same way any app does — from the language the user is running — so a developer on a French device opens a French debug menu without configuring anything. Arabic lays the menu out right to left.
Every non-English string was machine-authored and is marked needs_review in the catalog
rather than translated. Treat them as a working draft until a native speaker has approved
them.
UI/UX → Language lists your app's own localisations and switches between them, so a tester can check a screen in Japanese without changing the device language or reinstalling.
The switch takes effect app-wide on the next launch, so the page offers to quit — Later or Quit App, and Scyther never quits on its own. Scyther's menu switches immediately, which means it can briefly be in a different language than the screen behind it. That is expected: iOS reads the app's language once at launch, and no app can retranslate views already on screen without restarting.
The override is also available programmatically, as Scyther.localization (LanguageOverride):
Scyther.localization.setPreferredLanguage("fr") // forces French app-wide on next launch
Scyther.localization.preferredLanguage // "fr", or nil for the device default
Scyther.localization.availableLanguages // the host app's declared localisations
Scyther.localization.reset() // back to the device languageScyther.notifications.scheduleTest(title:body:delay:sound:incrementBadge:) takes optional
title and body; omitting either (or passing nil) falls back to Scyther's own localised
sample notification copy instead of English placeholder text.
The example app at Example/ScytherExample ships its own
Example/ScytherExample/Resources/Localizable.xcstrings, so it is fully localised in the same
languages independently of the package's own catalog.
UI/UX → Pseudo-localisation renders the interface with copy that behaves like a translation without being one, to find layout problems before any translation exists. Four modes, all off by default and all combinable:
| Mode | What it does | What it finds |
|---|---|---|
| Accented | Hello becomes Ĥéļļö |
Text still in plain ASCII was never localised |
| Lengthened | [Hello··], about 135% of the original |
Clipping and truncation |
| Right to Left | Mirrors the layout | Hard-coded leading/trailing assumptions |
| Show Keys | Renders Selected %lld items instead of Selected 5 items |
Which catalog entry produced a piece of copy |
The brackets Lengthened adds are the point of it: a label missing its closing ] was truncated,
which is easier to see than judging whether accented text looks a few characters short.
A fifth switch, Show Boundaries, decides whether those brackets are drawn, and it is the one switch that ships on. The padding dots already say a string grew; only the closing bracket says whether the end of it was cut off, which is the thing Lengthened exists to reveal — so the brackets stay by default, and the switch is there for when you want the expansion without the punctuation. It transforms nothing itself, so with every text mode off it does nothing at all.
Scyther's own interface is always transformed, because every string in the package is resolved
through localized(_:) and the transform sits in that path.
Your app is a different question, and the honest answer depends on how your code loads its strings:
NSLocalizedStringis reached. It is a thin wrapper over-[NSBundle localizedStringForKey:value:table:], an Objective-C method, which Scyther swizzles while a text mode is on. That covers UIKit apps, storyboard and XIB strings, and Swift code written the traditional way.String(localized:),LocalizedStringResourceand SwiftUI'sText("Some key")are not reached. This was measured, not assumed: with that method hooked, none of those paths ever reached the hook — including aTextrendered all the way to a bitmap withImageRenderer. Foundation's Swift-native lookup does not go throughNSBundleat all, and no other selector on the class sees them either.
So on a SwiftUI app whose copy is written as Text("…"), the three text modes pseudo-localise
Scyther's own menu and nothing else. That is a demonstration of the idea against a real localised
SwiftUI interface, not a test of your screens. On a UIKit or NSLocalizedString-based app it is a
test of your screens.
Right to Left is a different mechanism, and it is a next-launch setting. It changes no text, so it does not care how your copy is loaded — and nothing at all happens in the session where you flick the switch:
| Surface | When it mirrors | Checked on a device |
|---|---|---|
| Your app's UIKit views | On its next launch | yes |
| Your app's SwiftUI views | On its next launch | yes |
| The Scyther menu and every page in it | On its next launch | yes |
Switching it off unwinds the same way: everything comes back left-to-right on the launch after you switch it off. Both directions were checked on a device — relaunched with the keys present, and relaunched with them absent.
Because nothing takes effect until then, the toggle raises the same Relaunch required alert the Language page raises, with Later and Quit App. This is exactly how Xcode's own Right to Left Pseudolanguage scheme option behaves.
Earlier versions mirrored Scyther's own menu the moment the switch moved. That is deliberately gone:
anything that changed mid-session disagreed with something that had not, and UIKit answers a
disagreement by mirroring text that has already been laid out — which draws it backwards, Fonts as
stnoF. Waiting for a relaunch is the price of never seeing that.
How it works. Scyther writes AppleTextDirection and
NSForceRightToLeftWritingDirection into your standard UserDefaults — the two keys Xcode's own
Right to Left Pseudolanguage scheme option passes on the command line, which iOS resolves
before any view exists and which reach UIKit and SwiftUI alike. It is the same move the Language
page already makes with AppleLanguages.
Switching the mode off removes both keys rather than setting them false, so your app goes back to exactly the state it was in before Scyther was asked, on its next launch. Nothing is written on an App Store build, and a stale key from a TestFlight build is cleared rather than kept.
An earlier version tried to mirror your app through UIView.appearance() instead. It is gone: the
appearance proxy stamps a view once, as the view joins a window, and never revisits it, so
switching the mode off could not undo the views it had already stamped — and a leftover stamp
disagreeing with SwiftUI's own layout direction makes UIKit mirror text that has already been laid
out, which renders it backwards (Fonts as stnoF). It also never reached a SwiftUI view at all.
The defaults keys do what it was reaching for, at the only moment it can be done properly.
The rule Scyther holds itself to here is that it must never produce broken text that is not a localisation problem. A mangled link or a plural that stops expanding is not a finding; it is a defect the developer will spend an afternoon chasing in their own code.
- Off by default, persisted under
Scyther_pseudo_localization_*inUserDefaults.scyther. Show Boundaries is the one exception: it reads as on when nothing has been stored for it, so an existing install behaves exactly as it did before the switch existed. - The swizzle is installed only while a text mode is on and removed when the last one is switched off. An app that never opens the page never has its string loading touched.
- Only
Bundle.mainis transformed, so UIKit's own "Cancel" and "Done" are left alone — and within it, only the defaultLocalizabletable. A table your app named on purpose is left alone too, because teams routinely keep things there that are per-locale but are not copy: analytics identifiers, feature-flag names, segment keys. The cost is real and worth knowing: if your copy lives in a named table, the text modes will not reach it. .stringsdictplurals are left alone entirely. A plural format resolves to%#@count@ itemscarrying configuration that Foundation expands later; Scyther can preserve neither the variable name through accenting nor the attached configuration through any transform at all, so it returns the string Foundation produced, untouched, whatever the modes say. A plural label is therefore one of the few places pseudo-localisation shows nothing.- Accenting preserves anything that is not copy: format specifiers (
%@,%lld,%1$@,%.2f),.stringsdictvariables (%#@count@), brace placeholders ({name}), and URLs and email addresses.https://example.comstays a working link rather than becomingĥţţþš://éẋåɱþļé.çöɱ. - Nothing is installed on an App Store build or under XCTest. That guard sits on the swizzle and on the defaults write themselves, not only on their caller, and an App Store build honours no persisted mode. The one thing an App Store build still does is remove a right-to-left key left behind by a TestFlight build, which is the safe direction to err in.
- The text of the Pseudo-localisation page and its menu row is never transformed, so the modes can always be switched off — there is a Turn Everything Off button, and a Sample row that shows what the modes do on the one page where they do not apply. Right to Left flips this page along with the rest of the app after a relaunch; the text stays legible, which is what the exemption is for.
- Right to Left applies on the next launch and nothing changes before then, in either direction;
see the table above. It writes two keys into your app's standard
UserDefaultsto do it, and removes them when switched off. The toggle raises a Relaunch required alert so this is never a surprise.
Strings live in per-module fragments under Scripts/localization/strings/, which
Scripts/localization/build_catalog.py merges into the shipped String Catalog. Edit the
fragment and re-run the generator rather than editing the catalog directly — CI regenerates
it and fails on any drift.
To add a string, call localized("Your English text") at the call site and add the key with
every supported language to that module's fragment. To add a language, list its code in the
generator's LANGUAGES and in ScytherLocalization.supportedLanguages, then fill in every
fragment. A test fails the suite if a SwiftUI literal bypasses localized(_:), and another
fails if any key is missing a language.
Scyther never writes to UserDefaults.standard. Every setting it persists — feature flag
overrides, pinned menu items, spoofed locations, grid overlay configuration, appearance
overrides and the rest — lives in a private suite named com.scyther.settings.
This matters because apps commonly clear their own defaults when a user signs out:
// Both of these wipe the application domain only.
UserDefaults.standard.dictionaryRepresentation().keys
.forEach(UserDefaults.standard.removeObject(forKey:))
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)A named suite is a separate persistent domain, so neither call touches Scyther's state. Your debugging setup survives sign-out.
The store is exposed if you need it directly:
UserDefaults.scyther.bool(forKey: "Scyther_grid_overlay_enabled")Versions before this change stored settings in UserDefaults.standard. The first time
Scyther's store is accessed it moves every key prefixed scyther (case-insensitive) out of
the standard store and into the suite, skipping any key the suite already has a value for,
then records that it has done so. Existing overrides and preferences carry across
automatically, and your app's standard domain is left cleaner than it was. This prefix match
is the one place migration touches data it did not write itself: a key your own app happens
to store under a scyther-prefixed name would also be moved.
You can inspect and edit the suite from Data → UserDefaults, using the store picker at the top of the screen.
Add Scyther to your Package.swift:
dependencies: [
.package(url: "https://github.com/bstillitano/Scyther.git", branch: "main")
]Or in Xcode:
- Go to File > Add Package Dependencies
- Enter the repository URL:
https://github.com/bstillitano/Scyther.git - Select the
mainbranch
import Scyther
@main
struct MyApp: App {
init() {
// Start Scyther (automatically disabled on App Store builds)
Scyther.start()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}For UIKit apps:
import Scyther
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Scyther.start()
return true
}
}Scyther.start() only does anything the first time it is allowed to run. Later calls are ignored,
so calling it from more than one place is safe. A call refused on an App Store build does not
count, so a later Scyther.start(allowProductionBuilds: true) still starts Scyther.
Once started, shake your device (or press Control + Command + Z on a hardware keyboard or in the simulator) to open the Scyther debug menu.
You can also open it programmatically:
Scyther.showMenu()Register feature flags from your remote configuration system and allow developers to override them locally.
// After fetching your remote config
Scyther.featureFlags.register("new_checkout_flow", remoteValue: true)
Scyther.featureFlags.register("dark_mode_v2", remoteValue: false)if Scyther.featureFlags.isEnabled("new_checkout_flow") {
showNewCheckoutFlow()
} else {
showLegacyCheckoutFlow()
}// Allow users to toggle flags in the Scyther UI
Scyther.featureFlags.localOverridesEnabled = true
// Programmatically set a local override
Scyther.featureFlags.setLocalValue(true, for: "dark_mode_v2")
// Clear a single override, reverting the flag to its remote value
Scyther.featureFlags.clearLocalValue(for: "dark_mode_v2")
// Clear every override at once
Scyther.featureFlags.clearAllLocalValues()In the Scyther UI, each flag is controlled by a True / False / Remote dropdown menu.
Choosing Remote clears that flag's local override so it follows its remote value, and the
Reset all to Remote button clears every override in one tap. The Enable overrides toggle
at the top gates whether these local values are applied by isEnabled(_:).
The flag sections and the reset button are only shown while Enable overrides is on. With overrides off, local values have no effect, so the list and the reset button are hidden; the search field remains visible either way.
Toggles can be pinned via a left swipe. Pinned toggles appear in a Pinned section at the top of the list and also remain in the main list. Pins persist across launches in Scyther's private preferences suite.
Scyther.featureFlags is @MainActor-isolated, but you can read a flag's developer
override from any thread or actor via localOverride(for:). It is nonisolated because it
reads only UserDefaults-backed state. It returns nil when global overrides are off or the
flag was never overridden — meaning "use your own value":
// Safe from a background context — no main-actor hop required.
let override = Scyther.featureFlags.localOverride(for: "dark_mode_v2")
let darkModeV2 = override ?? myRemoteConfig.darkModeV2 // override wins when presentfor flag in Scyther.featureFlags.all {
print("\(flag.name): remote=\(flag.remoteValue), local=\(flag.localValue)")
}Switch between different backend environments without recompiling.
await Scyther.servers.register(id: "development", variables: [
"API_URL": "https://dev-api.example.com",
"WEBSOCKET_URL": "wss://dev-ws.example.com",
"DEBUG_MODE": "true"
])
await Scyther.servers.register(id: "staging", variables: [
"API_URL": "https://staging-api.example.com",
"WEBSOCKET_URL": "wss://staging-ws.example.com",
"DEBUG_MODE": "true"
])
await Scyther.servers.register(id: "production", variables: [
"API_URL": "https://api.example.com",
"WEBSOCKET_URL": "wss://ws.example.com",
"DEBUG_MODE": "false"
])// Get current server ID
let currentServer = await Scyther.servers.currentId
// Get a specific variable
let apiURL = await Scyther.servers.variables["API_URL"]
// Get all variables for current server
let allVars = await Scyther.servers.variablesImplement the ScytherDelegate to respond when users switch servers:
class AppCoordinator: ScytherDelegate {
init() {
Scyther.delegate = self
}
func scytherDidSwitchServer(to serverId: String) {
// Reconfigure your networking layer
APIClient.shared.configure(with: serverId)
// Clear cached data
CacheManager.shared.clearAll()
// Optionally restart the app or re-authenticate
AuthManager.shared.refreshToken()
}
}All HTTP requests made through URLSession are automatically intercepted and logged.
// Get the device's public IP address
let ip = await Scyther.network.ipAddress
print("Device IP: \(ip)")Network requests are displayed in the Scyther UI under Network Logs. Each request shows:
- URL and HTTP method
- Request/response headers
- Request/response body — both a structured Browse view (JSON tree) and a raw View view
- Status code and timing
- cURL command for reproduction, shareable from the export button in the navigation bar or the "Export cURL request" row at the bottom of the page
Above the list, a row of filter chips narrows the log: Method, Status, Host, Type, API, GraphQL, Duration, Code, and Recency. Tapping a chip opens a sheet with a multi-select checklist; selections apply immediately and combine with the search field. Active chips are fully tinted and show the selected value, or a count when several are selected; the Clear chip is red. Method, host, and exact status code options are built from the captured requests, so you only ever choose from values that exist. The Host list has an Include / Exclude control in its header, so the selected hosts can act as an allow list or a deny list. An icon-only chip at the start of the row opens a Filters sheet listing every dimension, grouped into Request, Response, and Timing, with its current selection; each row pushes that dimension's checklist. A Clear chip appears whenever a filter is active.
The export button in the navigation bar shares the requests currently shown (so search and
filters narrow the archive) as <App-Name>-Network-Log-<timestamp>.zip, named after the host app. The zip holds a HAR 1.2
file that opens in Charles, Proxyman, or Chrome DevTools, plus a folder per request with the raw
request body, raw response body, and a cURL command. The export sheet shows progress while the
zip is built, and a Redaction toggle (on by default) replaces common tokens, cookies, passwords,
and API keys in headers, URLs, bodies, and cURL commands with REDACTED, as a best-effort attempt
rather than a guarantee. Because the archive can include full headers, cookies, tokens, and
bodies, tapping Export shows a sensitivity alert before the system share sheet opens.
GraphQL operations are detected automatically — either by the request body shape (a JSON
payload with a query field) or by a graphql/gql URL path. When a request is recognised
as GraphQL:
- Request list: the row shows the operation name with a coloured query / mutation /
subscription lozenge, instead of just the shared endpoint URL (the URL moves to a subtitle).
Batched operations show as
Batch (N operations). - Request details: a dedicated GraphQL section displays the operation name, type, and a
Browse variables link that opens the structured data browser on the operation's
variables. - Search: the search field matches the GraphQL operation name in addition to URL, status code, and method.
Detection covers POST JSON request bodies; GraphQL-over-GET / persisted queries are not
currently detected.
The Browse request body, Browse response body and Browse variables links open a
structured data browser that drills into nested arrays and dictionaries. Its chrome is
localised — the title, the search prompt, the empty-section text, the Copy action, the
Array and Dictionary value labels and the Array Data / Dictionary Data headers of a
nested level — while the payload itself is shown exactly as it was sent or received: keys,
values, array indices, true / false / null and JSON text are never translated.
Network logs are automatically cleaned up to prevent disk bloat:
- 7-day retention: Log files older than 7 days are automatically deleted on app startup
- Manual cleanup: Clearing logs via the UI also deletes all associated files from disk
- Files managed:
SessionLog.log, request body files, and response body files
The chart button in the Network Logs toolbar opens Traffic Stats: what is slow, what is failing, and what was happening at the same time as what. Everything is computed from the requests already in memory, so it adds no capture, no storage and no cost to the request path.
The screen describes the list you were looking at. The log's search field and filter chips
narrow the requests before the figures are computed, so filtering to one host turns the summary
into that host's summary. A caption naming which it is — 21 of 340 requests — appears above the
figures only when a filter is on; unfiltered, it would only restate the first row below it
(Requests 8), so the section shows no caption at all rather than one saying nothing new.
| Row | Meaning |
|---|---|
| Requests | Every request in the filtered set, stubbed or not |
| Stubbed | How many of them a request override answered without touching the network |
| Failures | Requests that came back at 400 or above, plus loads that ended with no response at all |
| Failure rate | Failures over the requests that actually went to the network |
| Pending | Requests still in flight |
| Median / 95th Percentile | Round-trip duration, nearest rank |
| Fastest / Slowest | Shown instead of the percentiles below five completed requests |
| Bytes Received | Total response body length received over the network, whatever the bytes are |
| Elapsed | Wall-clock time from the first measured request starting to the last one finishing |
Pending and failed are exclusive. A load carries a response date whether or not a response arrived, so a request that ended in an error is a failure and one that has not come back yet is pending. Neither is counted as a zero-duration completion, which would flatter every latency figure.
Stubbed responses are counted but never measured. A response a request override synthesised never left the device: its duration measures Scyther rather than the server, and its status code was authored rather than returned. Averaging it into "slowest endpoints" or an error rate would make both lie, so a stub is counted in Requests and Stubbed and left out of every duration, failure and byte total, and out of the host and endpoint breakdowns entirely — Elapsed included, so a stub answered an hour after the last real request does not report an hour of network activity that never happened.
Percentiles use the nearest rank, not interpolation, so every duration reported is one a request actually took. Below five completed requests there are no percentiles at all — a median of three samples is noise — and the summary shows the fastest and slowest round trips instead.
Every request is placed on a shared seconds axis, oldest first: entries that overlap were in flight at the same time, and a staircase means the calls were serialised. A request that has not come back yet runs to the end of the axis, which is the moment the series was built, because its real end is not known. A request that failed is drawn for as long as it actually ran, not as one still running.
The overview strip draws whichever slice of the log it is given, every request in it as a short
line positioned by when it happened and coloured by outcome — succeeded, failed, pending or
stubbed — with one Canvas pass rather than a view per request, so a thousand-request log costs
the same as a ten-request one. A line too thin to see is still floored to one point wide so it can
be found.
On the Traffic Stats section the strip is zoomed to the most recent
TrafficStatsViewModel.recentWaterfallCount requests (five, chosen so every bar reads as its own
request rather than a hairline, short enough to sit above the fold, and small enough that "most
recent" reads as obviously true of what's on screen) rather than the whole log compressed onto one
axis — the drawn range is those few, not the whole log with a subset merely marked on it — with
those same requests listed as tappable rows beneath it, each opening its own log detail. This
replaced an earlier version that drew the whole log the same way the full page's minimap does: at
real request counts it read as an unreadable scatter of specks, telling a reader rough shape and
nothing about what had just happened, which is exactly what this section exists to answer. The
strip carries no tap of its own here any more — the rows beneath it already give more precise
navigation than "centred near where you tapped" ever did, and reusing that mapping unchanged would
have silently centred the full page on the wrong moment once the strip stopped drawing the same
span the page does. The section's existing caption — the count, the span and how many distinct
hosts were touched — is unchanged and still describes the whole log, kept as context beneath a
preview of only its most recent few. The header's See all link still opens the full page,
unchanged, anchored on the newest traffic, at half the log's span — the whole span only when
the zoom floor already sits above that half, which is also the case a log too short to zoom at all
always produces, before an hour-long capture with two short bursts of traffic showed every bar
flooring to the same three points regardless of whether the request took 43ms or 1.06s, and —
later — an ordinary log opening on a single legible request showed the first fix for that had
swung too far the other way.
See all shows the same strip, now also marking the current window — the reader zooms and drags this one, and it is already drawn narrower than the whole strip the moment the page opens on any log long enough to need it. Fitting the whole session into one screen width, or scrolling a plot wide enough not to, are both a scroll answer to what is really a zoom problem: against a 300 second log of requests between 32 ms and 1.4 s, either one either floors every bar to the same sliver or hands the reader a plot thousands of points wide to pan by hand.
The minimap — the strip with the colour legend beneath it, no divider between them — is fixed above
the page rather than scrolling with it: it sits outside the detail list entirely, as a sibling
above it, hand-styled to still read as one of the list's own inset-grouped sections (same
background material, same corner radius, the same horizontal margin the list's own sections use)
so the change is meant to be invisible apart from the stickiness, with a gap below it before the
list begins tuned by eye rather than borrowed from any system metric. A List cannot pin a
Section's own content — only .plain pins section headers, and this list is .insetGrouped —
which is why the minimap sits outside it rather than inside as a fixed section. Dragging the strip moves the
window anywhere in the log in a single gesture, tracked from the very first touch — the strip no
longer shares a scroll view with anything, so nothing needs to be told apart from a scroll any
more — the strip only ever moves the window, it never opens a request. Underneath it, the
detail list holds only the requests the
window currently contains, each a tappable row labelled with its duration and coloured by outcome,
running oldest first so time reads downward.
Dragging or zooming into a stretch of the log with nothing in it shows an empty state naming the
gap, with a button that returns the window to the most recent traffic — distinct from the page's
other empty state, shown instead of the whole list, for a log with no traffic captured at all. A
pinch anywhere on the page — the detail list or the fixed minimap card above it — narrows or
widens the window, holding its centre still, down to the point at which the shortest measured
request in the log would draw narrower than 24 points — past
that there is nothing left to magnify, only more gap between bars, and both the pinch and the
strip's adjustable action are disabled rather than left to silently do nothing.
.accessibilityAdjustableAction on the strip
puts the same zoom range behind VoiceOver's and Switch Control's adjustable gesture, and its
accessibility value announces how many requests the window holds after every change, so reaching
zoom never requires a pinch and never leaves a VoiceOver user guessing whether anything happened.
The minimap's own hand-built header carries a trailing Reset zoom button — styled the way
Traffic Stats' Waterfall section puts "See all" on its trailing edge — the moment the window has
been zoomed or scrubbed at all. It sits above the minimap card, not above the detail rows: the
button acts on the window, and the window is what the minimap draws, not the rows underneath it,
which are only ever a consequence of it. It calls the same resetWindow() the gap empty state's
own button already uses, and it stays visible once shown even if a further zoom happens to land
back on the same numbers as the opening window; only pressing it clears it.
Before that first touch, the same header's leading edge instead carries a small pinch hint —
localized("Pinch to change the range") beside a hand.pinch symbol, in the same secondary
caption styling the detail rows' own duration text uses — because the pinch is otherwise entirely
invisible: nothing on screen suggests a reader can narrow the window at all. TipKit was considered
and ruled out, for two reasons: it needs iOS 17 against this package's iOS 16 floor, and
Tips.configure() is process-global, which a debugging library embedded as a guest in someone
else's app has no business calling on the host's behalf. The hint and the reset button are exact
inverses of the same flag, WaterfallViewModel.hasAdjustedWindow, and never both appear at once;
pressing Reset zoom — or the gap empty state's equivalent button — brings the hint back rather
than retiring it permanently, since that flag does not distinguish a zoom from a scrub and a
reader who has only ever dragged the strip may not have discovered the pinch yet either.
A request already running when the window opens, or one that outlives it, is drawn clipped flush to the window's edge rather than shrunk to fit — the clip reads as "continues", where a shrunk bar would read as a request shorter than it actually ran. Tapping a row in the detail list opens that request's details.
Both surfaces follow the log's search and filter chips, and the detail section's own footer names how many of the log's total requests the current window holds — hidden, not just blank, whenever the window is over a gap and showing its own empty state instead of rows.
Slowest Endpoints groups requests by METHOD host/path with the query string dropped and any
numeric or UUID path segment collapsed to :id, so /users/1 and /users/2 aggregate rather
than producing one endpoint per record. A GraphQL operation carries its name —
POST api.example.com/graphql (GetUser) — because every operation in a GraphQL API is posted to
the same path and the name is what identifies the call. Each row shows the request count, the
median, and — as the figure beside it — the slowest round trip.
By Host puts the worst offender first: most failures, then slowest median. Each row shows the request count, how many failed, and the host's median round trip.
Stats describe the current session only. The log is an in-memory FIFO, so nothing is persisted across launches and there are no trends over time.
Network logging shows what the app asked for and what came back. Request Overrides, under
Networking → Request Overrides, changes it — mocking endpoints, serving local files, rewriting
headers and degrading the connection without touching the app's networking code. The API type is
called NetworkRule, so an override in the menu is a rule in code.
Each override matches on HTTP method, host, path and query. An omitted facet places no
constraint, and host and path accept * as a wildcard. Overrides are evaluated top to bottom, and
dragging a row is what changes precedence:
- The first matching stub — a mock or a map local — wins and short-circuits the network.
- The first matching condition supplies the latency, bandwidth ceiling and failure rate; conditions are not stacked.
- Every matching header rewrite applies, and the last override to name a header decides
what happens to it — a later
setbeats an earlierremoveexactly as it beats an earlierset, and a laterremovebeats an earlierset. Header names are compared case-insensitively, soAuthorizationandauthorizationare one header. Within one rewrite there is no order to appeal to, so headers are set before any are removed and a header named in both ends up removed.
- Method, host and path are compared case-insensitively. Query names are compared case-sensitively, because a query name is data rather than protocol.
- The path is compared percent-encoded, exactly as it travels.
%2Fis therefore not a separator —/v1/a%2Fbis one segment and does not satisfy a rule for/v1/a/b— and a path copied out of the log, out of a HAR or off an address bar matches the request it came from. A path typed with a literal space will not. - A trailing slash is part of the path:
/v1/usersand/v1/users/are different paths, and/v1/users*matches both. A URL with no path at all,https://api.example.com, is matched as/. - Every query pair listed must be present. A repeated key is satisfied by any of its
occurrences, so
page=2matches?page=1&page=2, and a key present with no value —?flag— reads as an empty value. Values are compared percent-decoded. - A pattern left blank places no constraint at all, exactly as leaving the facet out does.
An override carries a stub, a header rewrite and a condition independently, each with its own switch in the editor. Mock and map local are the one pair that cannot both apply, because they would both be answering the same request; everything else combines, so "mock this endpoint and make it slow" is one override rather than an impossibility.
A stub no longer suppresses the rest:
- A condition applies to a stubbed response. Its latency delays the synthesised answer — added to the stub's own delay, then clamped once — its failure rate can fail it, and its bandwidth ceiling paces the synthetic body.
- A header rewrite is recorded on the logged request but has no wire effect when the request is stubbed, because nothing is sent. The log still shows the request as it would have gone out.
The log's Overrides row credits everything that actually contributed, which for a stubbed request is the stub first and then whatever else applied to it. An override carrying several actions is named once. Each credit is its own row: one the store still holds pushes its editor and follows a rename made through it, and one whose override has since been deleted — or that has none, like the global conditioning — is named but inert.
| Action | What it does |
|---|---|
| Mock Response | Answers with a status code, headers and a body typed into the editor, after an optional delay. Headers are a dictionary, so a mock cannot repeat a header name — a HAR import keeps the last of a repeated Set-Cookie. |
| Map Local File | Answers with the contents of a file, with a status code and Content-Type. The file is chosen with the system file importer and copied into Scyther's rules directory, so the override keeps working after the document moves or goes away and no security-scoped bookmark is needed. A path supplied from code is used as given; an unreadable path, or one over 10 MB, falls through to the real network. |
| Rewrite Headers | Sets and removes headers on the outgoing request. |
| Network Condition | Adds latency, caps bandwidth in KB/s, and fails a fraction of matching requests with a URLError. Latency and a stub's delay are capped at 30 seconds together, and are waited out without holding a thread. The latency is applied first and the failure rolled after it, so "slow and flaky" is slow before it is flaky, and a rate of 1 never lets a request through. |
A bandwidth ceiling is likewise honoured for at most 30 seconds of added delay per response, so
that a debug tool cannot appear to have hung. A body larger than 30 × bandwidthKBps kilobytes
stops being paced part-way through and the rest is forwarded as fast as it arrives, which makes
the effective rate a function of body size: 1 MB at 10 KB/s takes about 30 seconds, not the 100
the ceiling implies. Pacing is measured across the whole response rather than per delivery, so a
response whose bytes have averaged out under the ceiling is never delayed, however the source
chose to chunk them; a response that has been idle banks at most one second of that idle as
credit, so a long-poll or an SSE stream is throttled rather than released in bursts.
- An override has to name an endpoint. It is not savable until it is named, given a host, path
or query, and given something to do. A facet that matches everything however it is spelled — a
path of
*set to Wildcard, or/set to Contains — does not count, because an override applied to every request in the app is the hazard the guard exists to prevent. A method on its own does not count either. - A mock body that is not text is left alone. A body holding bytes that are not valid UTF-8 — a captured image, most often — is shown as a size rather than opened in the text editor, because editing it as text would write every one of those bytes back as a replacement character. So is a body larger than a megabyte, which is more than anyone edits by hand.
- The map local file is one row. It reads Choose File until something is chosen and then shows the name of the document that was picked, not the name of the copy Scyther keeps. Tapping it opens the picker either way, so replacing a file is the same gesture as choosing one.
- The
Content-Typeis picked, not typed. The types a developer actually mocks are offered in a picker, with Custom revealing a free-text field for anything else. Picking a file fills it in from the document's type, soresponse.jsonarrives asapplication/json. - Status codes are stored inside
100...599, and a negative or non-numeric delay is stored as zero.
The request details page carries a Save as mock button whenever the response came off the
wire. It opens the override editor pre-filled from the capture: matching that request's method,
host and path exactly, answering with its status code, headers and body. The query string is left
unconstrained, and headers describing the wire encoding (Content-Encoding, Content-Length,
Transfer-Encoding) are dropped, because the stored body is the one URLSession already decoded.
The override arrives disabled — nothing changes until it is switched on, from the editor or with a swipe on the list.
A response an override already synthesised cannot be saved as a mock. Those rows are marked instead: a pink MOCKED badge in the log list, and an Overrides row in the details page's Developer Info section naming every override that shaped the request.
An override that shapes a request without answering it — a header rewrite, or a network
condition — leaves the row looking like ordinary traffic otherwise, so it carries a brown
OVERRIDDEN badge instead. The four badges use four colours nothing else in the log wears, so
MOCKED, OVERRIDDEN, REPLAY and HELD can appear together on one row and still be read
apart. A stubbed row never wears OVERRIDDEN, because MOCKED already says what happened.
Import from HAR, in the add menu of the overrides list, reads a HAR 1.2 document — one
exported by Scyther, or captured in Charles, Proxyman or Chrome DevTools — and turns each entry
into a mock override named <METHOD> <path> matching that method, host and path. Every imported
override arrives disabled.
Entries are read one at a time, so the things a real capture contains — an aborted request with no
response object, a multipart upload whose postData carries params and no text, an entry
whose URL cannot be parsed — cost those entries and nothing else rather than discarding the whole
import. The alert reports both numbers: how many overrides were added, and how many entries
produced none.
A response body labelled encoding: "base64" is decoded even when it is wrapped across lines, the
way Charles and other MIME-style encoders write it. Text that plainly is not base64 is taken as
the literal body it is rather than decoded into bytes that came from nowhere.
Enable Request Overrides, at the top of the list, suspends every override at once without deleting any of them — the quickest way to tell whether a behaviour belongs to the app or to an override. It is persisted across launches.
The count badge on the menu's Request Overrides row follows this switch: it reports how many overrides are being applied, so turning the switch off empties it however many overrides are enabled behind it.
Scyther.network.rules is the programmatic entry point. Like every other Scyther singleton it is
@MainActor-isolated, and every member of it is inert until Scyther.start() has run — which it
does not do on an App Store build. The calls below can sit unguarded in didFinishLaunching: on a
release build they read back nothing, write nothing to preferences, and put no file in the user's
container.
// Persisted: written to UserDefaults, listed in the menu, survives relaunch. The
// identifier is a constant, so relaunching updates this override instead of adding
// another copy of it beside the first.
let emptyCart = UUID(uuidString: "6F0B0C3E-4C1E-4E3D-9C0B-0F5E7A9D2B41")!
Scyther.network.rules.add(
.mock(id: emptyCart,
name: "Empty cart",
matching: .path("/api/cart"),
returning: .json(#"{"items": []}"#))
)
// This launch only: never written to disk, listed read-only under "Registered in Code".
Scyther.network.rules.addTransient(
.headers(name: "Staging auth",
matching: .host("*.staging.example.com"),
set: ["Authorization": "Bearer test-token"])
)
// Several actions at once: build the rule directly rather than through the
// single-action conveniences above.
Scyther.network.rules.add(
NetworkRule(name: "Slow cart",
match: .path("/api/cart"),
actions: NetworkRuleActions(stub: .mock(.json("{}")),
condition: NetworkCondition(latency: 3)))
)
// Read, edit and clear.
let overrides = Scyther.network.rules.all
Scyther.network.rules.remove(id: overrides[0].id)
Scyther.network.rules.isEnabled = false // suspend everything, delete nothingadd(_:) persists the override and shows it in the menu, where it can be edited, reordered or
deleted. addTransient(_:) does not: transient overrides live only for the launch that
registered them, appear read-only under "Registered in Code", and are evaluated after every
persisted override. Use the transient form for anything the app registers for itself.
Both are an upsert on NetworkRule.id: an override whose identifier is already known replaces
that override in place, and whatever body file the replaced override owned is reclaimed unless
another override still points at it. Code that runs on every launch should therefore pass a
constant id, as the example above does — an override built without one gets a fresh identifier
each time, so a call in didFinishLaunching would store another copy of the same override on every
launch.
An identifier lives in exactly one of the two lists. Registering a transient override under an
identifier the persisted list holds moves it across, and vice versa: the last registration wins
outright, rather than leaving two copies that remove(id:) can only half delete.
MockResponse.json(_:) writes nothing when it is built. The bytes travel with the value and are
written when the override holding it is added or updated, so a response that is never stored leaves
nothing on disk. If those bytes cannot be written the override is not stored — add(_:) and
update(_:) return false and the menu says so — because an override pointing at a body that is
not there would answer with the right status code and an empty body.
Overrides are persisted as JSON, which cannot express an infinite or NaN number. A latency, delay
or failure rate that is not finite — 1e400 typed into the editor parses to inf — is replaced
with 0 on the way in, rather than being allowed to fail the write for every override at once.
Global conditioning is sanitised the same way. If a write does fail, or if the saved overrides
cannot be read at launch, the overrides screen says so; an unreadable blob is set aside under its
own preferences key rather than overwritten, and a second one is added beside the first rather
than replacing it. While anything is set aside the orphan sweep stands down, so the bodies those
overrides point at are kept along with them. Discarding every override discards the set-aside
configurations too, and is what lets the sweep start reclaiming again — from the alert itself,
which offers Delete All Overrides, or from Scyther.network.rules.removeAll().
Transient overrides make a UI test hermetic without running a stub server.
Register after
Scyther.start(), not before. Every member ofScyther.network.rulesis inert untilstart()has run, andadd(_:),addTransient(_:)andupdate(_:)are all@discardableResult— so a block placed abovestart()compiles, runs, warns about nothing, and registers nothing. Since the block below sits behind a launch argument it reads as though it could go anywhere indidFinishLaunching; it cannot. If you want the compiler's help, read theBoolthese return:falsemeans nothing was stored.
// In the app, in didFinishLaunching, *after* Scyther.start().
Scyther.start()
if ProcessInfo.processInfo.arguments.contains("-UITestStubs") {
Scyther.network.rules.isEnabled = true
let registered = Scyther.network.rules.addTransient(
.mock(name: "Profile",
matching: .host("api.example.com", path: "/v1/profile", methods: ["GET"]),
returning: .json(#"{"name": "Ada"}"#))
)
assert(registered, "Scyther.start() must run before any override is registered")
Scyther.network.rules.addTransient(
.condition(name: "Slow uploads",
matching: .path("/v1/upload", methods: ["POST"]),
NetworkCondition(latency: 2, failureRate: 0.5))
)
}// In the test.
let app = XCUIApplication()
app.launchArguments += ["-UITestStubs"]
app.launch()Because they are transient, the overrides do not survive the launch that registered them: a stub
left behind by a failing run cannot quietly break the next one. The master switch is a different
matter — isEnabled is persisted, so setting it here leaves it on for the developer's next
ordinary launch as well. That is usually what you want, since it is on by default; if a run may
have turned it off, set it explicitly as the snippet does rather than assuming.
Note: Overrides apply only to traffic Scyther intercepts —
URLSessiontraffic through a standard configuration. A customURLSessionConfigurationthat does not carry Scyther'sURLProtocolbypasses overrides exactly as it bypasses logging.
Scyther sends a few requests of its own: the menu's IP address lookup, and every request sent from the replay editor. Those requests are logged like any other — seeing what the toolkit does is useful — but Scyther does not let its own features interfere with them.
Two parts of that are unconditional, because they are self-inflicted rather than preferences:
- Breakpoints never hold a Scyther request. A breakpoint on
api.ipify.orgused to hold the menu's own IP lookup; the held-request editor was presented over the menu, which re-created the menu, which asked for the IP address again, which was held again — one modal per second, stacking without limit, over the one screen a developer could have used to switch the breakpoint off. - Header rewrites never apply to one. The replay editor promises this request, exactly as edited; a rewrite silently restoring a header the developer just deleted contradicts it.
Mocks and conditioning are exempt by default and can be opted back into per request. Only the
replay editor exposes that, as its Apply Request Overrides toggle; nothing opts the IP lookup
back in.
A request is marked as Scyther's own with a URLProtocol property carrying a token minted once
per launch. The mark never becomes a header, so it never reaches the wire; it survives being
copied and is re-applied across redirects; and the host app's ordinary traffic cannot opt itself
out, because knowing the property key is not enough — the value has to be this launch's token.
It is not a secret from code already running in the process, which can read any protocol property
it likes; the guarantee is that it is unguessable and does not survive a relaunch.
The request details page carries a Replay this request button. It opens an editor pre-filled from the capture — method, URL, headers and body — and sends whatever is left there when the confirm button is tapped.
- Method — a picker of the common verbs, plus Other for anything the server invented.
- URL — a single-line field. The confirm button is disabled, and a warning appears, while what has been typed will not parse into an absolute HTTP URL.
- Headers — one row per captured header, editable, swipe-deletable, with an add row. Headers
URLSessionowns —Content-Length,HostandConnection— are shown but disabled, and are dropped rather than sent, because editing them has no effect. A duplicated header name travels as the one comma-joined field HTTP defines rather than one row silently winning. - Body — a row opening the same text editor the rest of the toolkit uses, showing a byte count. The logger only ever writes a UTF-8 request body to disk, so a binary body — a protobuf, a multipart upload — is not recoverable from the capture and the replay goes out without one. The editor says so on the row, in the section's footer, and in a confirmation before sending, rather than quietly sending a request the developer did not ask for; typing a body of your own retires the warning.
A replay goes out on an ordinary URLSession and is captured by the interceptor exactly as
traffic the app makes is. That has two consequences worth stating plainly:
- A replay is its own entry in the log, marked with a teal
REPLAYbadge — the same treatment the pinkMOCKEDbadge gets, in the one other colour nothing in the log competes for. - No breakpoint holds a replay and no header rewrite touches one. A replay is a request Scyther itself composes and sends — see Scyther's own traffic — and holding one would stall the very editor that sent it, while a rewrite would put back a header the developer had just deleted.
- Mocks and conditioning are a toggle,
Apply Request Overrides, defaulting off. Off is the honest comparison: this request, exactly as edited, against the server. On, a matching mock answers it and a matching condition shapes it exactly as they would app traffic — which is the only way to fire a specific, crafted request at an override without waiting for the app to make the call itself. Either way the log row says which happened, with the sameREPLAYandMOCKEDbadges and the same Overrides credit it has always used.
A request whose response an override synthesised has no Replay this request button: the override would simply synthesise the same response again.
Because resending a POST, PATCH or DELETE can repeat whatever it changed, any method
outside GET, HEAD and OPTIONS warns in the editor and asks for confirmation in an alert
naming the method before anything is sent. So does a replay whose body could not be captured, and
one aimed at a URL Scyther does not intercept — an ignored host, or a scheme that is not HTTP —
which would be sent and never appear in the log. The editor's footer and the confirmation show the
same list of warnings, so they cannot tell you different things.
The original's details page grows a Replays section listing every replay of it currently in the log, each row naming the replay's method and status and carrying the signed duration and size deltas — replay minus original — and linking to that replay's own page. A replay's page carries a Replayed from row linking the other way, or says the original is no longer in the log if it has since been cleared.
Both sections track the log as it changes, so a replay that lands seconds after the editor dismissed appears without leaving the page.
A row whose exchange Scyther shaped says so, in the log's own badge words — Original: MOCKED,
Replay: HELD OVERRIDDEN. A mocked response never left the device and a held one waited for a
developer to press a button, so a delta across either measures Scyther rather than the server, and
the section that exists for comparison should not report it as though it did not.
Networking → Breakpoints is the feature people reach for Charles or Proxyman to get: a matching request stops before it is sent, or a matching response stops before the app sees any of it, and the editor appears over whatever is on screen so it can be read and changed in place.
It is the one thing in the toolkit that deliberately holds the app up, so the master switch defaults to off and the menu row shows how many breakpoints are being applied.
A breakpoint is matching plus a stage. Matching is the same NetworkRuleMatch a request override
uses — methods, host, path, query, with the same wildcard and percent-encoding semantics — so a
match copied from an override behaves identically here.
| Field | What it does |
|---|---|
| Stage | Request holds it on the way out, Response on the way back, Both holds twice. |
| Timeout | 5 to 300 seconds, 60 by default. It cannot be switched off. |
The editor refuses to save a breakpoint with no name, or one whose match names no endpoint — a match that applies to everything would hold every request the app makes, one after another, for the timeout each.
The editor is presented over the key window, wherever the developer is in the app. One held exchange opens straight into its own page; several are listed and can be worked through in any order. Each page shows which breakpoint holds it, a live countdown to the automatic continue, and the exchange itself: method, URL, headers and body for a request; status, headers and body for a response.
There are four ways out, and doing nothing is one of them:
- the confirm button continues with whatever has been edited;
- Continue Without Changes passes the exchange on exactly as it arrived;
- Abort fails it with a
URLErroryou pick, exactly as though the network had produced it; - the timeout continues it unchanged, so a forgotten breakpoint cannot leave the app hanging.
A log entry that was held carries an indigo HELD badge, beside MOCKED and REPLAY, and the
log records what the app actually sent and received — the edit, not the original.
The request details page carries a Break on requests like this button, beside Save as mock and Replay this request. It opens the breakpoint editor pre-filled from the capture: the same matcher a mock built from that page uses — its method, host and path exactly, with the query left unconstrained — holding the request stage, for the default minute.
Unlike a mock, it arrives enabled: a breakpoint announces itself by stopping the request and putting a screen in front of you, so there is nothing to switch on afterwards and nothing hidden if you forget to. Nothing is written until the editor is confirmed either way.
It is offered wherever the entry has a URL, including one an override answered — seeing what the app sent to a stubbed endpoint is one of the things a breakpoint is for.
- Nothing blocks. A held request does not occupy the thread it was intercepted on. The pause is a stored continuation, so a breakpoint left open costs a suspended request and nothing else, and cancelling the request cancels the pause.
- A held response buffers. Bytes cannot be un-forwarded, so a response breakpoint withholds the whole body and hands it over once. Above 10 MB the pause is skipped and logged. The editor says so when the stage is chosen.
- It never fires during tests. Breakpoints report as off inside an XCTest process, so one left enabled cannot hang CI, and they are off on App Store builds along with the rest of Scyther.
- A stubbed request is not held. An override answers it without anything going in flight.
- A pause taken while the app is not active is skipped and logged: a held request the developer cannot see is indistinguishable from a hang. Only that one pause is skipped — an exchange already open in the editor survives a glance at Control Centre or the app switcher. Anything still held when the app actually goes to the background is continued unchanged there and then, rather than waiting out its timeout somewhere nobody can see it.
Networking → Network Conditioning degrades every request Scyther intercepts, which is what
Network Link Conditioner does without needing a Mac or a provisioning profile. The menu row shows
the active preset, or Off, so conditioning is never quietly on.
The screen carries a master switch, a preset picker, and the three numbers underneath it:
| Preset | Latency | Ceiling | Failures |
|---|---|---|---|
| Wi-Fi | 0.01 s | none | none |
| 4G | 0.05 s | 1,500 KB/s | none |
| 3G | 0.1 s | 100 KB/s | none |
| EDGE | 0.4 s | 30 KB/s | none |
| Very bad network | 0.5 s | 125 KB/s | 10% |
Picking a preset fills the three fields in; editing any of them makes the picker read Custom again, because that is what it now is. Custom is not something you pick — it is the absence of a preset — so the picker lists it only while it is what the numbers say, rather than offering a choice that would change nothing and then snap back.
A request the global condition slowed or failed carries the brown OVERRIDDEN badge in the log,
credited as Network Conditioning in the details page's Overrides row. It names it without
offering a link, because it is a screen rather than an override — but the log never shows a
conditioned request as ordinary traffic.
The global condition is a floor, not an addition. A request override whose own condition matches replaces it outright, so one endpoint can still be conditioned differently — or barely at all — while the rest of the app is on EDGE. An override that matches but carries no condition leaves the global one in place.
It is off by default, persisted across launches, and has its own switch: Enable Request Overrides does not reach it, and neither does turning every override off.
Capture all print() statements and console output.
// Get all captured logs
let logs = Scyther.console.logs
for entry in logs {
print("[\(entry.source.rawValue)] \(entry.formattedTimestamp): \(entry.message)")
}// Stop capturing (if needed)
Scyther.console.stopCapturing()
// Clear all logs
Scyther.console.clear()Capture uncaught exceptions and view them on subsequent app launches. This is useful for debugging crashes that occur during development and testing.
Scyther uses NSSetUncaughtExceptionHandler to intercept Objective-C and Swift exceptions before the app terminates. When a crash occurs:
- Exception details are captured (name, reason, stack trace)
- Device and app information is recorded
- Data is saved to UserDefaults immediately
- On next launch, the crash is visible in Scyther's Crash Logs viewer
If you use other crash reporting tools (Firebase Crashlytics, Sentry, Bugsnag, Instabug, etc.), the order you initialize them matters.
Crash reporters work by setting an exception handler. Only one handler can be active at a time, but handlers can "chain" by saving and forwarding to the previous handler.
Scyther must be started AFTER other crash reporters:
import Firebase
import Sentry
import Scyther
@main
struct MyApp: App {
init() {
// 1. Initialize other crash reporters FIRST
FirebaseApp.configure()
SentrySDK.start { options in
options.dsn = "your-dsn"
}
// 2. Start Scyther LAST
// Scyther will capture crashes AND forward them to the previous handlers
Scyther.start()
}
}Why this order?
- Scyther saves the existing handler (e.g., Crashlytics) when it starts
- When a crash occurs, Scyther logs it locally, then forwards to Crashlytics
- Both systems receive the crash data
If you start Scyther first, your other crash reporter will overwrite Scyther's handler, and Scyther won't capture crashes.
// Get all captured crashes (newest first)
let crashes = Scyther.crashes.all
// Get crash count
let count = Scyther.crashes.count
// Clear all crash logs
Scyther.crashes.clear()Each crash log includes:
- Exception name and reason
- Full stack trace (searchable with highlighting)
- App version and build number
- iOS version and device model
- Timestamp
The stack trace is searchable - use the search bar to filter frames and find specific methods, classes, or frameworks. Matching text is highlighted for easy identification.
In debug builds, you can trigger a test crash:
#if DEBUG
Scyther.crashes.triggerTestCrash()
#endif- Swift errors: Only captures
NSException-based crashes. Pure SwiftfatalError()orpreconditionFailure()may not be captured. - Symbolication: Stack traces contain memory addresses. Use Xcode's crash log tools for symbolicated traces.
- Storage: Up to 50 crashes are stored (oldest are removed automatically).
Browse the app sandbox from System Tools → File Browser: the Documents, Library,
Caches and tmp roots, any subdirectory, and a detail screen per file with its attributes,
a text, JSON, property list or image preview, Quick Look, Share, Copy Path and Delete.
Browse SQLite, CoreData, and SwiftData databases with full CRUD support. The Database Browser automatically discovers databases in your app's container and provides a visual interface for inspecting and modifying data.
Databases are automatically discovered in common locations:
Library/Application Support/(SwiftData, CoreData stores)Documents/(user-created databases)Library/(other app data)
The browser detects database types:
- SQLite: Plain
.sqlite,.sqlite3,.dbfiles - CoreData: Detected via
Z_-prefixed system tables - SwiftData: Detected via Swift-specific metadata
- Schema Browser: View tables, columns, types, primary keys, foreign keys, and indexes
- Record Browser: Paginated viewing of table records with sorting
- CRUD Operations: Add, edit, and delete records (for writable databases)
- SQL Query Editor: Execute raw SQL queries with formatted results
- Swipe-to-Delete: Quick record deletion with confirmation
For third-party databases like Realm or Firebase, you can create custom adapters without adding dependencies to Scyther:
// In your app, create an adapter conforming to DatabaseBrowserAdapter
class RealmDatabaseAdapter: DatabaseBrowserAdapter {
var identifier: String { "my-realm-db" }
var displayName: String { "My Realm Database" }
var databaseType: DatabaseType { .custom("Realm") }
var supportsRawSQL: Bool { false }
var supportsWrite: Bool { true }
var filePath: String? { realm.configuration.fileURL?.path }
func tables() async throws -> [TableInfo] {
// Return your Realm object schema as TableInfo
}
func schema(for table: String) async throws -> TableSchema {
// Return column info for the specified table
}
func records(in table: String, offset: Int, limit: Int, orderBy: String?, ascending: Bool) async throws -> [DatabaseRecord] {
// Query and return records
}
// Implement other protocol methods...
}
// Register the adapter with Scyther
Scyther.database.registerAdapter(RealmDatabaseAdapter(realm: myRealm))The DatabaseBrowserAdapter protocol requires:
| Method | Description |
|---|---|
tables() |
Return all tables/collections |
schema(for:) |
Return schema for a table |
records(in:offset:limit:orderBy:ascending:) |
Fetch paginated records |
insert(into:values:) |
Insert a new record |
update(in:primaryKey:values:) |
Update an existing record |
delete(from:primaryKey:) |
Delete a record |
executeQuery(_:) |
Execute raw SQL (if supported) |
Three screens inspect the values your app has already stored: Data → UserDefaults, Security → Keychain Browser and Data → Cookies.
Fake GPS coordinates for testing location-based features.
// Enable spoofing
Scyther.location.spoofingEnabled = true
// Set a preset location
Scyther.location.spoofedLocation = Location(
id: "sydney",
name: "Sydney, Australia",
latitude: -33.8688,
longitude: 151.2093
)Scyther includes 20+ preset locations:
// Available presets
LocationSpoofer.instance.spoofedLocation = .sydney
LocationSpoofer.instance.spoofedLocation = .tokyo
LocationSpoofer.instance.spoofedLocation = .newYork
LocationSpoofer.instance.spoofedLocation = .oslo
LocationSpoofer.instance.spoofedLocation = .berlin
// ... and many more// Set custom coordinates
let customLocation = Location(
id: "office",
name: "Company HQ",
latitude: 37.7749,
longitude: -122.4194
)
Scyther.location.spoofedLocation = customLocation// Add locations that appear in the Scyther UI
LocationSpoofer.instance.addLocation(Location(
id: "test-store",
name: "Test Store Location",
latitude: 40.7128,
longitude: -74.0060
))Simulate movement along a route:
LocationSpoofer.instance.spoofedRoute = .driveCityToSuburbTest custom URL schemes and universal links directly from the Scyther menu.
// Open a deep link programmatically
await Scyther.deepLinks.open("myapp://profile/123")Add commonly-used deep links for quick access:
Scyther.deepLinks.presets = [
DeepLinkPreset(name: "Home", url: "myapp://home"),
DeepLinkPreset(name: "Profile", url: "myapp://profile/123"),
DeepLinkPreset(name: "Settings", url: "myapp://settings"),
DeepLinkPreset(name: "Checkout", url: "myapp://checkout"),
]The Deep Link Tester also includes:
- QR Code Scanner: Scan QR codes containing deep links
- History: Previously tested links are saved for quick re-use
- Success/Failure Feedback: Visual indication of whether the link opened
Note: To use the QR code scanner, your app must include
NSCameraUsageDescriptionin its Info.plist with a description explaining camera usage (e.g., "Used to scan QR codes for deep link testing").
Schedule local test notifications to verify your notification handling.
// Simple test notification. Omit title and body to use Scyther's own localised copy.
Scyther.notifications.scheduleTest(
title: "Order Update",
body: "Your order #12345 has shipped!",
delay: 5 // seconds
)
// With all options
Scyther.notifications.scheduleTest(
title: "New Message",
body: "You have a new message from John",
delay: 10,
sound: true,
incrementBadge: true
)for notification in Scyther.notifications.logged {
print("Title: \(notification.aps.alert.title)")
print("Body: \(notification.aps.alert.body)")
}Display tokens in the Scyther UI:
// In your AppDelegate
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Scyther.apnsToken = token
}
// For Firebase
Messaging.messaging().token { token, error in
if let token = token {
Scyther.fcmToken = token
}
}Display an alignment grid over your UI:
// Enable grid overlay
Scyther.interface.gridOverlayEnabled = true
// Customize grid appearance (via GridOverlay singleton)
GridOverlay.instance.size = 8 // Grid size in points
GridOverlay.instance.opacity = 0.5 // Grid opacity (0.0 - 1.0)
GridOverlay.instance.colorScheme = .blueDraw the key window's safe-area insets and layout margins over your UI, each labelled with its measurement in points. Zero insets are not drawn:
// Enable layout guides
Scyther.interface.layoutGuidesEnabled = trueWith no key window there is nothing to draw over, and the menu's toggle is disabled rather than persisting a setting that shows nothing — the same rule the Layout Ruler's row reports with an alert.
Drag anywhere on the running app to measure between two points. Open the Scyther menu and choose Layout Ruler under UI/UX; the menu dismisses and an overlay takes the screen.
In Snap mode — the default — each end attaches to the nearest edge of the view beneath it, so
a drag roughly between two labels reports the real gap rather than how steady your thumb was, and
the readout names both ends (UILabel.bottom → UIImageView.top). In Free mode the ends stay
exactly where you put them and the readout carries the distance alone. An end with nothing under it
is named free point instead of reporting a snap that did not happen.
The measurement stays on screen so it can be read, is replaced by the next drag, cleared by a tap, and cleared by a rotation that actually resizes the app — its endpoints described a layout that no longer exists. An orientation change the app does not honour leaves it alone.
While active, the overlay takes over every touch on screen: the app underneath cannot be tapped, scrolled, or navigated until you leave the ruler. Nothing has frozen — tap Done to exit the overlay and hand touches straight back to the app; it stays on screen for exactly this reason. The shake gesture still reaches the menu regardless, being a motion event rather than a touch. Neither activation nor the mode is persisted, and with no key window to draw over the menu row reports that rather than activating with no visible way out.
Scyther's own interface is never measured: the probe skips it and finds the app underneath.
Display a real-time frame rate indicator to monitor rendering performance:
// Enable FPS counter
FPSCounter.instance.enabled = true
// Change position (topLeft, topRight, bottomLeft, bottomRight)
FPSCounter.instance.position = .bottomRightThe counter is color-coded for quick performance assessment:
- Green (55+ FPS): Excellent performance
- Yellow (30-54 FPS): Acceptable, may need optimization
- Red (<30 FPS): Poor performance, needs investigation
Show visual indicators for touch events (great for screen recordings):
// Enable touch visualization
Scyther.interface.touchVisualizerEnabled = true
// Customize appearance
var config = TouchVisualiserConfiguration()
config.showsTouchDuration = true
config.touchIndicatorColor = .systemBlue
TouchVisualiser.instance.config = configUI/UX → Accessibility Audit walks the live accessibility tree — not the view tree, which is why it works the same over SwiftUI and UIKit — and reports what a VoiceOver user or someone with low vision would run into. Three checks run independently, each with its own toggle at the top of the report screen:
-
Missing Labels: any element VoiceOver will land on whose accessibility label is empty. The exemptions are the short list, not the rule: static text reads its own content, and an element with an accessibility value reads that. A custom control whose author set
isAccessibilityElementand never set a trait is caught, which a trait-gated rule missed. -
Touch Targets: an interactive element measured against Apple's 44 × 44pt minimum — below 24pt on its shortest side is an error, 24pt up to 44pt is a warning. 24 is WCAG 2.5.8 AA's floor, the only number in this rule anyone can cite. The two severities say which number they cite, so an error reads "under WCAG 2.5.8's 24 × 24pt minimum" and a warning "under Apple's 44 × 44pt guidance". Links are capped at a warning, since WCAG 2.5.8 exempts a target inline in a sentence and nobody makes body-copy links 44pt tall.
-
Contrast: text measured against its background at 4.5:1, or 3:1 for large text (18pt, or 14pt bold) where the point size can actually be read off the element — a
UILabel,UIButton,UITextFieldorUITextView, including the smallest run of an attributed string and the size a label thatadjustsFontSizeToFitWidthcan shrink to. Where the size can't be read the strict threshold stands rather than being guessed at, and the finding says so, because the relaxation can only ever hide a failure. Non-text content — an icon-only button, or an element carrying.image— is graded at WCAG 1.4.11's 3:1 instead; but only an element that can affirmatively say it draws no text earns that, so a SwiftUIButton("Continue"), which can't, keeps the strict grade. A disabled control is not graded at all, since WCAG 1.4.3 exempts inactive components.The check is not gated on traits. A
UITableViewCellor a SwiftUI row that makes itself one VoiceOver stop carries neither.staticTextnor.button, and gating on those meant that on a list screen — the most ordinary screen in iOS — no text was sampled at all and the report printed a green tick. Where the element is a real view, the check descends into it for sampling only and measures eachUILabel/UITextField/UITextViewat its own bounds, so the finding boxes the label that failed rather than the whole row. Where there is nothing to descend into it measures the element's own frame instead — which is every SwiftUI screen, since SwiftUI draws its text into private layers and hangs synthetic accessibility elements off the hosting view, with noUILabelanywhere. An element with a non-empty label that isn't an image is text as far as this check is concerned, whatever framework drew it.Contrast is an estimate, not a measurement: the ratio is sampled from the pixels actually drawn on screen. Those pixels are clustered into two tonal groups and each group is represented by the colour most of it actually is — not by its mean, which antialiased glyph edges drag toward the page, and not by its darkest or lightest pixels, which any icon or gradient inside the frame can define. That is what lets
#767676on white, WCAG's canonical exactly-passing grey, report 4.54:1 rather than being failed, and what stops a#333333icon covering 3% of a label's frame hiding#949494text at a real 3.03:1. When a crop has no such structure — text on a gradient or a photograph, a glyph the sampler never caught at full coverage, or a pair too close together to tell from the capture's own dither — the element is reported as could not be measured rather than given a number nobody drew. (An element scrolled under an opaque bar never reaches the analyser at all: the walk skips it as invisible, one rule earlier.) Treat a contrast finding as worth a look, not as a certificate — it's always reported as a warning, never an error.
The walk reports only what is actually reachable, which is what keeps it from filing findings you
can do nothing about. It honours accessibilityElementsHidden and accessibilityViewIsModal —
including a modal set on a dialog nested inside a dimming container or a presented controller's
view, which is where apps really put it — and it skips content that cannot be seen: recycled cells
scrolled out of a table, the parked pages of a carousel, anything clipped away by an ancestor, and
anything covered by something opaque drawn over it. That last one is why a caption scrolled under a
navigation bar is no longer reported at 1.0:1 against the bar's own near-black material. Every one
of those rules is asked the same single question — "can this be seen?" — so a container the walk
skips is never one the report has already counted, and a container that does not clip is never
pruned for content its children still draw on screen.
Two checks run live; the report runs three. Missing Labels and Touch Targets read the
accessibility tree and the geometry of what is on screen. Contrast reads pixels, and getting those
pixels means rasterising the whole window with drawHierarchy(in:afterScreenUpdates: true) — a
forced full re-render on the main thread, measured at 436ms of an 800ms pass on a real screen. The
live overlay takes a pass on every navigation, so with contrast on that path the app froze for most
of a second every time you pushed, popped or switched tab, for an answer nobody was looking at yet.
So contrast is measured only at the moments you asked for a result: when you open the report, and
when you tap Re-run. Opening the report from the count pill takes that pass in the instant
before the sheet appears — while your app, not Scyther, is still what the window is showing, which
is also the only moment contrast can be measured honestly. The pill therefore counts two checks and
the report counts three, deliberately; and a contrast finding, existing only inside a report, gets
no live box to flash.
Show Issues On Screen draws a box around every current missing-label and touch-target finding
directly over the running app,
live, the same way GridOverlay and FPSCounter stay on screen without a manual refresh. The
overlay follows the app by watching it lay out. Anything that changes what is on screen lays
something out — a push lays out the incoming view, adding a subview marks its new superview as
needing layout, a scroll lays out on every frame it tracks, a reload lays out the cells that changed
— so a SwiftUI tab switch, a NavigationStack push, a swipe-back, a sheet of your own and a scroll
that comes to rest all re-audit, half a second after the screen stops moving. Scyther already
swizzles UIView.layoutSubviews for the view-borders overlay, so this costs no new hook.
It watches layout rather than which view controllers are showing, which is what it used to do: that
question has an honest answer in a UIKit app and almost none in a SwiftUI one, where a TabView
switch, a NavigationStack push and a List scroll all happen inside a single
UIHostingController. The chain never moved, so the boxes were drawn once at launch and then
described a screen that was no longer there.
Nothing runs while you are still moving: every layout restarts the half-second debounce, so the pass lands once the screen settles. The one deliberate exception is a screen that never settles — a spinner, a video layer, an auto-advancing carousel — which would otherwise defer the pass for ever, so a pass is let through after two seconds of unbroken movement. On a long scroll that is one live pass, about 121ms, roughly every two and a half seconds.
A pass cannot make itself run again. Everything the overlay draws — the boxes, the pill, the flash — lives inside Scyther's own top-level view wrapper, and a layout in there is ignored; and a pass that found what the last one found repaints nothing at all, so there is nothing to lay out either way.
What it does not notice is content that changes with no UIView laying out. Those keep the last
pass's boxes, and the way out of any stale pass is the pill: tapping it takes a fresh one.
A pill down the trailing edge of the screen counts the current findings; tapping it opens the report over whatever you're looking at, without going back through the menu. Closing it puts you straight back in the app with live mode still running. The pill sits on the side rather than the bottom deliberately: it is the one thing Scyther puts over your app that takes touches, and at the bottom centre it sat on top of tab bars and primary action buttons and took their taps.
Tapping a finding in the report flashes its box on the overlay so there's no doubt which element it means. Because the report is always in front of the app, the flash waits until you close it and then plays over the app itself — a box stroked across Scyther's own report would be pointing at a rectangle you can't see. It flashes where the element is now, and doesn't flash at all if that element has since gone.
The report itself is frozen the moment it loads and only changes when Re-run is tapped, so findings never shift under you mid-read. Opened from the pill, it opens onto the pass taken in the instant before the sheet appeared, rather than taking one of its own from underneath itself — which is how a pill reading "7 issues" used to open onto "No Issues Found", having dropped contrast because by then the screen behind the report was Scyther's. Re-run always takes a fresh pass.
Every report carries a line naming the checks that ran and the time they ran at — Missing Labels, Touch Targets, Contrast measured at 10:42:11 — so a contrast result on screen is dated, and a
report with no contrast in it is visibly not claiming one. That matters most for contrast, which is
the finding a scroll can invalidate fastest.
The toggles sit above the frozen report and take effect immediately, so the two can disagree. Switch a check off and its findings are hidden straight away; switch one on and the report says the check was not run in this pass and offers Re-run, rather than silently having no findings for it. The same banner covers the other way a check can be missing from a report you are reading — contrast, when the report opened onto a live pass — which is why it says only that the check was not run rather than asserting you had just switched it on.
An empty report never claims more than the pass supports. A green tick and "No Issues Found" appear only when every enabled check ran over the whole screen and found nothing. A walk that stopped early, or a check that was switched off, skipped, unmeasurable or able to read only part of the screen, gets "No Issues In What Was Checked"; nothing having run at all gets "Nothing Was Checked"; a pass whose every finding the toggles are hiding gets "Findings Hidden" rather than pretending it found nothing. "Nothing was wrong", "nothing was looked at", "this could not be measured" and "you are not being shown this" never read the same way.
Every pass carries the moment it was taken. A report opened from the pill is showing the last pass taken with nothing of Scyther's on screen, which is by definition older than the screen you are reading it on — so it says so, and shows its age, rather than presenting a pass from before a scroll as the current state of the app.
The pass runs a moment after the screen appears, not inside its transition, so the push finishes and you see a spinner rather than a stalled navigation while the walk happens. The pass is bounded three ways — depth (100), node count (5,000 nodes touched, including the ones the visibility rules then discard) and a wall-clock budget that covers the whole thing, including the window snapshot and the per-element pixel sampling, rather than just the tree walk.
The budget is a different number for each kind of pass. A live pass gets 0.25s, because it runs unasked while you navigate and the only acceptable cost is one you cannot feel. A report pass gets 2s, because it has a window snapshot in it that does not fit inside a quarter of a second at all, and because you asked for it and are waiting. The clock starts before the snapshot and is read again the moment it returns, so a capture that spends the whole budget stops the pass and raises the truncation banner rather than being excluded from the one bound on how long your app is held.
Any one of them stopping the pass puts a banner at the top of the report, and the banner says what that actually costs you: each limit abandons the whole remainder of the tree in tree order, not the branch it fired on. A list holding a few thousand scrolled-away cells can exhaust the node budget inside the table, and the toolbar and tab bar after it are then never walked at all. What is missing from a truncated report is unchecked, not clean — which is what the banner says, rather than blaming the screen for being too big.
A check that could read nothing at all is reported as exactly that, not as a check that passed — a window capture the system refused, or a screen captured fine on which nothing was legible.
A check that read some of the screen is a different report, and the distinction matters because the analyser refuses any crop it cannot trust: on a screen of photographs and gradients, contrast can legitimately measure a handful of elements and find a real defect in one of them. So a partial pass keeps its findings and states its coverage instead — "Contrast read 3 of 41 elements on this screen" — with the same closing sentence the unmeasurable banner uses, because the consequence is the same: what was not measured is missing from the report, not passing it. Partial coverage still costs the green tick, so a clean result can never be mistaken for a guarantee. The counts are the pass's own tally, element by element, taken from the same call the findings come from.
The audit skips Scyther's own UI, so its menu, its report and its overlays are never reported as
findings about your app. Ownership is decided structurally — a view is walked up its responder
chain to whichever view controller owns it, and everything Scyther presents is hosted in a
controller of Scyther's own — rather than by what a class happens to be called, which is what
matters in practice because every Scyther screen is SwiftUI and hangs off a private
_UIHostingView naming Scyther nowhere. The live overlay follows the same rule from the other
side: while a Scyther screen is in front of the app it draws no boxes and no count pill at all,
since every box describes an element of the app underneath and points at a rectangle where nothing
it describes is still on screen. Live mode stays on; the boxes come straight back when you dismiss
Scyther.
The live overlay goes further still: while a Scyther screen is in front of the app, no live pass runs at all. The question is asked when the pass comes up rather than when a controller appeared, because half a second of debounce separates the two and Scyther's own report rises inside that gap — a pass that landed there walked a window containing Scyther's own report and listed its Close and Re-run buttons as undersized touch targets. The skipped pass is taken as soon as Scyther's screen goes away.
It goes one step further for Contrast: while any Scyther screen is presented over the app — the menu, the report reached from the menu, the held-request editor — the check does not run at all. A presented screen dims and scales everything behind it, so the pixels the sampler would read are your app seen through Scyther's own dimming, and every ratio measured from them is an artefact. The report says so, and points you at the count pill, which is the one route into the report that measures contrast before Scyther covers anything. Missing Labels and Touch Targets come from the accessibility tree rather than from pixels, so nothing covering the screen changes their answer and they keep running either way.
The snapshot the contrast check reads is deliberately constrained, in three ways worth knowing about if a ratio ever looks wrong:
- It never contains Scyther's own drawing. The grid overlay, the FPS counter and the audit's own boxes and count pill are hidden for the instant the snapshot is taken and restored immediately afterwards. Without that, live mode measured each element through the box the previous pass had stroked around it, and a borderline element could flip between flagged and clean forever.
- It never contains content iOS protects. The snapshot is taken with
drawHierarchy, which honours the platform's non-capturable-content flags — secure text entry, DRM layers, Apple Pay. When it declines to render, there is no fallback: contrast is reported as a check that could not be measured, rather than measured through an API that ignores those flags. - It is captured at no more than 2 pixels per point, rather than a 3× device's native scale,
and that number comes from the display's scale rather than the window's own
contentScaleFactor— which is always 1, so the cap used to never apply and every snapshot was a 3 → 1 downscale that measurably degraded small text. The per-element crop is capped at 64 × 64 in any case, so nothing above 2× is measured, and a full-window bitmap is memory a host app can ill afford — which is why a live pass no longer takes one at all.
The audit also never runs on an App Store build, even with Scyther.start(allowProductionBuilds: true). Every other Scyther feature is gated by start() alone; this is the only one that reads
the user's screen as pixels, so it refuses on its own account as well. Nothing at all is set up on
such a build: no overlay is installed in the app's hit-testing chain, nothing watches the app lay
out, and no trigger — including the notification observers registered at launch — can schedule a
pass.
What the audit cannot see. A clean report is not a statement that your app is accessible. It is a statement that three checks found nothing on one screen as it looked at one moment, and the report screen says so under its own green tick. The gaps, none of which is a bug:
- Anything your app never exposed to accessibility. The audit walks the accessibility tree, so
an element that is not in it does not exist as far as this tool is concerned — a custom control
with no
isAccessibilityElement, a view hidden behindaccessibilityElementsHidden, anything VoiceOver simply never reaches. Those pass silently, and they are precisely the defect a VoiceOver user hits. This is the largest gap by far. - Whether a label means anything. The check tests that a label exists and is not whitespace. "Button", "image1" and "asdf" all pass it.
- Non-text contrast beyond a flat element's own frame. WCAG 1.4.11 covers icons, control boundaries, focus indicators and meaningful graphics; this measures a two-tone crop, reports on an icon over a plain background, and refuses a photograph, a gradient or a chart. Refusing is honest, but it is not coverage.
- Any appearance that is not currently showing. The other colour scheme, every Dynamic Type size but the current one, every locale but the current one — including right-to-left layouts and long translations — and Increased Contrast, Reduce Transparency, Bold Text and Button Shapes.
- Anything off screen. Below the fold of a scroll view, rows not laid out, screens you have not navigated to, and after a truncated pass everything past the stopping point in tree order.
- Contrast, while you are only watching the live overlay. The boxes and the pill cover two of the three checks, so a screen you never opened the report on has not had its contrast measured at all and a pill reading zero says nothing about it.
- Everything the three checks are not. Reading order, focus traps, custom rotors, accessibility actions, hint quality, Switch Control and Voice Control reachability, captions, timing and motion.
Contrast findings are estimates from rendered pixels and touch-target findings measure drawn frames rather than hit-testing insets, so a finding can also be wrong in the harmless direction. Use the audit to find defects; do not use it to certify their absence.
There is no separate settings screen and no public code API for this feature yet — everything lives on the report screen itself, reached from UI/UX → Accessibility Audit.
These are available as toggles in the Scyther menu under UI/UX:
- Slow Animations: Reduces animation speed to 10%
- Show View Frames: Adds colored borders to all views
- Show View Sizes: Displays width/height labels on views
Test how your app looks under different appearance settings without changing device settings:
// Force dark mode
Scyther.appearance.colorScheme = .dark
// Force light mode
Scyther.appearance.colorScheme = .light
// Follow system (default)
Scyther.appearance.colorScheme = .systemHigh Contrast Mode (iOS 17+):
// Enable high contrast
Scyther.appearance.highContrastEnabled = trueDynamic Type Override (iOS 17+):
Test all 12 content size categories, including 5 accessibility sizes:
// Test with extra large text
Scyther.appearance.contentSizeCategory = .extraExtraExtraLarge
// Test with accessibility sizes
Scyther.appearance.contentSizeCategory = .accessibilityExtraLarge
// Reset to system default
Scyther.appearance.contentSizeCategory = nilAll appearance settings are persisted across app launches and can be configured via the Scyther menu under UI/UX > Appearance.
Add your own debug options to the Scyther menu.
Display static information:
Scyther.developerOptions = [
DeveloperOption(
name: "User ID",
value: UserManager.shared.currentUserId ?? "Not logged in",
icon: UIImage(systemName: "person.circle")
),
DeveloperOption(
name: "Session Token",
value: String(AuthManager.shared.token?.prefix(20) ?? "None") + "...",
icon: UIImage(systemName: "key")
),
DeveloperOption(
name: "Cache Size",
value: CacheManager.shared.formattedSize,
icon: UIImage(systemName: "internaldrive")
)
]Navigate to custom debug screens:
Scyther.developerOptions.append(
DeveloperOption(
name: "Debug Settings",
icon: UIImage(systemName: "gear"),
viewController: DebugSettingsViewController()
)
)
Scyther.developerOptions.append(
DeveloperOption(
name: "Analytics Events",
icon: UIImage(systemName: "chart.bar"),
viewController: AnalyticsDebugViewController()
)
)Display custom environment variables in the Scyther menu.
Scyther.environmentVariables = [
"API_VERSION": "v2",
"FEATURE_SET": "premium",
"AB_TEST_GROUP": "B",
"BUILD_CONFIGURATION": "Debug",
"ANALYTICS_ENABLED": "true"
]These are displayed under Networking > Environment Variables in the menu.
By default, shaking the device opens the Scyther menu.
// This is the default
Scyther.invocationGesture = .shakeWhile invocationGesture is .shake, Control + Command + Z (⌃⌘Z) on a hardware keyboard also
opens the menu — no setup needed, and handy for an iPad in a keyboard case. It's the same chord the
Simulator uses for Device › Shake, so one shortcut works everywhere. It shows up as "Open Scyther"
in the shortcuts overlay you get by holding ⌘.
The shortcut is offered by every UIWindow, near the end of the responder chain, and is only ever
added to the window's commands. Your app's own shortcuts are untouched, and if your app binds ⌃⌘Z
itself, your command wins because it sits closer to the first responder. Two things hide Scyther's
shortcut: setting invocationGesture = .custom, or a UIWindow subclass that overrides
keyCommands without calling super.
For custom trigger mechanisms:
Scyther.invocationGesture = .custom
// Then trigger manually from your own gesture handler
func handleSecretGesture() {
Scyther.showMenu()
}// Show the menu
Scyther.showMenu()
// Show from a specific view controller
Scyther.showMenu(from: self)
// Hide the menu
Scyther.hideMenu()
// Check menu state
if Scyther.isPresented {
Scyther.hideMenu()
}Any row in the main menu can be pinned. Swipe left on a row and tap Pin; a Pinned section appears directly beneath Device containing your shortcuts.
Pinned rows stay in their original section as well, so the menu never changes shape — the Pinned section is purely an additional shortcut. Rows appear in the order you pinned them, oldest first. Swipe and tap Unpin on either copy to remove one.
Everything is pinnable, including information rows such as Bundle ID, inline toggles
such as Slow Animations, and any custom options you register via
Scyther.developerOptions.
Pins persist across launches in Scyther's private preferences suite, so they survive your
app clearing its own UserDefaults. See Preferences Storage.
Scyther's menu follows your app. If your app is localised into any of the twelve languages Scyther ships, the menu appears in whichever one the user is running; otherwise it follows the device language and falls back to English. There is nothing to configure and no setup step — adding a localisation to your app is enough.
Rows you register through Scyther.developerOptions display the name you pass in, unchanged,
so localise that string yourself if you want it translated alongside the rest.
To read the menu in a language your app does not ship, or to check how your own screens look in one, use UI/UX → Language. See Localisation.
| Property/Method | Type | Description |
|---|---|---|
start(allowProductionBuilds:) |
@MainActor static func |
Initializes Scyther. Later calls are ignored |
showMenu(from:) |
static func |
Presents the debug menu |
hideMenu(animated:completion:) |
static func |
Dismisses the debug menu |
isStarted |
Bool |
Whether Scyther has been started |
isPresented |
Bool |
Whether the menu is currently showing |
delegate |
ScytherDelegate? |
Delegate for receiving events |
invocationGesture |
ScytherGesture |
Gesture to open menu (.shake, which also enables the ⌃⌘Z keyboard shortcut, or .custom) |
developerOptions |
[DeveloperOption] |
Custom menu options |
environmentVariables |
[String: String] |
Custom environment variables |
apnsToken |
String? |
APNS device token |
fcmToken |
String? |
FCM device token |
| Subsystem | Access | Description |
|---|---|---|
Scyther.featureFlags |
FeatureFlags |
Feature flag management |
Scyther.servers |
Servers |
Server configuration |
Scyther.network |
Network |
Network logging |
Scyther.network.rules |
NetworkRules |
Request overrides — mocks, map local, header rewrites and per-endpoint conditioning, composed on one override |
Scyther.console |
Console |
Console output capture |
Scyther.crashes |
Crashes |
Crash logging and viewing |
Scyther.database |
DatabaseBrowsing |
Database browser and adapter registration |
Scyther.interface |
Interface |
UI debugging tools |
Scyther.location |
LocationSpoofing |
Location spoofing |
Scyther.notifications |
Notifications |
Push notification testing |
Scyther.appearance |
Appearance |
Appearance overrides (dark/light mode, Dynamic Type) |
Scyther.deepLinks |
DeepLinks |
Deep link testing with QR scanner |
Scyther.localization |
LanguageOverride |
App language override |
Scyther follows a clean architecture pattern with three main directories:
Sources/Scyther/
├── Core/ # Main entry point, InterfaceToolkit, AppEnvironment
├── Features/ # 18+ feature modules (NetworkLogger, FeatureFlags, etc.)
└── Shared/ # Reusable components, extensions, models
├── Components/ # SwiftUI components
├── Extensions/ # Swift/UIKit extensions
├── Models/ # Data models
├── SwiftUI/ # SwiftUI utilities (ViewModel, etc.)
└── ViewModifiers/ # Custom view modifiers
Each feature follows a consistent pattern:
FeatureName/
├── FeatureName.swift # Core logic, singleton
├── FeatureNameView.swift # SwiftUI UI
├── FeatureNameViewModel.swift # View model (if needed)
└── Supporting files...
Scyther uses a base ViewModel class located at Sources/Scyther/Shared/SwiftUI/ViewModel.swift that provides structured lifecycle management for SwiftUI views.
The ViewModel class provides four lifecycle hooks:
setup()- Called duringinit(), for synchronous setuponFirstAppear()- Called once on first view appearanceonAppear()- Called every time the view appearsonSubsequentAppear()- Called on appearances after the first
Subclass ViewModel for any feature that needs lifecycle management:
class MyFeatureViewModel: ViewModel {
@Published var data: [Item] = []
@Published var isLoading = false
override func onFirstAppear() async {
await super.onFirstAppear()
await loadInitialData()
}
override func onSubsequentAppear() async {
await super.onSubsequentAppear()
await refreshData()
}
private func loadInitialData() async {
isLoading = true
defer { isLoading = false }
// Load data...
}
}Use with the onFirstAppear view modifier:
struct MyFeatureView: View {
@StateObject private var viewModel = MyFeatureViewModel()
var body: some View {
List(viewModel.data) { item in
Text(item.name)
}
.onFirstAppear {
await viewModel.onFirstAppear()
}
}
}The base ViewModel class is marked @MainActor to ensure all lifecycle methods and published properties execute on the main thread.
Every feature uses a shared singleton instance:
static let instance = FeatureName() // or .shared
private init() { }This ensures a single source of truth and simplifies access patterns.
Open-source is what makes the world go round. I built Scyther to give back to the community that helped me grow as a developer.
No. Scyther uses no private APIs and has been shipping in production apps for years without App Store issues. By default, it's automatically disabled on App Store builds.
We recommend against it, but you can enable it:
Scyther.start(allowProductionBuilds: true)Warning: This could expose sensitive debugging information to end users.
Named after the Pokemon Scyther, a bug-type known for its cutting ability - just like this library cuts through bugs!
See CONTRIBUTING.md for the build and test commands, the architecture and testing conventions a change is expected to follow, and how to add or correct a localised string.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make the change, with tests and documentation
- Run the test suite and build the example app
- Open a Pull Request
- Use GitHub Issues for bug reports
- Include device model, iOS version, and Scyther version
- Provide minimal reproduction steps
If you discover a security vulnerability, please email b.stillitano95@gmail.com directly. Do not open a public issue.
Scyther is released under the MIT license. See LICENSE for details.
Scyther is maintained by Brandon Stillitano.
- Website: scyther.io
- Contact: scyther.io/contact.html
