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:
@@ -52,6 +52,9 @@
|
||||
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV4400004444000044440002 /* LiveFlightsView.swift */; };
|
||||
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV5500005555000055550002 /* LiveFlightDetailSheet.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -112,6 +115,9 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -148,6 +154,7 @@
|
||||
LV4400004444000044440002 /* LiveFlightsView.swift */,
|
||||
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */,
|
||||
LV6600006666000066660002 /* RootView.swift */,
|
||||
LV8800008888000088880002 /* OpenSkySettingsView.swift */,
|
||||
AA5555555555555555555555 /* Styles */,
|
||||
AA6666666666666666666666 /* Components */,
|
||||
);
|
||||
@@ -182,6 +189,7 @@
|
||||
1B20C5393D8F432A93097C2C /* Views */,
|
||||
D9E26DCDE2904210ABCA7855 /* Assets.xcassets */,
|
||||
53F457716F0642BDBCBA93EA /* airports.json */,
|
||||
LV9900009999000099990002 /* airlines.json */,
|
||||
);
|
||||
path = Flights;
|
||||
sourceTree = "<group>";
|
||||
@@ -225,6 +233,7 @@
|
||||
RE2200002222000022220002 /* RouteExplorerClient.swift */,
|
||||
LV2200002222000022220002 /* OpenSkyClient.swift */,
|
||||
LV3300003333000033330002 /* AircraftRegistry.swift */,
|
||||
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -338,6 +347,7 @@
|
||||
files = (
|
||||
F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */,
|
||||
80D2BC95002A4931B3C10B4C /* airports.json in Resources */,
|
||||
LV9900009999000099990001 /* airlines.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -390,6 +400,8 @@
|
||||
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */,
|
||||
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */,
|
||||
LV6600006666000066660001 /* RootView.swift in Sources */,
|
||||
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */,
|
||||
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
/// take off from / where's it going" in the detail sheet.
|
||||
struct OpenSkyFlight: Decodable, Sendable, Hashable {
|
||||
|
||||
@@ -1,15 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Tiny lookup for the most common ICAO airline codes → IATA + display name.
|
||||
/// Used to enrich live-tracker callsigns (e.g. "DAL1234" → "Delta", IATA "DL").
|
||||
/// Look up airline display info by ICAO callsign prefix.
|
||||
///
|
||||
/// Not exhaustive — about 80 entries covering the carriers we encounter
|
||||
/// most. Anything not in the table falls back to the raw 3-letter ICAO code.
|
||||
/// If this grows past ~200, switch to a bundled JSON.
|
||||
enum AircraftRegistry {
|
||||
/// ICAO airline code → (IATA, display name).
|
||||
static let airlines: [String: (iata: String, name: String)] = [
|
||||
// US legacy + major
|
||||
/// Backed by `airlines.json` (bundled, ~2,700 entries sourced from
|
||||
/// flightradar24.com's public `/mobile/airlines` feed) and falls back to
|
||||
/// a small hardcoded map of the most common carriers if the bundle is
|
||||
/// missing for any reason.
|
||||
///
|
||||
/// Lookup is by ICAO 3-letter code (e.g. "DAL" → Delta). IATA-only
|
||||
/// 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"),
|
||||
"DAL": ("DL", "Delta Air Lines"),
|
||||
"UAL": ("UA", "United Airlines"),
|
||||
@@ -20,98 +85,22 @@ enum AircraftRegistry {
|
||||
"HAL": ("HA", "Hawaiian Airlines"),
|
||||
"FFT": ("F9", "Frontier Airlines"),
|
||||
"AAY": ("G4", "Allegiant Air"),
|
||||
"JIA": ("OH", "PSA Airlines"),
|
||||
"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
|
||||
"AMX": ("AM", "Aeromexico"),
|
||||
"ACA": ("AC", "Air Canada"),
|
||||
"WJA": ("WS", "WestJet"),
|
||||
"JZA": ("QK", "Jazz Aviation"),
|
||||
|
||||
// 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
|
||||
"UAE": ("EK", "Emirates"),
|
||||
"QTR": ("QR", "Qatar Airways"),
|
||||
"BAW": ("BA", "British Airways"),
|
||||
"DLH": ("LH", "Lufthansa"),
|
||||
"AFR": ("AF", "Air France"),
|
||||
"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"),
|
||||
"ANA": ("NH", "All Nippon Airways"),
|
||||
"KAL": ("KE", "Korean Air"),
|
||||
"AAR": ("OZ", "Asiana"),
|
||||
"CCA": ("CA", "Air China"),
|
||||
"CSN": ("CZ", "China Southern"),
|
||||
"CES": ("MU", "China Eastern"),
|
||||
"CPA": ("CX", "Cathay Pacific"),
|
||||
"SIA": ("SQ", "Singapore Airlines"),
|
||||
"THA": ("TG", "Thai Airways"),
|
||||
"MAS": ("MH", "Malaysia Airlines"),
|
||||
"PAL": ("PR", "Philippine Airlines"),
|
||||
"GIA": ("GA", "Garuda Indonesia"),
|
||||
"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"),
|
||||
"UPS": ("5X", "UPS Airlines"),
|
||||
"GTI": ("5Y", "Atlas Air"),
|
||||
"ABX": ("GB", "ABX Air"),
|
||||
"GEC": ("LH", "Lufthansa Cargo"),
|
||||
"CKS": ("K4", "Kalitta Air")
|
||||
"UPS": ("5X", "UPS Airlines")
|
||||
]
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,39 @@ actor OpenSkyClient {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -51,6 +78,7 @@ actor OpenSkyClient {
|
||||
|
||||
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)
|
||||
@@ -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.
|
||||
/// OpenSky requires a `begin` and `end` window, max 30 days each.
|
||||
func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] {
|
||||
@@ -80,6 +134,7 @@ actor OpenSkyClient {
|
||||
guard let url = comps.url else { return [] }
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
applyAuth(&req)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
do {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -86,6 +86,19 @@ struct LiveFlightDetailSheet: View {
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
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)
|
||||
.font(.title.weight(.bold).monospaced())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
@@ -276,8 +289,14 @@ struct LiveFlightDetailSheet: View {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var airlineEntry: AircraftRegistry.Entry? {
|
||||
AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)
|
||||
}
|
||||
|
||||
private var airlineDisplayName: String {
|
||||
AircraftRegistry.airline(forICAO: aircraft.airlineICAO).name
|
||||
airlineEntry?.name
|
||||
?? aircraft.airlineICAO?.uppercased()
|
||||
?? "Unknown"
|
||||
}
|
||||
|
||||
private func loadRoute() async {
|
||||
|
||||
@@ -29,6 +29,8 @@ struct LiveFlightsView: View {
|
||||
// MARK: - Selection
|
||||
|
||||
@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
|
||||
// is 100/day so we keep the auto-refresh tab-conservative.
|
||||
@@ -58,12 +60,27 @@ struct LiveFlightsView: View {
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
OpenSkySettingsView()
|
||||
}
|
||||
.task(id: selectedAircraft?.icao24) {
|
||||
await loadTrackForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Map
|
||||
|
||||
private var mapLayer: some View {
|
||||
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
|
||||
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
|
||||
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)
|
||||
|
||||
private var overlayHeader: some View {
|
||||
@@ -216,6 +248,13 @@ struct LiveFlightsView: View {
|
||||
}
|
||||
.disabled(isLoading)
|
||||
.opacity(isLoading ? 0.3 : 1)
|
||||
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
@@ -272,9 +311,8 @@ struct LiveFlightsView: View {
|
||||
}
|
||||
}
|
||||
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 name = info.name
|
||||
return AirlineFilterItem(icao: icao, label: "\(name) (\(count))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user