mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user