Add iOS app with CarPlay and Siri

SwiftUI port of the Android app under ios/, with CarPlay browse and
Now Playing, Siri play-media intents, and a build-ipa.sh script that
makes an unsigned IPA for sideloading. README gets an iOS section and
the vibecoded disclaimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
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
}
}