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
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ struct AppSettingsView: View {
}
}
}
```

Use the `UserDefaultsStoreMock` class in Previews:

```swift
#if DEBUG
import SettingsMock

Expand All @@ -77,6 +81,8 @@ import SettingsMock
#endif
```

> Note: `AppSettingValues` is a mockable UserDefaults container already declared in the Settings library.

## Set a global key prefix:

```swift
Expand All @@ -99,20 +105,25 @@ struct MyApp: App {

## Custom Settings Container

```swift
import Settings
You do not need to put the user settings into a type annotated with the `@Settings` macro. You also can declare or use any regular struct, class or enum as your UserDefaults container:

@Settings(prefix: "app_") // keys prefixed with "app_"
```swift
struct AppSettings {
@Setting static var username: String = "Guest"
@Setting(name: "colorScheme") static var theme: String = "light" // key = "colorScheme"
@Setting static var apiKey: String? // optional: no default
}
```
This will store and read from the setting in Foundation's `UserDefaults.standard`.

AppSettings.username = "Alice"
print(AppSettings.theme) // "light"
If you require more control, like having key prefixes, using your own UserDefaults suite, or if you want to mock Foundation UserDefaults in Previews or elsewhere, use the `@Settings` macro:

```swift
@Settings(prefix: "app_") // keys prefixed with "app_"
struct AppSettings {
@Setting static var username: String = "Guest"
}
```


## Projected Value ($propertyName)

Access metadata and observe changes:
Expand Down Expand Up @@ -165,14 +176,18 @@ Settings.apiKey = nil // removes key from UserDefaults

## Nested Containers

Support keys within a name space:

```swift
struct AppSettings {}

extension AppSettings { enum UserSettings {} }

extension AppSettings.UserSettings {
@Setting static var email: String?
}

print(AppSettings.UserSettings.$email.key) // "app_UserSettings::email"
print(AppSettings.UserSettings.$email.key) // "UserSettings::email"
AppSettings.UserSettings.email = "alice@example.com"
```

Expand Down
26 changes: 15 additions & 11 deletions Sources/Settings/Combine/AsyncStreamPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import Combine
/// (convenient for UI consumers). If you prefer the loop off-main and only
/// hop to the main actor for delivery, consider modifying the Task to call
/// `await MainActor.run { ... }` for each delivery instead.
struct AsyncStreamPublisher<S>: Publisher where S: AsyncSequence & Sendable, S.Element: Sendable {
struct AsyncStreamPublisher<S>: Publisher
where S: AsyncSequence & Sendable, S.Element: Sendable {
typealias Output = S.Element
typealias Failure = Swift.Error

Expand All @@ -22,14 +23,15 @@ struct AsyncStreamPublisher<S>: Publisher where S: AsyncSequence & Sendable, S.E
self.sequence = sequence
}

func receive<Sink: Subscriber>(subscriber: Sink) where Sink.Input == Output, Sink.Failure == Failure {
func receive<Sink: Subscriber>(subscriber: Sink)
where Sink.Input == Output, Sink.Failure == Failure {
let erased = AnySubscriber<Output, Failure>(subscriber)
let subscription = Subscription(sequence: sequence, downstream: erased)
subscriber.receive(subscription: subscription)
}
}

extension AsyncStreamPublisher {
extension AsyncStreamPublisher {

// Subscription assumptions / environment
// - This publisher observes infrequently-changing sources (UserDefaults), so
Expand Down Expand Up @@ -73,7 +75,9 @@ extension AsyncStreamPublisher {
// @Sendable closure does not capture a generic subscriber metatype.
private struct DownstreamBox: @unchecked Sendable {
let downstream: AnySubscriber<Output, Failure>
init(_ downstream: AnySubscriber<Output, Failure>) { self.downstream = downstream }
init(_ downstream: AnySubscriber<Output, Failure>) {
self.downstream = downstream
}
}

private var task: Task<Void, Swift.Error>?
Expand All @@ -86,22 +90,22 @@ extension AsyncStreamPublisher {
do {
for try await value in sequence {
try Task.checkCancellation()
_ = downstreamBox.downstream.receive(value) // ignore returned demand for now
_ = downstreamBox.downstream.receive(value) // ignore returned demand for now
}

// natural completion -> send finished
downstreamBox.downstream.receive(completion: .finished)
}
catch is CancellationError {
} catch is CancellationError {
// we should reach here only, when the cancel function
// has been called – which cancels the task.
/* nothing */
}
catch {
} catch {
// We reach here, when the stream has been forcibly terminated
// or the `do` above threw another error. So, `downstream`
// should be intact.
downstreamBox.downstream.receive(completion: .failure(error))
downstreamBox.downstream.receive(
completion: .failure(error)
)
}
}
}
Expand Down
49 changes: 38 additions & 11 deletions Sources/Settings/MacroSetting/Attribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,67 @@ import Foundation
public protocol __Attribute<Container>: SendableMetatype {
associatedtype Container: __Settings_Container
associatedtype Value

static var name: String { get }

static func read() -> Value
static func write(value: Value)

static func reset()

static var defaultValue: Value { get }

static func registerDefault()

static var stream: AsyncThrowingStream<Value, Error> { get }

static func stream<Subject>(
for keyPath: KeyPath<Value, Subject>
) -> AsyncThrowingStream<Subject, Error> where Subject: Equatable, Subject: Sendable
) -> AsyncThrowingStream<Subject, Error>
where Subject: Equatable, Subject: Sendable
}

extension __Attribute {
extension __Attribute where Container: __Settings_Container{

public static var key: String { "\(Container.prefix)\(name)" }

/// Reset the value to its default by removing it from storage
public static func reset() {
Container.removeObject(forKey: key)
}

}

public protocol PropertyListValue {}

// MARK: - Container Resolver

// A type-erasing wrapper that conditionally conforms to `__Settings_Container`.
//
// When `Base` conforms to `__Settings_Container`, this wrapper forwards to the base.
// Otherwise, it provides default behavior using `UserDefaults.standard` with no prefix.
public struct __ContainerResolver<Base> {
private init() {} // Never instantiated
}

extension __ContainerResolver: __Settings_Container {
public static var store: any UserDefaultsStore {
UserDefaults.standard
}

public static var prefix: String { "" }
}

extension __ContainerResolver where Base: __Settings_Container {
public static var store: any UserDefaultsStore {
Base.store
}

public static var prefix: String {
Base.prefix
}
}


// MARK: - Internal

Expand All @@ -47,7 +76,6 @@ extension String: PropertyListValue {}
extension Date: PropertyListValue {}
extension Data: PropertyListValue {}


// Arrays are PropertyListValue only when their elements are PropertyListValue
// This prevents Array<SomeCodableType> from being treated as PropertyListValue
extension Array: PropertyListValue where Element: PropertyListValue {}
Expand All @@ -58,4 +86,3 @@ where Key == String, Value: PropertyListValue {}

typealias PropertyListArray = [Any]
typealias PropertyListDictionary = [String: Any]

46 changes: 31 additions & 15 deletions Sources/Settings/MacroSettings/MacroSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@
/// ## Attributes
/// - `prefix: String?` — Optional key prefix that is applied to all generated keys.
/// - `suiteName: String?` — Optional suite name used to create a `UserDefaults` instance via `UserDefaults(suiteName:)`.
/// If `nil`, the standard defaults are used.
/// If `nil`, `UserDefaults.standard` is used.
///
/// ## Members
/// - Within the container, declare properties using the `@Setting` member macro to bind keys and default values.
/// - You can also add `@Setting` members from extensions of the same container.
///
/// ## Example
/// A simple container with one `@Setting` property. This example also demonstrates
/// the `prefix` and `suiteName` parameters on `@Settings`.
/// A UserDefaults container with one `@Setting` property. The UserDefaults container defines
/// a `prefix`parameter - which will be prepended to all keys, and a `suiteName` parameter
/// which defines the suite name for the custom UserDefaults instance.
/// on `@Settings`.
///
/// ```swift
/// import Foundation
/// import Settings
///
/// @Settings(
/// prefix: "app_",
Expand All @@ -32,8 +35,13 @@
/// @Setting(key: "hasSeenOnboarding", default: false)
/// static var hasSeenOnboarding: Bool
/// }
/// ```
/// >Note: A UserDefaults container using the macro `@Settings` can be declared at top level of any
/// file. For better ergonomics, declare setting values as *static* members. Make the container public so
/// that it is visible in other modules.
///
/// // Usage
/// ## Usage
/// ```swift
/// // Read
/// let seen = AppSettings.hasSeenOnboarding
///
Expand All @@ -52,23 +60,31 @@
/// @Settings struct Settings {
/// @Setting var hasSeenOnboarding = false
/// }
///
///
/// var settings = Settings()
/// }
///
/// // SwiftUI views automatically update when settings.hasSeenOnboarding changes
/// ```
@attached(member)
/// SwiftUI views automatically update when settings.hasSeenOnboarding changes
///
/// >Note: A UserDefaults container using the macro `@Settings` can be declared within classes or
/// structs.

@attached(
member,
names: named(Config),
named(state),
named(store),
named(prefix)
)
@attached(
extension,
conformances: __Settings_Container,
names: named(prefix),
named(suiteName)
conformances: __Settings_Container
)
public macro Settings(
prefix: String? = nil,
suiteName: String? = nil
) = #externalMacro(
module: "SettingsMacros",
type: "SettingsMacro"
)
) =
#externalMacro(
module: "SettingsMacros",
type: "SettingsMacro"
)
44 changes: 43 additions & 1 deletion Sources/Settings/MacroSettings/Settings_Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ extension __Settings_Container {
attribute.write(value: newValue)
}
}

}

extension __Settings_Container {
Expand Down Expand Up @@ -161,3 +160,46 @@ extension __Settings_Container {
self.store.observer(forKey: key, update: update)
}
}

public struct __Settings_Container_Config: Sendable {
struct State {
var store: any UserDefaultsStore = Foundation.UserDefaults.standard
var prefix: String
}
private let _state: OSAllocatedUnfairLock<State>

public init(prefix: String) {
_state = .init(initialState: .init(prefix: prefix))
}

public var store: any UserDefaultsStore {
get {
_state.withLock { state in
state.store
}
}
nonmutating set {
_state.withLock { state in
state.store = newValue
}
}
}

public var prefix: String {
get {
_state.withLock { state in
state.prefix
}
}
nonmutating set {
_state.withLock { state in
state.prefix = newValue.replacing(".", with: "_")
}
}
}
}

public struct __UserDefaultsStandard: __Settings_Container {
public static var store: any UserDefaultsStore { UserDefaults.standard }
public static var prefix: String { "" }
}
5 changes: 4 additions & 1 deletion Sources/Settings/MacroSettings/UserDefaultsObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ final class UserDefaultsObserver: NSObject, Cancellable, @unchecked Sendable {
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
assert(keyPath == key, "KVO notification received for unexpected keyPath: \(keyPath ?? "nil"), expected: \(key)")
assert(
keyPath == key,
"KVO notification received for unexpected keyPath: \(keyPath ?? "nil"), expected: \(key)"
)
callback(keyPath, change?[.oldKey], change?[.newKey])
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Settings/SendableMetatype.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#if compiler(<6.2)
public typealias SendableMetatype = Any
public typealias SendableMetatype = Any
#endif
Loading