import Foundation import RastrilloApple @MainActor extension SheetModel { func watch() async { guard page != nil else { return } let source = grid // Paces reconnects: a server's Retry-After is a deadline no wake crosses; // otherwise back off, cut short by a connectivity change, with no timer // while the path is down. The backoff resets only after a stream that // stayed open for 5 s, timed from onOpen. let reconnect = NativeReconnect(connectivity: connectivity, now: reconnectClock) while !Task.isCancelled && grid == source { live = connectivity.isOffline(client.origin) ? "Offline" : "Connecting" do { // Subscribe before fetching the snapshot in onOpen; buffered deltas // then reconcile by version once the initial fetch finishes. let parser = SheetEventParser() try await client.stream( source + "/stream", onOpen: { [weak self] in await reconnect.opened() await self?.streamOpened(source: source) } ) { [weak self] line in let event = await parser.receive(line) guard let event else { return } await self?.received(event, source: source) } } catch is CancellationError { return } catch { guard !Task.isCancelled, grid == source else { return } reconnect.failed(error) // The failure itself says whether this server was unreachable; the // connectivity record of it arrives asynchronously. var offline = connectivity.isOffline(client.origin) if case NativeClientError.offline = error { offline = true } live = offline ? "Offline" : "Reconnecting" } if live == "Access changed" || live == "Paused" { return } if !connectivity.isPathSatisfied { live = "Offline" } await reconnect.pause() if Task.isCancelled { return } } } private func streamOpened(source: String) async { guard !Task.isCancelled, grid == source else { return } await refresh() guard !Task.isCancelled, grid == source else { return } live = "Live" } func received(_ event: SheetEvent, source: String) async { guard !Task.isCancelled, grid == source else { return } switch event.name { case "cells": live = "Live" if let response = try? await wire.decode(Data(event.data.utf8)) { guard !Task.isCancelled, grid == source else { return } await accept(response) } else { guard !Task.isCancelled, grid == source else { return } await refresh() } case "resync": await refresh() case "bye": if event.data.contains("access-changed") { live = "Access changed" cells = [:] styles = [:] error = "Your access changed. Reopen the sheet to continue." } else if event.data.contains("idle") { live = "Paused" } default: live = "Live" } } } struct SheetEvent: Sendable { let name: String let data: String } actor SheetEventParser { private var name = "" private var data = "" func receive(_ line: String) -> SheetEvent? { if line.isEmpty { let event = SheetEvent(name: name, data: data) name = "" data = "" return event } if line.hasPrefix("event:") { name = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) } if line.hasPrefix("data:") { if !data.isEmpty { data += "\n" } data += line.dropFirst(5).trimmingCharacters(in: .whitespaces) } return nil } }