Loading...
Loading...
Use when implementing widgets, Live Activities, or Control Center controls - enforces correct patterns for timeline management, data sharing, and extension lifecycle to prevent common crashes and memory issues
npx skill4agent add charleswiltgen/axiom axiom-extensions-widgets"Widgets are not mini apps. They're glanceable views into your app's data, rendered at strategic moments and displayed by the system. Extensions run in sandboxed environments with limited memory and execution time."
struct MyWidgetView: View {
@State private var data: String?
var body: some View {
VStack {
if let data = data {
Text(data)
}
}
.onAppear {
// ❌ WRONG — Network in widget view
Task {
let (data, _) = try await URLSession.shared.data(from: apiURL)
self.data = String(data: data, encoding: .utf8)
}
}
}
}// Main app — prefetch and save
func updateWidgetData() async {
let data = try await fetchFromAPI()
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set(data, forKey: "widgetData")
WidgetCenter.shared.reloadAllTimelines()
}
// Widget TimelineProvider — read from shared storage
struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let shared = UserDefaults(suiteName: "group.com.myapp")!
let data = shared.string(forKey: "widgetData") ?? "No data"
let entry = SimpleEntry(date: Date(), data: data)
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
// ✅ Network requests ARE allowed here
let data = try await fetchFromAPI()
let entry = SimpleEntry(date: Date(), data: data)
completion(Timeline(entries: [entry], policy: .atEnd))
}
}
}// Main app
UserDefaults.standard.set("Updated", forKey: "myKey")
// Widget extension
let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil!UserDefaults.standard// 1. Enable App Groups entitlement in BOTH targets:
// - Main app target: Signing & Capabilities → + App Groups → "group.com.myapp"
// - Widget extension target: Same group identifier
// 2. Main app
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set("Updated", forKey: "myKey")
// 3. Widget extension
let shared = UserDefaults(suiteName: "group.com.myapp")!
let value = shared.string(forKey: "myKey") // Returns "Updated"let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// Should print path, not "MISSING"func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ❌ WRONG — 60 entries at 1-minute intervals
for minuteOffset in 0..<60 {
let date = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: Date())!
entries.append(SimpleEntry(date: date, data: "Data"))
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ✅ CORRECT — 8 entries at 15-minute intervals (2 hours coverage)
for offset in 0..<8 {
let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: Date())!
entries.append(SimpleEntry(date: date, data: getData()))
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}.atEndstruct ThermostatControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Thermostat") {
ControlWidgetButton(action: GetTemperatureIntent()) {
// ❌ WRONG — Synchronous fetch blocks UI
let temp = HomeManager.shared.currentTemperature() // Blocking call
Label("\(temp)°", systemImage: "thermometer")
}
}
}
}struct ThermostatProvider: ControlValueProvider {
func currentValue() async throws -> ThermostatValue {
// ✅ CORRECT — Async fetch, non-blocking
let temp = try await HomeManager.shared.fetchTemperature()
return ThermostatValue(temperature: temp)
}
var previewValue: ThermostatValue {
ThermostatValue(temperature: 72) // Instant fallback
}
}
struct ThermostatValue: ControlValueProviderValue {
var temperature: Int
}
struct ThermostatControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Thermostat", provider: ThermostatProvider()) { value in
ControlWidgetButton(action: AdjustTemperatureIntent()) {
Label("\(value.temperature)°", systemImage: "thermometer")
}
}
}
}ControlValueProviderpreviewValue// Start activity
let activity = try Activity.request(attributes: attributes, content: initialContent)
// Later... event completes
// ❌ WRONG — Never call .end()
// Activity stays forever until user dismisses// When event completes
let finalState = DeliveryAttributes.ContentState(
status: .delivered,
deliveredAt: Date()
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .default // Removes after ~4 hours
)
// Or for immediate removal
await activity.end(nil, dismissalPolicy: .immediate)
// Or remove at specific time
let dismissTime = Date().addingTimeInterval(30 * 60) // 30 min
await activity.end(nil, dismissalPolicy: .after(dismissTime)).immediate.default.after(date)Activity.request()struct GameAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var teamALogo: Data // ❌ Large image data
var teamBLogo: Data
var playByPlay: [String] // ❌ Unbounded array
var statistics: [String: Any] // ❌ Large dictionary
}
var gameID: String
var venueName: String
}
// Fails if total size > 4KB
let activity = try Activity.request(attributes: attrs, content: content)struct GameAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var teamAScore: Int // ✅ Small primitives
var teamBScore: Int
var quarter: Int
var timeRemaining: String // "2:34"
var lastPlay: String? // Single most recent play
}
var gameID: String // ✅ Reference, not full data
var teamAName: String
var teamBName: String
}
// Use asset catalog for images in view
struct GameLiveActivityView: View {
var context: ActivityViewContext<GameAttributes>
var body: some View {
HStack {
Image(context.attributes.teamAName) // Asset catalog
Text("\(context.state.teamAScore)")
// ...
}
}
}let attributes = GameAttributes(gameID: "123", teamAName: "Hawks", teamBName: "Eagles")
let state = GameAttributes.ContentState(teamAScore: 14, teamBScore: 10, quarter: 2, timeRemaining: "5:23", lastPlay: nil)
let encoder = JSONEncoder()
if let attributesData = try? encoder.encode(attributes),
let stateData = try? encoder.encode(state) {
let totalSize = attributesData.count + stateData.count
print("Total size: \(totalSize) bytes")
if totalSize < 2048 {
print("✅ Safe with room to grow")
} else if totalSize < 3072 {
print("⚠️ Acceptable but monitor")
} else if totalSize < 3584 {
print("🔴 Risky - optimize now")
} else {
print("❌ CRITICAL - will likely fail")
}
}String@main
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("Shows data")
// ❌ MISSING: supportedFamilies() — widget won't appear!
}
}@main
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("Shows data")
.supportedFamilies([.systemSmall, .systemMedium]) // ✅ Required
}
}Cmd+Shift+KWidget/Extension Issue?
│
├─ Widget not appearing in gallery?
│ ├─ Check WidgetBundle registered in @main
│ ├─ Verify supportedFamilies() includes intended families
│ └─ Clean build folder, restart Xcode
│
├─ Widget not refreshing?
│ ├─ Timeline policy set to .never?
│ │ └─ Change to .atEnd or .after(date)
│ ├─ Budget exhausted? (too frequent reloads)
│ │ └─ Increase interval between entries (15-60 min)
│ └─ Manual reload
│ └─ WidgetCenter.shared.reloadAllTimelines()
│
├─ Widget shows empty/old data?
│ ├─ App Groups configured in BOTH targets?
│ │ ├─ No → Add "App Groups" entitlement
│ │ └─ Yes → Verify same group ID
│ ├─ Using UserDefaults.standard?
│ │ └─ Change to UserDefaults(suiteName: "group.com.myapp")
│ └─ Shared container path correct?
│ └─ Print containerURL, verify not nil
│
├─ Interactive button not working?
│ ├─ App Intent perform() returns value?
│ │ └─ Must return IntentResult
│ ├─ perform() updates shared data?
│ │ └─ Update App Group storage
│ └─ Calls WidgetCenter.reloadTimelines()?
│ └─ Reload to reflect changes
│
├─ Live Activity fails to start?
│ ├─ Data size > 4KB?
│ │ └─ Reduce ActivityAttributes + ContentState
│ ├─ Authorization enabled?
│ │ └─ Check ActivityAuthorizationInfo().areActivitiesEnabled
│ └─ pushType correct?
│ └─ nil for local updates, .token for push
│
├─ Control Center control unresponsive?
│ ├─ Async operation blocking UI?
│ │ └─ Use ControlValueProvider with async currentValue()
│ └─ Provide previewValue for instant fallback
│
└─ watchOS Live Activity not showing?
├─ supplementalActivityFamilies includes .small?
└─ Apple Watch paired and in range?# Verify entitlements
codesign -d --entitlements - /path/to/YourApp.app
# Should show com.apple.security.application-groups# Xcode Console
# Filter: "widget" OR "timeline"
# Look for: "Timeline reload failed", "Budget exhausted"WidgetCenter.shared.reloadAllTimelines()let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Container: \(container?.path ?? "NIL")")
// Must print valid path, not "NIL"let encoded = try JSONEncoder().encode(attributes)
print("Size: \(encoded.count) bytes") // Must be < 4096let authInfo = ActivityAuthorizationInfo()
print("Enabled: \(authInfo.areActivitiesEnabled)")nil.token// Add logging to BOTH app and widget
let group = "group.com.myapp.production" // Must match exactly
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: group
)
print("[\(Bundle.main.bundleIdentifier ?? "?")] Container: \(container?.path ?? "NIL")")
// Log EVERY read/write
let shared = UserDefaults(suiteName: group)!
print("Writing key 'lastUpdate' = \(Date())")
shared.set(Date(), forKey: "lastUpdate")# Device logs (Xcode → Window → Devices and Simulators → View Device Logs)
# Filter: Your app bundle ID
# Look for: Container path mismatchesgroup.com.myapp.devgroup.com.myapp.production// Main app — stamp every write
struct WidgetData: Codable {
var value: String
var timestamp: Date
var appVersion: String
}
let data = WidgetData(
value: "Latest",
timestamp: Date(),
appVersion: Bundle.main.appVersion
)
shared.set(try JSONEncoder().encode(data), forKey: "widgetData")
// Widget — verify version
if let data = shared.data(forKey: "widgetData"),
let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) {
print("Widget reading data from app version: \(decoded.appVersion)")
}// AppDelegate / @main App
func applicationDidBecomeActive(_ application: UIApplication) {
WidgetCenter.shared.reloadAllTimelines()
}Status: Investigating widget data sync issue
Root cause: App Groups configuration mismatch between app and widget extension in production build
Fix: Updated both targets to use identical group identifier, added logging to prevent recurrence
Timeline: Hotfix submitted to App Store review (24-48h)
Workaround for users: Force-quit app and relaunch (triggers widget refresh)// Start activity WITHOUT push (no entitlement needed)
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: nil // Local updates only
)
// In your app when data changes (user opens app, pulls to refresh)
await activity.update(ActivityContent(
state: updatedState,
staleDate: nil
))// 1. Entitlement: "com.apple.developer.activity-push-notification"
// 2. Request activity with push token
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: .token
)
// 3. Monitor for token
Task {
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
await sendTokenToServer(activityID: activity.id, token: tokenString)
}
}{
"aps": {
"timestamp": 1633046400,
"event": "update",
"content-state": {
"teamAScore": 14,
"teamBScore": 10,
"quarter": 2,
"timeRemaining": "5:23"
},
"alert": {
"title": "Touchdown!",
"body": "Team A scores"
}
}
}<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>// Log push receipt in Live Activity widget
#if DEBUG
let logURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)!.appendingPathComponent("push_log.txt")
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
try! "\(timestamp): Received push\n".append(to: logURL)
#endifLaunch Timeline:
- Phase 1 (immediate): Live Activities with app-driven updates. Updates appear when users open app or pull to refresh.
- Phase 2 (3-7 days): Push notification integration after Apple approval. Updates arrive within 1-3 seconds of server events.
Recommendation: Launch Phase 1 to market, communicate Phase 2 as "coming soon" once approved."Real-time" positioning requires clarification:
Technical: Live Activities update via push notifications with 1-3 second latency from server to device
Constraints: Apple's push system has rate limits (~10/hour standard, axiom-higher with special entitlement)
Competitive analysis: Competitors likely use same system with similar limitations
Recommendation: Position as "near real-time" (accurate) vs "instant" (misleading)struct LightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Light") {
ControlWidgetToggle(
isOn: LightManager.shared.isOn, // ❌ Blocking fetch
action: ToggleLightIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
}
}
}
}// 1. Value Provider for async state
struct LightProvider: ControlValueProvider {
func currentValue() async throws -> LightValue {
// Async fetch from HomeKit/server
let isOn = try await HomeManager.shared.fetchLightState()
return LightValue(isOn: isOn)
}
var previewValue: LightValue {
// Instant fallback from cache
let shared = UserDefaults(suiteName: "group.com.myapp")!
return LightValue(isOn: shared.bool(forKey: "lastKnownLightState"))
}
}
struct LightValue: ControlValueProviderValue {
var isOn: Bool
}
// 2. Optimistic Intent
struct ToggleLightIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Light"
func perform() async throws -> some IntentResult {
// Immediately update cache (optimistic)
let shared = UserDefaults(suiteName: "group.com.myapp")!
let currentState = shared.bool(forKey: "lastKnownLightState")
let newState = !currentState
shared.set(newState, forKey: "lastKnownLightState")
// Then update actual device (async)
try await HomeManager.shared.setLight(isOn: newState)
return .result()
}
}
// 3. Control with provider
struct LightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Light", provider: LightProvider()) { value in
ControlWidgetToggle(
isOn: value.isOn,
action: ToggleLightIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
.tint(isOn ? .yellow : .gray)
}
}
}
}suiteName.standard.end()