This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
VirtualPAPI is an iOS SwiftUI application for aviation navigation, specifically designed to provide visual approach guidance. The app displays configurable visual approach indicators (glide slope or PAPI/VASI style) and can receive location data from three sources: the device's internal GPS, X-Plane flight simulator via UDP, or GDL90-compatible devices via UDP.
# Build for iOS simulator
xcodebuild -scheme VirtualPAPI -destination 'platform=iOS Simulator,name=iPhone 17' build
# Build for device (requires proper code signing)
xcodebuild -scheme VirtualPAPI -destination 'generic/platform=iOS' build
# Clean build artifacts
xcodebuild -scheme VirtualPAPI clean# Run unit tests
xcodebuild test -scheme VirtualPAPI -destination 'platform=iOS Simulator,name=iPhone 17'
# Run specific test target
xcodebuild test -scheme VirtualPAPI -only-testing:VirtualPAPITests -destination 'platform=iOS Simulator,name=iPhone 17'
# Run UI tests
xcodebuild test -scheme VirtualPAPI -only-testing:VirtualPAPIUITests -destination 'platform=iOS Simulator,name=iPhone 17'The scripts/ directory contains aviation data from ourairports.com:
# Regenerate the SQLite database from CSV files
cd scripts
./gen_sqlite.pyThis creates aviation.db with airports and runways tables. The script processes:
airports.csv: Airport locations and elevationsrunways.csv: Runway coordinates, headings, and displaced thresholds
Database Management:
DatabaseManager(singleton) handles SQLite operations- Database copied from bundle to Documents directory on first launch
- Supports remote database updates via
downloadRemoteDatabase()method - Provides query methods:
getAirport(ident:),searchAirports(),getRunways(airportId:) - Returns table counts for diagnostics
The app uses SwiftUI's @StateObject and @EnvironmentObject pattern for global state:
-
VirtualPAPIApp.swift (app entry point) creates six core state objects:
AppSettings: User preferences (location source, visualization type, debug info, favorites, smoothing)GenericLocation: Abstract location provider consumed by UI, handles calculationsXGPSDataReader: UDP listener for X-Plane GPS data (port 49002)GDL90Reader: UDP listener for GDL90-compatible GPS devices (port 4000)AirportSelection: Selected airport, runway, and approach parametersHighFrequencyLocationTracker: CoreLocation-based GPS tracker for internal GPS
-
These are injected into the view hierarchy via
.environmentObject()and accessed with@EnvironmentObjectin child views. -
Location sources are managed centrally in VirtualPAPIApp:
startListenerForSource()andstopListenerForSource()methods toggle between sources- Only one location source is active at a time based on
AppSettings.locationSource - The active source updates
GenericLocationwhich the UI observes
The app supports three location sources, selectable via AppSettings.locationSource enum:
Internal GPS (LocationSource.internalGPS):
HighFrequencyLocationTrackeruses CoreLocation with high-frequency polling- Instantiated in VirtualPAPIApp and starts tracking on app launch (VirtualPAPIApp.swift:40-41)
- Updates
GenericLocationwith position, speed, and track data - Runs continuously but only updates GenericLocation when selected as active source
X-Plane Simulator (LocationSource.xPlane):
XGPSDataReaderlistens on UDP port 49002 for XGPS format packets- Uses a raw BSD socket (not
NWListener) that never callsconnect(), so it can't steal broadcast packets from other apps listening on the same port (e.g. ForeFlight) — see "Concurrency" below - Parses lat/lon/alt/speed/track from comma-separated ASCII data (XGPSDataReader.swift:119-142)
- Converts altitude from meters to feet (×3.2808399) and speed from m/s to knots (×1.9438445)
- Updates
GenericLocationonly when X-Plane is the selected source (XGPSDataReader.swift:108)
GDL90 Devices (LocationSource.gdl90):
GDL90Readerlistens on UDP port 4000 for GDL90-formatted packets- Uses the same raw BSD socket approach as
XGPSDataReaderfor receiving (never callsconnect()), to avoid stealing broadcast packets from other GDL90 apps on the same port - Implements full GDL90 protocol parsing with CRC validation
- Parses Message ID 10 (Ownship Report) for position, altitude, speed, and track
- Parses Message ID 11 (Ownship Geometric Altitude) for geometric altitude
- Broadcasts UDP heartbeat on port 63093 (via
NWConnection) to advertise availability to GDL90 devices - Updates
GenericLocationonly when GDL90 is the selected source (GDL90Reader.swift:175)
GenericLocation acts as the single source of truth for the UI and contains:
- Current position (lat/lon/alt), speed, and track
- Vincenty's formula implementation for accurate distance calculations on WGS84 ellipsoid (GenericLocation.swift:275-366)
- Active glide slope deviation calculations with configurable descent angle
- Distance and bearing calculations to selected runway
- Exponential moving average (EMA) smoothing for glide slope with configurable alpha
- Staleness detection (marks location stale if no updates for 5+ seconds)
- PAPI color calculation for 4-light PAPI display
Main Views:
-
ContentView.swift: Primary navigation and display view
- Shows selected airport/runway information with distance-to-go (DTG) and angle
- Displays bearing indicator (arrow) showing direction to destination
- Switches between GlideSlopeView and PapiView based on
AppSettings.visualization - Double-tap to toggle between visualization modes
- Favorite airports quick-access section (scrollable horizontal list)
- Navigation to AirportSelectionView and SettingsView
- Location staleness warning when GPS signal is lost
- Debug info display (lat/lon/alt/speed/track/source) when enabled
-
GlideSlopeView.swift: ILS-style glide slope indicator
- GeometryReader-based visual display with animated diamond indicator
- Indicator position based on
genericLocation.gsOffsetorgenericLocation.smoothedGsOffset - Vertical position shows deviation from configured descent angle (default 3°)
- Supports configurable smoothing via EMA
-
PapiView.swift: PAPI-style 4-light visual indicator
- Four horizontal lights that change color based on approach angle
- Red lights indicate too low, white lights indicate on or above glide slope
- Uses
genericLocation.papiColorsarray for interpolated colors - Standard PAPI configuration (2 red / 2 white = on glide slope)
-
AirportSelectionView.swift: Airport and runway selection interface
- Search functionality for airports by identifier or name
- Displays airport details (elevation, coordinates)
- Runway selection with visual layout
- Configurable descent angle (default 3.0°)
- Configurable aiming point (default 500 ft from threshold)
- Favorite airport toggle (star icon)
- Accounts for displaced thresholds in target calculation
-
SettingsView.swift: Configuration screen
- Location source picker (Internal GPS / X-Plane / GDL90)
- Visualization type picker (Glide Slope / PAPI)
- Responsiveness slider (EMA alpha: Smooth/Medium/Fast/Instantaneous)
- Network information (local IP address for UDP troubleshooting)
- Aviation database info (last modified date, airport/runway counts)
- Database update functionality (downloads latest data from remote source)
- Debug mode toggle
- Links to debug views (GDL90, Generic Location, Destination Map)
Debug Views:
- GDL90DebugView.swift: Real-time GDL90 protocol diagnostics
- GenericLocationDebugView.swift: Location calculation diagnostics
- DestinationMapView.swift: Map visualization of destination and current position
Core Models (Structs.swift):
Airport: Basic airport info (ident, name, coordinates, elevation)Runway: Runway-specific data (heading, displaced threshold, dimensions)
These match the SQLite schema in scripts/aviation.db.
Enums (AppSettings.swift):
LocationSource: Internal GPS / X-Plane / GDL90 (CaseIterable, Identifiable)VisualizationType: Glide Slope / PAPI (CaseIterable, Identifiable)
Settings and State:
-
AppSettings: Observable settings object with UserDefaults persistencelocationSource: Active GPS/simulator sourcevisualization: Display mode (glide slope or PAPI)emaAlpha: Smoothing factor (0.2 = smooth, 1.0 = instantaneous)showDebugInfo: Toggle for debug overlayfavoriteAirports: Array of airport identifiers- Includes migration logic from old
useXPlaneboolean setting
-
AirportSelection: Observable selection state- Selected airport and runway references
- Descent angle and aiming point configuration
- Target coordinates (calculated from runway + displaced threshold + aiming point)
- Favorite airports management with UserDefaults persistence
The XGPS protocol expects packets starting with "XGPS" header followed by comma-separated values. The parser (XGPSDataReader.swift:119-142):
- Validates 41+ byte packets with "XGPS" header
- Extracts longitude (component 1), latitude (component 2), altitude in meters (component 3), track in degrees (component 4), speed in m/s (component 5)
- Converts altitude from meters to feet (×3.2808399)
- Converts speed from m/s to knots (×1.9438445)
- Updates both
XGPSDataReaderandGenericLocationstates (only when X-Plane is selected source)
Why a raw BSD socket instead of NWListener: NWListener's UDP mode creates a per-sender NWConnection by internally connect()-ing a socket to the remote address ("established-over-unconnected"). On BSD-derived kernels, a connected socket takes delivery priority over other apps' plain wildcard-bound listening sockets on the same port, so this used to silently steal X-Plane's broadcast packets away from apps like ForeFlight running at the same time. XGPSDataReader.startListening() (XGPSDataReader.swift:27-73) instead opens a raw socket with SO_REUSEADDR/SO_REUSEPORT, binds to INADDR_ANY:49002, and only ever calls recvfrom() on a dedicated background Thread — never connect() — so it behaves like a normal passive listener and coexists with other apps.
The GDL90 protocol is a standard aviation data link protocol used by many portable GPS and ADS-B receivers. The implementation (GDL90Reader.swift):
Receiving: Uses the same raw BSD socket / recvfrom() approach as XGPSDataReader (GDL90Reader.swift:40-105) for the same reason — avoids stealing UDP broadcast packets from other GDL90-consuming apps on port 4000. The outbound heartbeat broadcast (below) is unaffected and still uses NWConnection, since sending isn't subject to this issue.
Framing and Validation:
- Messages framed with 0x7E flag bytes
- Byte stuffing: 0x7D escape byte followed by XOR 0x20
- CRC-16-CCITT validation with table-driven lookup (polynomial 0x1021)
- Validates CRC before processing any message
Message Parsing:
- Message ID 10 (Ownship Report): Position, pressure altitude, ground speed, track
- 24-bit signed lat/lon with LSB = 180/2^23 degrees
- 12-bit altitude with 25 ft resolution, -1000 ft offset
- 12-bit velocity with 1 knot resolution (0xFFF = invalid)
- 8-bit track with LSB = 360/256 = 1.40625 degrees
- Message ID 11 (Ownship Geometric Altitude): 16-bit signed with 5 ft resolution
Device Discovery:
- Broadcasts UDP heartbeat on port 63093 every 5 seconds
- JSON payload:
{"App": "VirtualPAPI", "GDL90": {"port": 4000}} - Allows GDL90 devices to discover and connect to the app
The AirportSelection class (AirportSelection.swift) manages destination configuration:
- Stores selected airport and runway
- Calculates final aiming point using
calculateAimingPoint()(AirportSelection.swift:94-147) - Accounts for displaced threshold + user-specified aiming point (default 500 ft)
- Uses great circle calculation to project target point along runway heading
- Updates
GenericLocationwith target coordinates for distance/bearing calculations
The glide slope deviation logic (GenericLocation.swift:222-244):
- Configurable descent angle (default 3.0°, stored in
AirportSelection.descentAngle) - Calculates actual angle:
atan((altitude - targetElevation) / distanceInFeet) * 180 / π - Deviation = actual angle - desired descent angle
- Positive deviation = aircraft above glide slope (fly down)
- Exponential Moving Average (EMA) smoothing applied:
EMA_new = alpha * current + (1 - alpha) * EMA_previous- Alpha configurable via
AppSettings.emaAlpha(0.2 = smooth, 1.0 = instantaneous)
- Alpha configurable via
- Display offset calculation (GenericLocation.swift:246-258):
- Full scale deviation = 0.7° (±45% of display height = ±1.55555°)
gsOffset = angleDeviation / 1.55555, clamped to ±0.45
- PAPI color calculation (GenericLocation.swift:260-273):
- Position shifts ±0.5 while deviation shifts ±0.7 (factor of 1.4)
- Each light interpolates smoothly between red and white
GenericLocation.distance() implements Vincenty's inverse formula for WGS84 ellipsoid (GenericLocation.swift:275-366), falling back to Haversine for antipodal points. Returns nautical miles.
The heading() method (GenericLocation.swift:107-139) calculates true bearing from current position to destination using the forward azimuth formula, returning 0-360° where 0° is North.
Relative bearing calculation (GenericLocation.swift:194-220):
- When track data is available, calculates where the destination is relative to current heading
- Positive values = turn right, negative = turn left
- Normalized to ±180° range for intuitive display
Most views include #Preview macros for Xcode canvas previews. When modifying views, ensure all required environment objects are provided in previews:
#Preview {
ContentView()
.environmentObject(AppSettings())
.environmentObject(GenericLocation())
.environmentObject(XGPSDataReader())
.environmentObject(GDL90Reader())
.environmentObject(AirportSelection())
.environmentObject(HighFrequencyLocationTracker())
}Note: Not all views require all environment objects. Check the view's @EnvironmentObject declarations to determine which are needed.
XGPSDataReaderandGDL90Readeruse@MainActorto ensure all UI updates happen on main thread- UDP receiving uses raw BSD sockets (
socket/bind/recvfrom), each with its own dedicated backgroundThreadrunning a blockingnonisolatedreceive loop (receiveLoop(fd:)) — notNWListener/DispatchQueue, to avoid the socket-priority issue described above GDL90Readerstill keepsDispatchQueue(label: "gdl90-udp-queue")for its outbound heartbeat broadcast (NWConnection-basedsendBroadcast()), which is unrelated to receiving- Location updates use
Task { @MainActor in ... }to hop back from the receive thread for thread-safe UI updates - Database operations in
DatabaseManagerare async/await capable for non-blocking updates
The app includes fully implemented features for real-world aviation use:
Airport/Runway Database:
- SQLite database with airports and runways from OurAirports.com
- Search and selection UI with favorites management
- Automatic database updates via Settings (downloads remote database)
- Displaced threshold and aiming point calculations
Multi-Source Location:
- Three independent location sources (GPS, X-Plane, GDL90)
- Automatic source switching with proper cleanup
- Location staleness detection (5-second timeout)
Visual Approach Guidance:
- Dual visualization modes (ILS-style glide slope, PAPI)
- Configurable descent angles (not just standard 3°)
- Configurable smoothing/responsiveness
- Real-time distance, bearing, and angle calculations
User Experience:
- Persistent settings and favorites (UserDefaults)
- Prevents screen sleep during use (UIApplication.shared.isIdleTimerDisabled)
- Debug views for troubleshooting GPS and protocol issues
- Deployment Target: iOS 26.0
- Development Team: B4F7YCNRD9
- Bundle ID: com.mfdutra.VirtualPAPI
- Swift Version: 5.0
- Supported Devices: iPhone and iPad (universal)