nn-widgets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1589 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.withIosWidget = void 0;
37
+ exports.resolveIosProps = resolveIosProps;
38
+ const config_plugins_1 = require("@expo/config-plugins");
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ // ──────────────────────────────────────────────
42
+ // Swift code generation helpers
43
+ // ──────────────────────────────────────────────
44
+ function generateDataProviderCode(props, bundleId) {
45
+ const useAppGroups = props.ios.useAppGroups;
46
+ const appGroupId = props.ios.appGroupIdentifier || `group.${bundleId}`;
47
+ const hasListWidget = props.widgets.some((w) => w.type === "list");
48
+ const hasSingleWidget = props.widgets.some((w) => w.type === "single");
49
+ const storageCode = useAppGroups
50
+ ? `
51
+ struct WidgetDataProvider {
52
+ static let appGroupIdentifier = "${appGroupId}"
53
+
54
+ static var userDefaults: UserDefaults? {
55
+ return UserDefaults(suiteName: appGroupIdentifier)
56
+ }
57
+
58
+ static func getValue<T>(forKey key: String) -> T? {
59
+ return userDefaults?.object(forKey: "widget_\\(key)") as? T
60
+ }
61
+
62
+ static func getString(forKey key: String, default defaultValue: String = "") -> String {
63
+ return getValue(forKey: key) ?? defaultValue
64
+ }
65
+
66
+ static func getInt(forKey key: String, default defaultValue: Int = 0) -> Int {
67
+ return getValue(forKey: key) ?? defaultValue
68
+ }
69
+
70
+ static func getBool(forKey key: String, default defaultValue: Bool = false) -> Bool {
71
+ return getValue(forKey: key) ?? defaultValue
72
+ }
73
+
74
+ static func getJSON(forKey key: String) -> Any? {
75
+ guard let jsonString: String = getValue(forKey: key),
76
+ let data = jsonString.data(using: .utf8) else { return nil }
77
+ return try? JSONSerialization.jsonObject(with: data)
78
+ }
79
+ }`
80
+ : `
81
+ // Note: App Groups is disabled. Widget cannot share data with main app.
82
+ // To enable data sharing, set ios.useAppGroups: true and configure App Groups in Apple Developer Portal.
83
+ struct WidgetDataProvider {
84
+ static var userDefaults: UserDefaults {
85
+ return UserDefaults.standard
86
+ }
87
+
88
+ static func getValue<T>(forKey key: String) -> T? {
89
+ return userDefaults.object(forKey: "widget_\\(key)") as? T
90
+ }
91
+
92
+ static func getString(forKey key: String, default defaultValue: String = "") -> String {
93
+ return getValue(forKey: key) ?? defaultValue
94
+ }
95
+
96
+ static func getInt(forKey key: String, default defaultValue: Int = 0) -> Int {
97
+ return getValue(forKey: key) ?? defaultValue
98
+ }
99
+
100
+ static func getBool(forKey key: String, default defaultValue: Bool = false) -> Bool {
101
+ return getValue(forKey: key) ?? defaultValue
102
+ }
103
+
104
+ static func getJSON(forKey key: String) -> Any? {
105
+ guard let jsonString: String = getValue(forKey: key),
106
+ let data = jsonString.data(using: .utf8) else { return nil }
107
+ return try? JSONSerialization.jsonObject(with: data)
108
+ }
109
+ }`;
110
+ // ── List item model (shared across list-type widgets) ──
111
+ const listItemModel = hasListWidget || hasSingleWidget
112
+ ? `
113
+
114
+ // MARK: - Widget Item Models
115
+
116
+ struct WidgetIconConfig {
117
+ let url: String
118
+ let size: CGFloat
119
+ let radius: CGFloat
120
+ let backgroundColor: String?
121
+
122
+ init(from dict: [String: Any]) {
123
+ self.url = dict["url"] as? String ?? ""
124
+ self.size = CGFloat(dict["size"] as? Double ?? 32)
125
+ self.radius = CGFloat(dict["radius"] as? Double ?? 0)
126
+ self.backgroundColor = dict["backgroundColor"] as? String
127
+ }
128
+
129
+ init(url: String) {
130
+ self.url = url
131
+ self.size = 32
132
+ self.radius = 0
133
+ self.backgroundColor = nil
134
+ }
135
+ }
136
+
137
+ struct WidgetTextConfig {
138
+ let text: String
139
+ let color: String?
140
+ let fontSize: String?
141
+ let fontWeight: String?
142
+
143
+ init(from dict: [String: Any]) {
144
+ self.text = dict["text"] as? String ?? ""
145
+ self.color = dict["color"] as? String
146
+ self.fontSize = dict["fontSize"] as? String
147
+ self.fontWeight = dict["fontWeight"] as? String
148
+ }
149
+
150
+ init(text: String) {
151
+ self.text = text
152
+ self.color = nil
153
+ self.fontSize = nil
154
+ self.fontWeight = nil
155
+ }
156
+
157
+ func resolvedFont(default defaultSize: Font = .body) -> Font {
158
+ guard let fs = fontSize else { return defaultSize }
159
+ switch fs {
160
+ case "caption2": return .caption2
161
+ case "caption": return .caption
162
+ case "footnote": return .footnote
163
+ case "subheadline": return .subheadline
164
+ case "body": return .body
165
+ case "headline": return .headline
166
+ case "title3": return .title3
167
+ case "title2": return .title2
168
+ case "title": return .title
169
+ default: return defaultSize
170
+ }
171
+ }
172
+
173
+ func resolvedWeight(default defaultWeight: Font.Weight = .regular) -> Font.Weight {
174
+ guard let fw = fontWeight else { return defaultWeight }
175
+ switch fw {
176
+ case "regular": return .regular
177
+ case "medium": return .medium
178
+ case "semibold": return .semibold
179
+ case "bold": return .bold
180
+ default: return defaultWeight
181
+ }
182
+ }
183
+ }
184
+
185
+ struct WidgetListItem: Identifiable {
186
+ let id: String
187
+ let icon: WidgetIconConfig?
188
+ let rightIcon: WidgetIconConfig?
189
+ let title: WidgetTextConfig
190
+ let description: WidgetTextConfig?
191
+ let deepLink: String?
192
+
193
+ init(from dict: [String: Any], index: Int) {
194
+ self.id = "item_\\(index)"
195
+
196
+ // Parse icon
197
+ if let iconDict = dict["icon"] as? [String: Any] {
198
+ self.icon = WidgetIconConfig(from: iconDict)
199
+ } else if let iconStr = dict["icon"] as? String {
200
+ self.icon = WidgetIconConfig(url: iconStr)
201
+ } else {
202
+ self.icon = nil
203
+ }
204
+
205
+ // Parse rightIcon
206
+ if let iconDict = dict["rightIcon"] as? [String: Any] {
207
+ self.rightIcon = WidgetIconConfig(from: iconDict)
208
+ } else if let iconStr = dict["rightIcon"] as? String {
209
+ self.rightIcon = WidgetIconConfig(url: iconStr)
210
+ } else {
211
+ self.rightIcon = nil
212
+ }
213
+
214
+ // Parse title
215
+ if let titleDict = dict["title"] as? [String: Any] {
216
+ self.title = WidgetTextConfig(from: titleDict)
217
+ } else if let titleStr = dict["title"] as? String {
218
+ self.title = WidgetTextConfig(text: titleStr)
219
+ } else {
220
+ self.title = WidgetTextConfig(text: "")
221
+ }
222
+
223
+ // Parse description
224
+ if let descDict = dict["description"] as? [String: Any] {
225
+ self.description = WidgetTextConfig(from: descDict)
226
+ } else if let descStr = dict["description"] as? String {
227
+ self.description = WidgetTextConfig(text: descStr)
228
+ } else {
229
+ self.description = nil
230
+ }
231
+
232
+ self.deepLink = dict["deepLink"] as? String
233
+ }
234
+ }
235
+
236
+ // MARK: - Icon View Helper
237
+
238
+ struct WidgetIconView: View {
239
+ let config: WidgetIconConfig
240
+ let accentColor: Color
241
+
242
+ var body: some View {
243
+ ZStack {
244
+ if let bg = config.backgroundColor {
245
+ RoundedRectangle(cornerRadius: config.radius)
246
+ .fill(Color(hex: bg))
247
+ }
248
+
249
+ if UIImage(named: config.url) != nil {
250
+ Image(config.url)
251
+ .resizable()
252
+ .aspectRatio(contentMode: .fit)
253
+ .padding(config.size * 0.15)
254
+ } else {
255
+ Image(systemName: config.url)
256
+ .font(.system(size: config.size * 0.5))
257
+ .foregroundColor(
258
+ config.backgroundColor != nil ? .white : accentColor
259
+ )
260
+ }
261
+ }
262
+ .frame(width: config.size, height: config.size)
263
+ .clipShape(RoundedRectangle(cornerRadius: config.radius))
264
+ }
265
+ }`
266
+ : "";
267
+ return `${storageCode}
268
+ ${listItemModel}`;
269
+ }
270
+ /** Generate the TimelineEntry, TimelineProvider, Views, and Widget struct for one widget */
271
+ function generateSingleWidgetCode(w) {
272
+ const deepLinkLine = w.deepLinkUrl
273
+ ? `private let deepLinkUrl = URL(string: "${w.deepLinkUrl}")`
274
+ : `private let deepLinkUrl: URL? = nil`;
275
+ // ── Image-only mode ──
276
+ if (w.type === "image") {
277
+ return generateImageWidgetCode(w, deepLinkLine);
278
+ }
279
+ // ── List mode ──
280
+ if (w.type === "list") {
281
+ return generateListWidgetCode(w, deepLinkLine);
282
+ }
283
+ // ── Grid mode ──
284
+ if (w.type === "grid") {
285
+ return generateGridWidgetCode(w, deepLinkLine);
286
+ }
287
+ // ── Single mode (default) ──
288
+ return generateSingleTypeWidgetCode(w, deepLinkLine);
289
+ }
290
+ function generateImageWidgetCode(w, deepLinkLine) {
291
+ const hasImage = w.image && (w.image.small || w.image.medium || w.image.large);
292
+ const smallImage = w.image?.small
293
+ ? `"${w.name}_small"`
294
+ : w.image?.medium
295
+ ? `"${w.name}_medium"`
296
+ : `"${w.name}_large"`;
297
+ const mediumImage = w.image?.medium
298
+ ? `"${w.name}_medium"`
299
+ : w.image?.large
300
+ ? `"${w.name}_large"`
301
+ : smallImage;
302
+ const largeImage = w.image?.large ? `"${w.name}_large"` : mediumImage;
303
+ return `
304
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
305
+ // MARK: - ${w.name} (Image)
306
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
307
+
308
+ struct ${w.name}Entry: TimelineEntry {
309
+ let date: Date
310
+ static var placeholder: ${w.name}Entry { ${w.name}Entry(date: Date()) }
311
+ }
312
+
313
+ struct ${w.name}Provider: TimelineProvider {
314
+ func placeholder(in context: Context) -> ${w.name}Entry { .placeholder }
315
+ func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) { completion(.placeholder) }
316
+ func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
317
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
318
+ completion(Timeline(entries: [.placeholder], policy: .after(nextUpdate)))
319
+ }
320
+ }
321
+
322
+ struct ${w.name}EntryView: View {
323
+ @Environment(\\.widgetFamily) var family
324
+ let entry: ${w.name}Entry
325
+
326
+ var body: some View {
327
+ switch family {
328
+ case .systemSmall:
329
+ Image(${smallImage})
330
+ .resizable()
331
+ .aspectRatio(contentMode: .fill)
332
+ case .systemMedium:
333
+ Image(${mediumImage})
334
+ .resizable()
335
+ .aspectRatio(contentMode: .fill)
336
+ case .systemLarge, .systemExtraLarge:
337
+ Image(${largeImage})
338
+ .resizable()
339
+ .aspectRatio(contentMode: .fill)
340
+ default:
341
+ Image(${smallImage})
342
+ .resizable()
343
+ .aspectRatio(contentMode: .fill)
344
+ }
345
+ }
346
+ }
347
+
348
+ struct ${w.name}: Widget {
349
+ let kind: String = "${w.name}"
350
+ ${deepLinkLine}
351
+
352
+ var body: some WidgetConfiguration {
353
+ StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
354
+ if #available(iOS 17.0, *) {
355
+ ${w.name}EntryView(entry: entry)
356
+ .containerBackground(.clear, for: .widget)
357
+ .widgetURL(deepLinkUrl)
358
+ } else {
359
+ ${w.name}EntryView(entry: entry)
360
+ .widgetURL(deepLinkUrl)
361
+ }
362
+ }
363
+ .configurationDisplayName("${w.displayName}")
364
+ .description("${w.description}")
365
+ .supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
366
+ .contentMarginsDisabled()
367
+ }
368
+ }`;
369
+ }
370
+ function generateListWidgetCode(w, deepLinkLine) {
371
+ const bgColor = w.style?.backgroundColor
372
+ ? `Color(hex: "${w.style.backgroundColor}")`
373
+ : ".clear";
374
+ const titleColor = w.style?.titleColor
375
+ ? `Color(hex: "${w.style.titleColor}")`
376
+ : ".primary";
377
+ const subtitleColor = w.style?.subtitleColor
378
+ ? `Color(hex: "${w.style.subtitleColor}")`
379
+ : ".secondary";
380
+ const accentColor = w.style?.accentColor
381
+ ? `Color(hex: "${w.style.accentColor}")`
382
+ : ".accentColor";
383
+ return `
384
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
385
+ // MARK: - ${w.name} (List)
386
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
387
+
388
+ struct ${w.name}Entry: TimelineEntry {
389
+ let date: Date
390
+ let items: [WidgetListItem]
391
+ let fallbackTitle: String
392
+ let fallbackSubtitle: String
393
+
394
+ static var placeholder: ${w.name}Entry {
395
+ ${w.name}Entry(
396
+ date: Date(),
397
+ items: [],
398
+ fallbackTitle: "${w.fallbackTitle}",
399
+ fallbackSubtitle: "${w.fallbackSubtitle}"
400
+ )
401
+ }
402
+ }
403
+
404
+ struct ${w.name}Provider: TimelineProvider {
405
+ func placeholder(in context: Context) -> ${w.name}Entry { .placeholder }
406
+
407
+ func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) {
408
+ completion(createEntry())
409
+ }
410
+
411
+ func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
412
+ let entry = createEntry()
413
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
414
+ completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
415
+ }
416
+
417
+ private func createEntry() -> ${w.name}Entry {
418
+ // Read items JSON from shared UserDefaults
419
+ var items: [WidgetListItem] = []
420
+ if let rawItems = WidgetDataProvider.getJSON(forKey: "${w.name}_items") as? [[String: Any]] {
421
+ items = rawItems.enumerated().map { WidgetListItem(from: $0.element, index: $0.offset) }
422
+ }
423
+
424
+ return ${w.name}Entry(
425
+ date: Date(),
426
+ items: items,
427
+ fallbackTitle: WidgetDataProvider.getString(forKey: "${w.name}_title", default: "${w.fallbackTitle}"),
428
+ fallbackSubtitle: WidgetDataProvider.getString(forKey: "${w.name}_subtitle", default: "${w.fallbackSubtitle}")
429
+ )
430
+ }
431
+ }
432
+
433
+ // MARK: ${w.name} List Item Row
434
+
435
+ struct ${w.name}ItemRow: View {
436
+ let item: WidgetListItem
437
+ let defaultTitleColor: Color
438
+ let defaultDescColor: Color
439
+ let defaultAccentColor: Color
440
+
441
+ var body: some View {
442
+ HStack(spacing: 12) {
443
+ if let icon = item.icon {
444
+ WidgetIconView(config: icon, accentColor: defaultAccentColor)
445
+ }
446
+
447
+ VStack(alignment: .leading, spacing: 2) {
448
+ Text(item.title.text)
449
+ .font(item.title.resolvedFont(default: .subheadline))
450
+ .fontWeight(item.title.resolvedWeight(default: .semibold))
451
+ .foregroundColor(
452
+ item.title.color != nil
453
+ ? Color(hex: item.title.color!)
454
+ : defaultTitleColor
455
+ )
456
+ .lineLimit(1)
457
+ .truncationMode(.tail)
458
+
459
+ if let desc = item.description, !desc.text.isEmpty {
460
+ Text(desc.text)
461
+ .font(desc.resolvedFont(default: .caption))
462
+ .fontWeight(desc.resolvedWeight(default: .regular))
463
+ .foregroundColor(
464
+ desc.color != nil
465
+ ? Color(hex: desc.color!)
466
+ : defaultDescColor
467
+ )
468
+ .lineLimit(1)
469
+ .truncationMode(.tail)
470
+ }
471
+ }
472
+
473
+ Spacer()
474
+
475
+ if let rightIcon = item.rightIcon {
476
+ WidgetIconView(config: rightIcon, accentColor: defaultAccentColor)
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ // MARK: ${w.name} Views
483
+
484
+ struct ${w.name}EntryView: View {
485
+ @Environment(\\.widgetFamily) var family
486
+ let entry: ${w.name}Entry
487
+
488
+ private let titleColor: Color = ${titleColor}
489
+ private let subtitleColor: Color = ${subtitleColor}
490
+ private let accentColor: Color = ${accentColor}
491
+
492
+ var body: some View {
493
+ if entry.items.isEmpty {
494
+ // Fallback: no data set yet
495
+ VStack(spacing: 8) {${w.showAppIcon
496
+ ? `
497
+ Image("AppIcon")
498
+ .resizable()
499
+ .frame(width: 40, height: 40)
500
+ .cornerRadius(10)`
501
+ : ""}
502
+ Text(entry.fallbackTitle)
503
+ .font(.headline)
504
+ .foregroundColor(titleColor)
505
+ Text(entry.fallbackSubtitle)
506
+ .font(.caption)
507
+ .foregroundColor(subtitleColor)
508
+ }
509
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
510
+ .background(${bgColor})
511
+ } else {
512
+ VStack(alignment: .leading, spacing: 8) {
513
+ ForEach(entry.items.prefix(maxItems)) { item in
514
+ if let deepLink = item.deepLink, let url = URL(string: deepLink) {
515
+ Link(destination: url) {
516
+ ${w.name}ItemRow(
517
+ item: item,
518
+ defaultTitleColor: titleColor,
519
+ defaultDescColor: subtitleColor,
520
+ defaultAccentColor: accentColor
521
+ )
522
+ }
523
+ } else {
524
+ ${w.name}ItemRow(
525
+ item: item,
526
+ defaultTitleColor: titleColor,
527
+ defaultDescColor: subtitleColor,
528
+ defaultAccentColor: accentColor
529
+ )
530
+ }
531
+ }
532
+ }
533
+ .padding()
534
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
535
+ .background(${bgColor})
536
+ }
537
+ }
538
+
539
+ private var maxItems: Int {
540
+ switch family {
541
+ case .systemSmall: return 3
542
+ case .systemMedium: return 3
543
+ case .systemLarge, .systemExtraLarge: return 7
544
+ default: return 3
545
+ }
546
+ }
547
+ }
548
+
549
+ struct ${w.name}: Widget {
550
+ let kind: String = "${w.name}"
551
+ ${deepLinkLine}
552
+
553
+ var body: some WidgetConfiguration {
554
+ StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
555
+ if #available(iOS 17.0, *) {
556
+ ${w.name}EntryView(entry: entry)
557
+ .containerBackground(${bgColor}, for: .widget)
558
+ .widgetURL(deepLinkUrl)
559
+ } else {
560
+ ${w.name}EntryView(entry: entry)
561
+ .padding()
562
+ .background(${bgColor})
563
+ .widgetURL(deepLinkUrl)
564
+ }
565
+ }
566
+ .configurationDisplayName("${w.displayName}")
567
+ .description("${w.description}")
568
+ .supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
569
+ }
570
+ }`;
571
+ }
572
+ function generateGridWidgetCode(w, deepLinkLine) {
573
+ // Parse gridLayout (e.g. "3x2" → 3 columns, 2 rows)
574
+ let gridCols = null;
575
+ let gridRows = null;
576
+ if (w.gridLayout) {
577
+ const match = w.gridLayout.match(/^(\d+)x(\d+)$/);
578
+ if (match) {
579
+ gridCols = parseInt(match[1], 10);
580
+ gridRows = parseInt(match[2], 10);
581
+ }
582
+ }
583
+ const bgColor = w.style?.backgroundColor
584
+ ? `Color(hex: "${w.style.backgroundColor}")`
585
+ : ".clear";
586
+ const titleColor = w.style?.titleColor
587
+ ? `Color(hex: "${w.style.titleColor}")`
588
+ : ".primary";
589
+ const subtitleColor = w.style?.subtitleColor
590
+ ? `Color(hex: "${w.style.subtitleColor}")`
591
+ : ".secondary";
592
+ const accentColor = w.style?.accentColor
593
+ ? `Color(hex: "${w.style.accentColor}")`
594
+ : ".accentColor";
595
+ return `
596
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
597
+ // MARK: - ${w.name} (Grid)
598
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
599
+
600
+ struct ${w.name}Entry: TimelineEntry {
601
+ let date: Date
602
+ let items: [WidgetListItem]
603
+ let fallbackTitle: String
604
+ let fallbackSubtitle: String
605
+
606
+ static var placeholder: ${w.name}Entry {
607
+ ${w.name}Entry(
608
+ date: Date(),
609
+ items: [],
610
+ fallbackTitle: "${w.fallbackTitle}",
611
+ fallbackSubtitle: "${w.fallbackSubtitle}"
612
+ )
613
+ }
614
+ }
615
+
616
+ struct ${w.name}Provider: TimelineProvider {
617
+ func placeholder(in context: Context) -> ${w.name}Entry { .placeholder }
618
+
619
+ func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) {
620
+ completion(createEntry())
621
+ }
622
+
623
+ func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
624
+ let entry = createEntry()
625
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
626
+ completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
627
+ }
628
+
629
+ private func createEntry() -> ${w.name}Entry {
630
+ var items: [WidgetListItem] = []
631
+ if let rawItems = WidgetDataProvider.getJSON(forKey: "${w.name}_items") as? [[String: Any]] {
632
+ items = rawItems.enumerated().map { WidgetListItem(from: $0.element, index: $0.offset) }
633
+ }
634
+
635
+ return ${w.name}Entry(
636
+ date: Date(),
637
+ items: items,
638
+ fallbackTitle: WidgetDataProvider.getString(forKey: "${w.name}_title", default: "${w.fallbackTitle}"),
639
+ fallbackSubtitle: WidgetDataProvider.getString(forKey: "${w.name}_subtitle", default: "${w.fallbackSubtitle}")
640
+ )
641
+ }
642
+ }
643
+
644
+ // MARK: ${w.name} Grid Item Cell
645
+
646
+ struct ${w.name}GridCell: View {
647
+ let item: WidgetListItem
648
+ let defaultTitleColor: Color
649
+ let defaultAccentColor: Color
650
+
651
+ var body: some View {
652
+ VStack(spacing: 6) {
653
+ if let icon = item.icon {
654
+ WidgetIconView(config: icon, accentColor: defaultAccentColor)
655
+ }
656
+
657
+ Text(item.title.text)
658
+ .font(item.title.resolvedFont(default: .caption))
659
+ .fontWeight(item.title.resolvedWeight(default: .medium))
660
+ .foregroundColor(
661
+ item.title.color != nil
662
+ ? Color(hex: item.title.color!)
663
+ : defaultTitleColor
664
+ )
665
+ .lineLimit(1)
666
+ .truncationMode(.tail)
667
+ }
668
+ .frame(maxWidth: .infinity)
669
+ }
670
+ }
671
+
672
+ // MARK: ${w.name} Views
673
+
674
+ struct ${w.name}EntryView: View {
675
+ @Environment(\\.widgetFamily) var family
676
+ let entry: ${w.name}Entry
677
+
678
+ private let titleColor: Color = ${titleColor}
679
+ private let subtitleColor: Color = ${subtitleColor}
680
+ private let accentColor: Color = ${accentColor}
681
+
682
+ var body: some View {
683
+ if entry.items.isEmpty {
684
+ VStack(spacing: 8) {${w.showAppIcon
685
+ ? `
686
+ Image("AppIcon")
687
+ .resizable()
688
+ .frame(width: 40, height: 40)
689
+ .cornerRadius(10)`
690
+ : ""}
691
+ Text(entry.fallbackTitle)
692
+ .font(.headline)
693
+ .foregroundColor(titleColor)
694
+ Text(entry.fallbackSubtitle)
695
+ .font(.caption)
696
+ .foregroundColor(subtitleColor)
697
+ }
698
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
699
+ .background(${bgColor})
700
+ } else {
701
+ let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: gridColumns)
702
+
703
+ LazyVGrid(columns: columns, spacing: 12) {
704
+ ForEach(entry.items.prefix(maxItems)) { item in
705
+ if let deepLink = item.deepLink, let url = URL(string: deepLink) {
706
+ Link(destination: url) {
707
+ ${w.name}GridCell(
708
+ item: item,
709
+ defaultTitleColor: titleColor,
710
+ defaultAccentColor: accentColor
711
+ )
712
+ }
713
+ } else {
714
+ ${w.name}GridCell(
715
+ item: item,
716
+ defaultTitleColor: titleColor,
717
+ defaultAccentColor: accentColor
718
+ )
719
+ }
720
+ }
721
+ }
722
+ .padding()
723
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
724
+ .background(${bgColor})
725
+ }
726
+ }
727
+
728
+ private var gridColumns: Int {
729
+ ${gridCols
730
+ ? `return ${gridCols}`
731
+ : `switch family {
732
+ case .systemSmall: return 2
733
+ case .systemMedium: return 4
734
+ case .systemLarge, .systemExtraLarge: return 4
735
+ default: return 3
736
+ }`}
737
+ }
738
+
739
+ private var maxItems: Int {
740
+ ${gridCols && gridRows
741
+ ? `return ${gridCols * gridRows}`
742
+ : `switch family {
743
+ case .systemSmall: return 4
744
+ case .systemMedium: return 4
745
+ case .systemLarge, .systemExtraLarge: return 8
746
+ default: return 4
747
+ }`}
748
+ }
749
+ }
750
+
751
+ struct ${w.name}: Widget {
752
+ let kind: String = "${w.name}"
753
+ ${deepLinkLine}
754
+
755
+ var body: some WidgetConfiguration {
756
+ StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
757
+ if #available(iOS 17.0, *) {
758
+ ${w.name}EntryView(entry: entry)
759
+ .containerBackground(${bgColor}, for: .widget)
760
+ .widgetURL(deepLinkUrl)
761
+ } else {
762
+ ${w.name}EntryView(entry: entry)
763
+ .padding()
764
+ .background(${bgColor})
765
+ .widgetURL(deepLinkUrl)
766
+ }
767
+ }
768
+ .configurationDisplayName("${w.displayName}")
769
+ .description("${w.description}")
770
+ .supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
771
+ }
772
+ }`;
773
+ }
774
+ function generateSingleTypeWidgetCode(w, deepLinkLine) {
775
+ const bgColor = w.style?.backgroundColor
776
+ ? `Color(hex: "${w.style.backgroundColor}")`
777
+ : ".clear";
778
+ const titleColor = w.style?.titleColor
779
+ ? `Color(hex: "${w.style.titleColor}")`
780
+ : ".primary";
781
+ const subtitleColor = w.style?.subtitleColor
782
+ ? `Color(hex: "${w.style.subtitleColor}")`
783
+ : ".secondary";
784
+ const accentColor = w.style?.accentColor
785
+ ? `Color(hex: "${w.style.accentColor}")`
786
+ : ".accentColor";
787
+ const appIconSmall = w.showAppIcon
788
+ ? `Image("AppIcon")
789
+ .resizable()
790
+ .frame(width: 32, height: 32)
791
+ .cornerRadius(8)`
792
+ : "";
793
+ const appIconMedium = w.showAppIcon
794
+ ? `Image("AppIcon")
795
+ .resizable()
796
+ .frame(width: 48, height: 48)
797
+ .cornerRadius(12)`
798
+ : "";
799
+ const appIconLarge = w.showAppIcon
800
+ ? `Image("AppIcon")
801
+ .resizable()
802
+ .frame(width: 56, height: 56)
803
+ .cornerRadius(14)`
804
+ : "";
805
+ return `
806
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
807
+ // MARK: - ${w.name}
808
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
809
+
810
+ struct ${w.name}Entry: TimelineEntry {
811
+ let date: Date
812
+ let title: String
813
+ let subtitle: String
814
+ let value: Int
815
+
816
+ static var placeholder: ${w.name}Entry {
817
+ ${w.name}Entry(
818
+ date: Date(),
819
+ title: "${w.displayName}",
820
+ subtitle: "",
821
+ value: 0
822
+ )
823
+ }
824
+ }
825
+
826
+ struct ${w.name}Provider: TimelineProvider {
827
+ func placeholder(in context: Context) -> ${w.name}Entry {
828
+ return .placeholder
829
+ }
830
+
831
+ func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) {
832
+ completion(createEntry())
833
+ }
834
+
835
+ func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
836
+ let entry = createEntry()
837
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
838
+ let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
839
+ completion(timeline)
840
+ }
841
+
842
+ private func createEntry() -> ${w.name}Entry {
843
+ return ${w.name}Entry(
844
+ date: Date(),
845
+ title: WidgetDataProvider.getString(forKey: "${w.name}_title", default: "${w.displayName}"),
846
+ subtitle: WidgetDataProvider.getString(forKey: "${w.name}_subtitle", default: ""),
847
+ value: WidgetDataProvider.getInt(forKey: "${w.name}_value", default: 0)
848
+ )
849
+ }
850
+ }
851
+
852
+ // MARK: ${w.name} Views
853
+
854
+ struct ${w.name}SmallView: View {
855
+ let entry: ${w.name}Entry
856
+
857
+ var body: some View {
858
+ VStack(alignment: .leading, spacing: 4) {
859
+ HStack {
860
+ ${appIconSmall}
861
+ Text(entry.title)
862
+ .font(.headline)
863
+ .fontWeight(.bold)
864
+ .foregroundColor(${titleColor})
865
+ Spacer()
866
+ }
867
+
868
+ if !entry.subtitle.isEmpty {
869
+ Text(entry.subtitle)
870
+ .font(.caption)
871
+ .foregroundColor(${subtitleColor})
872
+ }
873
+
874
+ Spacer()
875
+
876
+ Text("\\(entry.value)")
877
+ .font(.title)
878
+ .fontWeight(.bold)
879
+ .foregroundColor(${accentColor})
880
+ }
881
+ .padding()
882
+ .background(${bgColor})
883
+ }
884
+ }
885
+
886
+ struct ${w.name}MediumView: View {
887
+ let entry: ${w.name}Entry
888
+
889
+ var body: some View {
890
+ HStack {
891
+ ${appIconMedium}
892
+
893
+ VStack(alignment: .leading, spacing: 4) {
894
+ Text(entry.title)
895
+ .font(.headline)
896
+ .fontWeight(.bold)
897
+ .foregroundColor(${titleColor})
898
+
899
+ if !entry.subtitle.isEmpty {
900
+ Text(entry.subtitle)
901
+ .font(.subheadline)
902
+ .foregroundColor(${subtitleColor})
903
+ }
904
+
905
+ Spacer()
906
+ }
907
+
908
+ Spacer()
909
+
910
+ VStack {
911
+ Spacer()
912
+ Text("\\(entry.value)")
913
+ .font(.largeTitle)
914
+ .fontWeight(.bold)
915
+ .foregroundColor(${accentColor})
916
+ Spacer()
917
+ }
918
+ }
919
+ .padding()
920
+ .background(${bgColor})
921
+ }
922
+ }
923
+
924
+ struct ${w.name}LargeView: View {
925
+ let entry: ${w.name}Entry
926
+
927
+ var body: some View {
928
+ VStack(alignment: .leading, spacing: 12) {
929
+ HStack {
930
+ ${appIconLarge}
931
+ VStack(alignment: .leading) {
932
+ Text(entry.title)
933
+ .font(.title2)
934
+ .fontWeight(.bold)
935
+ .foregroundColor(${titleColor})
936
+ if !entry.subtitle.isEmpty {
937
+ Text(entry.subtitle)
938
+ .font(.body)
939
+ .foregroundColor(${subtitleColor})
940
+ }
941
+ }
942
+ Spacer()
943
+ }
944
+
945
+ Spacer()
946
+
947
+ HStack {
948
+ Spacer()
949
+ Text("\\(entry.value)")
950
+ .font(.system(size: 64))
951
+ .fontWeight(.bold)
952
+ .foregroundColor(${accentColor})
953
+ Spacer()
954
+ }
955
+
956
+ Spacer()
957
+ }
958
+ .padding()
959
+ .background(${bgColor})
960
+ }
961
+ }
962
+
963
+ struct ${w.name}EntryView: View {
964
+ @Environment(\\.widgetFamily) var family
965
+ let entry: ${w.name}Entry
966
+
967
+ var body: some View {
968
+ switch family {
969
+ case .systemSmall:
970
+ ${w.name}SmallView(entry: entry)
971
+ case .systemMedium:
972
+ ${w.name}MediumView(entry: entry)
973
+ case .systemLarge, .systemExtraLarge:
974
+ ${w.name}LargeView(entry: entry)
975
+ default:
976
+ ${w.name}SmallView(entry: entry)
977
+ }
978
+ }
979
+ }
980
+
981
+ struct ${w.name}: Widget {
982
+ let kind: String = "${w.name}"
983
+ ${deepLinkLine}
984
+
985
+ var body: some WidgetConfiguration {
986
+ StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
987
+ if #available(iOS 17.0, *) {
988
+ ${w.name}EntryView(entry: entry)
989
+ .containerBackground(${bgColor}, for: .widget)
990
+ .widgetURL(deepLinkUrl)
991
+ } else {
992
+ ${w.name}EntryView(entry: entry)
993
+ .padding()
994
+ .background(${bgColor})
995
+ .widgetURL(deepLinkUrl)
996
+ }
997
+ }
998
+ .configurationDisplayName("${w.displayName}")
999
+ .description("${w.description}")
1000
+ .supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
1001
+ }
1002
+ }`;
1003
+ }
1004
+ /** Generate the full Swift file with all widgets + WidgetBundle */
1005
+ function generateWidgetSwiftCode(props, bundleId) {
1006
+ const dataProvider = generateDataProviderCode(props, bundleId);
1007
+ const widgetBlocks = props.widgets
1008
+ .map((w) => generateSingleWidgetCode(w))
1009
+ .join("\n");
1010
+ const bundleBody = props.widgets.map((w) => ` ${w.name}()`).join("\n");
1011
+ // Include Color hex extension if any widget uses style or is list/single type
1012
+ // (list/single items can have per-item colors set at runtime)
1013
+ const needsColorExt = props.widgets.some((w) => w.type === "list" ||
1014
+ w.type === "single" ||
1015
+ w.style?.backgroundColor ||
1016
+ w.style?.titleColor ||
1017
+ w.style?.subtitleColor ||
1018
+ w.style?.accentColor);
1019
+ const colorExtension = needsColorExt
1020
+ ? `
1021
+ // MARK: - Color Hex Extension
1022
+
1023
+ extension Color {
1024
+ init(hex: String) {
1025
+ let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
1026
+ var int: UInt64 = 0
1027
+ Scanner(string: hex).scanHexInt64(&int)
1028
+ let a, r, g, b: UInt64
1029
+ switch hex.count {
1030
+ case 6: (a, r, g, b) = (255, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
1031
+ case 8: (a, r, g, b) = ((int >> 24) & 0xFF, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
1032
+ default: (a, r, g, b) = (255, 0, 0, 0)
1033
+ }
1034
+ self.init(
1035
+ .sRGB,
1036
+ red: Double(r) / 255,
1037
+ green: Double(g) / 255,
1038
+ blue: Double(b) / 255,
1039
+ opacity: Double(a) / 255
1040
+ )
1041
+ }
1042
+ }
1043
+ `
1044
+ : "";
1045
+ // Determine if we need UIKit import (for UIImage check in icon rendering)
1046
+ const needsUIKit = props.widgets.some((w) => w.type === "list" || w.type === "single");
1047
+ const uiKitImport = needsUIKit ? "\nimport UIKit" : "";
1048
+ return `//
1049
+ // Widgets.swift
1050
+ // ${props.extensionName}
1051
+ //
1052
+ // Auto-generated by nn-widgets
1053
+ //
1054
+
1055
+ import WidgetKit
1056
+ import SwiftUI${uiKitImport}
1057
+ ${colorExtension}
1058
+ // MARK: - Widget Data Provider
1059
+ ${dataProvider}
1060
+ ${widgetBlocks}
1061
+
1062
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1063
+ // MARK: - Widget Bundle
1064
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1065
+
1066
+ @main
1067
+ struct AppWidgetsBundle: WidgetBundle {
1068
+ var body: some Widget {
1069
+ ${bundleBody}
1070
+ }
1071
+ }
1072
+ `;
1073
+ }
1074
+ // ──────────────────────────────────────────────
1075
+ // Plist / entitlements helpers
1076
+ // ──────────────────────────────────────────────
1077
+ function generateWidgetEntitlements(appGroupId, useAppGroups) {
1078
+ if (!useAppGroups || !appGroupId) {
1079
+ return `<?xml version="1.0" encoding="UTF-8"?>
1080
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1081
+ <plist version="1.0">
1082
+ <dict>
1083
+ </dict>
1084
+ </plist>
1085
+ `;
1086
+ }
1087
+ return `<?xml version="1.0" encoding="UTF-8"?>
1088
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1089
+ <plist version="1.0">
1090
+ <dict>
1091
+ <key>com.apple.security.application-groups</key>
1092
+ <array>
1093
+ <string>${appGroupId}</string>
1094
+ </array>
1095
+ </dict>
1096
+ </plist>
1097
+ `;
1098
+ }
1099
+ function generateWidgetInfoPlist(props, bundleId) {
1100
+ return `<?xml version="1.0" encoding="UTF-8"?>
1101
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1102
+ <plist version="1.0">
1103
+ <dict>
1104
+ <key>CFBundleDevelopmentRegion</key>
1105
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
1106
+ <key>CFBundleDisplayName</key>
1107
+ <string>${props.extensionName}</string>
1108
+ <key>CFBundleExecutable</key>
1109
+ <string>$(EXECUTABLE_NAME)</string>
1110
+ <key>CFBundleIdentifier</key>
1111
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
1112
+ <key>CFBundleInfoDictionaryVersion</key>
1113
+ <string>6.0</string>
1114
+ <key>CFBundleName</key>
1115
+ <string>$(PRODUCT_NAME)</string>
1116
+ <key>CFBundlePackageType</key>
1117
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
1118
+ <key>CFBundleShortVersionString</key>
1119
+ <string>$(MARKETING_VERSION)</string>
1120
+ <key>CFBundleVersion</key>
1121
+ <string>$(CURRENT_PROJECT_VERSION)</string>
1122
+ <key>NSExtension</key>
1123
+ <dict>
1124
+ <key>NSExtensionPointIdentifier</key>
1125
+ <string>com.apple.widgetkit-extension</string>
1126
+ </dict>
1127
+ </dict>
1128
+ </plist>
1129
+ `;
1130
+ }
1131
+ // ──────────────────────────────────────────────
1132
+ // Config plugin
1133
+ // ──────────────────────────────────────────────
1134
+ const withIosWidget = (config, props = {}) => {
1135
+ const bundleIdentifier = config.ios?.bundleIdentifier || "com.app.widget";
1136
+ // Resolve props with defaults (normalisation happens in index.ts,
1137
+ // but we handle it here as well for standalone usage)
1138
+ const resolvedProps = resolveIosProps(props, config.name || "Widget", bundleIdentifier);
1139
+ const extensionName = resolvedProps.extensionName;
1140
+ const useAppGroups = resolvedProps.ios.useAppGroups;
1141
+ const appGroupId = resolvedProps.ios.appGroupIdentifier;
1142
+ // Add App Groups to main app entitlements (only if enabled)
1143
+ if (useAppGroups && appGroupId) {
1144
+ config = (0, config_plugins_1.withEntitlementsPlist)(config, (config) => {
1145
+ config.modResults["com.apple.security.application-groups"] =
1146
+ config.modResults["com.apple.security.application-groups"] || [];
1147
+ const groups = config.modResults["com.apple.security.application-groups"];
1148
+ if (!groups.includes(appGroupId)) {
1149
+ groups.push(appGroupId);
1150
+ }
1151
+ return config;
1152
+ });
1153
+ config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
1154
+ config.modResults["NNWidgetsAppGroup"] = appGroupId;
1155
+ return config;
1156
+ });
1157
+ }
1158
+ // Configure Xcode project with widget extension
1159
+ config = (0, config_plugins_1.withXcodeProject)(config, async (config) => {
1160
+ const xcodeProject = config.modResults;
1161
+ const projectRoot = config.modRequest.projectRoot;
1162
+ const projectName = config.modRequest.projectName || "App";
1163
+ const iosPath = path.join(projectRoot, "ios");
1164
+ const extensionPath = path.join(iosPath, extensionName);
1165
+ const extensionBundleId = `${bundleIdentifier}.${extensionName}`;
1166
+ // Check if target already exists
1167
+ const existingTarget = xcodeProject.pbxTargetByName(extensionName);
1168
+ if (existingTarget) {
1169
+ console.log(`[nn-widgets] Widget extension target "${extensionName}" already exists, skipping...`);
1170
+ return config;
1171
+ }
1172
+ // Create extension directory
1173
+ if (!fs.existsSync(extensionPath)) {
1174
+ fs.mkdirSync(extensionPath, { recursive: true });
1175
+ }
1176
+ // Write single Swift file containing ALL widgets
1177
+ const swiftCode = generateWidgetSwiftCode(resolvedProps, bundleIdentifier);
1178
+ const swiftFileName = "Widgets.swift";
1179
+ fs.writeFileSync(path.join(extensionPath, swiftFileName), swiftCode);
1180
+ // Write entitlements
1181
+ const entitlements = generateWidgetEntitlements(appGroupId, useAppGroups);
1182
+ const entitlementsFileName = `${extensionName}.entitlements`;
1183
+ fs.writeFileSync(path.join(extensionPath, entitlementsFileName), entitlements);
1184
+ // Write Info.plist
1185
+ const infoPlist = generateWidgetInfoPlist(resolvedProps, bundleIdentifier);
1186
+ fs.writeFileSync(path.join(extensionPath, "Info.plist"), infoPlist);
1187
+ // Create Assets.xcassets for widget extension
1188
+ const assetsPath = path.join(extensionPath, "Assets.xcassets");
1189
+ if (!fs.existsSync(assetsPath)) {
1190
+ fs.mkdirSync(assetsPath, { recursive: true });
1191
+ fs.writeFileSync(path.join(assetsPath, "Contents.json"), JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2));
1192
+ // Create AccentColor
1193
+ const accentColorPath = path.join(assetsPath, "AccentColor.colorset");
1194
+ fs.mkdirSync(accentColorPath, { recursive: true });
1195
+ fs.writeFileSync(path.join(accentColorPath, "Contents.json"), JSON.stringify({
1196
+ colors: [{ idiom: "universal" }],
1197
+ info: { author: "xcode", version: 1 },
1198
+ }, null, 2));
1199
+ // Create WidgetBackground
1200
+ const widgetBgPath = path.join(assetsPath, "WidgetBackground.colorset");
1201
+ fs.mkdirSync(widgetBgPath, { recursive: true });
1202
+ fs.writeFileSync(path.join(widgetBgPath, "Contents.json"), JSON.stringify({
1203
+ colors: [{ idiom: "universal" }],
1204
+ info: { author: "xcode", version: 1 },
1205
+ }, null, 2));
1206
+ // Copy AppIcon if ANY widget has showAppIcon enabled
1207
+ const needsAppIcon = resolvedProps.widgets.some((w) => w.showAppIcon);
1208
+ if (needsAppIcon) {
1209
+ const mainAppAssetsPath = path.join(iosPath, projectName, "Images.xcassets", "AppIcon.appiconset");
1210
+ const widgetAppIconPath = path.join(assetsPath, "AppIcon.imageset");
1211
+ if (fs.existsSync(mainAppAssetsPath)) {
1212
+ fs.mkdirSync(widgetAppIconPath, { recursive: true });
1213
+ const iconFiles = fs
1214
+ .readdirSync(mainAppAssetsPath)
1215
+ .filter((f) => f.endsWith(".png"));
1216
+ if (iconFiles.length > 0) {
1217
+ const iconToCopy = iconFiles.find((f) => f.includes("1024") || f.includes("512") || f.includes("180")) || iconFiles[0];
1218
+ const sourcePath = path.join(mainAppAssetsPath, iconToCopy);
1219
+ const destPath = path.join(widgetAppIconPath, "AppIcon.png");
1220
+ fs.copyFileSync(sourcePath, destPath);
1221
+ fs.writeFileSync(path.join(widgetAppIconPath, "Contents.json"), JSON.stringify({
1222
+ images: [
1223
+ {
1224
+ filename: "AppIcon.png",
1225
+ idiom: "universal",
1226
+ scale: "1x",
1227
+ },
1228
+ { idiom: "universal", scale: "2x" },
1229
+ { idiom: "universal", scale: "3x" },
1230
+ ],
1231
+ info: { author: "xcode", version: 1 },
1232
+ }, null, 2));
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ // ── Copy widget images to Assets.xcassets ──
1238
+ for (const w of resolvedProps.widgets) {
1239
+ if (!w.image)
1240
+ continue;
1241
+ const sizes = [
1242
+ { key: "small", path: w.image.small },
1243
+ { key: "medium", path: w.image.medium },
1244
+ { key: "large", path: w.image.large },
1245
+ ];
1246
+ for (const size of sizes) {
1247
+ if (!size.path)
1248
+ continue;
1249
+ const assetName = `${w.name}_${size.key}`;
1250
+ const imagesetPath = path.join(assetsPath, `${assetName}.imageset`);
1251
+ fs.mkdirSync(imagesetPath, { recursive: true });
1252
+ // Resolve source path relative to projectRoot
1253
+ const imgSourcePath = path.isAbsolute(size.path)
1254
+ ? size.path
1255
+ : path.join(projectRoot, size.path);
1256
+ if (!fs.existsSync(imgSourcePath)) {
1257
+ console.warn(`[nn-widgets] Widget image not found: ${imgSourcePath} (widget: ${w.name}, size: ${size.key})`);
1258
+ continue;
1259
+ }
1260
+ const ext = path.extname(imgSourcePath);
1261
+ const destFileName = `${assetName}${ext}`;
1262
+ fs.copyFileSync(imgSourcePath, path.join(imagesetPath, destFileName));
1263
+ fs.writeFileSync(path.join(imagesetPath, "Contents.json"), JSON.stringify({
1264
+ images: [
1265
+ { filename: destFileName, idiom: "universal", scale: "1x" },
1266
+ { idiom: "universal", scale: "2x" },
1267
+ { idiom: "universal", scale: "3x" },
1268
+ ],
1269
+ info: { author: "xcode", version: 1 },
1270
+ }, null, 2));
1271
+ console.log(`[nn-widgets] Copied widget image: ${assetName} → ${imagesetPath}`);
1272
+ }
1273
+ }
1274
+ // ── Copy widget icons to Assets.xcassets ──
1275
+ for (const w of resolvedProps.widgets) {
1276
+ if (!w.icons)
1277
+ continue;
1278
+ for (const [iconName, iconPath] of Object.entries(w.icons)) {
1279
+ const imagesetPath = path.join(assetsPath, `${iconName}.imageset`);
1280
+ fs.mkdirSync(imagesetPath, { recursive: true });
1281
+ const imgSourcePath = path.isAbsolute(iconPath)
1282
+ ? iconPath
1283
+ : path.join(projectRoot, iconPath);
1284
+ if (!fs.existsSync(imgSourcePath)) {
1285
+ console.warn(`[nn-widgets] Widget icon not found: ${imgSourcePath} (widget: ${w.name}, icon: ${iconName})`);
1286
+ continue;
1287
+ }
1288
+ const ext = path.extname(imgSourcePath);
1289
+ const destFileName = `${iconName}${ext}`;
1290
+ fs.copyFileSync(imgSourcePath, path.join(imagesetPath, destFileName));
1291
+ fs.writeFileSync(path.join(imagesetPath, "Contents.json"), JSON.stringify({
1292
+ images: [
1293
+ {
1294
+ filename: destFileName,
1295
+ idiom: "universal",
1296
+ scale: "1x",
1297
+ },
1298
+ { idiom: "universal", scale: "2x" },
1299
+ { idiom: "universal", scale: "3x" },
1300
+ ],
1301
+ info: { author: "xcode", version: 1 },
1302
+ }, null, 2));
1303
+ console.log(`[nn-widgets] Copied widget icon: ${iconName} → ${imagesetPath}`);
1304
+ }
1305
+ }
1306
+ // ── Xcode project manipulation ──────────────────
1307
+ // Create PBXGroup for extension
1308
+ const widgetGroup = xcodeProject.addPbxGroup([], extensionName, extensionName);
1309
+ // Add group to the main project group
1310
+ const projectSection = xcodeProject.pbxProjectSection();
1311
+ for (const key in projectSection) {
1312
+ if (projectSection[key] && projectSection[key].mainGroup) {
1313
+ const mainGroupUuid = projectSection[key].mainGroup;
1314
+ const pbxGroupSection = xcodeProject.hash.project.objects["PBXGroup"];
1315
+ if (pbxGroupSection?.[mainGroupUuid]?.children) {
1316
+ pbxGroupSection[mainGroupUuid].children.push({
1317
+ value: widgetGroup.uuid,
1318
+ comment: extensionName,
1319
+ });
1320
+ }
1321
+ break;
1322
+ }
1323
+ }
1324
+ // Create the widget extension target
1325
+ const target = xcodeProject.addTarget(extensionName, "app_extension", extensionName, extensionBundleId);
1326
+ if (!target) {
1327
+ console.error("[nn-widgets] Failed to create widget extension target");
1328
+ return config;
1329
+ }
1330
+ const targetUuid = target.uuid;
1331
+ // Add Swift file to sources build phase
1332
+ const swiftFileRef = xcodeProject.generateUuid();
1333
+ const pbxFileReferenceSection = xcodeProject.hash.project.objects["PBXFileReference"];
1334
+ pbxFileReferenceSection[swiftFileRef] = {
1335
+ isa: "PBXFileReference",
1336
+ lastKnownFileType: "sourcecode.swift",
1337
+ path: `${extensionName}/${swiftFileName}`,
1338
+ sourceTree: '"<group>"',
1339
+ };
1340
+ pbxFileReferenceSection[`${swiftFileRef}_comment`] = swiftFileName;
1341
+ const sourcesBuildPhase = xcodeProject.addBuildPhase([], "PBXSourcesBuildPhase", "Sources", targetUuid);
1342
+ if (sourcesBuildPhase?.buildPhase) {
1343
+ const buildFileUuid = xcodeProject.generateUuid();
1344
+ const pbxBuildFileSection = xcodeProject.hash.project.objects["PBXBuildFile"];
1345
+ pbxBuildFileSection[buildFileUuid] = {
1346
+ isa: "PBXBuildFile",
1347
+ fileRef: swiftFileRef,
1348
+ fileRef_comment: swiftFileName,
1349
+ };
1350
+ pbxBuildFileSection[`${buildFileUuid}_comment`] =
1351
+ `${swiftFileName} in Sources`;
1352
+ sourcesBuildPhase.buildPhase.files.push({
1353
+ value: buildFileUuid,
1354
+ comment: `${swiftFileName} in Sources`,
1355
+ });
1356
+ }
1357
+ // Resources & Frameworks build phases
1358
+ const resourcesBuildPhase = xcodeProject.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", targetUuid);
1359
+ // Add Assets.xcassets to the widget extension's Resources build phase
1360
+ const assetsFileRef = xcodeProject.generateUuid();
1361
+ pbxFileReferenceSection[assetsFileRef] = {
1362
+ isa: "PBXFileReference",
1363
+ lastKnownFileType: "folder.assetcatalog",
1364
+ path: `${extensionName}/Assets.xcassets`,
1365
+ sourceTree: '"<group>"',
1366
+ };
1367
+ pbxFileReferenceSection[`${assetsFileRef}_comment`] = "Assets.xcassets";
1368
+ if (resourcesBuildPhase?.buildPhase) {
1369
+ const assetsBuildFileUuid = xcodeProject.generateUuid();
1370
+ const pbxBuildFileSection2 = xcodeProject.hash.project.objects["PBXBuildFile"];
1371
+ pbxBuildFileSection2[assetsBuildFileUuid] = {
1372
+ isa: "PBXBuildFile",
1373
+ fileRef: assetsFileRef,
1374
+ fileRef_comment: "Assets.xcassets",
1375
+ };
1376
+ pbxBuildFileSection2[`${assetsBuildFileUuid}_comment`] =
1377
+ "Assets.xcassets in Resources";
1378
+ resourcesBuildPhase.buildPhase.files.push({
1379
+ value: assetsBuildFileUuid,
1380
+ comment: "Assets.xcassets in Resources",
1381
+ });
1382
+ }
1383
+ xcodeProject.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", targetUuid);
1384
+ // Configure build settings
1385
+ const configurations = xcodeProject.pbxXCBuildConfigurationSection();
1386
+ for (const configKey in configurations) {
1387
+ const configObj = configurations[configKey];
1388
+ if (typeof configObj === "object" && configObj.buildSettings) {
1389
+ const bs = configObj.buildSettings;
1390
+ if (bs.PRODUCT_BUNDLE_IDENTIFIER === `"${extensionBundleId}"` ||
1391
+ bs.PRODUCT_BUNDLE_IDENTIFIER === extensionBundleId ||
1392
+ bs.PRODUCT_NAME === `"${extensionName}"` ||
1393
+ bs.PRODUCT_NAME === extensionName) {
1394
+ bs.ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = '"AccentColor"';
1395
+ bs.ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME =
1396
+ '"WidgetBackground"';
1397
+ bs.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${entitlementsFileName}"`;
1398
+ bs.CODE_SIGN_STYLE = "Automatic";
1399
+ bs.CURRENT_PROJECT_VERSION = "1";
1400
+ bs.GENERATE_INFOPLIST_FILE = "NO";
1401
+ bs.INFOPLIST_FILE = `"${extensionName}/Info.plist"`;
1402
+ bs.INFOPLIST_KEY_CFBundleDisplayName = `"${resolvedProps.extensionName}"`;
1403
+ bs.INFOPLIST_KEY_NSHumanReadableCopyright = '""';
1404
+ bs.IPHONEOS_DEPLOYMENT_TARGET = resolvedProps.ios.deploymentTarget;
1405
+ bs.LD_RUNPATH_SEARCH_PATHS =
1406
+ '"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"';
1407
+ bs.MARKETING_VERSION = "1.0";
1408
+ bs.PRODUCT_BUNDLE_IDENTIFIER = `"${extensionBundleId}"`;
1409
+ bs.PRODUCT_NAME = '"$(TARGET_NAME)"';
1410
+ bs.SKIP_INSTALL = "YES";
1411
+ bs.SWIFT_EMIT_LOC_STRINGS = "YES";
1412
+ bs.SWIFT_VERSION = "5.0";
1413
+ bs.TARGETED_DEVICE_FAMILY = '"1,2"';
1414
+ if (resolvedProps.ios.devTeam) {
1415
+ bs.DEVELOPMENT_TEAM = resolvedProps.ios.devTeam;
1416
+ }
1417
+ }
1418
+ }
1419
+ }
1420
+ // Embed extension in main app
1421
+ const mainTarget = xcodeProject.getFirstTarget();
1422
+ if (mainTarget?.uuid) {
1423
+ xcodeProject.addTargetDependency(mainTarget.uuid, [targetUuid]);
1424
+ const nativeTargetSection = xcodeProject.hash.project.objects["PBXNativeTarget"];
1425
+ let productRef = null;
1426
+ for (const key in nativeTargetSection) {
1427
+ if (typeof nativeTargetSection[key] === "object" &&
1428
+ nativeTargetSection[key].name === extensionName) {
1429
+ productRef = nativeTargetSection[key].productReference;
1430
+ break;
1431
+ }
1432
+ }
1433
+ if (productRef) {
1434
+ const copyFilesBuildPhaseUuid = xcodeProject.generateUuid();
1435
+ const buildFileUuid = xcodeProject.generateUuid();
1436
+ const pbxBuildFileSection = xcodeProject.hash.project.objects["PBXBuildFile"];
1437
+ pbxBuildFileSection[buildFileUuid] = {
1438
+ isa: "PBXBuildFile",
1439
+ fileRef: productRef,
1440
+ fileRef_comment: `${extensionName}.appex`,
1441
+ settings: { ATTRIBUTES: ["RemoveHeadersOnCopy"] },
1442
+ };
1443
+ pbxBuildFileSection[`${buildFileUuid}_comment`] =
1444
+ `${extensionName}.appex in Embed Foundation Extensions`;
1445
+ const pbxCopyFilesBuildPhaseSection = xcodeProject.hash.project.objects["PBXCopyFilesBuildPhase"] || {};
1446
+ xcodeProject.hash.project.objects["PBXCopyFilesBuildPhase"] =
1447
+ pbxCopyFilesBuildPhaseSection;
1448
+ pbxCopyFilesBuildPhaseSection[copyFilesBuildPhaseUuid] = {
1449
+ isa: "PBXCopyFilesBuildPhase",
1450
+ buildActionMask: 2147483647,
1451
+ dstPath: '""',
1452
+ dstSubfolderSpec: 13,
1453
+ files: [
1454
+ {
1455
+ value: buildFileUuid,
1456
+ comment: `${extensionName}.appex in Embed Foundation Extensions`,
1457
+ },
1458
+ ],
1459
+ name: '"Embed Foundation Extensions"',
1460
+ runOnlyForDeploymentPostprocessing: 0,
1461
+ };
1462
+ pbxCopyFilesBuildPhaseSection[`${copyFilesBuildPhaseUuid}_comment`] =
1463
+ "Embed Foundation Extensions";
1464
+ for (const key in nativeTargetSection) {
1465
+ if (typeof nativeTargetSection[key] === "object" &&
1466
+ nativeTargetSection[key].name === projectName) {
1467
+ if (!nativeTargetSection[key].buildPhases) {
1468
+ nativeTargetSection[key].buildPhases = [];
1469
+ }
1470
+ nativeTargetSection[key].buildPhases.push({
1471
+ value: copyFilesBuildPhaseUuid,
1472
+ comment: "Embed Foundation Extensions",
1473
+ });
1474
+ break;
1475
+ }
1476
+ }
1477
+ }
1478
+ }
1479
+ const widgetNames = resolvedProps.widgets.map((w) => w.name).join(", ");
1480
+ console.log(`[nn-widgets] Created iOS widget extension "${extensionName}" with widgets: ${widgetNames}`);
1481
+ return config;
1482
+ });
1483
+ return config;
1484
+ };
1485
+ exports.withIosWidget = withIosWidget;
1486
+ // ──────────────────────────────────────────────
1487
+ // Props resolution helper
1488
+ // ──────────────────────────────────────────────
1489
+ function resolveIosProps(props, appName, bundleIdentifier) {
1490
+ const useAppGroups = props.ios?.useAppGroups !== false;
1491
+ // Normalise widgets array (handle legacy single-widget format)
1492
+ let widgets;
1493
+ if (props.widgets && props.widgets.length > 0) {
1494
+ widgets = props.widgets.map((w) => {
1495
+ // Resolve image: normalise string → per-size object
1496
+ let resolvedImage;
1497
+ if (w.image) {
1498
+ if (typeof w.image === "string") {
1499
+ resolvedImage = { small: w.image, medium: w.image, large: w.image };
1500
+ }
1501
+ else {
1502
+ resolvedImage = {
1503
+ small: w.image.small,
1504
+ medium: w.image.medium,
1505
+ large: w.image.large,
1506
+ };
1507
+ // Only include if at least one size is set
1508
+ if (!resolvedImage.small &&
1509
+ !resolvedImage.medium &&
1510
+ !resolvedImage.large) {
1511
+ resolvedImage = undefined;
1512
+ }
1513
+ }
1514
+ }
1515
+ // Auto-detect type: if image is set and type is not, use "image"
1516
+ const hasImage = resolvedImage &&
1517
+ (resolvedImage.small || resolvedImage.medium || resolvedImage.large);
1518
+ let resolvedType = w.type || (hasImage ? "image" : "single");
1519
+ return {
1520
+ name: w.name,
1521
+ displayName: w.displayName,
1522
+ description: w.description || "A widget for your app",
1523
+ type: resolvedType,
1524
+ deepLinkUrl: w.deepLinkUrl,
1525
+ showAppIcon: w.showAppIcon !== false,
1526
+ widgetFamilies: w.widgetFamilies || [
1527
+ "systemSmall",
1528
+ "systemMedium",
1529
+ "systemLarge",
1530
+ ],
1531
+ image: resolvedImage,
1532
+ icons: w.icons,
1533
+ gridLayout: w.gridLayout,
1534
+ style: w.style,
1535
+ fallbackTitle: w.fallbackTitle || w.displayName,
1536
+ fallbackSubtitle: w.fallbackSubtitle || "Open app to start",
1537
+ android: {
1538
+ minWidth: w.android?.minWidth || props.android?.minWidth || 110,
1539
+ minHeight: w.android?.minHeight || props.android?.minHeight || 40,
1540
+ resizeMode: w.android?.resizeMode ||
1541
+ props.android?.resizeMode ||
1542
+ "horizontal|vertical",
1543
+ },
1544
+ };
1545
+ });
1546
+ }
1547
+ else {
1548
+ // Legacy single-widget format → convert to widgets[]
1549
+ widgets = [
1550
+ {
1551
+ name: props.widgetName || "AppWidget",
1552
+ displayName: props.displayName || appName,
1553
+ description: props.description || "A widget for your app",
1554
+ type: "single",
1555
+ deepLinkUrl: props.ios?.deepLinkUrl || `${bundleIdentifier}://widget`,
1556
+ showAppIcon: props.ios?.showAppIcon !== false,
1557
+ widgetFamilies: props.ios?.widgetFamilies || [
1558
+ "systemSmall",
1559
+ "systemMedium",
1560
+ "systemLarge",
1561
+ ],
1562
+ fallbackTitle: props.displayName || appName,
1563
+ fallbackSubtitle: "Open app to start",
1564
+ android: {
1565
+ minWidth: props.android?.minWidth || 110,
1566
+ minHeight: props.android?.minHeight || 40,
1567
+ resizeMode: props.android?.resizeMode || "horizontal|vertical",
1568
+ },
1569
+ },
1570
+ ];
1571
+ }
1572
+ return {
1573
+ extensionName: "WidgetsExtension",
1574
+ widgets,
1575
+ ios: {
1576
+ deploymentTarget: props.ios?.deploymentTarget || "17.0",
1577
+ useAppGroups,
1578
+ appGroupIdentifier: useAppGroups
1579
+ ? props.ios?.appGroupIdentifier || `group.${bundleIdentifier}`
1580
+ : undefined,
1581
+ devTeam: props.ios?.devTeam,
1582
+ },
1583
+ android: {
1584
+ minSdkVersion: props.android?.minSdkVersion || 26,
1585
+ updatePeriodMillis: props.android?.updatePeriodMillis || 1800000,
1586
+ },
1587
+ };
1588
+ }
1589
+ exports.default = exports.withIosWidget;