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