import Foundation import Observation /// Whether Plex or stemserve answers right now. In the car the phone is usually on cellular /// and the LAN addresses just time out, so the car UI asks this first (a quick probe, /// remembered briefly) and goes straight to cached data when home is out of reach. @Observable final class HomeReach: @unchecked Sendable { private static let ttl: UInt64 = 20_000_000_000 @ObservationIgnored private let settings: Settings @ObservationIgnored private let session: URLSession @ObservationIgnored private let lock = AsyncLock() @ObservationIgnored private let stamp = UnfairLock() @ObservationIgnored private var checkedAt: UInt64 = 0 @ObservationIgnored private var _last = true @ObservationIgnored private var listeners: [() -> Void] = [] /// The last answer; true until a probe says otherwise. Changes as the phone joins or leaves home Wi-Fi. private(set) var home = true /// Readable from any thread (the car's Plex client asks it on every request). var lastKnown: Bool { stamp.with { _last } } init(settings: Settings, net: Connectivity) { self.settings = settings let c = URLSessionConfiguration.ephemeral c.timeoutIntervalForRequest = 2 c.timeoutIntervalForResource = 2.5 c.waitsForConnectivity = false session = URLSession(configuration: c) // A new network (home Wi-Fi in range, or lost) invalidates the answer; ask again once it // settles, so the car's lists can switch over without anyone browsing first. net.onChange { [weak self] in guard let self else { return } self.stamp.with { self.checkedAt = 0 } Task { try? await Task.sleep(nanoseconds: 1_500_000_000) _ = await self.reachable() } } } /// Main-thread callback when [home] changes. func onChange(_ f: @escaping () -> Void) { listeners.append(f) } func reachable() async -> Bool { await lock.run { let now = DispatchTime.now().uptimeNanoseconds let (at, last) = stamp.with { (checkedAt, _last) } if at != 0 && now - at < Self.ttl { return last } let plex = settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/identity" let stems = settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info" async let a = answers(plex) async let b = answers(stems) let (ra, rb) = await (a, b) let ok = ra || rb stamp.with { _last = ok; checkedAt = DispatchTime.now().uptimeNanoseconds } await MainActor.run { if home != ok { home = ok listeners.forEach { $0() } } } return ok } } private func answers(_ s: String) async -> Bool { guard let url = URL(string: s), let (_, r) = try? await session.data(from: url) else { return false } return ((r as? HTTPURLResponse)?.statusCode ?? 500) < 500 } }