6b33a104c8
Three follow-ups to the live tab landed together:
1) Bundled airline registry
- airlines.json (208KB, 2,695 entries sourced from FR24's
/mobile/airlines feed and slimmed to {icao,iata,name,logo}).
- AircraftRegistry rewritten as an instance singleton that loads
the bundle at startup, indexes by both ICAO and IATA, and falls
back to a small hardcoded subset if the bundle is unavailable.
- Detail sheet now shows the airline's logo (loaded from FR24's
CDN via AsyncImage) alongside the callsign. Filter chips use
the real names everywhere.
2) OpenSky account login
- OpenSkyCredentials: Keychain wrapper that stores username +
password using SecItem APIs. Posts a notification on change so
the OpenSkyClient can refresh its in-memory copy.
- OpenSkyClient now sends HTTP Basic auth when credentials are
present. Anonymous fallback unchanged.
- OpenSkySettingsView: tap the gear in the footer to sign in.
Credentials are verified against /states/all before being
stored; sign-out clears Keychain. Raises the quota from ~100
to ~4000 requests/day.
3) Flight trails
- AircraftTrack model decodes OpenSky's /tracks/all heterogeneous
path array into typed TrackPoint entries.
- OpenSkyClient.track(icao24:) fetches the current/most-recent
track for an aircraft.
- LiveFlightsView renders a MapPolyline trail along the path of
whichever aircraft is currently selected. Cleared on
deselection; race-guarded against rapid selection changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
9.9 KiB
Swift
243 lines
9.9 KiB
Swift
import Foundation
|
|
|
|
/// Thin client for the OpenSky Network REST API. Two endpoints:
|
|
/// - `/states/all` — live aircraft state vectors (positions, velocity, etc.)
|
|
/// - `/flights/aircraft` — recent flight history per aircraft (used for the
|
|
/// tap-to-detail "where did this come from / going to" panel)
|
|
///
|
|
/// Anonymous access is rate-limited (~10s minimum between requests, 100/day);
|
|
/// authenticated access gets higher quotas but requires the user to register.
|
|
/// We default to anonymous + heavy debouncing in the UI layer.
|
|
actor OpenSkyClient {
|
|
enum ClientError: Error, LocalizedError {
|
|
case requestFailed(status: Int)
|
|
case decodingFailed(Error)
|
|
case throttled
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .requestFailed(let s): return "OpenSky HTTP \(s)"
|
|
case .decodingFailed(let e): return "Could not parse OpenSky response: \(e.localizedDescription)"
|
|
case .throttled: return "OpenSky rate limit reached. Wait a moment and retry."
|
|
}
|
|
}
|
|
}
|
|
|
|
private let session: URLSession
|
|
private var basicAuthHeader: String?
|
|
|
|
init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 20
|
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
session = URLSession(configuration: config)
|
|
basicAuthHeader = Self.makeBasicAuth(OpenSkyCredentials.shared.load())
|
|
|
|
// Re-read credentials whenever the Settings screen saves new ones.
|
|
NotificationCenter.default.addObserver(
|
|
forName: .openSkyCredentialsChanged, object: nil, queue: nil
|
|
) { [weak self] _ in
|
|
Task { await self?.reloadCredentials() }
|
|
}
|
|
}
|
|
|
|
private func reloadCredentials() {
|
|
basicAuthHeader = Self.makeBasicAuth(OpenSkyCredentials.shared.load())
|
|
}
|
|
|
|
nonisolated private static func makeBasicAuth(_ creds: OpenSkyCredentials.Credentials?) -> String? {
|
|
guard let creds else { return nil }
|
|
let raw = "\(creds.username):\(creds.password)"
|
|
guard let data = raw.data(using: .utf8) else { return nil }
|
|
return "Basic \(data.base64EncodedString())"
|
|
}
|
|
|
|
/// Apply auth header to a URLRequest if credentials are stored.
|
|
private func applyAuth(_ request: inout URLRequest) {
|
|
if let auth = basicAuthHeader {
|
|
request.setValue(auth, forHTTPHeaderField: "Authorization")
|
|
}
|
|
}
|
|
|
|
/// Aircraft inside the given lat/lon bounding box.
|
|
///
|
|
/// Use the smallest bounding box that covers the user's visible map — too
|
|
/// wide and you'll pull thousands of state vectors per call (and burn
|
|
/// quota faster).
|
|
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
|
|
var comps = URLComponents(string: "https://opensky-network.org/api/states/all")!
|
|
comps.queryItems = [
|
|
URLQueryItem(name: "lamin", value: String(latMin)),
|
|
URLQueryItem(name: "lomin", value: String(lonMin)),
|
|
URLQueryItem(name: "lamax", value: String(latMax)),
|
|
URLQueryItem(name: "lomax", value: String(lonMax))
|
|
]
|
|
guard let url = comps.url else { throw ClientError.requestFailed(status: -1) }
|
|
return try await decodeStates(from: url)
|
|
}
|
|
|
|
private func decodeStates(from url: URL) async throws -> [LiveAircraft] {
|
|
var req = URLRequest(url: url)
|
|
applyAuth(&req)
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await session.data(for: req)
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
if status == 429 { throw ClientError.throttled }
|
|
guard status == 200 else { throw ClientError.requestFailed(status: status) }
|
|
|
|
do {
|
|
let resp = try JSONDecoder().decode(StatesResponse.self, from: data)
|
|
return resp.states ?? []
|
|
} catch {
|
|
throw ClientError.decodingFailed(error)
|
|
}
|
|
}
|
|
|
|
/// In-flight track for the given aircraft — sequence of (time, lat, lon,
|
|
/// alt, heading, onGround) points covering the most recent flight (or
|
|
/// current flight if it's still airborne). Used to draw the trail on
|
|
/// the map when an aircraft is selected.
|
|
func track(icao24: String) async -> AircraftTrack? {
|
|
var comps = URLComponents(string: "https://opensky-network.org/api/tracks/all")!
|
|
comps.queryItems = [
|
|
URLQueryItem(name: "icao24", value: icao24.lowercased()),
|
|
URLQueryItem(name: "time", value: "0") // 0 = current/most-recent
|
|
]
|
|
guard let url = comps.url else { return nil }
|
|
|
|
var req = URLRequest(url: url)
|
|
applyAuth(&req)
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: req)
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
guard status == 200 else { return nil }
|
|
return try? JSONDecoder().decode(AircraftTrack.self, from: data)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Flights an aircraft has flown in the past N days.
|
|
/// OpenSky requires a `begin` and `end` window, max 30 days each.
|
|
func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] {
|
|
let now = Int(Date().timeIntervalSince1970)
|
|
let begin = now - (daysBack * 86400)
|
|
var comps = URLComponents(string: "https://opensky-network.org/api/flights/aircraft")!
|
|
comps.queryItems = [
|
|
URLQueryItem(name: "icao24", value: icao24.lowercased()),
|
|
URLQueryItem(name: "begin", value: String(begin)),
|
|
URLQueryItem(name: "end", value: String(now))
|
|
]
|
|
guard let url = comps.url else { return [] }
|
|
|
|
var req = URLRequest(url: url)
|
|
applyAuth(&req)
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: req)
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
// 404 from OpenSky means "no flights in this window" — not an error.
|
|
guard status == 200 else { return [] }
|
|
return (try? JSONDecoder().decode([OpenSkyFlight].self, from: data)) ?? []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Decoding
|
|
|
|
/// OpenSky returns each state vector as a heterogeneous array — index 0 is
|
|
/// the ICAO24 hex, 1 is callsign, 2 is country, 3 is unix time of last
|
|
/// position, 4 is unix time of last contact, 5 is longitude, 6 is latitude,
|
|
/// 7 is barometric altitude in meters, 8 is on_ground bool, 9 is velocity
|
|
/// m/s, 10 is true_track degrees, 11 is vertical_rate m/s, 12 is sensors
|
|
/// (array of receiver IDs, skipped), 13 is geometric altitude meters, 14
|
|
/// is squawk, 15 is spi bool (skipped), 16 is position_source (skipped),
|
|
/// 17 is category. Most entries can be `null`.
|
|
private struct StatesResponse: Decodable {
|
|
let time: Int
|
|
let states: [LiveAircraft]?
|
|
|
|
enum CodingKeys: String, CodingKey { case time, states }
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
time = try c.decode(Int.self, forKey: .time)
|
|
let raw = try c.decodeIfPresent([RawStateVector].self, forKey: .states)
|
|
states = raw?.compactMap(LiveAircraft.from)
|
|
}
|
|
}
|
|
|
|
/// Intermediate decoder that consumes the heterogeneous array into named
|
|
/// optional fields without exploding on null values or shape drift.
|
|
private struct RawStateVector: Decodable {
|
|
let icao24: String
|
|
let callsign: String?
|
|
let originCountry: String
|
|
let timePosition: Int?
|
|
let lastContact: Int
|
|
let longitude: Double?
|
|
let latitude: Double?
|
|
let baroAltitude: Double?
|
|
let onGround: Bool
|
|
let velocity: Double?
|
|
let trueTrack: Double?
|
|
let verticalRate: Double?
|
|
let geoAltitude: Double?
|
|
let squawk: String?
|
|
let category: Int?
|
|
|
|
init(from decoder: Decoder) throws {
|
|
var c = try decoder.unkeyedContainer()
|
|
icao24 = (try? c.decode(String.self)) ?? ""
|
|
callsign = try? c.decodeIfPresent(String.self)
|
|
originCountry = (try? c.decode(String.self)) ?? ""
|
|
timePosition = try? c.decodeIfPresent(Int.self)
|
|
lastContact = (try? c.decode(Int.self)) ?? 0
|
|
longitude = try? c.decodeIfPresent(Double.self)
|
|
latitude = try? c.decodeIfPresent(Double.self)
|
|
baroAltitude = try? c.decodeIfPresent(Double.self)
|
|
onGround = (try? c.decode(Bool.self)) ?? false
|
|
velocity = try? c.decodeIfPresent(Double.self)
|
|
trueTrack = try? c.decodeIfPresent(Double.self)
|
|
verticalRate = try? c.decodeIfPresent(Double.self)
|
|
// [12] sensors array — skip.
|
|
_ = try? c.decodeIfPresent([Int].self)
|
|
geoAltitude = try? c.decodeIfPresent(Double.self)
|
|
squawk = try? c.decodeIfPresent(String.self)
|
|
// [15] spi, [16] position_source — skip.
|
|
_ = try? c.decode(Bool.self)
|
|
_ = try? c.decode(Int.self)
|
|
category = try? c.decodeIfPresent(Int.self)
|
|
}
|
|
}
|
|
|
|
private extension LiveAircraft {
|
|
static func from(_ raw: RawStateVector) -> LiveAircraft? {
|
|
guard let lat = raw.latitude, let lon = raw.longitude, !raw.icao24.isEmpty else {
|
|
return nil
|
|
}
|
|
return LiveAircraft(
|
|
icao24: raw.icao24,
|
|
callsign: raw.callsign,
|
|
originCountry: raw.originCountry,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
baroAltitude: raw.baroAltitude,
|
|
geoAltitude: raw.geoAltitude,
|
|
velocity: raw.velocity,
|
|
trueTrack: raw.trueTrack,
|
|
verticalRate: raw.verticalRate,
|
|
onGround: raw.onGround,
|
|
squawk: raw.squawk,
|
|
category: raw.category,
|
|
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact))
|
|
)
|
|
}
|
|
}
|