import Foundation import OSLog import CoreLocation import WeatherKit private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Weather") /// Liefert den aktuellen Standort, oder sagt warum nicht. /// /// Eigene Klasse statt eines Aufrufs mittendrin: `CLLocationManager` meldet /// asynchron über einen Delegaten, und die Berechtigung kann sich jederzeit /// ändern. Beides gehört an eine Stelle. @MainActor final class LocationProvider: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private var pending: [CheckedContinuation] = [] /// Wird gerufen, wenn der Nutzer die Erlaubnis erteilt oder entzieht. var onAuthorizationChange: (() -> Void)? override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyKilometer } var authorization: CLAuthorizationStatus { manager.authorizationStatus } var isDenied: Bool { authorization == .denied || authorization == .restricted } var isUndetermined: Bool { authorization == .notDetermined } func requestAuthorization() { guard authorization == .notDetermined else { return } manager.requestWhenInUseAuthorization() } /// Eine einzelne Ortung. Liefert `nil`, wenn keine Berechtigung vorliegt /// oder nichts hereinkommt. func currentLocation() async -> CLLocation? { guard !isDenied, !isUndetermined else { return nil } if let cached = manager.location, cached.timestamp.timeIntervalSinceNow > -900 { return cached } return await withCheckedContinuation { continuation in pending.append(continuation) manager.requestLocation() } } private func resume(with location: CLLocation?) { let waiting = pending pending.removeAll() waiting.forEach { $0.resume(returning: location) } } nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { Task { @MainActor in self.resume(with: locations.last) } } /// Ohne diesen Rückruf merkt niemand, dass der Nutzer gerade zugestimmt /// hat: `authorizationStatus` ist eine Abfrage, keine Beobachtung. Die /// Einstellungen zeigten deshalb weiter „Anfragen", obwohl die Ortung /// längst lief. nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { Task { @MainActor in self.onAuthorizationChange?() } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { Task { @MainActor in log.error("Ortung fehlgeschlagen: \(error.localizedDescription, privacy: .public)") self.resume(with: nil) } } } /// Wetter aus Apples WeatherKit. @MainActor public final class WeatherKitSource { private let service = WeatherService.shared private let location = LocationProvider() /// Einmal geholt, dann behalten — sie ändert sich nicht. private var cachedAttribution: WeatherAttribution? public init() {} /// Weiterreichen, damit das Modell einen beobachtbaren Wert daraus machen kann. public var onLocationAuthorizationChange: (() -> Void)? { get { location.onAuthorizationChange } set { location.onAuthorizationChange = newValue } } public var locationIsDenied: Bool { location.isDenied } public var locationIsUndetermined: Bool { location.isUndetermined } public var locationIsAuthorized: Bool { !location.isDenied && !location.isUndetermined } /// Fragt die Ortungsberechtigung an. /// /// Muss aus einem Vordergrundfenster gerufen werden — wie beim Kalender /// zeigt macOS den Dialog sonst nicht an, und die App wirkt kaputt. public func requestLocationAuthorization() { location.requestAuthorization() } public func load(_ place: WeatherPlace) async -> WeatherState { let coordinate: CLLocation let placeName: String switch place { case .current: if location.isUndetermined { log.notice("Wetter: Ortungsberechtigung noch nicht erteilt") return .locationUndetermined } // Zeitlimit: CoreLocation kann auf eine Ortung warten, die nie // kommt — etwa ohne Sicht auf WLAN-Netze. guard let here = await withTimeout(seconds: 8, operation: { await self.location.currentLocation() }) ?? nil else { let denied = location.isDenied log.error("Wetter: keine Ortung erhalten (verweigert: \(denied, privacy: .public))") return denied ? .locationDenied : .unavailable(lastKnown: nil, reason: "Standort nicht verfügbar") } coordinate = here placeName = await name(for: here) case .fixed(let name, let latitude, let longitude): coordinate = CLLocation(latitude: latitude, longitude: longitude) placeName = name } let service = self.service let fetched = await withTimeout(seconds: 12) { () -> Result in do { return .success(try await service.weather(for: coordinate)) } catch { return .failure(error) } } switch fetched { case .success(let weather): return .ready(Self.snapshot(from: weather, placeName: placeName, attribution: await attribution())) case .failure(let error): log.error("WeatherKit: \(error.localizedDescription, privacy: .public)") return .unavailable(lastKnown: nil, reason: error.localizedDescription) case nil: log.error("WeatherKit: Zeitlimit überschritten") return .unavailable(lastKnown: nil, reason: String( localized: "weather.error.timeout", bundle: .module)) } } /// Apples Namensnennung. Keine Kür — sie ist Bedingung der Nutzung. /// /// Aber sie darf den Wetterwert nicht aufhalten. Genau daran hing das /// Widget: das Wetter war in 185 ms da, und dieser Aufruf kam nie zurück. /// Also mit knappem Zeitlimit, und das Ergebnis wird behalten — die /// Namensnennung ändert sich nicht. private func attribution() async -> WeatherAttribution? { if let cachedAttribution { return cachedAttribution } let service = self.service let fetched = await withTimeout(seconds: 3) { () -> WeatherAttribution? in guard let legal = try? await service.attribution else { return nil } return WeatherAttribution(name: "Apple Weather", legalPageURL: legal.legalPageURL, logoURL: legal.combinedMarkDarkURL) } ?? nil cachedAttribution = fetched return fetched } /// Koordinaten zu einem Ortsnamen. /// /// Mit Zeitlimit wie alles andere hier: eine Ortssuche ohne Netz wartet /// sonst, bis jemand das Fenster schließt. public func coordinates(for query: String) async -> (name: String, coordinate: CLLocationCoordinate2D)? { let found = await withTimeout(seconds: 8) { try? await CLGeocoder().geocodeAddressString(query).first } ?? nil guard let placemark = found, let location = placemark.location else { return nil } let name = [placemark.locality, placemark.administrativeArea, placemark.country] .compactMap { $0 } .first ?? query return (name, location.coordinate) } private func name(for location: CLLocation) async -> String { // Auch hier ein Zeitlimit: ohne Netz wartet die Umkehrsuche lange. // `CLGeocoder` ist nicht `Sendable`, also wird es im Aufruf angelegt // statt eingefangen — es dient ohnehin nur diesem einen Zweck. let found = await withTimeout(seconds: 5) { try? await CLGeocoder().reverseGeocodeLocation(location).first } ?? nil guard let placemark = found else { return "Aktueller Ort" } return placemark.locality ?? placemark.subAdministrativeArea ?? placemark.administrativeArea ?? "Aktueller Ort" } // MARK: - Umwandlung private static func snapshot(from weather: Weather, placeName: String, attribution: WeatherAttribution?) -> WeatherSnapshot { let current = weather.currentWeather let today = weather.dailyForecast.first return WeatherSnapshot( placeName: placeName, temperature: current.temperature, apparentTemperature: current.apparentTemperature, condition: map(current.condition), isDaylight: current.isDaylight, humidity: current.humidity, windSpeed: current.wind.speed, high: today?.highTemperature, low: today?.lowTemperature, hourly: weather.hourlyForecast.forecast .filter { $0.date >= Date() } .prefix(12) .map { HourlyPoint(date: $0.date, temperature: $0.temperature, condition: map($0.condition), precipitationChance: $0.precipitationChance) }, asOf: current.date, attribution: attribution) } /// WeatherKit kennt deutlich mehr Lagen als hier unterschieden werden. /// Feiner aufzulösen bringt nichts: das Widget hat ein Symbol und ein Wort /// Platz, und „mäßiger Sprühregen" liest dort niemand. private static func map(_ condition: WeatherKit.WeatherCondition) -> WeatherCondition { switch condition { case .clear: .clear case .mostlyClear: .mostlyClear case .partlyCloudy, .mostlyCloudy: .partlyCloudy case .cloudy: .cloudy case .foggy, .haze, .smoky: .fog case .drizzle, .freezingDrizzle: .drizzle case .rain, .sunShowers, .freezingRain: .rain case .heavyRain: .heavyRain case .sleet, .wintryMix, .hail: .sleet case .snow, .flurries, .sunFlurries, .blowingSnow: .snow case .heavySnow, .blizzard: .heavySnow case .thunderstorms, .isolatedThunderstorms, .scatteredThunderstorms, .strongStorms, .tropicalStorm, .hurricane: .thunderstorm case .windy, .breezy, .blowingDust: .windy case .hot: .hot case .frigid: .frigid @unknown default: .unknown } } }