Sharing data with an app extension
An app extension is a separate process with its own container. A content blocker cannot read the app's documents, and the app cannot reach into the extension. Anything they both need lives in an App Group.
Declaring the group
Both targets need the same identifier in their entitlements.
<key>com.apple.security.application-groups</key>
<array>
<string>group.io.example.app</string>
</array>
The string is duplicated in each target's entitlements file, and nothing checks that the two agree. A typo produces a container the other process cannot see, with no error at build time and no crash at runtime: reads simply return nothing.
Define the identifier once in shared code and never type it twice.
enum SharedContainer {
static let groupID = "group.io.example.app"
static var url: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)
}
}
containerURL(forSecurityApplicationGroupIdentifier:) returns nil when the entitlement is missing. Treat that nil as a hard failure. A fallback path writes to a directory the other process will never read, and the bug then surfaces as stale data somewhere else entirely.
Small state
UserDefaults has a shared-suite initialiser.
let defaults = UserDefaults(suiteName: SharedContainer.groupID)
defaults?.set(true, forKey: "blockingEnabled")
This suits flags, counters, and small settings. It does not suit anything large, or anything written frequently from both sides, because the whole suite is serialised on write.
Larger state
For a rule list or a database, write files into the container.
func ruleListURL() throws -> URL {
guard let container = SharedContainer.url else {
throw ContainerError.entitlementMissing
}
return container.appendingPathComponent("rules.json")
}
Two constraints shape what is safe to keep there.
The extension has very little memory. A content blocker or a packet tunnel is terminated for exceeding a limit far below what the app enjoys. A structure the app holds comfortably may be impossible in the extension, so shared formats favour streaming or indexed access over whole-file parsing.
Both processes can run at once. The app can be open while the extension is working. There is no cross-process lock by default, so a file written non-atomically can be read half-written.
try data.write(to: url, options: .atomic)
An atomic write goes to a temporary file and is renamed into place. A reader sees either the old file or the new one, never a partial one.
Replacement breaks readers that cached
An atomic write replaces the file, so the inode changes. Anything holding a file descriptor, a memory-mapped region, or a cached URL resource value still points at the previous version, and will until it reopens.
An extension that opens a rule file once at launch and keeps it mapped serves stale rules indefinitely after an update. Reopening on a change, or checking the modification date before use, is the fix.
The same replacement resets file-level flags such as isExcludedFromBackup. That flag therefore belongs on a directory, not on the files inside it. iCloud Backup and local-only files works through what that costs when SQLite is involved.
Failing loudly when the entitlement is absent
Ad-hoc and simulator builds sometimes lose the App Group entitlement, and every read then returns nothing. Code that quietly substitutes a per-process directory appears to work in development and fails in release.
static func requiredURL() throws -> URL {
guard let url else {
assertionFailure("App Group entitlement missing from this build")
throw ContainerError.entitlementMissing
}
return url
}
An assertion in development and a thrown error in release surfaces the misconfiguration where it happens, rather than as an empty rule list three screens later.