Skip to content

Commit e1503b7

Browse files
fatbobmanclaude
andcommitted
Add limitToInstance parameter for App Group cross-process notification support
- Added `limitToInstance` parameter to @ObservableDefaults macro (default: true) - When set to false, enables receiving UserDefaults notifications from all instances - Essential for App Group scenarios where widgets/extensions modify shared UserDefaults - Added comprehensive tests for cross-process notification behavior - Maintains backward compatibility with default behavior unchanged This fixes the issue where @default macro cannot trigger view refresh when App Group is enabled, as it was only listening to notifications from the specific UserDefaults instance. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 10e0525 commit e1503b7

3 files changed

Lines changed: 150 additions & 5 deletions

File tree

Sources/ObservableDefaults/Macros.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,26 @@ public macro ObservableOnly() = #externalMacro(
146146
/// ignoreExternalChanges: false,
147147
/// suiteName: "group.myapp",
148148
/// prefix: "myApp_",
149-
/// observeFirst: false
149+
/// observeFirst: false,
150+
/// limitToInstance: true
150151
/// )
151152
/// class Settings {
152153
/// // Properties automatically managed
153154
/// }
154155
/// ```
155156
///
157+
/// Cross-process synchronization (App Groups):
158+
/// ```swift
159+
/// @ObservableDefaults(
160+
/// suiteName: "group.myapp",
161+
/// prefix: "widget_", // Use unique prefix to avoid key conflicts
162+
/// limitToInstance: false // Enable cross-process notifications
163+
/// )
164+
/// class WidgetSettings {
165+
/// var sharedData: String = "shared" // Syncs across app and widgets
166+
/// }
167+
/// ```
168+
///
156169
/// Observe First mode (prioritizes observation over persistence):
157170
/// ```swift
158171
/// @ObservableDefaults(observeFirst: true)
@@ -170,6 +183,7 @@ public macro ObservableOnly() = #externalMacro(
170183
/// - `suiteName`: Custom UserDefaults suite name (default: nil, uses standard)
171184
/// - `prefix`: Prefix for all UserDefaults keys (default: nil, must not contain '.')
172185
/// - `observeFirst`: Enables Observe First mode (default: false)
186+
/// - `limitToInstance`: Limits observations to the specific UserDefaults instance. Set to false for App Group cross-process synchronization (default: true)
173187
/// - `defaultIsolationIsMainActor`: Set to true when project's defaultIsolation is MainActor (default: false)
174188
///
175189
/// Generated initializer (when autoInit is true):
@@ -213,6 +227,7 @@ public macro ObservableDefaults(
213227
suiteName: String = "",
214228
prefix: String = "",
215229
observeFirst: Bool = false,
230+
limitToInstance: Bool = true,
216231
defaultIsolationIsMainActor: Bool = false) = #externalMacro(
217232
module: "ObservableDefaultsMacros",
218233
type: "ObservableDefaultsMacros")

Sources/ObservableDefaultsMacros/Macros/ObservableDefaultsMacro.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ public enum ObservableDefaultsMacros {
6666
static let prefix: String = "prefix"
6767
/// Parameter for enabling Observe First mode
6868
static let observeFirst: String = "observeFirst"
69+
/// Parameter for limiting observations to specific UserDefaults instance
70+
static let limitToInstance: String = "limitToInstance"
6971
/// Parameter for indicating when project's defaultIsolation is set to MainActor
7072
static let defaultIsolationIsMainActor: String = "defaultIsolationIsMainActor"
7173
}
@@ -108,6 +110,7 @@ extension ObservableDefaultsMacros: MemberMacro {
108110
prefix,
109111
ignoreExternalChanges,
110112
_,
113+
limitToInstance,
111114
defaultIsolationIsMainActor,
112115
suiteNameExpression
113116
) = extractProperty(node)
@@ -351,7 +354,7 @@ extension ObservableDefaultsMacros: MemberMacro {
351354
notificationObserver = NotificationCenter.default
352355
.addObserver(
353356
forName: UserDefaults.didChangeNotification,
354-
object: userDefaults,
357+
object: \(raw: limitToInstance ? "userDefaults" : "nil"),
355358
queue: .main
356359
) { [weak host, prefix, observableKeysBlacklist] notification in
357360
guard let host else { return }
@@ -412,7 +415,7 @@ extension ObservableDefaultsMacros: MemberMacro {
412415
NotificationCenter.default
413416
.addObserver(
414417
forName: UserDefaults.didChangeNotification,
415-
object: userDefaults,
418+
object: \(raw: limitToInstance ? "userDefaults" : "nil"),
416419
queue: nil,
417420
using: userDefaultsDidChange
418421
)
@@ -538,7 +541,7 @@ extension ObservableDefaultsMacros: MemberAttributeMacro {
538541
providingAttributesFor member: some DeclSyntaxProtocol,
539542
in _: some MacroExpansionContext) throws -> [SwiftSyntax.AttributeSyntax]
540543
{
541-
let (_, _, _, _, observeFirst, _, _) = extractProperty(node)
544+
let (_, _, _, _, observeFirst, _, _, _) = extractProperty(node)
542545
guard let varDecl = member.as(VariableDeclSyntax.self),
543546
varDecl.isObservable
544547
else {
@@ -583,6 +586,7 @@ extension ObservableDefaultsMacros {
583586
prefix: String,
584587
ignoreExternalChanges: Bool,
585588
observeFirst: Bool,
589+
limitToInstance: Bool,
586590
defaultIsolationIsMainActor: Bool,
587591
invalidSuiteNameExpression: ExprSyntax?)
588592
{
@@ -591,6 +595,7 @@ extension ObservableDefaultsMacros {
591595
var prefix = ""
592596
var ignoreExternalChanges = false
593597
var observeFirst = false
598+
var limitToInstance = true
594599
var defaultIsolationIsMainActor = false
595600
var invalidSuiteNameExpression: ExprSyntax?
596601

@@ -622,14 +627,18 @@ extension ObservableDefaultsMacros {
622627
let booleanLiteral = argument.expression.as(BooleanLiteralExprSyntax.self)
623628
{
624629
observeFirst = booleanLiteral.literal.text == "true"
630+
} else if argument.label?.text == ObservableDefaultsMacros.limitToInstance,
631+
let booleanLiteral = argument.expression.as(BooleanLiteralExprSyntax.self)
632+
{
633+
limitToInstance = booleanLiteral.literal.text == "true"
625634
} else if argument.label?.text == ObservableDefaultsMacros.defaultIsolationIsMainActor,
626635
let booleanLiteral = argument.expression.as(BooleanLiteralExprSyntax.self)
627636
{
628637
defaultIsolationIsMainActor = booleanLiteral.literal.text == "true"
629638
}
630639
}
631640
}
632-
return (autoInit, suiteName, prefix, ignoreExternalChanges, observeFirst, defaultIsolationIsMainActor, invalidSuiteNameExpression)
641+
return (autoInit, suiteName, prefix, ignoreExternalChanges, observeFirst, limitToInstance, defaultIsolationIsMainActor, invalidSuiteNameExpression)
633642
}
634643
}
635644

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// CrossProcessNotificationTests.swift
3+
// ObservableDefaults
4+
//
5+
// Tests for cross-process notification functionality with limitToInstance parameter
6+
//
7+
8+
import Testing
9+
@testable import ObservableDefaults
10+
import Foundation
11+
12+
@ObservableDefaults(
13+
suiteName: "test.crossprocess.suite1",
14+
prefix: "app_",
15+
limitToInstance: false // Enable cross-process notifications
16+
)
17+
class SettingsWithPrefix {
18+
var userName: String = "InitialUser"
19+
var counter: Int = 0
20+
}
21+
22+
@ObservableDefaults(
23+
suiteName: "test.crossprocess.suite2",
24+
limitToInstance: false // Enable cross-process notifications
25+
)
26+
class SettingsNoPrefix {
27+
var userName: String = "OtherUser"
28+
var counter: Int = 100
29+
}
30+
31+
@Suite("Cross-Process Notification Tests", .serialized)
32+
struct CrossProcessNotificationTests {
33+
34+
func cleanupUserDefaults() {
35+
// Clean up UserDefaults before each test
36+
if let suite = UserDefaults(suiteName: "test.crossprocess.suite1") {
37+
for key in suite.dictionaryRepresentation().keys {
38+
suite.removeObject(forKey: key)
39+
}
40+
suite.synchronize()
41+
}
42+
if let suite = UserDefaults(suiteName: "test.crossprocess.suite2") {
43+
for key in suite.dictionaryRepresentation().keys {
44+
suite.removeObject(forKey: key)
45+
}
46+
suite.synchronize()
47+
}
48+
}
49+
50+
@Test("Only receive notifications for matching suite when limitToInstance is false")
51+
func testCrossProcessNotificationFiltering() async throws {
52+
cleanupUserDefaults()
53+
54+
// Create instances
55+
let settingsWithPrefix = SettingsWithPrefix()
56+
let settingsNoPrefix = SettingsNoPrefix()
57+
58+
// Verify initial values
59+
#expect(settingsWithPrefix.userName == "InitialUser")
60+
#expect(settingsWithPrefix.counter == 0)
61+
#expect(settingsNoPrefix.userName == "OtherUser")
62+
#expect(settingsNoPrefix.counter == 100)
63+
64+
// Modify the suite WITH prefix directly through UserDefaults
65+
let suiteWithPrefix = UserDefaults(suiteName: "test.crossprocess.suite1")!
66+
suiteWithPrefix.set("UpdatedUser", forKey: "app_userName") // Has prefix
67+
suiteWithPrefix.set(42, forKey: "app_counter") // Has prefix
68+
69+
// Post notification to simulate external change
70+
NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil)
71+
72+
// Allow time for notification processing
73+
try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
74+
75+
// Values should be updated automatically due to limitToInstance: false
76+
#expect(settingsWithPrefix.userName == "UpdatedUser")
77+
#expect(settingsWithPrefix.counter == 42)
78+
79+
// Now modify the suite WITHOUT prefix (should NOT affect settingsWithPrefix)
80+
let suiteNoPrefix = UserDefaults(suiteName: "test.crossprocess.suite2")!
81+
suiteNoPrefix.set("ChangedOtherUser", forKey: "userName") // No prefix
82+
suiteNoPrefix.set(200, forKey: "counter") // No prefix
83+
84+
// Post notification to simulate external change
85+
NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil)
86+
87+
// Allow time for notification processing
88+
try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
89+
90+
// settingsWithPrefix should remain unchanged (different suite, different keys)
91+
#expect(settingsWithPrefix.userName == "UpdatedUser") // Should stay the same
92+
#expect(settingsWithPrefix.counter == 42) // Should stay the same
93+
94+
// settingsNoPrefix should have new values when accessed
95+
#expect(settingsNoPrefix.userName == "ChangedOtherUser")
96+
#expect(settingsNoPrefix.counter == 200)
97+
}
98+
99+
@Test("Verify prefix isolation between suites")
100+
func testPrefixIsolation() async throws {
101+
cleanupUserDefaults()
102+
103+
let settingsWithPrefix = SettingsWithPrefix()
104+
let settingsNoPrefix = SettingsNoPrefix()
105+
106+
// Set values through the instances
107+
settingsWithPrefix.userName = "PrefixUser"
108+
settingsNoPrefix.userName = "NoPrefixUser"
109+
110+
// Check UserDefaults directly to verify keys
111+
let suiteWithPrefix = UserDefaults(suiteName: "test.crossprocess.suite1")!
112+
let suiteNoPrefix = UserDefaults(suiteName: "test.crossprocess.suite2")!
113+
114+
// Keys should be different due to prefix
115+
#expect(suiteWithPrefix.string(forKey: "app_userName") == "PrefixUser")
116+
#expect(suiteWithPrefix.string(forKey: "userName") == nil) // No key without prefix
117+
118+
#expect(suiteNoPrefix.string(forKey: "userName") == "NoPrefixUser")
119+
#expect(suiteNoPrefix.string(forKey: "app_userName") == nil) // No key with prefix
120+
}
121+
}

0 commit comments

Comments
 (0)