krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.13.0: Hutch/Networking/SystemStatusService.swift · raw

  1import Foundation
  2
  3struct SystemStatusService: Sendable {
  4    nonisolated static let statusURL = URL(string: "https://status.sr.ht/")!
  5    nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")!
  6
  7    private let session: URLSession
  8    private let now: @Sendable () -> Date
  9
 10    nonisolated init(session: URLSession = .shared, now: @escaping @Sendable () -> Date = Date.init) {
 11        self.session = session
 12        self.now = now
 13    }
 14
 15    func fetchSnapshot() async throws -> SystemStatusSnapshot {
 16        let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
 17        return try Self.parseSnapshotHTML(html, fetchedAt: now())
 18    }
 19
 20    func fetchIncidentFeed() async throws -> [StatusIncident] {
 21        let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
 22        return try await Self.parseIncidentFeedXML(data)
 23    }
 24
 25    private func fetchText(from url: URL, accept: String) async throws -> String {
 26        let data = try await fetchData(from: url, accept: accept)
 27        guard let text = String(data: data, encoding: .utf8) else {
 28            throw SRHTError.decodingError(
 29                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
 30            )
 31        }
 32        return text
 33    }
 34
 35    private func fetchData(from url: URL, accept: String) async throws -> Data {
 36        var request = URLRequest(url: url)
 37        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
 38        request.setValue(accept, forHTTPHeaderField: "Accept")
 39
 40        let (data, response): (Data, URLResponse)
 41        do {
 42            (data, response) = try await session.data(for: request)
 43        } catch {
 44            throw SRHTError.networkError(error)
 45        }
 46
 47        if let http = response as? HTTPURLResponse,
 48           !(200...299).contains(http.statusCode) {
 49            throw SRHTError.httpError(http.statusCode)
 50        }
 51
 52        return data
 53    }
 54
 55    private var userAgent: String {
 56        let bundle = Bundle.main
 57        let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
 58            ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
 59            ?? "Hutch"
 60        let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev"
 61        return "\(name)/\(version) (System Status)"
 62    }
 63}
 64
 65extension SystemStatusService {
 66    nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot {
 67        let services = parseServices(in: html)
 68        let incidents = parseHTMLIncidentCards(in: html)
 69        let summaries = parseActiveIncidentSummaries(in: html)
 70
 71        let activeIncidents = incidents
 72            .filter { $0.isActive == true }
 73            .map { incident in
 74                let summary = incident.url.flatMap { summaries[$0.absoluteString] } ?? incident.summary
 75                return StatusIncident(
 76                    id: incident.id,
 77                    title: incident.title,
 78                    summary: summary,
 79                    url: incident.url,
 80                    publishedAt: incident.publishedAt,
 81                    updatedAt: incident.updatedAt,
 82                    isActive: incident.isActive
 83                )
 84            }
 85
 86        return SystemStatusSnapshot(services: services, activeIncidents: activeIncidents, lastUpdated: fetchedAt)
 87    }
 88
 89    nonisolated static func parseIncidentFeedXML(_ data: Data) async throws -> [StatusIncident] {
 90        try await MainActor.run {
 91            let parser = SystemStatusFeedParser()
 92            return try parser.parse(data: data)
 93        }
 94    }
 95
 96    nonisolated private static func parseServices(in html: String) -> [StatusServiceState] {
 97        firstMatches(
 98            in: html,
 99            pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"#
100        ).compactMap { captures in
101            guard captures.count >= 2 else { return nil }
102
103            let rawStatus = captures[0]
104            let content = captures[1]
105            guard let linkCaptures = firstMatches(
106                in: content,
107                pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"#
108            ).first,
109                  linkCaptures.count >= 2,
110                  let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else {
111                return nil
112            }
113
114            let href = linkCaptures[0]
115            let cleanedName = cleanText(linkCaptures[1])
116            let readableStatus = cleanText(statusText)
117            let level = statusLevel(fromHTMLStatus: rawStatus)
118
119            return StatusServiceState(
120                id: normalizedSlug(from: href, fallback: cleanedName) ?? cleanedName,
121                name: cleanedName,
122                slug: normalizedSlug(from: href, fallback: cleanedName),
123                status: level == .unknown ? statusLevel(fromLabel: readableStatus) : level,
124                description: nil
125            )
126        }
127    }
128
129    nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] {
130        firstMatches(
131            in: html,
132            pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"#
133        ).compactMap { captures in
134            guard captures.count >= 2 else { return nil }
135            let href = captures[0]
136            let content = captures[1]
137            guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#),
138                  let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#),
139                  let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else {
140                return nil
141            }
142
143            let url = URL(string: href, relativeTo: statusURL)?.absoluteURL
144            let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet")
145            return StatusIncident(
146                id: url?.absoluteString ?? cleanText(titleHTML),
147                title: cleanText(titleHTML),
148                summary: nil,
149                url: url,
150                publishedAt: publishedAt,
151                updatedAt: nil,
152                isActive: isActive
153            )
154        }
155    }
156
157    nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] {
158        firstMatches(
159            in: html,
160            pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"#
161        ).reduce(into: [:]) { partialResult, captures in
162            guard let content = captures.first,
163                  let titleLinkCaptures = firstMatches(
164                    in: content,
165                    pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"#
166                  ).first,
167                  let href = titleLinkCaptures.first else {
168                return
169            }
170
171            let paragraphs = firstMatches(in: content, pattern: #"<p>([\s\S]*?)</p>"#)
172                .compactMap(\.first)
173                .map(cleanText)
174                .filter { !$0.isEmpty }
175
176            let summary = paragraphs.dropFirst(2).first ?? paragraphs.dropFirst().first
177            guard let summary, !summary.isEmpty else { return }
178            if let url = URL(string: href, relativeTo: statusURL)?.absoluteURL {
179                partialResult[url.absoluteString] = summary
180            }
181        }
182    }
183
184    nonisolated private static func statusLevel(fromHTMLStatus status: String) -> StatusLevel {
185        switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
186        case "ok":
187            .operational
188        case "disrupted":
189            .degraded
190        case "down":
191            .majorOutage
192        case "notice":
193            .maintenance
194        default:
195            .unknown
196        }
197    }
198
199    nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel {
200        switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
201        case "operational":
202            .operational
203        case "disrupted", "degraded":
204            .degraded
205        case "down", "major outage":
206            .majorOutage
207        case "maintenance":
208            .maintenance
209        default:
210            .unknown
211        }
212    }
213
214    nonisolated private static func normalizedSlug(from href: String, fallback name: String) -> String? {
215        if href.contains("/affected/") {
216            let trimmed = href.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
217            if let slug = trimmed.split(separator: "/").last {
218                return String(slug)
219            }
220        }
221        return name.isEmpty ? nil : name
222    }
223
224    nonisolated private static func firstMatch(in text: String, pattern: String) -> String? {
225        firstMatches(in: text, pattern: pattern).first?.first
226    }
227
228    nonisolated private static func firstMatches(in text: String, pattern: String) -> [[String]] {
229        guard let regex = try? NSRegularExpression(
230            pattern: pattern,
231            options: [.caseInsensitive, .dotMatchesLineSeparators]
232        ) else {
233            return []
234        }
235
236        let range = NSRange(text.startIndex..., in: text)
237        return regex.matches(in: text, range: range).map { match in
238            (1..<match.numberOfRanges).compactMap { captureIndex in
239                guard let captureRange = Range(match.range(at: captureIndex), in: text) else { return nil }
240                return String(text[captureRange])
241            }
242        }
243    }
244
245    nonisolated private static func cleanText(_ text: String) -> String {
246        let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
247        let decoded = decodeHTMLEntities(stripped)
248        return decoded
249            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
250            .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression)
251            .replacingOccurrences(of: "", with: "")
252            .trimmingCharacters(in: .whitespacesAndNewlines)
253    }
254
255    nonisolated private static let htmlIssueDateFormatter: DateFormatter = {
256        let formatter = DateFormatter()
257        formatter.locale = Locale(identifier: "en_US_POSIX")
258        formatter.timeZone = TimeZone(identifier: "UTC")
259        formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz"
260        return formatter
261    }()
262}
263
264@MainActor
265private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable {
266    private var incidents: [StatusIncident] = []
267    private var currentItem: FeedItem?
268    private var textBuffer = ""
269
270    func parse(data: Data) throws -> [StatusIncident] {
271        incidents = []
272        currentItem = nil
273        textBuffer = ""
274
275        let parser = XMLParser(data: data)
276        parser.delegate = self
277        guard parser.parse() else {
278            throw parser.parserError ?? SRHTError.decodingError(
279                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Failed to parse status feed"))
280            )
281        }
282        return incidents.sorted { $0.publishedAt > $1.publishedAt }
283    }
284
285    func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
286        textBuffer = ""
287        if elementName == "item" {
288            currentItem = FeedItem()
289        }
290    }
291
292    func parser(_ parser: XMLParser, foundCharacters string: String) {
293        textBuffer += string
294    }
295
296    func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
297        if let string = String(data: CDATABlock, encoding: .utf8) {
298            textBuffer += string
299        }
300    }
301
302    func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
303        guard var currentItem else {
304            textBuffer = ""
305            return
306        }
307
308        let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
309        switch elementName {
310        case "title":
311            currentItem.title = value
312        case "link":
313            currentItem.link = value
314        case "guid":
315            currentItem.guid = value
316        case "description":
317            currentItem.description = value
318        case "pubDate":
319            currentItem.pubDate = value
320        case "category":
321            currentItem.category = value
322        case "item":
323            if let incident = currentItem.makeIncident() {
324                incidents.append(incident)
325            }
326            self.currentItem = nil
327        default:
328            self.currentItem = currentItem
329        }
330
331        if elementName != "item" {
332            self.currentItem = currentItem
333        }
334        textBuffer = ""
335    }
336
337    private struct FeedItem {
338        var title = ""
339        var link = ""
340        var guid = ""
341        var description = ""
342        var pubDate = ""
343        var category = ""
344
345        func makeIncident() -> StatusIncident? {
346            let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "")
347            guard !cleanedTitle.isEmpty,
348                  let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else {
349                return nil
350            }
351
352            let url = URL(string: link)
353            let updatedAt = category.isEmpty ? nil : SystemStatusFeedParser.updatedDateFormatter.date(from: category)
354
355            return StatusIncident(
356                id: guid.isEmpty ? (url?.absoluteString ?? cleanedTitle) : guid,
357                title: cleanedTitle,
358                summary: SystemStatusFeedParser.summary(from: description),
359                url: url,
360                publishedAt: publishedAt,
361                updatedAt: updatedAt,
362                isActive: category.isEmpty
363            )
364        }
365    }
366
367    nonisolated private static func summary(from html: String) -> String? {
368        html
369            .components(separatedBy: "</p>")
370            .map { $0.replacingOccurrences(of: "<p>", with: "") }
371            .map(stripHTML)
372            .map {
373                $0.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
374                    .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression)
375                    .trimmingCharacters(in: .whitespacesAndNewlines)
376            }
377            .first { !$0.isEmpty }
378    }
379
380    nonisolated private static func stripHTML(_ text: String) -> String {
381        let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
382        return decodeHTMLEntities(stripped)
383    }
384
385    nonisolated private static let pubDateFormatter: DateFormatter = {
386        let formatter = DateFormatter()
387        formatter.locale = Locale(identifier: "en_US_POSIX")
388        formatter.timeZone = TimeZone(identifier: "UTC")
389        formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
390        return formatter
391    }()
392
393    nonisolated private static let updatedDateFormatter: DateFormatter = {
394        let formatter = DateFormatter()
395        formatter.locale = Locale(identifier: "en_US_POSIX")
396        formatter.timeZone = TimeZone(identifier: "UTC")
397        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
398        return formatter
399    }()
400}