Feat: Add image size limits and URL timeout configuration (P4.19)

- Add .imageMaxPixelCount(_:) modifier to reject oversized images
- Add .imageURLTimeout(_:) modifier with configurable timeout (default: 30s)
- Replace Data(contentsOf:) with URLSession.dataTask for proper timeout support
- Add ImageLoadError.imageTooLarge error case
- Wire environment values through _ImageCore to PlatformImageLoader
This commit is contained in:
phranck
2026-02-14 02:17:26 +01:00
parent e214215610
commit be19689b84
3 changed files with 145 additions and 8 deletions
+75 -6
View File
@@ -45,6 +45,9 @@ enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
/// A URL download failed.
case downloadFailed(String)
/// The image exceeds the maximum allowed pixel count.
case imageTooLarge(pixelCount: Int, limit: Int)
var description: String {
switch self {
case .fileNotFound(let path):
@@ -55,6 +58,8 @@ enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
return "Image decoding failed: \(reason)"
case .downloadFailed(let reason):
return "Image download failed: \(reason)"
case .imageTooLarge(let pixelCount, let limit):
return "Image too large: \(pixelCount) pixels (limit: \(limit))"
}
}
@@ -71,6 +76,21 @@ enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
struct PlatformImageLoader: ImageLoader {
func loadImage(from path: String) throws -> RGBAImage {
try loadImage(from: path, maxPixelCount: nil)
}
func loadImage(from data: Data) throws -> RGBAImage {
try loadImage(from: data, maxPixelCount: nil)
}
/// Loads an image from a file path with an optional pixel count limit.
///
/// - Parameters:
/// - path: The absolute file path to the image.
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
/// - Returns: The decoded image as `RGBAImage`.
/// - Throws: `ImageLoadError` if the file cannot be read, decoded, or exceeds the limit.
func loadImage(from path: String, maxPixelCount: Int?) throws -> RGBAImage {
guard FileManager.default.fileExists(atPath: path) else {
throw ImageLoadError.fileNotFound(path)
}
@@ -85,10 +105,22 @@ struct PlatformImageLoader: ImageLoader {
}
defer { stbi_image_free(rawPixels) }
let pixelCount = Int(width) * Int(height)
if let limit = maxPixelCount, pixelCount > limit {
throw ImageLoadError.imageTooLarge(pixelCount: pixelCount, limit: limit)
}
return pixelsFromRaw(rawPixels, width: Int(width), height: Int(height))
}
func loadImage(from data: Data) throws -> RGBAImage {
/// Loads an image from raw data with an optional pixel count limit.
///
/// - Parameters:
/// - data: The image file data.
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
/// - Returns: The decoded image as `RGBAImage`.
/// - Throws: `ImageLoadError` if the data cannot be decoded or exceeds the limit.
func loadImage(from data: Data, maxPixelCount: Int?) throws -> RGBAImage {
var width: Int32 = 0
var height: Int32 = 0
var channels: Int32 = 0
@@ -111,6 +143,11 @@ struct PlatformImageLoader: ImageLoader {
}
defer { stbi_image_free(pixels) }
let pixelCount = Int(width) * Int(height)
if let limit = maxPixelCount, pixelCount > limit {
throw ImageLoadError.imageTooLarge(pixelCount: pixelCount, limit: limit)
}
return pixelsFromRaw(pixels, width: Int(width), height: Int(height))
}
}
@@ -181,10 +218,19 @@ extension PlatformImageLoader {
/// On first access the image is downloaded synchronously and cached.
/// Subsequent calls for the same URL return the cached copy.
///
/// - Parameter urlString: The URL to download.
/// - Parameters:
/// - urlString: The URL to download.
/// - cache: The image cache to use.
/// - timeout: The download timeout in seconds (default: 30).
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
/// - Returns: The decoded image.
/// - Throws: `ImageLoadError` on network or decoding failure.
func loadImage(from urlString: String, cache: URLImageCache = .shared) throws -> RGBAImage {
/// - Throws: `ImageLoadError` on network or decoding failure, or if image exceeds size limit.
func loadImage(
from urlString: String,
cache: URLImageCache = .shared,
timeout: TimeInterval = 30,
maxPixelCount: Int? = nil
) throws -> RGBAImage {
if let cached = cache.get(urlString) {
return cached
}
@@ -195,12 +241,35 @@ extension PlatformImageLoader {
let data: Data
do {
data = try Data(contentsOf: url)
var request = URLRequest(url: url)
request.timeoutInterval = timeout
nonisolated(unsafe) var responseData: Data?
nonisolated(unsafe) var responseError: Error?
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { d, _, error in
responseData = d
responseError = error
semaphore.signal()
}
task.resume()
semaphore.wait()
if let error = responseError {
throw error
}
guard let downloaded = responseData else {
throw ImageLoadError.downloadFailed("No data received")
}
data = downloaded
} catch let error as ImageLoadError {
throw error
} catch {
throw ImageLoadError.downloadFailed(error.localizedDescription)
}
let image = try loadImage(from: data)
let image = try loadImage(from: data, maxPixelCount: maxPixelCount)
cache.set(urlString, image: image)
return image
}
+61
View File
@@ -4,6 +4,7 @@
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - Image Source
@@ -122,6 +123,16 @@ private struct ImageAspectRatioKey: EnvironmentKey {
static let defaultValue: Double? = nil
}
/// Environment key for the maximum allowed image pixel count.
private struct ImageMaxPixelCountKey: EnvironmentKey {
static let defaultValue: Int? = nil
}
/// Environment key for the URL download timeout in seconds.
private struct ImageURLTimeoutKey: EnvironmentKey {
static let defaultValue: TimeInterval = 30
}
// MARK: - EnvironmentValues
extension EnvironmentValues {
@@ -168,6 +179,23 @@ extension EnvironmentValues {
get { self[ImageAspectRatioKey.self] }
set { self[ImageAspectRatioKey.self] = newValue }
}
/// The maximum allowed total pixel count for loaded images.
///
/// Images exceeding this limit will fail with `ImageLoadError.imageTooLarge`.
/// `nil` means no limit (default).
var imageMaxPixelCount: Int? {
get { self[ImageMaxPixelCountKey.self] }
set { self[ImageMaxPixelCountKey.self] = newValue }
}
/// The timeout in seconds for URL image downloads.
///
/// Defaults to 30 seconds.
var imageURLTimeout: TimeInterval {
get { self[ImageURLTimeoutKey.self] }
set { self[ImageURLTimeoutKey.self] = newValue }
}
}
// MARK: - View Modifiers
@@ -261,4 +289,37 @@ extension View {
public func scaledToFill() -> some View {
aspectRatio(contentMode: .fill)
}
/// Sets the maximum allowed pixel count for image loading.
///
/// Images with more total pixels than this limit will fail to load
/// with `ImageLoadError.imageTooLarge`. Use this to prevent excessive
/// memory usage from very large images.
///
/// ```swift
/// Image(.url("https://example.com/photo.png"))
/// .imageMaxPixelCount(4_000_000) // ~4 megapixels
/// ```
///
/// - Parameter maxPixels: The maximum total pixel count, or `nil` for no limit.
/// - Returns: A modified view.
public func imageMaxPixelCount(_ maxPixels: Int?) -> some View {
environment(\.imageMaxPixelCount, maxPixels)
}
/// Sets the timeout for URL image downloads.
///
/// If the download does not complete within the specified interval,
/// it fails with `ImageLoadError.downloadFailed`.
///
/// ```swift
/// Image(.url("https://example.com/photo.png"))
/// .imageURLTimeout(10) // 10 seconds
/// ```
///
/// - Parameter seconds: The timeout in seconds (default: 30).
/// - Returns: A modified view.
public func imageURLTimeout(_ seconds: TimeInterval) -> some View {
environment(\.imageURLTimeout, seconds)
}
}
+9 -2
View File
@@ -62,6 +62,8 @@ struct _ImageCore: View, Renderable, Layoutable {
let aspectRatioOverride = context.environment.imageAspectRatio
let placeholderText = context.environment.imagePlaceholderText
let showSpinner = context.environment.imagePlaceholderSpinner
let maxPixelCount = context.environment.imageMaxPixelCount
let urlTimeout = context.environment.imageURLTimeout
// Retrieve or create persistent phase state
let phaseKey = StateStorage.StateKey(identity: identity, propertyIndex: StateIndex.phase)
@@ -95,9 +97,14 @@ struct _ImageCore: View, Renderable, Layoutable {
let rawImage: RGBAImage
switch src {
case .file(let path):
rawImage = try loader.loadImage(from: path)
rawImage = try loader.loadImage(from: path, maxPixelCount: maxPixelCount)
case .url(let urlString):
rawImage = try loader.loadImage(from: urlString, cache: .shared)
rawImage = try loader.loadImage(
from: urlString,
cache: .shared,
timeout: urlTimeout,
maxPixelCount: maxPixelCount
)
}
// Store the raw image; conversion happens per render pass.