diff --git a/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings index 5787db0..db1dd4e 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/WeatherProvider/Localizable.xcstrings @@ -1,6 +1,22 @@ { "sourceLanguage": "en", "strings": { + "weather.error.timeout": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung beim Abruf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Request timed out" + } + } + } + }, "widget.weather.asOf": { "comment": "%@ ist die Uhrzeit des Messwerts", "localizations": { diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift index 63428f9..000b203 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift @@ -12,8 +12,32 @@ public struct WeatherCache: Sendable { public static let maxAge: TimeInterval = 15 * 60 private var entries: [String: WeatherSnapshot] = [:] + private let url: URL? - public init() {} + /// Liest beim Anlegen, was zuletzt bekannt war. + /// + /// Der Zwischenspeicher liegt auf der Platte und nicht nur im Arbeits- + /// speicher: sonst steht beim Start ein Rädchen da, bis der erste Abruf + /// durch ist — und wenn der hängt, für immer. Ein Wert von vor einer + /// Stunde mit Zeitstempel ist die bessere Auskunft. + public init(url: URL? = WeatherCache.standardURL) { + self.url = url + guard let url, let data = try? Data(contentsOf: url) else { return } + // Eine unlesbare Datei ist kein Grund, das Widget aufzugeben. + entries = (try? JSONDecoder().decode([String: WeatherSnapshot].self, from: data)) ?? [:] + } + + public static var standardURL: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("Onyx/weather.json") + } + + public func save() throws { + guard let url else { return } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(entries).write(to: url, options: .atomic) + } /// Der Stand, wenn er noch frisch genug ist. public func valid(for place: WeatherPlace, now: Date = Date()) -> WeatherSnapshot? { @@ -77,3 +101,23 @@ public extension WeatherCondition { } } } + +/// Führt einen Aufruf mit Zeitlimit aus. +/// +/// WeatherKit hat keines. Beim Testen lieferte es das Wetter in 185 ms und +/// blieb danach beim Abruf der Namensnennung stehen — das Widget zeigte für +/// immer ein Rädchen. Ein Netzaufruf ohne Zeitlimit ist ein Aufruf, der +/// hängen darf. +func withTimeout(seconds: TimeInterval, + operation: @escaping @Sendable () async -> T) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(for: .seconds(seconds)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } +} diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift index 875da54..88bdf5e 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift @@ -84,7 +84,8 @@ public final class WeatherKitSource { private let service = WeatherService.shared private let location = LocationProvider() - private let geocoder = CLGeocoder() + /// Einmal geholt, dann behalten — sie ändert sich nicht. + private var cachedAttribution: WeatherAttribution? public init() {} @@ -114,7 +115,11 @@ public final class WeatherKitSource { log.notice("Wetter: Ortungsberechtigung noch nicht erteilt") return .locationUndetermined } - guard let here = await location.currentLocation() else { + // 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 @@ -129,27 +134,54 @@ public final class WeatherKitSource { placeName = name } - do { - let weather = try await service.weather(for: coordinate) + 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: try? await attribution())) - } catch { + 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. - private func attribution() async throws -> WeatherAttribution { - let legal = try await service.attribution - return WeatherAttribution(name: "Apple Weather", - legalPageURL: legal.legalPageURL, - logoURL: legal.combinedMarkDarkURL) + /// + /// 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 } private func name(for location: CLLocation) async -> String { - guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else { + // 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 diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift index 7cb4d3e..ed62078 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift @@ -6,7 +6,7 @@ import Foundation /// gegen ein Framework gebunden sein, das eine kostenpflichtige Mitgliedschaft /// und eine Entitlement voraussetzt. Fällt WeatherKit einmal weg, wird nur die /// Abbildung ausgetauscht. -public enum WeatherCondition: String, Equatable, Sendable, CaseIterable { +public enum WeatherCondition: String, Equatable, Sendable, CaseIterable, Codable { case clear case mostlyClear case partlyCloudy @@ -36,7 +36,7 @@ public enum WeatherPlace: Equatable, Sendable, Codable { public var isCurrent: Bool { self == .current } } -public struct HourlyPoint: Equatable, Sendable, Identifiable { +public struct HourlyPoint: Equatable, Sendable, Identifiable, Codable { public let date: Date public let temperature: Measurement public let condition: WeatherCondition @@ -58,7 +58,7 @@ public struct HourlyPoint: Equatable, Sendable, Identifiable { /// Das ist keine Kür: die Nennung samt Link auf die Rechtshinweise ist /// Bedingung der Nutzung. Deshalb steht sie im Datenmodell und nicht in einer /// Notiz, die man beim Bauen der Oberfläche übersieht. -public struct WeatherAttribution: Equatable, Sendable { +public struct WeatherAttribution: Equatable, Sendable, Codable { public let name: String public let legalPageURL: URL public let logoURL: URL? @@ -70,7 +70,7 @@ public struct WeatherAttribution: Equatable, Sendable { } } -public struct WeatherSnapshot: Equatable, Sendable { +public struct WeatherSnapshot: Equatable, Sendable, Codable { public let placeName: String public let temperature: Measurement public let apparentTemperature: Measurement diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift index 4805ffa..e9229dc 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -29,6 +29,11 @@ public final class WeatherModel { public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) { self.source = source self.place = place + // Sofort zeigen, was zuletzt bekannt war. Ein Wert von vor einer Stunde + // mit Zeitstempel ist eine Auskunft; ein Rädchen ist keine. + if let known = cache.lastKnown(for: place) { + state = .ready(known) + } syncAuthorization() source.onLocationAuthorizationChange = { [weak self] in guard let self else { return } @@ -78,11 +83,16 @@ public final class WeatherModel { switch result { case .ready(let snapshot): cache.store(snapshot, for: place) + try? cache.save() state = .ready(snapshot) case .unavailable(_, let reason): // Der letzte bekannte Stand bleibt sichtbar. Ein alter Messwert // mit Zeitstempel ist ehrlicher und nützlicher als ein leeres Feld. state = .unavailable(lastKnown: cache.lastKnown(for: place), reason: reason) + case .loading, .idle: + // Kommt von der Quelle nie zurück; hier stehen zu bleiben wäre + // genau das Rädchen, das behoben werden sollte. + state = cache.lastKnown(for: place).map { .ready($0) } ?? result default: state = result } diff --git a/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift b/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift index 0dbf15f..e5e54cd 100644 --- a/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift +++ b/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift @@ -25,7 +25,7 @@ struct WeatherCacheTests { @Test("Frischer Eintrag wird wiederverwendet") func freshEntryIsReused() { let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) #expect(cache.valid(for: place, now: now.addingTimeInterval(60)) != nil) @@ -34,7 +34,7 @@ struct WeatherCacheTests { @Test("Nach Ablauf der Frist wird neu geladen") func staleEntryIsDiscarded() { let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) let later = now.addingTimeInterval(WeatherCache.maxAge + 1) @@ -44,7 +44,7 @@ struct WeatherCacheTests { @Test("Genau auf der Frist gilt der Eintrag noch") func exactlyAtMaxAgeIsStillValid() { let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) #expect(cache.valid(for: place, now: now.addingTimeInterval(WeatherCache.maxAge)) != nil) @@ -55,7 +55,7 @@ struct WeatherCacheTests { // Sonst zeigt das Widget nach dem Umstellen des Ortes weiter das alte // Wetter — und zwar überzeugend, weil Zahlen dastehen. let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) #expect(cache.valid(for: other, now: now) == nil) @@ -64,7 +64,7 @@ struct WeatherCacheTests { @Test("Der aktuelle Standort ist ein eigener Eintrag") func currentLocationIsItsOwnEntry() { let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: .current) #expect(cache.valid(for: .current, now: now) != nil) @@ -73,7 +73,7 @@ struct WeatherCacheTests { @Test("Leerer Zwischenspeicher liefert nichts") func emptyCacheMisses() { - #expect(WeatherCache().valid(for: place, now: Date()) == nil) + #expect(WeatherCache(url: nil).valid(for: place, now: Date()) == nil) } @Test("Ein abgelaufener Eintrag bleibt als letzter bekannter Stand erhalten") @@ -81,7 +81,7 @@ struct WeatherCacheTests { // Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel // deutlich besser als ein leeres Feld. let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) let later = now.addingTimeInterval(WeatherCache.maxAge * 10) @@ -92,7 +92,7 @@ struct WeatherCacheTests { @Test("Ein neuer Eintrag ersetzt den alten desselben Ortes") func storingReplaces() { let now = Date() - var cache = WeatherCache() + var cache = WeatherCache(url: nil) cache.store(snapshot(at: now.addingTimeInterval(-1000)), for: place) cache.store(snapshot(at: now), for: place) @@ -103,6 +103,59 @@ struct WeatherCacheTests { func maxAgeMatchesPlan() { #expect(WeatherCache.maxAge == 15 * 60) } + + @Test("Der Stand überlebt einen Neustart") + func survivesRestart() throws { + // Der eigentliche Punkt. Ohne Datei steht beim Start ein Rädchen da, + // bis der erste Abruf durch ist — und wenn der hängt, für immer. + let url = Self.temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + + var cache = WeatherCache(url: url) + cache.store(snapshot(at: Date()), for: place) + try cache.save() + + let restored = WeatherCache(url: url).lastKnown(for: place) + #expect(restored?.temperature.value == 18) + #expect(restored?.placeName == "Lemgo") + } + + @Test("Auch die Stundenwerte überleben") + func hourlySurvives() throws { + let url = Self.temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + + let point = HourlyPoint(date: Date(), temperature: .init(value: 19, unit: .celsius), + condition: .clear, precipitationChance: 0.1) + var cache = WeatherCache(url: url) + cache.store(WeatherSnapshot(placeName: "Lemgo", + temperature: .init(value: 18, unit: .celsius), + apparentTemperature: .init(value: 17, unit: .celsius), + condition: .partlyCloudy, isDaylight: true, humidity: 0.6, + windSpeed: .init(value: 10, unit: .kilometersPerHour), + high: nil, low: nil, hourly: [point], + asOf: Date(), attribution: nil), for: place) + try cache.save() + + #expect(WeatherCache(url: url).lastKnown(for: place)?.hourly.count == 1) + } + + @Test("Eine unlesbare Datei führt nicht zum Absturz") + func brokenFileIsSurvivable() throws { + let url = Self.temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + try Data("kein JSON".utf8).write(to: url) + + // Leer ist hier richtig: ein Zwischenspeicher, dem man nicht trauen + // kann, ist kein Grund, das Widget aufzugeben. + #expect(WeatherCache(url: url).lastKnown(for: place) == nil) + } + + /// Nie der echte Ablageort — Tests dürfen ihn nicht anfassen. + private static func temporaryURL() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("onyx-weather-\(UUID().uuidString).json") + } } @Suite("Wettersymbole") @@ -136,3 +189,25 @@ struct WeatherSymbolTests { } } } + +@Suite("Zeitlimit") +struct TimeoutTests { + + @Test("Ein schneller Aufruf kommt durch") + func fastCallReturns() async { + let value = await withTimeout(seconds: 1) { 42 } + #expect(value == 42) + } + + @Test("Ein hängender Aufruf gibt auf") + func slowCallGivesUp() async { + // Genau das ist passiert: WeatherKit lieferte das Wetter in 185 ms, + // danach hing die Abfrage der Namensnennung — und das Widget zeigte + // für immer ein Rädchen. + let value: Int? = await withTimeout(seconds: 0.05) { + try? await Task.sleep(for: .seconds(5)) + return 42 + } + #expect(value == nil) + } +}