Live tab: bundled airline DB, OpenSky login, in-flight trails

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>
This commit is contained in:
Trey T
2026-05-27 06:16:49 -05:00
parent 888943deb4
commit 6b33a104c8
9 changed files with 490 additions and 93 deletions
+12
View File
@@ -52,6 +52,9 @@
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV4400004444000044440002 /* LiveFlightsView.swift */; }; LV4400004444000044440001 /* LiveFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV4400004444000044440002 /* LiveFlightsView.swift */; };
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV5500005555000055550002 /* LiveFlightDetailSheet.swift */; }; LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV5500005555000055550002 /* LiveFlightDetailSheet.swift */; };
LV6600006666000066660001 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV6600006666000066660002 /* RootView.swift */; }; LV6600006666000066660001 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV6600006666000066660002 /* RootView.swift */; };
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV7700007777000077770002 /* OpenSkyCredentials.swift */; };
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV8800008888000088880002 /* OpenSkySettingsView.swift */; };
LV9900009999000099990001 /* airlines.json in Resources */ = {isa = PBXBuildFile; fileRef = LV9900009999000099990002 /* airlines.json */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -112,6 +115,9 @@
LV4400004444000044440002 /* LiveFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightsView.swift; sourceTree = "<group>"; }; LV4400004444000044440002 /* LiveFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightsView.swift; sourceTree = "<group>"; };
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightDetailSheet.swift; sourceTree = "<group>"; }; LV5500005555000055550002 /* LiveFlightDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightDetailSheet.swift; sourceTree = "<group>"; };
LV6600006666000066660002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; }; LV6600006666000066660002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
LV7700007777000077770002 /* OpenSkyCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkyCredentials.swift; sourceTree = "<group>"; };
LV8800008888000088880002 /* OpenSkySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkySettingsView.swift; sourceTree = "<group>"; };
LV9900009999000099990002 /* airlines.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = airlines.json; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -148,6 +154,7 @@
LV4400004444000044440002 /* LiveFlightsView.swift */, LV4400004444000044440002 /* LiveFlightsView.swift */,
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */, LV5500005555000055550002 /* LiveFlightDetailSheet.swift */,
LV6600006666000066660002 /* RootView.swift */, LV6600006666000066660002 /* RootView.swift */,
LV8800008888000088880002 /* OpenSkySettingsView.swift */,
AA5555555555555555555555 /* Styles */, AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */, AA6666666666666666666666 /* Components */,
); );
@@ -182,6 +189,7 @@
1B20C5393D8F432A93097C2C /* Views */, 1B20C5393D8F432A93097C2C /* Views */,
D9E26DCDE2904210ABCA7855 /* Assets.xcassets */, D9E26DCDE2904210ABCA7855 /* Assets.xcassets */,
53F457716F0642BDBCBA93EA /* airports.json */, 53F457716F0642BDBCBA93EA /* airports.json */,
LV9900009999000099990002 /* airlines.json */,
); );
path = Flights; path = Flights;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -225,6 +233,7 @@
RE2200002222000022220002 /* RouteExplorerClient.swift */, RE2200002222000022220002 /* RouteExplorerClient.swift */,
LV2200002222000022220002 /* OpenSkyClient.swift */, LV2200002222000022220002 /* OpenSkyClient.swift */,
LV3300003333000033330002 /* AircraftRegistry.swift */, LV3300003333000033330002 /* AircraftRegistry.swift */,
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -338,6 +347,7 @@
files = ( files = (
F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */, F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */,
80D2BC95002A4931B3C10B4C /* airports.json in Resources */, 80D2BC95002A4931B3C10B4C /* airports.json in Resources */,
LV9900009999000099990001 /* airlines.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -390,6 +400,8 @@
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */, LV4400004444000044440001 /* LiveFlightsView.swift in Sources */,
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */, LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */,
LV6600006666000066660001 /* RootView.swift in Sources */, LV6600006666000066660001 /* RootView.swift in Sources */,
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */,
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+31
View File
@@ -110,6 +110,37 @@ func aircraftCategoryName(_ code: Int?) -> String? {
} }
} }
/// In-flight position track for one aircraft, returned by OpenSky's
/// `/tracks/all?icao24=...&time=0` endpoint. Each path entry is a
/// `[time, lat, lon, baroAlt, trueTrack, onGround]` heterogeneous array,
/// which we decode into a typed `TrackPoint`.
struct AircraftTrack: Decodable, Sendable {
let icao24: String
let callsign: String?
let startTime: Int
let endTime: Int
let path: [TrackPoint]
struct TrackPoint: Decodable, Sendable, Hashable {
let time: Int
let latitude: Double
let longitude: Double
let baroAltitude: Double?
let trueTrack: Double?
let onGround: Bool
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
time = (try? c.decode(Int.self)) ?? 0
latitude = (try? c.decodeIfPresent(Double.self)) ?? 0
longitude = (try? c.decodeIfPresent(Double.self)) ?? 0
baroAltitude = try? c.decodeIfPresent(Double.self)
trueTrack = try? c.decodeIfPresent(Double.self)
onGround = (try? c.decode(Bool.self)) ?? false
}
}
}
/// Historical OpenSky flight record used to surface "where did this aircraft /// Historical OpenSky flight record used to surface "where did this aircraft
/// take off from / where's it going" in the detail sheet. /// take off from / where's it going" in the detail sheet.
struct OpenSkyFlight: Decodable, Sendable, Hashable { struct OpenSkyFlight: Decodable, Sendable, Hashable {
+79 -90
View File
@@ -1,15 +1,80 @@
import Foundation import Foundation
/// Tiny lookup for the most common ICAO airline codes IATA + display name. /// Look up airline display info by ICAO callsign prefix.
/// Used to enrich live-tracker callsigns (e.g. "DAL1234" "Delta", IATA "DL").
/// ///
/// Not exhaustive about 80 entries covering the carriers we encounter /// Backed by `airlines.json` (bundled, ~2,700 entries sourced from
/// most. Anything not in the table falls back to the raw 3-letter ICAO code. /// flightradar24.com's public `/mobile/airlines` feed) and falls back to
/// If this grows past ~200, switch to a bundled JSON. /// a small hardcoded map of the most common carriers if the bundle is
enum AircraftRegistry { /// missing for any reason.
/// ICAO airline code (IATA, display name). ///
static let airlines: [String: (iata: String, name: String)] = [ /// Lookup is by ICAO 3-letter code (e.g. "DAL" Delta). IATA-only
// US legacy + major /// lookups also work (e.g. "DL"). Anything that doesn't match either
/// returns the raw code as the name so the UI never blanks out.
final class AircraftRegistry: @unchecked Sendable {
static let shared = AircraftRegistry()
struct Entry: Sendable {
let icao: String? // 3-letter ICAO
let iata: String? // 2-3 char IATA
let name: String // display name
let logoURL: URL? // FR24 CDN URL, may be nil
}
private let byICAO: [String: Entry]
private let byIATA: [String: Entry]
private init() {
let url = Bundle.main.url(forResource: "airlines", withExtension: "json")
guard let url, let data = try? Data(contentsOf: url),
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
// Fallback to hardcoded subset if the bundled file is missing.
byICAO = Self.builtIn.reduce(into: [:]) { acc, kv in
acc[kv.key] = Entry(icao: kv.key, iata: kv.value.0, name: kv.value.1, logoURL: nil)
}
byIATA = byICAO.values.reduce(into: [:]) { acc, e in
if let i = e.iata { acc[i] = e }
}
return
}
var icaoMap: [String: Entry] = [:]
var iataMap: [String: Entry] = [:]
for row in raw {
let icao = (row["i"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let iata = (row["a"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let name = row["n"] as? String ?? ""
let logo = (row["l"] as? String).flatMap(URL.init(string:))
guard !name.isEmpty else { continue }
let entry = Entry(icao: icao, iata: iata, name: name, logoURL: logo)
if let icao { icaoMap[icao.uppercased()] = entry }
if let iata { iataMap[iata.uppercased()] = entry }
}
byICAO = icaoMap
byIATA = iataMap
}
/// Look up by ICAO 3-letter prefix (e.g. "DAL").
func lookup(icao: String?) -> Entry? {
guard let icao = icao?.uppercased(), !icao.isEmpty else { return nil }
return byICAO[icao]
}
/// Look up by IATA 2-3 char code (e.g. "DL").
func lookup(iata: String?) -> Entry? {
guard let iata = iata?.uppercased(), !iata.isEmpty else { return nil }
return byIATA[iata]
}
/// Convenience that returns a non-nil display name, falling back to
/// the raw ICAO code or "Unknown".
func displayName(icao: String?) -> String {
if let e = lookup(icao: icao) { return e.name }
return icao?.uppercased() ?? "Unknown"
}
/// Static fallback used when the bundled JSON isn't available.
private static let builtIn: [String: (String, String)] = [
"AAL": ("AA", "American Airlines"), "AAL": ("AA", "American Airlines"),
"DAL": ("DL", "Delta Air Lines"), "DAL": ("DL", "Delta Air Lines"),
"UAL": ("UA", "United Airlines"), "UAL": ("UA", "United Airlines"),
@@ -20,98 +85,22 @@ enum AircraftRegistry {
"HAL": ("HA", "Hawaiian Airlines"), "HAL": ("HA", "Hawaiian Airlines"),
"FFT": ("F9", "Frontier Airlines"), "FFT": ("F9", "Frontier Airlines"),
"AAY": ("G4", "Allegiant Air"), "AAY": ("G4", "Allegiant Air"),
"JIA": ("OH", "PSA Airlines"), "AMX": ("AM", "Aeromexico"),
"ASH": ("YX", "Mesa Airlines"),
"ENY": ("MQ", "Envoy Air"),
"RPA": ("YX", "Republic Airways"),
"SKW": ("OO", "SkyWest"),
"EDV": ("9E", "Endeavor Air"),
"GJS": ("OO", "GoJet"),
"JSX": ("XE", "JSX"),
// Canada
"ACA": ("AC", "Air Canada"), "ACA": ("AC", "Air Canada"),
"WJA": ("WS", "WestJet"), "WJA": ("WS", "WestJet"),
"JZA": ("QK", "Jazz Aviation"), "UAE": ("EK", "Emirates"),
"QTR": ("QR", "Qatar Airways"),
// Mexico / LatAm
"AMX": ("AM", "Aeromexico"),
"VOI": ("Y4", "Volaris"),
"VIV": ("VB", "Viva Aerobus"),
"AVA": ("AV", "Avianca"),
"GLO": ("G3", "GOL"),
"AZU": ("AD", "Azul"),
"LAN": ("LA", "LATAM"),
"TAM": ("JJ", "LATAM Brasil"),
"CMP": ("CM", "Copa Airlines"),
// Europe
"BAW": ("BA", "British Airways"), "BAW": ("BA", "British Airways"),
"DLH": ("LH", "Lufthansa"), "DLH": ("LH", "Lufthansa"),
"AFR": ("AF", "Air France"), "AFR": ("AF", "Air France"),
"KLM": ("KL", "KLM"), "KLM": ("KL", "KLM"),
"IBE": ("IB", "Iberia"),
"AEA": ("UX", "Air Europa"),
"SWR": ("LX", "Swiss"),
"AUA": ("OS", "Austrian Airlines"),
"BEL": ("SN", "Brussels Airlines"),
"SAS": ("SK", "SAS"),
"FIN": ("AY", "Finnair"),
"TAP": ("TP", "TAP Portugal"),
"VIR": ("VS", "Virgin Atlantic"),
"EZY": ("U2", "EasyJet"),
"RYR": ("FR", "Ryanair"),
"WZZ": ("W6", "Wizz Air"),
"AZA": ("AZ", "ITA Airways"),
"THY": ("TK", "Turkish Airlines"),
"AEE": ("A3", "Aegean Airlines"),
"ROT": ("RO", "TAROM"),
"LOT": ("LO", "LOT Polish"),
"CSA": ("OK", "Czech Airlines"),
// Middle East
"UAE": ("EK", "Emirates"),
"ETD": ("EY", "Etihad Airways"),
"QTR": ("QR", "Qatar Airways"),
"SVA": ("SV", "Saudia"),
"KAC": ("KU", "Kuwait Airways"),
"ELY": ("LY", "El Al"),
// Asia / Pacific
"ANA": ("NH", "All Nippon Airways"),
"JAL": ("JL", "Japan Airlines"), "JAL": ("JL", "Japan Airlines"),
"ANA": ("NH", "All Nippon Airways"),
"KAL": ("KE", "Korean Air"), "KAL": ("KE", "Korean Air"),
"AAR": ("OZ", "Asiana"),
"CCA": ("CA", "Air China"),
"CSN": ("CZ", "China Southern"),
"CES": ("MU", "China Eastern"),
"CPA": ("CX", "Cathay Pacific"), "CPA": ("CX", "Cathay Pacific"),
"SIA": ("SQ", "Singapore Airlines"), "SIA": ("SQ", "Singapore Airlines"),
"THA": ("TG", "Thai Airways"),
"MAS": ("MH", "Malaysia Airlines"),
"PAL": ("PR", "Philippine Airlines"),
"GIA": ("GA", "Garuda Indonesia"),
"QFA": ("QF", "Qantas"), "QFA": ("QF", "Qantas"),
"ANZ": ("NZ", "Air New Zealand"),
"VOZ": ("VA", "Virgin Australia"),
"JST": ("JQ", "Jetstar"),
"AIC": ("AI", "Air India"),
"IGO": ("6E", "IndiGo"),
// Cargo
"FDX": ("FX", "FedEx"), "FDX": ("FX", "FedEx"),
"UPS": ("5X", "UPS Airlines"), "UPS": ("5X", "UPS Airlines")
"GTI": ("5Y", "Atlas Air"),
"ABX": ("GB", "ABX Air"),
"GEC": ("LH", "Lufthansa Cargo"),
"CKS": ("K4", "Kalitta Air")
] ]
/// Look up display info for an ICAO code. Returns the raw code as the
/// name if unknown so the UI never shows blank.
static func airline(forICAO code: String?) -> (iata: String?, name: String) {
guard let code = code?.uppercased() else { return (nil, "Unknown") }
if let hit = airlines[code] { return (hit.iata, hit.name) }
return (nil, code)
}
} }
+55
View File
@@ -24,12 +24,39 @@ actor OpenSkyClient {
} }
private let session: URLSession private let session: URLSession
private var basicAuthHeader: String?
init() { init() {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20 config.timeoutIntervalForRequest = 20
config.requestCachePolicy = .reloadIgnoringLocalCacheData config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config) 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. /// Aircraft inside the given lat/lon bounding box.
@@ -51,6 +78,7 @@ actor OpenSkyClient {
private func decodeStates(from url: URL) async throws -> [LiveAircraft] { private func decodeStates(from url: URL) async throws -> [LiveAircraft] {
var req = URLRequest(url: url) var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req) let (data, response) = try await session.data(for: req)
@@ -66,6 +94,32 @@ actor OpenSkyClient {
} }
} }
/// 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. /// Flights an aircraft has flown in the past N days.
/// OpenSky requires a `begin` and `end` window, max 30 days each. /// OpenSky requires a `begin` and `end` window, max 30 days each.
func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] { func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] {
@@ -80,6 +134,7 @@ actor OpenSkyClient {
guard let url = comps.url else { return [] } guard let url = comps.url else { return [] }
var req = URLRequest(url: url) var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
do { do {
+91
View File
@@ -0,0 +1,91 @@
import Foundation
import Security
/// Stores the optional OpenSky Network username + password in the Keychain.
///
/// Anonymous OpenSky access is capped at ~100 requests per 24h per IP.
/// A free OpenSky account bumps the cap to 4000/day, which is the difference
/// between "the tab works for casual viewing" and "the tab keeps refreshing
/// without complaint all day". The Settings screen lets the user paste their
/// OpenSky credentials; we stash them in the Keychain and `OpenSkyClient`
/// reads them on each request.
///
/// Posts `Notification.Name.openSkyCredentialsChanged` after every write so
/// the client can refresh its in-memory copy.
final class OpenSkyCredentials: @unchecked Sendable {
static let shared = OpenSkyCredentials()
private let service = "com.flights.app.opensky"
private let account = "credentials"
struct Credentials: Sendable, Equatable {
let username: String
let password: String
}
/// Read the stored credentials, or nil if none have been saved.
func load() -> Credentials? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let dict = item as? [String: Any],
let data = dict[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8),
let username = dict[kSecAttrGeneric as String] as? Data,
let usernameStr = String(data: username, encoding: .utf8)
else { return nil }
return Credentials(username: usernameStr, password: password)
}
/// Save credentials. Overwrites any existing entry.
func save(username: String, password: String) {
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty, !password.isEmpty else { return }
let usernameData = username.data(using: .utf8) ?? Data()
let passwordData = password.data(using: .utf8) ?? Data()
// Try update first, fall back to add.
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let updateAttrs: [String: Any] = [
kSecAttrGeneric as String: usernameData,
kSecValueData as String: passwordData
]
let status = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary)
if status == errSecItemNotFound {
var addQuery = updateQuery
addQuery[kSecAttrGeneric as String] = usernameData
addQuery[kSecValueData as String] = passwordData
SecItemAdd(addQuery as CFDictionary, nil)
}
NotificationCenter.default.post(name: .openSkyCredentialsChanged, object: nil)
}
/// Remove any stored credentials (back to anonymous).
func clear() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
NotificationCenter.default.post(name: .openSkyCredentialsChanged, object: nil)
}
}
extension Notification.Name {
static let openSkyCredentialsChanged = Notification.Name("openSkyCredentialsChanged")
}
+20 -1
View File
@@ -86,6 +86,19 @@ struct LiveFlightDetailSheet: View {
private var header: some View { private var header: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) { HStack(spacing: 10) {
if let logoURL = airlineEntry?.logoURL {
AsyncImage(url: logoURL) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFit()
default:
RoundedRectangle(cornerRadius: 8)
.fill(FlightTheme.accent.opacity(0.2))
}
}
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Text(aircraft.trimmedCallsign ?? aircraft.icao24) Text(aircraft.trimmedCallsign ?? aircraft.icao24)
.font(.title.weight(.bold).monospaced()) .font(.title.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary) .foregroundStyle(FlightTheme.textPrimary)
@@ -276,8 +289,14 @@ struct LiveFlightDetailSheet: View {
// MARK: - Helpers // MARK: - Helpers
private var airlineEntry: AircraftRegistry.Entry? {
AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)
}
private var airlineDisplayName: String { private var airlineDisplayName: String {
AircraftRegistry.airline(forICAO: aircraft.airlineICAO).name airlineEntry?.name
?? aircraft.airlineICAO?.uppercased()
?? "Unknown"
} }
private func loadRoute() async { private func loadRoute() async {
+40 -2
View File
@@ -29,6 +29,8 @@ struct LiveFlightsView: View {
// MARK: - Selection // MARK: - Selection
@State private var selectedAircraft: LiveAircraft? @State private var selectedAircraft: LiveAircraft?
@State private var selectedTrack: AircraftTrack?
@State private var showSettings: Bool = false
// Refresh interval paired with the rate-limit guard. Anonymous OpenSky // Refresh interval paired with the rate-limit guard. Anonymous OpenSky
// is 100/day so we keep the auto-refresh tab-conservative. // is 100/day so we keep the auto-refresh tab-conservative.
@@ -58,12 +60,27 @@ struct LiveFlightsView: View {
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.sheet(isPresented: $showSettings) {
OpenSkySettingsView()
}
.task(id: selectedAircraft?.icao24) {
await loadTrackForSelection()
}
} }
// MARK: - Map // MARK: - Map
private var mapLayer: some View { private var mapLayer: some View {
Map(position: $position, selection: $selectedAircraft.iconSelection) { Map(position: $position, selection: $selectedAircraft.iconSelection) {
// Trail polyline for the currently selected aircraft.
if let track = selectedTrack {
let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
MapPolyline(coordinates: coords)
.stroke(FlightTheme.accent, lineWidth: 3)
}
ForEach(filteredAircraft) { ac in ForEach(filteredAircraft) { ac in
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) { Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id) AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id)
@@ -82,6 +99,21 @@ struct LiveFlightsView: View {
} }
} }
/// Fetches the trail (in-progress path) for whichever aircraft is
/// currently selected. Cleared automatically when selection clears.
private func loadTrackForSelection() async {
guard let selected = selectedAircraft else {
selectedTrack = nil
return
}
let track = await openSky.track(icao24: selected.icao24)
// Guard against race: only commit if the user didn't change selection
// while the call was in flight.
if selectedAircraft?.icao24 == selected.icao24 {
selectedTrack = track
}
}
// MARK: - Header (filters + search) // MARK: - Header (filters + search)
private var overlayHeader: some View { private var overlayHeader: some View {
@@ -216,6 +248,13 @@ struct LiveFlightsView: View {
} }
.disabled(isLoading) .disabled(isLoading)
.opacity(isLoading ? 0.3 : 1) .opacity(isLoading ? 0.3 : 1)
Button {
showSettings = true
} label: {
Image(systemName: "gearshape")
.foregroundStyle(.white)
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -272,9 +311,8 @@ struct LiveFlightsView: View {
} }
} }
return seen.keys.sorted().map { icao in return seen.keys.sorted().map { icao in
let info = AircraftRegistry.airline(forICAO: icao) let name = AircraftRegistry.shared.displayName(icao: icao)
let count = seen[icao] ?? 0 let count = seen[icao] ?? 0
let name = info.name
return AirlineFilterItem(icao: icao, label: "\(name) (\(count))") return AirlineFilterItem(icao: icao, label: "\(name) (\(count))")
} }
} }
+161
View File
@@ -0,0 +1,161 @@
import SwiftUI
/// Settings screen for the Live Flights tab. Currently just OpenSky
/// account credentials used to bump the request quota from anonymous's
/// ~100/day to the authenticated 4000/day.
struct OpenSkySettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var username: String = ""
@State private var password: String = ""
@State private var isSaving: Bool = false
@State private var isAuthed: Bool = false
@State private var saveError: String?
@State private var saveSuccess: Bool = false
var body: some View {
NavigationStack {
Form {
Section {
if isAuthed {
HStack {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(FlightTheme.onTime)
Text("Signed in as \(username)")
.font(.subheadline.weight(.semibold))
}
Button(role: .destructive) {
OpenSkyCredentials.shared.clear()
username = ""
password = ""
isAuthed = false
saveSuccess = false
saveError = nil
} label: {
Text("Sign out")
}
} else {
TextField("Username", text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Password", text: $password)
Button {
saveCredentials()
} label: {
HStack {
if isSaving { ProgressView() }
Text(isSaving ? "Saving…" : "Save")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.disabled(isSaving || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| password.isEmpty)
}
} header: {
Text("OpenSky Network")
} footer: {
Text(footerText)
.font(.caption)
}
if let saveError {
Section {
Text(saveError)
.font(.caption)
.foregroundStyle(FlightTheme.cancelled)
}
}
if saveSuccess {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(FlightTheme.onTime)
Text("Credentials saved — quota raised to 4,000/day.")
.font(.caption)
}
}
}
}
.navigationTitle("Live Flights Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
}
}
}
.onAppear { loadExisting() }
}
}
private var footerText: String {
if isAuthed {
return "Quota: ~4,000 requests/day. Sign out to go back to anonymous."
}
return "Anonymous access is capped at ~100 requests/day per IP. Sign in to a free OpenSky account (opensky-network.org/register) to raise the cap to ~4,000/day. Credentials are stored in the iOS Keychain."
}
private func loadExisting() {
if let creds = OpenSkyCredentials.shared.load() {
username = creds.username
password = "" // never expose the stored password back to the UI
isAuthed = true
}
}
private func saveCredentials() {
let u = username.trimmingCharacters(in: .whitespacesAndNewlines)
let p = password
guard !u.isEmpty, !p.isEmpty else { return }
isSaving = true
saveError = nil
saveSuccess = false
Task {
// Sanity-check the credentials by hitting the /states/all
// endpoint once and watching for HTTP 401. If they're bad,
// we won't save them.
let ok = await verify(username: u, password: p)
await MainActor.run {
isSaving = false
if ok {
OpenSkyCredentials.shared.save(username: u, password: p)
isAuthed = true
saveSuccess = true
password = ""
} else {
saveError = "Could not authenticate with OpenSky. Double-check the username and password."
}
}
}
}
/// Send a tiny request to /states/all with the candidate creds to
/// see whether OpenSky accepts them (401 bad credentials).
private func verify(username: String, password: String) async -> Bool {
// 1° x 1° box near MSP tiny payload.
var comps = URLComponents(string: "https://opensky-network.org/api/states/all")!
comps.queryItems = [
URLQueryItem(name: "lamin", value: "44.5"),
URLQueryItem(name: "lomin", value: "-93.5"),
URLQueryItem(name: "lamax", value: "45.5"),
URLQueryItem(name: "lomax", value: "-92.5")
]
guard let url = comps.url else { return false }
var req = URLRequest(url: url)
let raw = "\(username):\(password)"
if let data = raw.data(using: .utf8) {
req.setValue("Basic \(data.base64EncodedString())", forHTTPHeaderField: "Authorization")
}
do {
let (_, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
return status == 200
} catch {
return false
}
}
}
File diff suppressed because one or more lines are too long