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:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
struct HttpError: LocalizedError {
|
||||
let code: Int
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// A thin URLSession wrapper. With [offlineCache] set, successful GETs are kept on disk and
|
||||
/// answered from there when the network fails, so the library still browses with the server away.
|
||||
final class Http: @unchecked Sendable {
|
||||
let session: URLSession
|
||||
private let cacheDir: URL?
|
||||
/// When true, GETs never touch the network: cached body or HTTP 504.
|
||||
var forceCache: (() -> Bool)?
|
||||
|
||||
init(connectTimeout: TimeInterval, readTimeout: TimeInterval, offlineCache: URL? = nil, memoryMb: Int = 0) {
|
||||
let c = URLSessionConfiguration.default
|
||||
c.timeoutIntervalForRequest = max(connectTimeout, readTimeout)
|
||||
c.timeoutIntervalForResource = 3600
|
||||
c.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
c.urlCache = memoryMb > 0 ? URLCache(memoryCapacity: memoryMb << 20, diskCapacity: 0) : nil
|
||||
c.waitsForConnectivity = false
|
||||
c.httpMaximumConnectionsPerHost = 8
|
||||
session = URLSession(configuration: c)
|
||||
cacheDir = offlineCache
|
||||
if let d = offlineCache { try? FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) }
|
||||
}
|
||||
|
||||
private func cacheFile(_ req: URLRequest) -> URL? {
|
||||
guard let dir = cacheDir, req.httpMethod ?? "GET" == "GET", let url = req.url?.absoluteString else { return nil }
|
||||
var key = "GET " + url
|
||||
for h in ["X-Plex-Container-Start", "X-Plex-Container-Size"] {
|
||||
if let v = req.value(forHTTPHeaderField: h) { key += "|\(h)=\(v)" }
|
||||
}
|
||||
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return dir.appendingPathComponent(hash)
|
||||
}
|
||||
|
||||
/// Returns the body of a 2xx response, or throws [HttpError] / the URL error.
|
||||
func data(_ req: URLRequest) async throws -> Data {
|
||||
let file = cacheFile(req)
|
||||
if let file, forceCache?() == true {
|
||||
if let d = try? Data(contentsOf: file) { return d }
|
||||
throw HttpError(code: 504, message: "Offline, and not cached")
|
||||
}
|
||||
do {
|
||||
let (data, resp) = try await session.data(for: req)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(code) else {
|
||||
throw HttpError(code: code, message: "HTTP \(code) from \(req.url?.host ?? "server")")
|
||||
}
|
||||
if let file { try? data.write(to: file, options: .atomic) }
|
||||
return data
|
||||
} catch let e as URLError {
|
||||
if e.code != .cancelled, let file, let d = try? Data(contentsOf: file) { return d }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
func string(_ req: URLRequest) async throws -> String {
|
||||
String(decoding: try await data(req), as: UTF8.self)
|
||||
}
|
||||
|
||||
/// Status code only; network errors become false.
|
||||
func ok(_ req: URLRequest) async -> Bool {
|
||||
guard let (_, resp) = try? await session.data(for: req) else { return false }
|
||||
return ((resp as? HTTPURLResponse)?.statusCode).map { (200..<300).contains($0) } ?? false
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
/// Appends query items, skipping nil values.
|
||||
func with(_ params: [(String, Any?)]) -> URL {
|
||||
guard var c = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self }
|
||||
var items = c.queryItems ?? []
|
||||
for (k, v) in params { if let v { items.append(URLQueryItem(name: k, value: "\(v)")) } }
|
||||
c.queryItems = items.isEmpty ? nil : items
|
||||
// URLComponents leaves '+' alone, which servers read as a space.
|
||||
c.percentEncodedQuery = c.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
|
||||
return c.url ?? self
|
||||
}
|
||||
}
|
||||
|
||||
/// Form-style percent-encoding for a single query value.
|
||||
func urlEncode(_ s: String) -> String {
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~")
|
||||
return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s
|
||||
}
|
||||
|
||||
/// Online / Wi-Fi state, for offline fallbacks and Wi-Fi-only downloads.
|
||||
@Observable
|
||||
final class Connectivity: @unchecked Sendable {
|
||||
private(set) var online = true
|
||||
private(set) var unmetered = true
|
||||
@ObservationIgnored private let monitor = NWPathMonitor()
|
||||
@ObservationIgnored private var listeners: [() -> Void] = []
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
let on = path.status == .satisfied
|
||||
let un = on && !path.isExpensive && !path.isConstrained
|
||||
if on != self.online || un != self.unmetered {
|
||||
self.online = on
|
||||
self.unmetered = un
|
||||
self.listeners.forEach { $0() }
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "connectivity"))
|
||||
}
|
||||
|
||||
/// Main-thread callback whenever online or unmetered changes.
|
||||
func onChange(_ f: @escaping () -> Void) { listeners.append(f) }
|
||||
}
|
||||
Reference in new issue
Block a user