Everything native needs this first, and it is worth having whatever happens to the rest: a CarPlay browse tree is built from exactly these calls. API covers what a client reads and writes -- feeds, entries with the server's own paging, filtering, sorting and search, read and pinned, position, read-all, download, delete, fetch -- and APIModels is what ipx sends, with the server's field names kept so a type here can be checked against web.rs without translating first. It sends nothing of its own to authenticate: ipx decides who is asking by cookie and CookieBridge keeps HTTPCookieStorage in step, which is the same property that lets a plain <audio src> work. Events reads /api/events with URLSession's byte stream, so there is no new dependency. An event this build has never heard of decodes as .other rather than throwing, because a new one on the server must not stop the stream. It reconnects with a backoff: a deploy closes the stream cleanly and should be picked straight back up, a daemon that is down should not be hammered. Library is gone, folded into API. Two clients writing positions was one too many, and the page's forwarded beacon no longer carries a path: it asks the host to save, and the host uses its own clock, which is the only one not stale when the web view has been frozen in the background. The tests run against the browser suite's fixture daemon rather than canned JSON, because the shapes being decoded are the server's and a fixture would only prove it matches itself. Thirteen pass, including a live scan reported over SSE and the playback round trip, which still works after the move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
5.9 KiB
Swift
127 lines
5.9 KiB
Swift
import Foundation
|
|
|
|
/// What the daemon is doing, as it does it.
|
|
///
|
|
/// `GET /api/events` is server-sent events carrying the same broadcast the control socket does,
|
|
/// so a scan started from anywhere shows up here. The stream is a broadcast: a client attached
|
|
/// to a busy daemon also sees that daemon's other work.
|
|
enum IPXEvent: Decodable, Sendable {
|
|
case feedStart(feed: String)
|
|
case feedSkip(feed: String, reason: String)
|
|
case feedDone(feed: String, new: Int, downloaded: Int, failed: Int)
|
|
case feedError(feed: String, msg: String)
|
|
/// Carries the enclosure id, without which a UI cannot tell one download from another and
|
|
/// ends up animating every pending row. Throttled to whole percents by the server.
|
|
case progress(feed: String, enclosure: Int, file: String, done: Int, total: Int?)
|
|
case downloadDone(feed: String, enclosure: Int, path: String, bytes: Int)
|
|
case downloadError(feed: String, enclosure: Int, msg: String)
|
|
case reaped(path: String, bytes: Int)
|
|
case scanDone(feeds: Int)
|
|
case reapDone(files: Int, bytes: Int)
|
|
/// Anything this build does not know about. New events must not break an old app.
|
|
case other(String)
|
|
|
|
private enum Keys: String, CodingKey {
|
|
case ev, feed, reason, new, downloaded, failed, msg, enclosure, file, done, total,
|
|
path, bytes, feeds, files
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: Keys.self)
|
|
let ev = try c.decode(String.self, forKey: .ev)
|
|
func str(_ k: Keys) -> String { (try? c.decode(String.self, forKey: k)) ?? "" }
|
|
func int(_ k: Keys) -> Int { (try? c.decode(Int.self, forKey: k)) ?? 0 }
|
|
switch ev {
|
|
case "feed_start": self = .feedStart(feed: str(.feed))
|
|
case "feed_skip": self = .feedSkip(feed: str(.feed), reason: str(.reason))
|
|
case "feed_done": self = .feedDone(feed: str(.feed), new: int(.new),
|
|
downloaded: int(.downloaded), failed: int(.failed))
|
|
case "feed_error": self = .feedError(feed: str(.feed), msg: str(.msg))
|
|
case "progress": self = .progress(feed: str(.feed), enclosure: int(.enclosure),
|
|
file: str(.file), done: int(.done),
|
|
total: try? c.decode(Int.self, forKey: .total))
|
|
case "download_done": self = .downloadDone(feed: str(.feed), enclosure: int(.enclosure),
|
|
path: str(.path), bytes: int(.bytes))
|
|
case "download_error": self = .downloadError(feed: str(.feed), enclosure: int(.enclosure),
|
|
msg: str(.msg))
|
|
case "reaped": self = .reaped(path: str(.path), bytes: int(.bytes))
|
|
case "scan_done": self = .scanDone(feeds: int(.feeds))
|
|
case "reap_done": self = .reapDone(files: int(.files), bytes: int(.bytes))
|
|
default: self = .other(ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads the event stream and keeps reading it.
|
|
///
|
|
/// URLSession's byte stream does the parsing work; there is no need for another dependency.
|
|
/// The connection drops -- a deploy, a sleeping laptop, a tunnel blinking -- so it reconnects,
|
|
/// backing off so a daemon that is down is not hammered.
|
|
@MainActor
|
|
final class Events {
|
|
private(set) var isConnected = false
|
|
private var task: Task<Void, Never>?
|
|
private var handlers: [UUID: (IPXEvent) -> Void] = [:]
|
|
|
|
/// Returns a token; hold it for as long as you want the events, and forget it to stop.
|
|
@discardableResult
|
|
func onEvent(_ handler: @escaping (IPXEvent) -> Void) -> UUID {
|
|
let id = UUID()
|
|
handlers[id] = handler
|
|
return id
|
|
}
|
|
|
|
func remove(_ id: UUID) { handlers[id] = nil }
|
|
|
|
func start() {
|
|
guard task == nil else { return }
|
|
task = Task { [weak self] in
|
|
var backoff = 1.0
|
|
while !Task.isCancelled {
|
|
let ok = await self?.readOnce() ?? false
|
|
if Task.isCancelled { return }
|
|
// A clean end means the daemon closed it, which is normal on a deploy: try again
|
|
// soon. A failure means it is down, so wait longer each time, up to half a minute.
|
|
backoff = ok ? 1.0 : min(backoff * 2, 30)
|
|
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
task?.cancel()
|
|
task = nil
|
|
isConnected = false
|
|
}
|
|
|
|
/// One connection, for as long as it lasts. True if it ended rather than failed.
|
|
private func readOnce() async -> Bool {
|
|
guard let url = ServerSettings.url("/api/events") else { return false }
|
|
var req = URLRequest(url: url)
|
|
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
|
// The stream is meant to stay open; the default timeout would cut it every minute.
|
|
req.timeoutInterval = .infinity
|
|
|
|
do {
|
|
let (bytes, response) = try await URLSession.shared.bytes(for: req)
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false }
|
|
isConnected = true
|
|
defer { isConnected = false }
|
|
|
|
for try await line in bytes.lines {
|
|
if Task.isCancelled { return true }
|
|
// Every event ipx sends is unnamed, so the data lines are the whole of it.
|
|
// Comments (": keep-alive") and blank separators are skipped.
|
|
guard line.hasPrefix("data:") else { continue }
|
|
let json = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
|
|
guard let data = json.data(using: .utf8),
|
|
let event = try? JSONDecoder().decode(IPXEvent.self, from: data) else { continue }
|
|
for handler in handlers.values { handler(event) }
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|