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}