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? private var retry: Task? private var checker: Task? 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[..