NightscoutUtils.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. // LoopFollow
  2. // NightscoutUtils.swift
  3. import CryptoKit
  4. import Foundation
  5. class NightscoutUtils {
  6. enum NightscoutError: Error, LocalizedError {
  7. case emptyAddress
  8. case invalidURL
  9. case networkError
  10. case siteNotFound
  11. case invalidToken
  12. case tokenRequired
  13. case unknown
  14. var errorDescription: String? {
  15. switch self {
  16. case .emptyAddress:
  17. return String(localized: "The address is empty.")
  18. case .invalidURL:
  19. return String(localized: "The URL is invalid.")
  20. case .networkError:
  21. return String(localized: "A network error occurred.")
  22. case .siteNotFound:
  23. return String(localized: "The site was not found.")
  24. case .invalidToken:
  25. return String(localized: "The token is invalid.")
  26. case .tokenRequired:
  27. return String(localized: "A token is required.")
  28. case .unknown:
  29. return String(localized: "An unknown error occurred.")
  30. }
  31. }
  32. }
  33. enum EventType: String {
  34. case cage = "Site Change"
  35. case carbsToday = "Carb Correction"
  36. case sage = "Sensor Start"
  37. case sgv
  38. case profile
  39. case treatments
  40. case deviceStatus
  41. case iage = "Insulin Change"
  42. case temporaryOverride = "Temporary Override"
  43. case temporaryOverrideCancel = "Temporary Override Cancel"
  44. case dbSize
  45. var endpoint: String {
  46. switch self {
  47. case .cage, .carbsToday, .sage, .treatments, .iage:
  48. return "/api/v1/treatments.json"
  49. case .sgv:
  50. return "/api/v1/entries.json"
  51. case .profile:
  52. return "/api/v1/profiles.json"
  53. case .deviceStatus:
  54. return "/api/v1/devicestatus.json"
  55. case .temporaryOverride, .temporaryOverrideCancel:
  56. return "/api/v2/notifications/loop"
  57. case .dbSize:
  58. return "/api/v2/properties/dbsize"
  59. }
  60. }
  61. }
  62. static func executeRequest<T: Decodable>(eventType: EventType, parameters: [String: String], completion: @escaping (Result<T, Error>) -> Void) {
  63. let baseURL = Storage.shared.url.value
  64. let token = Storage.shared.token.value
  65. guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
  66. completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
  67. return
  68. }
  69. var request = URLRequest(url: url)
  70. request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
  71. let task = URLSession.shared.dataTask(with: request) { data, _, error in
  72. guard let data = data, error == nil else {
  73. completion(.failure(error!))
  74. return
  75. }
  76. let decoder = JSONDecoder()
  77. do {
  78. let decodedObject = try decoder.decode(T.self, from: data)
  79. DispatchQueue.main.async {
  80. completion(.success(decodedObject))
  81. }
  82. } catch let decodingError as DecodingError {
  83. let typeName = String(describing: T.self)
  84. switch decodingError {
  85. case let .typeMismatch(type, context):
  86. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
  87. case let .valueNotFound(type, context):
  88. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
  89. case let .keyNotFound(key, context):
  90. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
  91. case .dataCorrupted:
  92. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
  93. @unknown default:
  94. LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
  95. }
  96. completion(.failure(decodingError))
  97. } catch {
  98. LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
  99. completion(.failure(error))
  100. }
  101. }
  102. task.resume()
  103. }
  104. static func executeDynamicRequest(eventType: EventType, parameters: [String: String], completion: @escaping (Result<Any, Error>) -> Void) {
  105. let baseURL = Storage.shared.url.value
  106. let token = Storage.shared.token.value
  107. guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
  108. completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
  109. return
  110. }
  111. var request = URLRequest(url: url)
  112. request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
  113. let task = URLSession.shared.dataTask(with: request) { data, _, error in
  114. guard let data = data, error == nil else {
  115. completion(.failure(error!))
  116. return
  117. }
  118. do {
  119. if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
  120. DispatchQueue.main.async {
  121. completion(.success(jsonObject))
  122. }
  123. } else if let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] {
  124. DispatchQueue.main.async {
  125. completion(.success(jsonArray))
  126. }
  127. } else {
  128. completion(.failure(NSError(domain: "NightscoutUtils", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON Structure"])))
  129. }
  130. } catch {
  131. completion(.failure(error))
  132. }
  133. }
  134. task.resume()
  135. }
  136. static func createURLRequest(url: String, token: String?, path: String) -> URLRequest? {
  137. var requestURLString = "\(url)\(path)"
  138. if let token = token {
  139. let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token
  140. requestURLString += "?token=\(encodedToken)"
  141. }
  142. guard let requestURL = URL(string: requestURLString) else {
  143. return nil
  144. }
  145. var request = URLRequest(url: requestURL)
  146. request.httpMethod = "GET"
  147. return request
  148. }
  149. // Strip whitespace/newlines/control characters that can sneak in via paste.
  150. // Neither a URL nor a token may legally contain them, and a stray one breaks
  151. // WebSocket connect (invalid percent-encoded query traps on iOS 26) or makes
  152. // URL parsing fall back to a lossy cleanup that mangles the address.
  153. static func sanitizeConnectionInput(_ input: String) -> String {
  154. input.unicodeScalars
  155. .filter { !CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.controlCharacters.contains($0) }
  156. .map(String.init)
  157. .joined()
  158. }
  159. static func constructURL(baseURL: String, token: String?, endpoint: String, parameters: [String: String]) -> URL? {
  160. var components = URLComponents(string: baseURL)
  161. components?.path = endpoint
  162. var queryItems = [URLQueryItem]()
  163. if let token = token, !token.isEmpty {
  164. queryItems.append(URLQueryItem(name: "token", value: token))
  165. }
  166. for (key, value) in parameters {
  167. queryItems.append(URLQueryItem(name: key, value: value))
  168. }
  169. // Assigning an empty array would still append a bare "?" to the URL.
  170. if !queryItems.isEmpty {
  171. components?.queryItems = queryItems
  172. }
  173. return components?.url
  174. }
  175. static func verifyURLAndToken(completion: @escaping (NightscoutError?, String?, Bool, Bool) -> Void) {
  176. let urlUser = Storage.shared.url.value
  177. let token = Storage.shared.token.value
  178. if urlUser.isEmpty {
  179. completion(.emptyAddress, nil, false, false)
  180. return
  181. }
  182. guard let _ = URL(string: urlUser), urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://") else {
  183. completion(.invalidURL, nil, false, false)
  184. return
  185. }
  186. guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json") else {
  187. completion(.invalidURL, nil, false, false)
  188. return
  189. }
  190. let task = URLSession.shared.dataTask(with: request) { data, response, error in
  191. var nsWriteAuth = false
  192. var nsAdminAuth = false
  193. if let httpResponse = response as? HTTPURLResponse {
  194. switch httpResponse.statusCode {
  195. case 200:
  196. if let data = data {
  197. do {
  198. if let jsonResponse = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  199. let authorized = jsonResponse["authorized"] as? [String: Any],
  200. let token = authorized["token"] as? String,
  201. let permissionGroups = authorized["permissionGroups"] as? [[String]]
  202. {
  203. if permissionGroups.contains(where: { $0.contains("*") }) {
  204. nsWriteAuth = true
  205. nsAdminAuth = true
  206. } else if permissionGroups.contains(where: { $0.contains("api:treatments:create") }) {
  207. nsWriteAuth = true
  208. }
  209. completion(nil, token, nsWriteAuth, nsAdminAuth)
  210. } else {
  211. completion(nil, nil, false, false)
  212. }
  213. } catch {
  214. completion(nil, nil, false, false)
  215. }
  216. } else {
  217. completion(nil, nil, false, false)
  218. }
  219. case 401:
  220. if token.isEmpty {
  221. completion(.tokenRequired, nil, false, false)
  222. } else {
  223. completion(.invalidToken, nil, false, false)
  224. }
  225. default:
  226. completion(.unknown, nil, false, false)
  227. }
  228. } else {
  229. if let urlError = error as? URLError {
  230. switch urlError.code {
  231. case .notConnectedToInternet, .networkConnectionLost, .timedOut, .dataNotAllowed:
  232. completion(.networkError, nil, false, false)
  233. default:
  234. // cannotFindHost, cannotConnectToHost, etc. — the address is wrong or the site is down.
  235. completion(.siteNotFound, nil, false, false)
  236. }
  237. } else {
  238. completion(.networkError, nil, false, false)
  239. }
  240. }
  241. }
  242. task.resume()
  243. }
  244. static func parseDate(_ rawString: String) -> Date? {
  245. var mutableDate = rawString
  246. if mutableDate.hasSuffix("Z") {
  247. mutableDate = String(mutableDate.dropLast())
  248. } else if let offsetRange = mutableDate.range(of: "[\\+\\-]\\d{2}:\\d{2}$",
  249. options: .regularExpression)
  250. {
  251. mutableDate.removeSubrange(offsetRange)
  252. }
  253. mutableDate = mutableDate.replacingOccurrences(
  254. of: "\\.\\d+",
  255. with: "",
  256. options: .regularExpression
  257. )
  258. let dateFormatter = DateFormatter()
  259. dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
  260. dateFormatter.locale = Locale(identifier: "en_US")
  261. dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
  262. let result = dateFormatter.date(from: mutableDate)
  263. if result == nil {
  264. print("Unable to parse string: '\(mutableDate)'")
  265. }
  266. return result
  267. }
  268. static func retrieveJWTToken() async throws -> String {
  269. let urlUser = Storage.shared.url.value
  270. let token = Storage.shared.token.value
  271. if urlUser.isEmpty {
  272. throw NightscoutError.emptyAddress
  273. }
  274. guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json"),
  275. urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://")
  276. else {
  277. throw NightscoutError.invalidURL
  278. }
  279. let sessionConfig = URLSessionConfiguration.default
  280. sessionConfig.waitsForConnectivity = true
  281. sessionConfig.networkServiceType = .responsiveData
  282. let session = URLSession(configuration: sessionConfig)
  283. let (data, response) = try await session.data(for: request)
  284. guard let httpResponse = response as? HTTPURLResponse else {
  285. throw NightscoutError.networkError
  286. }
  287. switch httpResponse.statusCode {
  288. case 200:
  289. if let jsonResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  290. let authorized = jsonResponse["authorized"] as? [String: Any],
  291. let jwtToken = authorized["token"] as? String
  292. {
  293. return jwtToken
  294. } else {
  295. throw NightscoutError.invalidToken
  296. }
  297. case 401:
  298. throw token.isEmpty ? NightscoutError.tokenRequired : NightscoutError.invalidToken
  299. default:
  300. throw NightscoutError.unknown
  301. }
  302. }
  303. static func executePostRequest<T: Decodable>(eventType: EventType, body: [String: Any]) async throws -> T {
  304. let jwtToken = try await retrieveJWTToken()
  305. let baseURL = Storage.shared.url.value
  306. guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
  307. throw NightscoutError.invalidURL
  308. }
  309. var request = URLRequest(url: url)
  310. request.httpMethod = "POST"
  311. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  312. request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
  313. request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
  314. let sessionConfig = URLSessionConfiguration.default
  315. sessionConfig.waitsForConnectivity = true
  316. sessionConfig.networkServiceType = .responsiveData
  317. let session = URLSession(configuration: sessionConfig)
  318. let (data, response) = try await session.data(for: request)
  319. guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
  320. throw NightscoutError.networkError
  321. }
  322. let decoder = JSONDecoder()
  323. return try decoder.decode(T.self, from: data)
  324. }
  325. static func executePostRequest(eventType: EventType, body: [String: Any]) async throws -> String {
  326. let jwtToken = try await retrieveJWTToken()
  327. let baseURL = Storage.shared.url.value
  328. guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
  329. throw NightscoutError.invalidURL
  330. }
  331. var request = URLRequest(url: url)
  332. request.httpMethod = "POST"
  333. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  334. request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
  335. request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
  336. let sessionConfig = URLSessionConfiguration.default
  337. sessionConfig.waitsForConnectivity = true
  338. sessionConfig.networkServiceType = .responsiveData
  339. let session = URLSession(configuration: sessionConfig)
  340. let (data, response) = try await session.data(for: request)
  341. var responseString: String
  342. responseString = String(data: data, encoding: .utf8) ?? ""
  343. guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
  344. if responseString != "" {
  345. return responseString
  346. } else {
  347. throw NightscoutError.networkError
  348. }
  349. }
  350. return responseString
  351. }
  352. // MARK: - Token Provisioning
  353. /// Name of the Nightscout authorization subject LoopFollow creates when a
  354. /// user provisions a token from their API secret.
  355. static let provisionedSubjectName = "LoopFollow"
  356. private struct AuthSubject: Decodable {
  357. let id: String?
  358. let name: String?
  359. let accessToken: String?
  360. let roles: [String]?
  361. enum CodingKeys: String, CodingKey {
  362. case id = "_id"
  363. case name, accessToken, roles
  364. }
  365. }
  366. /// Creates (or reuses) a read-only Nightscout access token using the site's
  367. /// API secret. The secret only authorizes these requests and is never
  368. /// persisted. Returns the access token for a `readable` subject named
  369. /// `provisionedSubjectName`.
  370. ///
  371. /// The full API secret authenticates as Nightscout's `admin` role (the `*`
  372. /// permission), which includes `admin:api:subjects:create`.
  373. ///
  374. /// Nightscout serves the subjects list from an in-memory cache that doesn't
  375. /// refresh promptly after a write, so a freshly-created subject (and its
  376. /// token) can't be read back reliably right after creating it. Instead we
  377. /// derive the token locally: it's a pure function of the subject's `_id`
  378. /// (returned by the create call) and the API secret. See `accessToken(for:)`.
  379. static func provisionReadOnlyToken(url: String, secret: String) async throws -> String {
  380. let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
  381. guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress }
  382. guard let baseURL = URL(string: trimmedURL),
  383. trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
  384. else { throw NightscoutError.invalidURL }
  385. let secretHash = sha1Hex(secret)
  386. // Reuse an existing subject if one is already visible (idempotent re-runs
  387. // once the site's cache has caught up).
  388. if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) {
  389. return existing
  390. }
  391. let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash)
  392. return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash)
  393. }
  394. /// Returns `true` when `secret` authenticates as the site's API secret.
  395. /// Read-only: it probes an admin-gated endpoint with the hashed secret and
  396. /// creates nothing, so it's safe to call just to find out what the user pasted.
  397. static func verifyAPISecret(url: String, secret: String) async -> Bool {
  398. let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
  399. let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines)
  400. guard !trimmedSecret.isEmpty,
  401. let baseURL = URL(string: trimmedURL),
  402. trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
  403. else { return false }
  404. let endpoint = baseURL.appendingPathComponent("api/v2/authorization/subjects")
  405. var request = URLRequest(url: endpoint)
  406. request.httpMethod = "GET"
  407. request.setValue(sha1Hex(trimmedSecret), forHTTPHeaderField: "api-secret")
  408. request.setValue("application/json", forHTTPHeaderField: "Accept")
  409. request.cachePolicy = .reloadIgnoringLocalCacheData
  410. do {
  411. let (_, response) = try await URLSession.shared.data(for: request)
  412. return (response as? HTTPURLResponse)?.statusCode == 200
  413. } catch {
  414. return false
  415. }
  416. }
  417. private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? {
  418. let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
  419. var request = URLRequest(url: url)
  420. request.httpMethod = "GET"
  421. request.setValue(secretHash, forHTTPHeaderField: "api-secret")
  422. request.setValue("application/json", forHTTPHeaderField: "Accept")
  423. request.cachePolicy = .reloadIgnoringLocalCacheData
  424. let (data, response) = try await URLSession.shared.data(for: request)
  425. try validateProvisioningResponse(response)
  426. let subjects = try JSONDecoder().decode([AuthSubject].self, from: data)
  427. return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken
  428. }
  429. /// Creates the subject and returns its `_id`.
  430. private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String {
  431. let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
  432. var request = URLRequest(url: url)
  433. request.httpMethod = "POST"
  434. request.setValue(secretHash, forHTTPHeaderField: "api-secret")
  435. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  436. request.httpBody = try JSONSerialization.data(withJSONObject: [
  437. "name": provisionedSubjectName,
  438. "roles": ["readable"],
  439. ])
  440. let (data, response) = try await URLSession.shared.data(for: request)
  441. try validateProvisioningResponse(response)
  442. // Nightscout returns the created subject wrapped in an array
  443. // (`[{…}]`) on current versions, but a bare object on some older ones,
  444. // so accept either shape.
  445. let subject = try decodeCreatedSubject(from: data)
  446. guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown }
  447. return id
  448. }
  449. private static func decodeCreatedSubject(from data: Data) throws -> AuthSubject {
  450. let decoder = JSONDecoder()
  451. if let array = try? decoder.decode([AuthSubject].self, from: data) {
  452. guard let first = array.first else { throw NightscoutError.unknown }
  453. return first
  454. }
  455. return try decoder.decode(AuthSubject.self, from: data)
  456. }
  457. /// Reproduces Nightscout's subject-token derivation (`lib/authorization`):
  458. /// abbrev = name lowercased, non-`\w` characters removed, first 10 chars
  459. /// digest = sha1( sha1Hex(apiSecret) + subjectId )
  460. /// token = "\(abbrev)-\(digest[0..<16])"
  461. private static func accessToken(forName name: String, id: String, secretHash: String) -> String {
  462. let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_")
  463. let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10))
  464. let digest = sha1Hex(secretHash + id)
  465. return abbrev + "-" + String(digest.prefix(16))
  466. }
  467. private static func validateProvisioningResponse(_ response: URLResponse) throws {
  468. guard let http = response as? HTTPURLResponse else {
  469. throw NightscoutError.networkError
  470. }
  471. switch http.statusCode {
  472. case 200 ..< 300:
  473. return
  474. case 401, 403:
  475. // The API secret was missing or wrong.
  476. throw NightscoutError.invalidToken
  477. case 404:
  478. throw NightscoutError.siteNotFound
  479. default:
  480. throw NightscoutError.unknown
  481. }
  482. }
  483. private static func sha1Hex(_ string: String) -> String {
  484. Insecure.SHA1.hash(data: Data(string.utf8))
  485. .map { String(format: "%02x", $0) }
  486. .joined()
  487. }
  488. static func extractErrorReason(from responseString: String) -> String {
  489. // 1) Try to parse the entire string as JSON and return the "message"
  490. if let data = responseString.data(using: .utf8) {
  491. if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  492. let message = json["message"] as? String
  493. {
  494. return message
  495. }
  496. }
  497. // 2) If not valid JSON (or no "message"), try to parse it as HTML <title>
  498. if let startRange = responseString.range(of: "<title>"),
  499. let endRange = responseString.range(of: "</title>")
  500. {
  501. let titleRange = startRange.upperBound ..< endRange.lowerBound
  502. let titleContent = responseString[titleRange].trimmingCharacters(in: .whitespacesAndNewlines)
  503. if !titleContent.isEmpty {
  504. return titleContent
  505. }
  506. }
  507. // 3) Fallback: just return the entire raw string
  508. return responseString
  509. }
  510. }