Meshtastic-Apple/Meshtastic/API/Helpers/URL+fetch.swift
2025-12-14 17:13:20 -05:00

99 lines
2.9 KiB
Swift

//
// URL+fetch.swift
// Meshtastic
//
// Created by jake on 12/6/25.
//
import Foundation
extension URL {
/// Custom error type for the URL extension
enum TimeoutError: Error, LocalizedError {
case timedOut(TimeInterval)
var errorDescription: String? {
switch self {
case .timedOut(let seconds):
return "The operation timed out after \(seconds) seconds."
}
}
}
/// Fetches data from the URL (local or remote) with a strict timeout.
/// - Parameter timeout: The duration in seconds to wait before throwing an error.
/// - Returns: The `Data` retrieved.
func data(timeout: TimeInterval) async throws -> Data {
return try await withThrowingTaskGroup(of: Data.self) { group in
// Task 1: The Fetch Operation
group.addTask {
if self.isFileURL {
// Handle Local Files
// Note: Data(contentsOf:) is synchronous (blocking).
// Running it inside a Task allows it to be raced, though
// the underlying thread may remain blocked until OS IO completes
// if cancellation occurs.
return try Data(contentsOf: self)
} else {
// Handle Remote Network Requests
let (data, _) = try await URLSession.shared.data(from: self)
return data
}
}
// Task 2: The Timer
group.addTask {
// Convert seconds to nanoseconds
let nanoseconds = UInt64(timeout * 1_000_000_000)
try await Task.sleep(nanoseconds: nanoseconds)
// If we wake up, it means the fetch hasn't finished yet
throw TimeoutError.timedOut(timeout)
}
// Race Handling
// Wait for the first task to finish (either success or error)
guard let result = try await group.next() else {
// Should not be reachable, but required by compiler
throw URLError(.unknown)
}
// If we are here, one task finished successfully.
// Cancel the other task immediately.
group.cancelAll()
return result
}
}
/// Performs a HEAD request to fetch the ETag header for the URL.
/// - Parameter session: The URLSession to use (defaults to .shared).
/// - Returns: The ETag string if found and the request is successful, otherwise nil.
func eTag(using session: URLSession = .shared) async throws -> String? {
var request = URLRequest(url: self)
request.httpMethod = "HEAD"
// Ensure we don't use the local cache so we get the real ETag from the server
request.cachePolicy = .reloadIgnoringLocalCacheData
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return nil
}
// Optional: Check for success status codes (200-299)
guard (200...299).contains(httpResponse.statusCode) else {
// You might want to return nil or throw a specific error here
// depending on your requirements (e.g. 404 Not Found)
return nil
}
// Header lookup is case-insensitive
return httpResponse.value(forHTTPHeaderField: "ETag")
}
}