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