macOS client store and API
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
private let staleAfter: TimeInterval = 40
|
||||
private let retryAfter: Duration = .seconds(2)
|
||||
private let checkEvery: Duration = .seconds(5)
|
||||
|
||||
private enum Frame: Sendable {
|
||||
case open
|
||||
case ping
|
||||
case ready
|
||||
case event(id: Int64?, ByreEvent)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class EventStream {
|
||||
private let api: API
|
||||
private let lead: Bool
|
||||
private let onEvent: (ByreEvent) -> Void
|
||||
private let onState: (Bool) -> Void
|
||||
private let onReady: () -> Void
|
||||
private var last: Int64
|
||||
private var heard = Date()
|
||||
private var reading: Task<Void, Never>?
|
||||
private var retry: Task<Void, Never>?
|
||||
private var checker: Task<Void, Never>?
|
||||
private var generation = 0
|
||||
private let monitor = NWPathMonitor()
|
||||
private var online: Bool?
|
||||
private var activeObserver: (any NSObjectProtocol)?
|
||||
|
||||
init(api: API, after: Int64, lead: Bool, onEvent: @escaping (ByreEvent) -> Void, onState: @escaping (Bool) -> Void, onReady: @escaping () -> Void) {
|
||||
self.api = api
|
||||
self.lead = lead
|
||||
self.onEvent = onEvent
|
||||
self.onState = onState
|
||||
self.onReady = onReady
|
||||
last = after
|
||||
checker = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: checkEvery)
|
||||
self?.check()
|
||||
}
|
||||
}
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let up = path.status == .satisfied
|
||||
Task { @MainActor in
|
||||
self?.pathChanged(up)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .main)
|
||||
activeObserver = NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.check()
|
||||
}
|
||||
}
|
||||
connect()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
generation += 1
|
||||
checker?.cancel()
|
||||
retry?.cancel()
|
||||
reading?.cancel()
|
||||
monitor.cancel()
|
||||
if let activeObserver {
|
||||
NotificationCenter.default.removeObserver(activeObserver)
|
||||
}
|
||||
activeObserver = nil
|
||||
}
|
||||
|
||||
private func pathChanged(_ up: Bool) {
|
||||
let was = online
|
||||
online = up
|
||||
if was == false && up {
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func check() {
|
||||
if Date().timeIntervalSince(heard) < staleAfter {
|
||||
return
|
||||
}
|
||||
onState(false)
|
||||
connect()
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
retry?.cancel()
|
||||
reading?.cancel()
|
||||
generation += 1
|
||||
let gen = generation
|
||||
heard = Date()
|
||||
var query = [URLQueryItem(name: "after", value: String(last))]
|
||||
if lead {
|
||||
query.append(URLQueryItem(name: "lead", value: "true"))
|
||||
}
|
||||
var request = URLRequest(url: api.url("/api/events", query))
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
let session = api.session
|
||||
reading = Task.detached { [weak self] in
|
||||
do {
|
||||
try await Self.read(session, request) { frame in
|
||||
self?.receive(frame, gen)
|
||||
}
|
||||
await self?.failed(gen)
|
||||
} catch {
|
||||
await self?.failed(gen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(_ frame: Frame, _ gen: Int) {
|
||||
guard gen == generation else {
|
||||
return
|
||||
}
|
||||
heard = Date()
|
||||
switch frame {
|
||||
case .open:
|
||||
onState(true)
|
||||
case .ping:
|
||||
break
|
||||
case .ready:
|
||||
onReady()
|
||||
case .event(let id, let ev):
|
||||
if let id, id > last {
|
||||
last = id
|
||||
}
|
||||
if ev.seq > last {
|
||||
last = ev.seq
|
||||
}
|
||||
onEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func failed(_ gen: Int) {
|
||||
guard gen == generation else {
|
||||
return
|
||||
}
|
||||
onState(false)
|
||||
retry = Task { [weak self] in
|
||||
try? await Task.sleep(for: retryAfter)
|
||||
if !Task.isCancelled && self?.generation == gen {
|
||||
self?.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func read(_ session: URLSession, _ request: URLRequest, _ deliver: @escaping @MainActor @Sendable (Frame) -> Void) async throws {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
let code = (response as! HTTPURLResponse).statusCode
|
||||
if code != 200 {
|
||||
throw ApiError(status: code, message: "\(code) \(HTTPURLResponse.localizedString(forStatusCode: code))")
|
||||
}
|
||||
await deliver(.open)
|
||||
let decoder = jsonDecoder()
|
||||
var line: [UInt8] = []
|
||||
var afterCR = false
|
||||
var kind = ""
|
||||
var data: [String] = []
|
||||
var id: Int64?
|
||||
for try await byte in bytes {
|
||||
if byte == 0x0A && afterCR {
|
||||
afterCR = false
|
||||
continue
|
||||
}
|
||||
afterCR = byte == 0x0D
|
||||
if byte != 0x0A && byte != 0x0D {
|
||||
line.append(byte)
|
||||
continue
|
||||
}
|
||||
let text = String(decoding: line, as: UTF8.self)
|
||||
line.removeAll(keepingCapacity: true)
|
||||
if !text.isEmpty {
|
||||
if text.hasPrefix(":") {
|
||||
continue
|
||||
}
|
||||
let colon = text.firstIndex(of: ":") ?? text.endIndex
|
||||
let field = String(text[..<colon])
|
||||
var value = colon == text.endIndex ? "" : String(text[text.index(after: colon)...])
|
||||
if value.hasPrefix(" ") {
|
||||
value.removeFirst()
|
||||
}
|
||||
switch field {
|
||||
case "event":
|
||||
kind = value
|
||||
case "data":
|
||||
data.append(value)
|
||||
case "id":
|
||||
id = Int64(value)
|
||||
default:
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
defer {
|
||||
kind = ""
|
||||
data = []
|
||||
id = nil
|
||||
}
|
||||
if data.isEmpty {
|
||||
continue
|
||||
}
|
||||
switch kind {
|
||||
case "ping":
|
||||
await deliver(.ping)
|
||||
case "ready":
|
||||
await deliver(.ready)
|
||||
case "", "message":
|
||||
do {
|
||||
let ev = try decoder.decode(ByreEvent.self, from: Data(data.joined(separator: "\n").utf8))
|
||||
await deliver(.event(id: id, ev))
|
||||
} catch {
|
||||
log.error("decode event: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user