While I can't share the exact code, I am migrating a declarative interface we have currently for UserDefaults with multiple processors that handle key functionality. The callers can pick and choose the processors to have desired data format and location. In this I am passing UserDefaults instance inside a @Sendable closure that is causing warnings:
//
// UserDefaultStorage+Processor.swift
//
// A minimal, self-contained sample of a processor pipeline over `UserDefaults`.
// Demonstrates the protocol, the pipeline executor, and two concrete processors:
// an app-group suite router and a transparent AES-GCM encryption layer.
//
import Foundation
import CryptoKit
/// The actor that owns all storage access. In the real codebase this is a
/// per-instance actor; here it is a minimal stand-in so the sample compiles.
actor StorageInstance {
/// The app-group identifier this instance is configured with, if any.
let appGroupID: String?
init(appGroupID: String? = nil) {
self.appGroupID = appGroupID
}
}
// MARK: - UserDefaultStorage
/// A typed `UserDefaults`-backed storage location with an ordered pipeline of
/// processors that can intercept and transform its get / set / dictionary operations.
struct UserDefaultStorage<Value> {
/// The suite scope of a storage location (e.g. standard vs. app-group suite).
enum Scope: Sendable {
case standard
case suite(name: String)
}
let scope: Scope
let processors: [any UserDefaultProcessor<Value>]
init(scope: Scope = .standard, processors: [any UserDefaultProcessor<Value>] = []) {
self.scope = scope
self.processors = processors
}
}
// MARK: - Pipeline execution
extension UserDefaultStorage {
/// The base `UserDefaults` resolved from ``scope``.
private func baseDefaults() -> UserDefaults {
switch scope {
case .standard:
return .standard
case .suite(let name):
return UserDefaults(suiteName: name) ?? .standard
}
}
/// Folds every processor over the base actions, then runs the requested operation.
///
/// Each processor receives the actions accumulated so far and may replace them.
/// `.skip` leaves the accumulated actions untouched.
private func resolvedActions(
instance: isolated StorageInstance
) throws -> (
get: @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
set: @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
dictionary: @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any]
) {
// Base actions: plain UserDefaults access.
var get: @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any? = { _, ud, key in
ud.object(forKey: key)
}
var set: @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void = { _, ud, key, value in
if let value { ud.set(value, forKey: key) } else { ud.removeObject(forKey: key) }
}
var dictionary: @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any] = { _, ud in
ud.dictionaryRepresentation()
}
for processor in processors {
let result = try processor.process(
get: get, set: set, dictionary: dictionary,
scope: scope, instance: instance
)
switch result {
case .skip:
continue
case .transformed(let newGet, let newSet, let newDictionary):
get = newGet
set = newSet
dictionary = newDictionary
}
}
return (get, set, dictionary)
}
/// Reads the value stored under `key`, running it through the full processor pipeline.
func read(forKey key: String, instance: isolated StorageInstance) throws -> Any? {
let actions = try resolvedActions(instance: instance)
return try actions.get(instance, baseDefaults(), key)
}
/// Writes `value` under `key`, running it through the full processor pipeline.
func write(_ value: Any?, forKey key: String, instance: isolated StorageInstance) throws {
let actions = try resolvedActions(instance: instance)
try actions.set(instance, baseDefaults(), key, value)
}
/// Returns the full dictionary representation, running it through the pipeline.
func dictionaryRepresentation(instance: isolated StorageInstance) throws -> [String: Any] {
let actions = try resolvedActions(instance: instance)
return try actions.dictionary(instance, baseDefaults())
}
}
// MARK: - UserDefaultProcessor
/// A type that can intercept and transform `UserDefaults` get, set, and
/// dictionary-representation operations performed by a ``UserDefaultStorage``.
///
/// Processors are applied in the order they appear in
/// ``UserDefaultStorage/processors``. Each call receives the three action closures
/// accumulated from all prior processors and returns either `.skip` — leaving every
/// action unchanged — or `.transformed` with replacement closures for all subsequent
/// processors and the final execution.
///
/// Each action closure receives the `isolated StorageInstance` as its first argument
/// so processors and closures have direct, isolation-safe access to instance state.
///
/// The associated type `Value` matches the `Value` of the ``UserDefaultStorage``
/// the processor is attached to.
protocol UserDefaultProcessor<Value>: Hashable, Sendable {
associatedtype Value
/// Processes all `UserDefaults` actions for an operation cycle.
///
/// - Parameters:
/// - get: The current read closure.
/// - set: The current write closure.
/// - dictionary: The current dictionary-read closure.
/// - scope: The suite scope of the ``UserDefaultStorage`` issuing the operation.
/// - instance: The isolated instance in whose context the operation occurs.
/// - Returns: `.skip` to leave all current actions unchanged, or `.transformed`
/// with replacement closures for all subsequent processors and the final execution.
/// - Throws: Any error the processor encounters.
func process(
get: @escaping @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
set: @escaping @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
dictionary: @escaping @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any],
scope: UserDefaultStorage<Value>.Scope,
instance: isolated StorageInstance
) throws -> UserDefaultStorage<Value>.ProcessorResult
}
// MARK: - ProcessorResult
extension UserDefaultStorage {
/// The return type of ``UserDefaultProcessor/process(get:set:dictionary:scope:instance:)``.
///
/// - `.skip`: Leave the current actions unchanged.
/// - `.transformed`: Replace all three action closures for all subsequent processors
/// and the final execution. Each closure receives the `isolated StorageInstance`
/// as its first argument. The value written during a set operation is passed
/// directly as the `Any?` parameter of the `set` closure.
enum ProcessorResult: Sendable {
/// Leave all current actions unchanged.
case skip
/// Replace all current actions with the provided closures.
case transformed(
get: @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
set: @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
dictionary: @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any]
)
}
}
// MARK: - AppGroupProcessor
/// Routes every operation to the instance's app-group `UserDefaults` suite when one
/// is configured, so values are visible to app extensions. If no app group is set,
/// the processor skips and leaves the standard suite in place.
struct AppGroupProcessor<Value>: UserDefaultProcessor {
func process(
get: @escaping @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
set: @escaping @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
dictionary: @escaping @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any],
scope: UserDefaultStorage<Value>.Scope,
instance: isolated StorageInstance
) throws -> UserDefaultStorage<Value>.ProcessorResult {
// Only redirect when the instance actually has an app group configured.
guard let appGroupID = instance.appGroupID,
let suite = UserDefaults(suiteName: appGroupID) else {
return .skip
}
// Replace the `UserDefaults` each downstream action receives with the suite.
return .transformed(
get: { instance, _, key in try get(instance, suite, key) },
set: { instance, _, key, value in try set(instance, suite, key, value) },
dictionary: { instance, _ in try dictionary(instance, suite) }
)
}
}
// MARK: - EncryptionProcessor
/// Transparently encrypts values on write and decrypts them on read using AES-GCM.
///
/// Stored ciphertext is a base64 string of the GCM combined representation
/// (nonce ‖ ciphertext ‖ tag). On read, values that fail to decrypt are returned
/// as-is, which lets plaintext written before encryption was enabled migrate lazily.
struct EncryptionProcessor<Value>: UserDefaultProcessor {
/// The symmetric key as raw bytes, so the processor stays `Hashable`/`Sendable`.
/// In production this is derived/loaded from the Keychain, never hard-coded.
let keyData: Data
private var key: SymmetricKey { SymmetricKey(data: keyData) }
func process(
get: @escaping @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
set: @escaping @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
dictionary: @escaping @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any],
scope: UserDefaultStorage<Value>.Scope,
instance: isolated StorageInstance
) throws -> UserDefaultStorage<Value>.ProcessorResult {
let key = self.key
return .transformed(
get: { instance, ud, key2 in
guard let stored = try get(instance, ud, key2) else { return nil }
// Only attempt to decrypt base64 strings; pass everything else through.
guard let base64 = stored as? String,
let decrypted = Self.decrypt(base64, using: key) else {
return stored
}
return decrypted
},
set: { instance, ud, key2, value in
guard let value else {
try set(instance, ud, key2, nil)
return
}
// Encrypt string/data payloads; store the base64 ciphertext.
if let encrypted = Self.encrypt(value, using: key) {
try set(instance, ud, key2, encrypted)
} else {
try set(instance, ud, key2, value)
}
},
dictionary: dictionary
)
}
// MARK: Crypto helpers
private static func encrypt(_ value: Any, using key: SymmetricKey) -> String? {
let plaintext: Data
if let string = value as? String {
plaintext = Data(string.utf8)
} else if let data = value as? Data {
plaintext = data
} else {
return nil // non-encryptable scalar (Bool/Int/…): leave to the base layer
}
guard let sealed = try? AES.GCM.seal(plaintext, using: key),
let combined = sealed.combined else { return nil }
return combined.base64EncodedString()
}
private static func decrypt(_ base64: String, using key: SymmetricKey) -> String? {
guard let combined = Data(base64Encoded: base64),
let box = try? AES.GCM.SealedBox(combined: combined),
let plaintext = try? AES.GCM.open(box, using: key) else { return nil }
return String(decoding: plaintext, as: UTF8.self)
}
}
// MARK: - Usage
/*
let key = SymmetricKey(size: .bits256)
let storage = UserDefaultStorage<String>(
scope: .standard,
processors: [
AppGroupProcessor<String>(), // route to app-group suite
EncryptionProcessor<String>(keyData: key.withUnsafeBytes(Data.init)) // then encrypt
]
)
let instance = StorageInstance(appGroupID: "group.com.example.app")
try await instance.run {
try storage.write("secret-token", forKey: "auth", instance: $0)
let token = try storage.read(forKey: "auth", instance: $0) as? String
print(token ?? "nil") // "secret-token" — decrypted from the app-group suite
}
*/