Files

75 lines
3.2 KiB
Swift

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 urls = settings.servers.map { $0.base + ($0.kind == .plex ? "/identity" : "/System/Info/Public") }
+ [settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"]
let ok = await withTaskGroup(of: Bool.self) { g in
for u in urls { g.addTask { await self.answers(u) } }
for await r in g where r { g.cancelAll(); return true }
return false
}
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
}
}