Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 98 additions & 17 deletions Sources/SnappTheming/Theme/SnappThemingImageProcessorsRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,30 @@ import Foundation

/// A registry for managing image processors in the SnappTheming framework.
///
/// ### Usage
/// Use this registry to register custom image processors that handle specific image formats
/// or transformations within the theming system. The registry maintains a thread-safe collection
/// of processors that can be accessed throughout your application's lifetime.
///
/// ## Overview
///
/// The registry follows the singleton pattern and provides thread-safe access to registered
/// image processors. Register your processors early in your application lifecycle, typically
/// in your app's initialization phase.
///
/// ## Topics
///
/// ### Getting the Shared Instance
///
/// - ``shared``
///
/// ### Managing Processors
///
/// - ``register(_:)``
/// - ``unregister(_:)``
/// - ``registeredProcessors()``
///
/// ## Usage
///
/// ```swift
/// @main
/// struct ExampleApp: App {
Expand All @@ -25,7 +48,6 @@ import Foundation
/// .register(SnappThemingSVGSupportSVGProcessor())
///
/// self.configuration = AvailableTheme.night.configuration
///
/// self.json = themeJSON
/// }
///
Expand All @@ -37,36 +59,95 @@ import Foundation
/// }
/// ```
public final class SnappThemingImageProcessorsRegistry: @unchecked Sendable {
/// The default shared instance of the registry.
/// This singleton provides a thread-safe shared instance.
public static let shared: SnappThemingImageProcessorsRegistry = .init()
/// The shared instance of the registry.
///
/// Use this instance to register and access image processors throughout your application.
/// The shared instance is thread-safe and can be accessed from any thread.
///
/// ## Example
///
/// ```swift
/// SnappThemingImageProcessorsRegistry.shared.register(MyCustomProcessor())
/// ```
public static let shared = SnappThemingImageProcessorsRegistry()

/// A private initializer to enforce the singleton pattern.
private init() {}

/// An internal thread-safe array for storing external image processors.
private var _externalImageProcessors: [any SnappThemingExternalImageProcessorProtocol] = []
private let queue = DispatchQueue(label: "ImageProcessorsQueue")
private let queue = DispatchQueue(label: "ImageProcessorsQueue", attributes: .concurrent)

/// Registers a new external image processor.
///
/// - Parameter processor: The image processor to register.
/// - Discussion:
/// This method is thread-safe. Registered processors are stored in a private array.
/// This method adds a processor to the registry's collection. Processors are typically
/// registered during application initialization and remain available throughout the
/// app's lifetime.
///
/// - Parameter processor: The image processor conforming to
/// ``SnappThemingExternalImageProcessorProtocol`` to register.
///
/// - Note: This method is thread-safe and can be called from any thread. However,
/// registering processors after your UI has been initialized may lead to inconsistent
/// behavior. It's recommended to register all processors during app startup.
///
/// ## Example
///
/// ```swift
/// let svgProcessor = SnappThemingSVGSupportSVGProcessor()
/// SnappThemingImageProcessorsRegistry.shared.register(svgProcessor)
/// ```
public func register(_ processor: any SnappThemingExternalImageProcessorProtocol) {
queue.sync {
queue.sync(flags: .barrier) { [weak self] in
guard let self else { return }
_externalImageProcessors.append(processor)
}
}

/// Retrieves all registered external image processors.
/// Retrieves all currently registered external image processors.
///
/// This method returns a snapshot of all processors registered at the time of the call.
/// The returned array is a copy, so modifications to it won't affect the registry.
///
/// - Returns: An array of all registered image processors conforming to
/// ``SnappThemingExternalImageProcessorProtocol``.
///
/// - Returns: A copy of the current list of registered processors.
/// - Discussion:
/// Accessing the processors is thread-safe and returns a snapshot of the current state.
/// ## Example
///
/// ```swift
/// let processors = SnappThemingImageProcessorsRegistry.shared.registeredProcessors()
/// print("Total processors registered: \(processors.count)")
/// ```
public func registeredProcessors() -> [any SnappThemingExternalImageProcessorProtocol] {
queue.sync {
queue.sync(flags: .barrier) { [weak self] in
guard let self else { return [] }
return _externalImageProcessors
}
}

/// Unregisters all image processors of a specific type.
///
/// This method removes all processors from the registry that match the specified type.
///
/// - Parameter processorType: The metatype of the processor to remove. Pass the type
/// followed by `.self` (e.g., `MyProcessor.self`).
///
/// - Note: This method is thread-safe and can be called from any thread. If no processors
/// of the specified type are found in the registry, this method does nothing.
///
/// ## Example
///
/// ```swift
/// // Register an SVG processor
/// SnappThemingImageProcessorsRegistry.shared
/// .register(SnappThemingSVGSupportSVGProcessor())
///
/// // Later, remove all SVG processors by type:
/// SnappThemingImageProcessorsRegistry.shared
/// .unregister(SnappThemingSVGSupportSVGProcessor.self)
/// ```
public func unregister<T>(_ processorType: T.Type) where T: SnappThemingExternalImageProcessorProtocol {
queue.sync(flags: .barrier) { [weak self] in
guard let self else { return }
_externalImageProcessors.removeAll { type(of: $0) == processorType }
}
}
}
28 changes: 28 additions & 0 deletions Tests/SnappThemingTests/Extensions/FileManager+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// File.swift
// SnappTheming
//
// Created by Oleksii Kolomiiets on 11/8/25.
//

import Foundation

extension FileManager {
static let withFileExistTrue: MockFileManager = { withFileExistTrue() }()
static func withFileExistTrue(
_ createDirectoryError: MockFileManager.FileManagerError? = nil,
cachedURLs: [URL] = []
) -> MockFileManager {
let fileManager = MockFileManager()
fileManager.fileExists = true
fileManager.urlsResult = cachedURLs
fileManager.createDirectoryError = createDirectoryError
return fileManager
}

static let emptyCacheDirectory: MockFileManager = {
let fileManager = MockFileManager()
fileManager.urlsResult = []
return fileManager
}()
}
19 changes: 19 additions & 0 deletions Tests/SnappThemingTests/Extensions/SnappThemingImage+system.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// File.swift
// SnappTheming
//
// Created by Oleksii Kolomiiets on 11/8/25.
//

import SnappTheming
import Foundation

extension SnappThemingImage {
static func system(name: String) -> SnappThemingImage? {
#if canImport(UIKit)
SnappThemingImage(systemName: name)
#elseif canImport(AppKit)
SnappThemingImage(systemSymbolName: name, accessibilityDescription: "test")
#endif
}
}
3 changes: 1 addition & 2 deletions Tests/SnappThemingTests/ImageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,9 @@ struct ImageTests {

let rawData: String = try #require(
declaration.images.basket)
let imageData = try #require(try? SnappThemingDataURI(from: rawData))
let _ = try #require(try? SnappThemingDataURI(from: rawData))
let image: Image = declaration.images.basket

#expect(imageData != nil)
#expect(image == fallbackImage)
}
}
16 changes: 16 additions & 0 deletions Tests/SnappThemingTests/Mocks/MockExternalProcessor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// MockExternalProcessor.swift
// SnappTheming
//
// Created by Oleksii Kolomiiets on 11/8/25.
//

import Foundation
import SnappTheming
import UniformTypeIdentifiers

final class MockExternalProcessor: SnappThemingExternalImageProcessorProtocol {
func process(_ object: SnappTheming.SnappThemingImageObject, of type: UTType) -> SnappThemingImage? {
.system(name: "pencil")
}
}
30 changes: 30 additions & 0 deletions Tests/SnappThemingTests/Mocks/MockFileManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// MockFileManager.swift
// SnappTheming
//
// Created by Oleksii Kolomiiets on 11/8/25.
//

import Foundation

class MockFileManager: FileManager, @unchecked Sendable {
enum FileManagerError: Error {
case failedToCreateDirectory
}
var fileExists: Bool = false
override func fileExists(atPath path: String) -> Bool {
fileExists
}

var urlsResult: [URL] = []
override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] {
urlsResult
}

var createDirectoryError: Error? = nil
override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws {
if let createDirectoryError {
throw createDirectoryError
}
}
}
Loading