mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
100 lines
2.9 KiB
Swift
100 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")
|
||
|
|
}
|
||
|
|
}
|