axiom-extensions-widgets by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-extensions-widgets"小组件不是迷你应用。它们是您应用数据的可一览视图,在策略性时刻渲染并由系统显示。扩展在沙盒环境中运行,内存和执行时间有限。"
心智模型:将小组件视为时间线上的存档快照,而非实时视图。您的小组件不会"持续运行"——它渲染、被存档,然后系统显示快照。
扩展沙盒化:扩展具有:
✅ 在以下情况使用此技能:
❌ 请勿将此技能用于:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
→ 此技能涵盖时间线策略、刷新预算、手动重新加载和应用组配置
→ 此技能解释应用组权限、共享 UserDefaults 和容器 URL
→ 此技能涵盖容器路径、UserDefaults 套件名称和 WidgetCenter 重新加载
→ 此技能涵盖 4KB 数据限制、ActivityAttributes 约束、授权检查
→ 此技能涵盖异步 ValueProvider 模式和乐观 UI
→ 此技能涵盖应用意图 perform() 实现和 WidgetCenter 重新加载
时间成本:2-4 小时调试小组件空白或显示错误的原因
struct MyWidgetView: View {
@State private var data: String?
var body: some View {
VStack {
if let data = data {
Text(data)
}
}
.onAppear {
// ❌ 错误 — 在小组件视图中进行网络调用
Task {
let (data, _) = try await URLSession.shared.data(from: apiURL)
self.data = String(data: data, encoding: .utf8)
}
}
}
}
失败原因:小组件视图被渲染、存档和重用。视图中的网络调用不可靠,可能不会执行。
// 主应用 — 预取并保存
func updateWidgetData() async {
let data = try await fetchFromAPI()
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set(data, forKey: "widgetData")
WidgetCenter.shared.reloadAllTimelines()
}
// 小组件 TimelineProvider — 从共享存储读取
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)
}
}
模式:在主应用中获取数据,保存到共享存储,在小组件中读取。
TimelineProvider 可以进行网络请求吗?
可以,但有重要注意事项:
struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
// ✅ 此处允许网络请求
let data = try await fetchFromAPI()
let entry = SimpleEntry(date: Date(), data: data)
completion(Timeline(entries: [entry], policy: .atEnd))
}
}
}
约束:
最佳实践:在主应用中预取(更快、更可靠),仅将 TimelineProvider 网络请求作为后备方案。
时间成本:1-2 小时调试小组件显示空/默认数据的原因
// 主应用
UserDefaults.standard.set("Updated", forKey: "myKey")
// 小组件扩展
let value = UserDefaults.standard.string(forKey: "myKey") // 返回 nil!
失败原因:UserDefaults.standard 在应用与扩展中访问不同的容器。
// 1. 在 BOTH 目标中启用应用组权限:
// - 主应用目标:Signing & Capabilities → + App Groups → "group.com.myapp"
// - 小组件扩展目标:相同的组标识符
// 2. 主应用
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set("Updated", forKey: "myKey")
// 3. 小组件扩展
let shared = UserDefaults(suiteName: "group.com.myapp")!
let value = shared.string(forKey: "myKey") // 返回 "Updated"
验证:
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// 应打印路径,而非 "MISSING"
时间成本:用户体验差、电池消耗、小组件停止更新
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ❌ 错误 — 60 个条目,间隔 1 分钟
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)
}
为何不好:系统每天提供 40-70 次重新加载。此方法每小时使用 24 次重新加载 → 2-3 小时内耗尽预算。
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ✅ 正确 — 8 个条目,间隔 15 分钟(覆盖 2 小时)
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)
}
指南:
.atEnd 策略进行自动重新加载时间成本:控制中心控件无响应,用户体验差
struct ThermostatControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Thermostat") {
ControlWidgetButton(action: GetTemperatureIntent()) {
// ❌ 错误 — 同步获取阻塞 UI
let temp = HomeManager.shared.currentTemperature() // 阻塞调用
Label("\(temp)°", systemImage: "thermometer")
}
}
}
}
为何不好:按钮在主线程上渲染。阻塞网络/数据库调用会冻结 UI。
struct ThermostatProvider: ControlValueProvider {
func currentValue() async throws -> ThermostatValue {
// ✅ 正确 — 异步获取,非阻塞
let temp = try await HomeManager.shared.fetchTemperature()
return ThermostatValue(temperature: temp)
}
var previewValue: ThermostatValue {
ThermostatValue(temperature: 72) // 即时后备值
}
}
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")
}
}
}
}
模式:使用 ControlValueProvider 处理异步数据,提供即时 previewValue 后备值。
时间成本:用户烦恼,负面评价
// 启动实时活动
let activity = try Activity.request(attributes: attributes, content: initialContent)
// 稍后... 事件完成
// ❌ 错误 — 从不调用 .end()
// 实时活动永远存在,直到用户关闭
为何不好:除非显式结束,否则实时活动会无限期持续。
// 当事件完成时
let finalState = DeliveryAttributes.ContentState(
status: .delivered,
deliveredAt: Date()
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .default // 约 4 小时后移除
)
// 或立即移除
await activity.end(nil, dismissalPolicy: .immediate)
// 或在特定时间移除
let dismissTime = Date().addingTimeInterval(30 * 60) // 30 分钟
await activity.end(nil, dismissalPolicy: .after(dismissTime))
最佳实践:
.immediate — 瞬时事件(计时器完成、歌曲结束).default — 大多数实时活动(显示"已完成"状态约 4 小时).after(date) — 特定结束时间(会议结束、航班降落)时间成本:实时活动静默启动失败,难以调试
Activity.request() 抛出错误struct GameAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var teamALogo: Data // ❌ 大图像数据
var teamBLogo: Data
var playByPlay: [String] // ❌ 无界数组
var statistics: [String: Any] // ❌ 大字典
}
var gameID: String
var venueName: String
}
// 如果总大小 > 4KB 则失败
let activity = try Activity.request(attributes: attrs, content: content)
失败原因:ActivityAttributes + ContentState 组合必须 < 4KB。
struct GameAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var teamAScore: Int // ✅ 小型基本类型
var teamBScore: Int
var quarter: Int
var timeRemaining: String // "2:34"
var lastPlay: String? // 单个最近的比赛记录
}
var gameID: String // ✅ 引用,非完整数据
var teamAName: String
var teamBName: String
}
// 在视图中使用资源目录获取图像
struct GameLiveActivityView: View {
var context: ActivityViewContext<GameAttributes>
var body: some View {
HStack {
Image(context.attributes.teamAName) // 资源目录
Text("\(context.state.teamAScore)")
// ...
}
}
}
策略:
硬限制:4096 字节(4KB)
目标指南:
为何安全边际重要:您稍后会添加字段(新功能、更多数据)。从 3.8KB 开始意味着没有增长空间。
检查大小:
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")
}
}
优化优先级(当超过 2KB 时):
String 描述替换为枚举(如果是固定集合)时间成本:30 分钟调试不可见的小组件
@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() — 小组件不会出现!
}
}
失败原因:没有 supportedFamilies(),系统不知道提供哪些尺寸。
@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]) // ✅ 必需
}
}
其他常见原因:
Cmd+Shift+K)Widget/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?
在调试任何小组件或扩展问题之前,请完成此清单:
☐ 应用组已启用 在主应用和扩展目标中
# 验证权限
codesign -d --entitlements - /path/to/YourApp.app
# 应显示 com.apple.security.application-groups
☐ 小组件在小部件图库中(不仅在主屏幕上)
☐ 控制台日志 用于时间线错误
# Xcode 控制台
# 过滤:"widget" 或 "timeline"
# 查找:"Timeline reload failed", "Budget exhausted"
☐ 手动重新加载测试
WidgetCenter.shared.reloadAllTimelines()
☐ 共享容器可访问
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Container: \(container?.path ?? "NIL")")
// 必须打印有效路径,而非 "NIL"
☐ ActivityAttributes < 4KB
let encoded = try JSONEncoder().encode(attributes)
print("Size: \(encoded.count) bytes") // 必须 < 4096
☐ 授权检查
let authInfo = ActivityAuthorizationInfo()
print("Enabled: \(authInfo.areActivitiesEnabled)")
☐ pushType 与服务器集成匹配
nil → 仅本地更新.token → 期望推送通知☐ 关闭策略已实现
"只需更频繁地强制时间线重新加载"
"小组件在测试中正常工作"
"用户只需重启手机"
// 向应用和小组件添加日志记录
let group = "group.com.myapp.production" // 必须完全匹配
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: group
)
print("[\(Bundle.main.bundleIdentifier ?? "?")] Container: \(container?.path ?? "NIL")")
// 记录每次读取/写入
let shared = UserDefaults(suiteName: group)!
print("Writing key 'lastUpdate' = \(Date())")
shared.set(Date(), forKey: "lastUpdate")
验证:运行应用,然后运行小组件。两者都应打印相同的容器路径。
# 设备日志(Xcode → Window → Devices and Simulators → View Device Logs)
# 过滤:您的应用包标识符
# 查找:容器路径不匹配
常见问题:
group.com.myapp.devgroup.com.myapp.production// 主应用 — 每次写入时打戳
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")
// 小组件 — 验证版本
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)
"只需每 5 秒创建一个条目"
"向小组件视图添加 WebSocket"
"将刷新间隔降低到 1 秒"
关键现实检查:推送通知权限批准需要 3-7 天。在批准前切勿承诺功能。
立即发布 应用驱动的更新:
// 启动实时活动,无需推送(无需权限)
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: nil // 仅本地更新
)
// 当您的应用数据更改时(用户打开应用,下拉刷新)
await activity.update(ActivityContent(
state: updatedState,
staleDate: nil
))
设定期望:当用户与应用交互时发生更新。这对于 v1.0 是可接受的,并且需要零批准。
权限批准后,切换到推送:
// 1. 权限:"com.apple.developer.activity-push-notification"
// 2. 使用推送令牌请求实时活动
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: .token
)
// 3. 监视令牌
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"
}
}
}
标准推送限制:约每小时 10-12 次
对于需要更频繁推送的应用(体育、股票):
<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>
需要在 App Store Connect 中提供理由:"实时体育比分需要即时更新以提升用户参与度"
// 在实时活动小组件中记录推送接收
#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)
#endif
向营销/管理层(阶段 1):
Launch 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.
向营销/管理层(阶段 2):
"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, // ❌ 阻塞式获取
action: ToggleLightIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
}
}
}
}
// 1. 用于异步状态的值提供者
struct LightProvider: ControlValueProvider {
func currentValue() async throws -> LightValue {
// 从 HomeKit/服务器异步获取
let isOn = try await HomeManager.shared.fetchLightState()
return LightValue(isOn: isOn)
}
var previewValue: LightValue {
// 从缓存中即时后备值
let shared = UserDefaults(suiteName: "group.com.myapp")!
return LightValue(isOn: shared.bool(forKey: "lastKnownLightState"))
}
}
struct LightValue: ControlValueProviderValue {
var isOn: Bool
}
// 2. 乐观意图
struct ToggleLightIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Light"
func perform() async throws -> some IntentResult {
// 立即更新缓存(乐观)
let shared = UserDefaults(suiteName: "group.com.myapp")!
let currentState = shared.bool(forKey: "lastKnownLightState")
let newState = !currentState
shared.set(newState, forKey: "lastKnownLightState")
// 然后更新实际设备(异步)
try await HomeManager.shared.setLight(isOn: newState)
return .result()
}
}
// 3. 带有提供者的控件
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() 并指定适当的关闭策略请记住:小组件不是迷你应用。它们是系统渲染的可一览快照。扩展在具有严格资源限制的沙盒环境中运行。遵循此技能中的模式
"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."
Mental model : Think of widgets as archived snapshots on a timeline, not live views. Your widget doesn't "run" continuously — it renders, gets archived, and the system displays the snapshot.
Extension sandboxing : Extensions have:
✅ Use this skill when :
❌ Do NOT use this skill for :
→ This skill covers timeline policies, refresh budgets, manual reload, and App Groups configuration
→ This skill explains App Groups entitlement, shared UserDefaults, and container URLs
→ This skill covers container paths, UserDefaults suite names, and WidgetCenter reload
→ This skill covers 4KB data limit, ActivityAttributes constraints, authorization checks
→ This skill covers async ValueProvider patterns and optimistic UI
→ This skill covers App Intent perform() implementation and WidgetCenter reload
Time cost : 2-4 hours debugging why widgets are blank or show errors
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)
}
}
}
}
Why it fails : Widget views are rendered, archived, and reused. Network calls in views are unreliable and may not execute.
// 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)
}
}
Pattern : Fetch data in main app, save to shared storage, read in widget.
Can TimelineProvider make network requests?
Yes, but with important caveats:
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))
}
}
}
Constraints :
Best practice : Prefetch in main app (faster, more reliable), use TimelineProvider network as fallback only.
Time cost : 1-2 hours debugging why widget shows empty/default data
// Main app
UserDefaults.standard.set("Updated", forKey: "myKey")
// Widget extension
let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil!
Why it fails : UserDefaults.standard accesses different containers in app vs. extension.
// 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"
Verification :
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// Should print path, not "MISSING"
Time cost : Poor user experience, battery drain, widgets stop updating
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)
}
Why it's bad : System gives 40-70 reloads/day. This approach uses 24 reloads/hour → exhausts budget in 2-3 hours.
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)
}
Guidelines :
.atEnd policy for automatic reloadTime cost : Control Center control unresponsive, poor UX
struct 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")
}
}
}
}
Why it's bad : Button renders on main thread. Blocking network/database calls freeze UI.
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")
}
}
}
}
Pattern : Use ControlValueProvider for async data, provide instant previewValue fallback.
Time cost : User annoyance, negative reviews
// Start activity
let activity = try Activity.request(attributes: attributes, content: initialContent)
// Later... event completes
// ❌ WRONG — Never call .end()
// Activity stays forever until user dismisses
Why it's bad : Activities persist indefinitely unless explicitly ended.
// 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))
Best practices :
.immediate — Transient events (timer completed, song finished).default — Most activities (shows "completed" state for ~4 hours).after(date) — Specific end time (meeting ends, flight lands)Time cost : Activity fails to start silently, hard to debug
Activity.request() throws errorstruct 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)
Why it fails : ActivityAttributes + ContentState combined must be < 4KB.
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)")
// ...
}
}
}
Strategies :
Hard limit : 4096 bytes (4KB)
Target guidance :
Why safety margins matter : You'll add fields later (new features, more data). Starting at 3.8KB leaves zero room for growth.
Checking size :
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")
}
}
Optimization priorities (when over 2KB):
String descriptions with enums (if fixed set)Time cost : 30 minutes debugging invisible widget
@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!
}
}
Why it fails : Without supportedFamilies(), system doesn't know which sizes to offer.
@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
}
}
Other common causes :
Cmd+Shift+K)Widget/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?
Before debugging any widget or extension issue, complete this checklist:
☐ App Groups enabled in BOTH main app AND extension targets
# Verify entitlements
codesign -d --entitlements - /path/to/YourApp.app
# Should show com.apple.security.application-groups
☐ Widget in Widget Gallery (not just on Home Screen)
☐ Console logs for timeline errors
# Xcode Console
# Filter: "widget" OR "timeline"
# Look for: "Timeline reload failed", "Budget exhausted"
☐ Manual reload test
WidgetCenter.shared.reloadAllTimelines()
☐ Shared container accessible
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Container: \(container?.path ?? "NIL")")
// Must print valid path, not "NIL"
☐ ActivityAttributes < 4KB
let encoded = try JSONEncoder().encode(attributes)
print("Size: \(encoded.count) bytes") // Must be < 4096
☐ Authorization check
let authInfo = ActivityAuthorizationInfo()
print("Enabled: \(authInfo.areActivitiesEnabled)")
☐ pushType matches server integration
nil → local updates only.token → expects push notifications☐ Dismissal policy implemented
"Just force a timeline reload more often"
"The widget worked in testing"
"Users should just restart their phone"
// 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")
Verify : Run app, then widget. Both should print SAME container path.
# Device logs (Xcode → Window → Devices and Simulators → View Device Logs)
# Filter: Your app bundle ID
# Look for: Container path mismatches
Common issues:
group.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()
}
To stakeholders :
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)
"Just create entries every 5 seconds"
"Add WebSocket to widget view"
"Lower refresh interval to 1 second"
Critical reality check : Push notification entitlement approval takes 3-7 days. Never promise features before approval.
Ship immediately with app-driven updates:
// 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
))
Set expectations : Updates occur when user interacts with app. This is acceptable for v1.0 and requires zero approval.
After entitlement approved , switch to push:
// 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"
}
}
}
Standard push limit : ~10-12 per hour
For apps requiring more frequent pushes (sports, stocks):
<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>
Requires justification in App Store Connect: "Live sports scores require immediate updates for user engagement"
// 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)
#endif
To marketing/exec (Phase 1) :
Launch 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.
To marketing/exec (Phase 2) :
"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)
}
}
}
}
Result : Control responds instantly with cached state, actual device updates in background.
Before shipping widgets or Live Activities:
suiteName (not .standard).end() with appropriate dismissal policyRemember : Widgets are NOT mini apps. They're glanceable snapshots rendered by the system. Extensions run in sandboxed environments with strict resource limits. Follow the patterns in this skill to avoid the most common pitfalls.
Weekly Installs
90
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubFailSocketPassSnykPass
Installed on
opencode75
codex69
claude-code69
gemini-cli67
cursor65
github-copilot63