App Groups: One Drop Box for Every Door Into the App
~15 min · beyond-the-app, app-groups, entitlements, extensions, capture
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Absence of capability is not absence of work."
Many Processes, One Place to Leave Things
An iOS app is not one process. Its share extension, its widgets, an App Intent that runs without opening the app, and the part that receives a watch transfer in the background all run separately, often while the app itself is not running. An App Group gives them a shared container on the device: the same group identifier in each target's entitlements, and FileManager.containerURL(forSecurityApplicationGroupIdentifier:) returns the same folder to every one of them. The archive's -allowProvisioningUpdates registers the group along with the identifiers, so nothing is created by hand.
The family uses that container as a single drop box. Every door into the app (the share sheet, the App Intents, the watch receiver) writes a capture into it, one folder per capture, media first and the manifest last, because a folder without a manifest is still being written. Only the app imports, on its next foreground. Two writers on the app's own outbox file would save a few seconds and cost correctness, so they are not allowed. The container is also strictly per device: a watch app never sees the phone's App Group, which is why the wrist keeps its own queue.
The Share That Saved Nothing
The travel journal's share extension ran, showed its compose sheet, dismissed on Post, and wrote nothing. No crash, no log. The entitlement file was right and the group ids matched. The build was not: it had been built for the Simulator with CODE_SIGNING_ALLOWED=NO, the usual fast path, and unsigned code carries no entitlements. containerURL answered nil, quietly, and every App Group, shared Keychain and push path in that build was dead. The check that settles it before testing is simctl get_app_container … groups, which prints the group's path only when the entitlement is live. For contrast, measured on a Mac while writing this lesson, the same call returns a path even for an unsigned command-line tool, so a Mac test cannot stand in for the phone here.
The second lesson from that bug is worth more. The extension's first version said guard let root = containerURL() else { return } inside a method that dismissed the sheet afterwards, so a share with no container was discarded and looked exactly like success. On a capture surface that is the worst failure there is: the person discovers days later that a memory was never written. A missing container is now an alert that names it, with Keep Editing so the typed note survives. Any guard … else { return } over an operating-system resource that can be absent for a configuration reason is a silent-loss bug waiting for the day the configuration is wrong.
Code
A drop box in the App Group container: named failure, media first, manifest last·swift
import Foundation
struct CaptureManifest: Codable, Sendable {
let id: String // client-minted: the importer and the engine dedupe on it
let text: String
let files: [String]
let capturedAt: Date
let origin: String // "share", "intent", "watch"
}
enum DropBoxError: Error, Equatable {
/// The entitlement is missing (an unsigned build, a mismatched group id). Say so; never "nothing to do".
case containerUnavailable(group: String)
}
/// One folder per capture in the App Group container. Every process that captures writes here;
/// only the app imports. Media first, manifest last: a folder without a manifest is still being written.
struct SharedDropBox: Sendable {
let root: URL
init(group: String) throws(DropBoxError) {
guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
throw .containerUnavailable(group: group)
}
root = container.appending(path: "inbox", directoryHint: .isDirectory)
}
init(root: URL) { self.root = root } // tests
func drop(_ manifest: CaptureManifest, files: [String: Data]) throws {
let folder = root.appending(path: manifest.id, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
for (name, data) in files {
try data.write(to: folder.appending(path: name), options: [.atomic, .completeFileProtection])
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(manifest)
.write(to: folder.appending(path: "manifest.json"), options: [.atomic, .completeFileProtection])
}
/// Complete captures only. A folder still missing its manifest is skipped silently; it is mid-write.
func pending() throws -> [CaptureManifest] {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let folders = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
return try folders.compactMap { folder in
let manifest = folder.appending(path: "manifest.json")
guard FileManager.default.fileExists(atPath: manifest.path) else { return nil }
return try decoder.decode(CaptureManifest.self, from: Data(contentsOf: manifest))
}
.sorted { $0.capturedAt < $1.capturedAt }
}
}
Prove the entitlement is live before testing anything that needs it·bash
# Is the entitlement LIVE in this build? Prints the group and its path, or nothing at all.
xcrun simctl get_app_container "$SPARK_SIMULATOR_ID" com.example.spark.mobile groups
# An unsigned build (CODE_SIGNING_ALLOWED=NO) carries no entitlements, so the line above
# prints nothing and containerURL(forSecurityApplicationGroupIdentifier:) returns nil.
# For anything that touches a capability, build signed:
xcodebuild -project SparkMobile.xcodeproj -scheme SparkMobile \
-destination "id=$SPARK_SIMULATOR_ID" -allowProvisioningUpdates build
# In an archive, both the app and the extension must list the same group:
codesign -d --entitlements - build/SparkMobile-42.xcarchive/Products/Applications/SparkMobile.app
codesign -d --entitlements - build/SparkMobile-42.xcarchive/Products/Applications/SparkMobile.app/PlugIns/SparkShare.appex
Give SparkMobile and its share extension the App Group group.com.example.spark in project.yml, then build once with CODE_SIGNING_ALLOWED=NO and once signed, running simctl get_app_container … groups after each. Record both outputs. Next, put SharedDropBox behind the extension's Post button and make containerUnavailable show an alert that keeps the typed text. Finally, write a test that creates a capture folder without a manifest and asserts pending() skips it.
Hint
XcodeGen writes the entitlements file from entitlements.properties, so the group goes there for both targets. The alert should offer Keep Editing and Discard, and only Discard may dismiss the sheet without saving.
Progress
Progress is local-only — sign in to sync across devices.