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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
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 {
|
||||||
|
|||||||
@@ -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))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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