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
4 changes: 2 additions & 2 deletions Development/Design Concepts/DESIGN-Container Migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ public class UserDefaultsMigrationManager {
public protocol UserDefaultsContainer {
static var version: Int { get }
static var prefix: String { get }
static var store: UserDefaults { get }
static var store: any UserDefaultsStore { get }

/// Migration function executed during version transitions
static func migrate(from oldVersion: Int, to newVersion: Int)
Expand Down Expand Up @@ -722,4 +722,4 @@ extension MySettings: UserDefaultsContainer {
@Setting(key: "user_name") var username: String = "anonymous"
// Explicit key: "MyApp.user_name"
}
```
```
57 changes: 37 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,43 @@ Or in Xcode:
- **Observable** — Built-in `Combine` publishers and `AsyncSequence` streams
- **Customizable** — Namespaced keys and pluggable encoders/decoders

## Quick Start
## SwiftUI Integration

```swift
import Settings
import SwiftUI

// Special mockable Settings container.
// Uses UserDefaults.standard per default.
extension AppSettingValues {
@Setting public var score: Int = 0
}

struct AppSettingsView: View {
@AppSetting(\.$score) var score

var body: some View {
Form {
TextField("Enter your score", value: $score, format: .number)
.textFieldStyle(.roundedBorder)
.padding()

Text("Your score was \(score).")
}
}
}

#if DEBUG
import SettingsMock

#Preview {
AppSettingsView()
.environment(\.userDefaultsStore, UserDefaultsStoreMock())
}
#endif
```

## Custom Settings Container

```swift
import Settings
Expand All @@ -74,25 +110,6 @@ AppSettings.username = "Alice"
print(AppSettings.theme) // "light"
```


## SwiftUI Integration

```swift
struct SettingsView: View {
@State private var theme = AppSettings.theme

var body: some View {
Picker("Theme", selection: $theme) {
Text("Light").tag("light")
Text("Dark").tag("dark")
}
.onChange(of: theme) { _, newValue in
AppSettings.theme = newValue
}
}
}
```

## Projected Value ($propertyName)

Access metadata and observe changes:
Expand Down
2 changes: 2 additions & 0 deletions Sources/Settings/MacroSetting/Attribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public protocol __Attribute<Container>: SendableMetatype {

static func reset()

static var defaultValue: Value { get }

static func registerDefault()

static var stream: AsyncThrowingStream<Value, Error> { get }
Expand Down
4 changes: 2 additions & 2 deletions Sources/Settings/MacroSetting/AttributeOptional.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import Combine
import Foundation

public protocol __AttributeOptional<Container>: __Attribute {
associatedtype Value
public protocol __AttributeOptional<Container>: __Attribute where Value: ExpressibleByNilLiteral {
associatedtype Wrapped = Value
associatedtype Encoder = Never
associatedtype Decoder = Never
Expand All @@ -16,6 +15,7 @@ public protocol __AttributeOptional<Container>: __Attribute {

extension __AttributeOptional {
public static func registerDefault() { /* nothing */ }
public static var defaultValue: Value { nil }
}

// MARK: - Read / Write
Expand Down
12 changes: 5 additions & 7 deletions Sources/Settings/MacroSettings/Settings_Container.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import Foundation
import os

public protocol __Settings_Container {
associatedtype Store: UserDefaultsStore
associatedtype Observer: Cancellable

static var store: Store { get }
static var store: any UserDefaultsStore { get }
static var prefix: String { get }

// MARK: - UserDefaults Operations
Expand Down Expand Up @@ -35,7 +33,7 @@ public protocol __Settings_Container {
static func observer(
forKey: String,
update: @escaping @Sendable (Any?, Any?) -> Void
) -> Observer
) -> any Cancellable

}

Expand Down Expand Up @@ -65,7 +63,7 @@ extension __Settings_Container {

extension __Settings_Container {
public static var prefix: String { "" }
public static var store: Foundation.UserDefaults { .standard }
public static var store: any UserDefaultsStore { Foundation.UserDefaults.standard }
}

// MARK: - Default UserDefaults Operations
Expand Down Expand Up @@ -159,7 +157,7 @@ extension __Settings_Container {
public static func observer(
forKey key: String,
update: @escaping @Sendable (Any?, Any?) -> Void
) -> some Cancellable {
) -> any Cancellable {
self.store.observer(forKey: key, update: update)
}
}
2 changes: 1 addition & 1 deletion Sources/Settings/MacroSettings/UserDefaultsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public protocol Cancellable: Sendable {
func cancel()
}

public protocol UserDefaultsStore {
public protocol UserDefaultsStore: Sendable {

associatedtype Observer: Cancellable

Expand Down
70 changes: 50 additions & 20 deletions Sources/Settings/SwiftUI/AppSetting.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if canImport(SwiftUI)
import SwiftUI
import Combine
import os

/// A default container for UserDefaults that uses a configurable store.
///
Expand Down Expand Up @@ -47,13 +48,19 @@ import Combine
/// // Reset to standard
/// AppSettingValues.resetStore()
/// ```
@MainActor
public struct AppSettingValues: __Settings_Container {

fileprivate static var currentStore: any UserDefaultsStore = UserDefaults.standard

public static var store: any UserDefaultsStore {
currentStore
private static let _store = OSAllocatedUnfairLock<any UserDefaultsStore>(initialState: UserDefaults.standard)
public internal(set) static var store: any UserDefaultsStore {
get {
_store.withLock { store in
store
}
}
set {
_store.withLock { store in
store = newValue
}
}
}
}

Expand Down Expand Up @@ -136,26 +143,34 @@ public struct AppSetting<Attribute: __Attribute>: @MainActor DynamicProperty
where Attribute.Value: Sendable {

@State private var value: Attribute.Value
@State private var cancellable: AnyCancellable?
@State private var observer: Observer = Observer()
@Environment(\.userDefaultsStore) private var environmentStore

/// The current UserDefaults value.
public var wrappedValue: Attribute.Value {
get { value }
get {
value
}
nonmutating set {
value = newValue
Attribute.write(value: newValue)
}
}

/// Provides a binding to the UserDefaults value.
public var projectedValue: Binding<Attribute.Value> {
$value
Binding(
get: {
value
},
set: { value in
Attribute.write(value: value)
}
)
}

/// Creates a new AppSetting property wrapper for the specified attribute.
public init(_ attribute: Attribute.Type) {
self._value = State(initialValue: Attribute.read())
self._value = .init(initialValue: Attribute.defaultValue)
}

/// Creates a new AppSetting property wrapper from a UserDefaults projected value.
Expand All @@ -165,7 +180,7 @@ where Attribute.Value: Sendable {
/// @MyAppSetting(MyAppSettingValues.$username) var username
/// ```
public init(_ proxy: __AttributeProxy<Attribute>) {
self._value = State(initialValue: Attribute.read())
self._value = .init(initialValue: Attribute.defaultValue)
}

/// Creates a new AppSetting property wrapper using a key path to an
Expand All @@ -186,7 +201,7 @@ where Attribute.Value: Sendable {
public init(
_ keyPath: KeyPath<AppSettingValues, __AttributeProxy<Attribute>>
) where Attribute.Container == AppSettingValues {
self._value = State(initialValue: Attribute.read())
self._value = .init(initialValue: Attribute.defaultValue)
}

/// Creates a new AppSetting property wrapper using a key path to a property
Expand All @@ -207,22 +222,37 @@ where Attribute.Value: Sendable {
public init(
_ keyPath: KeyPath<Attribute.Container, __AttributeProxy<Attribute>>
) {
self._value = State(initialValue: Attribute.read())
self._value = .init(initialValue: Attribute.defaultValue)
}

/// Called by SwiftUI to set up the publisher subscription.
public mutating func update() {
AppSettingValues.currentStore = self.environmentStore
AppSettingValues.store = environmentStore
if observer.cancellable == nil {
observer.start(binding: $value)
}
}
}

extension AppSetting {

@MainActor
final class Observer {
var cancellable: AnyCancellable? = nil

init() {}

if cancellable == nil {
cancellable = Attribute.publisher
func start(binding: Binding<Attribute.Value>) {
if cancellable == nil {
cancellable = Attribute.publisher
.catch { _ in Just(Attribute.read()) }
.receive(on: DispatchQueue.main)
.sink { [wrappedValue = $value] newValue in
wrappedValue.wrappedValue = newValue
.sink { newValue in
binding.wrappedValue = newValue
}
}
}
}

}

#endif
7 changes: 6 additions & 1 deletion Sources/SettingsClient/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Combine
// }
//
// struct Container<Prefix: ConstString>: __Container {
// static var store: UserDefaults { Foundation.UserDefaults.standard }
// static var store: any UserDefaultsStore { Foundation.UserDefaults.standard }
// static var prefix: String { Prefix.value }
//
// static func clear() {
Expand Down Expand Up @@ -133,6 +133,11 @@ try await main1()
// MARK: - App
import SwiftUI

extension AppSettingValues {
@Setting var user: String?
@Setting var theme: String = "default"
}

// @main
struct SettingsView: View {
@Environment(\.userDefaultsStore) var settings
Expand Down
1 change: 1 addition & 0 deletions Sources/SettingsMock/UserDefaultsMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable
public func set(_ url: URL?, forKey key: String) { set(url as Any?, forKey: key) }

public func register(defaults newDefaults: [String : Any]) {
print("register(defaults(\(newDefaults)")
for (key, newDefault) in newDefaults {
// Only accept property-list-serializable defaults
guard let copy = plistDeepCopy(newDefault) else { continue }
Expand Down
2 changes: 1 addition & 1 deletion Tests/SettingsMockTests/UserDefaultsStoreMockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct UserDefaultsStoreMockTests {
}

struct TestContainer<Prefix: ConstString>: __Settings_Container {
static var store: UserDefaultsStoreMock { UserDefaultsStoreMock.standard }
static var store: any UserDefaultsStore { UserDefaultsStoreMock.standard }
static var prefix: String { Prefix.value }

static func clear() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct AttributeCodingTests {
}

struct TestContainer<Prefix: ConstString>: __Settings_Container {
static var store: UserDefaults { UserDefaults.standard }
static var store: any UserDefaultsStore { UserDefaults.standard }
static var prefix: String { Prefix.value }

static func clear() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct AttributeCustomCodableTypesTests {
}

struct TestContainer<Prefix: ConstString>: __Settings_Container {
static var store: UserDefaults { UserDefaults.standard }
static var store: any UserDefaultsStore { UserDefaults.standard }
static var prefix: String { Prefix.value }

static func clear() {
Expand Down
2 changes: 1 addition & 1 deletion Tests/SettingsTests/AttributeTests/AttributeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct AttributeTests {
}

struct TestContainer<Prefix: ConstString>: __Settings_Container {
static var store: UserDefaults { UserDefaults.standard }
static var store: any UserDefaultsStore { UserDefaults.standard }
static var prefix: String { Prefix.value }

static func clear() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct DefaultRegistrarTests {

struct TrackedContainer: __Settings_Container {
static let tracker = RegistrationTracker()
static var store: UserDefaults { .standard }
static var store: any UserDefaultsStore { Foundation.UserDefaults.standard }
static var prefix: String { "DefaultRegistrarTest_" }
static var suiteName: String? { nil }

Expand Down Expand Up @@ -107,7 +107,7 @@ protocol ConstString {
}

struct TestContainer<Prefix: ConstString>: __Settings_Container {
static var store: UserDefaults { .standard }
static var store: any UserDefaultsStore { Foundation.UserDefaults.standard }
static var prefix: String { Prefix.value }
static var suiteName: String? { nil }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import Foundation
import Testing

// This is ugly, but UserDefaults is documented to be thread-safe, so this
// should be OK.
extension UserDefaults: @retroactive @unchecked Sendable {}

extension UserDefaults {
func observeKey<Value: Sendable>(_ key: String, valueType _: Value.Type)
-> AsyncStream<Value?>
Expand Down
Loading
Loading