voltra 2.2.0 → 2.3.1

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.
Files changed (138) hide show
  1. package/build/cjs/apply/index.js +6 -1
  2. package/build/cjs/apply/index.js.map +1 -1
  3. package/build/cjs/config/normalize.js +150 -31
  4. package/build/cjs/config/normalize.js.map +1 -1
  5. package/build/cjs/config/perConfiguration.js +29 -0
  6. package/build/cjs/config/perConfiguration.js.map +1 -0
  7. package/build/cjs/config/serverUpdate.js +119 -0
  8. package/build/cjs/config/serverUpdate.js.map +1 -0
  9. package/build/cjs/config/widgetKind.js +25 -0
  10. package/build/cjs/config/widgetKind.js.map +1 -0
  11. package/build/cjs/dependencies/platformPackages.js +9 -0
  12. package/build/cjs/dependencies/platformPackages.js.map +1 -1
  13. package/build/cjs/discovery/ios.js +50 -31
  14. package/build/cjs/discovery/ios.js.map +1 -1
  15. package/build/cjs/discovery/metro.js +52 -0
  16. package/build/cjs/discovery/metro.js.map +1 -0
  17. package/build/cjs/index.js +6 -2
  18. package/build/cjs/index.js.map +1 -1
  19. package/build/cjs/platforms/android/generated.js +98 -40
  20. package/build/cjs/platforms/android/generated.js.map +1 -1
  21. package/build/cjs/platforms/android/widgetSizing.js +89 -0
  22. package/build/cjs/platforms/android/widgetSizing.js.map +1 -0
  23. package/build/cjs/platforms/ios/apply.js +21 -11
  24. package/build/cjs/platforms/ios/apply.js.map +1 -1
  25. package/build/cjs/platforms/ios/buildConfigurationValues.js +73 -0
  26. package/build/cjs/platforms/ios/buildConfigurationValues.js.map +1 -0
  27. package/build/cjs/platforms/ios/entitlements.js +34 -19
  28. package/build/cjs/platforms/ios/entitlements.js.map +1 -1
  29. package/build/cjs/platforms/ios/generated.js +79 -32
  30. package/build/cjs/platforms/ios/generated.js.map +1 -1
  31. package/build/cjs/platforms/ios/plist.js +22 -4
  32. package/build/cjs/platforms/ios/plist.js.map +1 -1
  33. package/build/cjs/platforms/ios/targetName.js.map +1 -1
  34. package/build/cjs/platforms/ios/xcodeTarget.js +166 -23
  35. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  36. package/build/cjs/platforms/shared/widgetModule.js +18 -145
  37. package/build/cjs/platforms/shared/widgetModule.js.map +1 -1
  38. package/build/esm/apply/index.js +6 -1
  39. package/build/esm/apply/index.js.map +1 -1
  40. package/build/esm/config/normalize.js +150 -31
  41. package/build/esm/config/normalize.js.map +1 -1
  42. package/build/esm/config/perConfiguration.js +25 -0
  43. package/build/esm/config/perConfiguration.js.map +1 -0
  44. package/build/esm/config/serverUpdate.js +112 -0
  45. package/build/esm/config/serverUpdate.js.map +1 -0
  46. package/build/esm/config/widgetKind.js +20 -0
  47. package/build/esm/config/widgetKind.js.map +1 -0
  48. package/build/esm/dependencies/platformPackages.js +8 -0
  49. package/build/esm/dependencies/platformPackages.js.map +1 -1
  50. package/build/esm/discovery/ios.js +50 -31
  51. package/build/esm/discovery/ios.js.map +1 -1
  52. package/build/esm/discovery/metro.js +46 -0
  53. package/build/esm/discovery/metro.js.map +1 -0
  54. package/build/esm/index.js +1 -0
  55. package/build/esm/index.js.map +1 -1
  56. package/build/esm/platforms/android/generated.js +100 -42
  57. package/build/esm/platforms/android/generated.js.map +1 -1
  58. package/build/esm/platforms/android/widgetSizing.js +85 -0
  59. package/build/esm/platforms/android/widgetSizing.js.map +1 -0
  60. package/build/esm/platforms/ios/apply.js +21 -11
  61. package/build/esm/platforms/ios/apply.js.map +1 -1
  62. package/build/esm/platforms/ios/buildConfigurationValues.js +68 -0
  63. package/build/esm/platforms/ios/buildConfigurationValues.js.map +1 -0
  64. package/build/esm/platforms/ios/entitlements.js +34 -19
  65. package/build/esm/platforms/ios/entitlements.js.map +1 -1
  66. package/build/esm/platforms/ios/generated.js +80 -33
  67. package/build/esm/platforms/ios/generated.js.map +1 -1
  68. package/build/esm/platforms/ios/plist.js +22 -4
  69. package/build/esm/platforms/ios/plist.js.map +1 -1
  70. package/build/esm/platforms/ios/targetName.js.map +1 -1
  71. package/build/esm/platforms/ios/xcodeTarget.js +166 -23
  72. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  73. package/build/esm/platforms/shared/widgetModule.js +16 -107
  74. package/build/esm/platforms/shared/widgetModule.js.map +1 -1
  75. package/build/types/apply/index.d.ts.map +1 -1
  76. package/build/types/config/normalize.d.ts.map +1 -1
  77. package/build/types/config/perConfiguration.d.ts +18 -0
  78. package/build/types/config/perConfiguration.d.ts.map +1 -0
  79. package/build/types/config/serverUpdate.d.ts +80 -0
  80. package/build/types/config/serverUpdate.d.ts.map +1 -0
  81. package/build/types/config/types.d.ts +91 -30
  82. package/build/types/config/types.d.ts.map +1 -1
  83. package/build/types/config/widgetKind.d.ts +11 -0
  84. package/build/types/config/widgetKind.d.ts.map +1 -0
  85. package/build/types/dependencies/platformPackages.d.ts +1 -0
  86. package/build/types/dependencies/platformPackages.d.ts.map +1 -1
  87. package/build/types/discovery/ios.d.ts +10 -0
  88. package/build/types/discovery/ios.d.ts.map +1 -1
  89. package/build/types/discovery/metro.d.ts +21 -0
  90. package/build/types/discovery/metro.d.ts.map +1 -0
  91. package/build/types/index.d.ts +3 -1
  92. package/build/types/index.d.ts.map +1 -1
  93. package/build/types/platforms/android/generated.d.ts.map +1 -1
  94. package/build/types/platforms/android/widgetSizing.d.ts +50 -0
  95. package/build/types/platforms/android/widgetSizing.d.ts.map +1 -0
  96. package/build/types/platforms/ios/apply.d.ts.map +1 -1
  97. package/build/types/platforms/ios/buildConfigurationValues.d.ts +33 -0
  98. package/build/types/platforms/ios/buildConfigurationValues.d.ts.map +1 -0
  99. package/build/types/platforms/ios/entitlements.d.ts +4 -4
  100. package/build/types/platforms/ios/entitlements.d.ts.map +1 -1
  101. package/build/types/platforms/ios/generated.d.ts +14 -2
  102. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  103. package/build/types/platforms/ios/plist.d.ts +3 -3
  104. package/build/types/platforms/ios/plist.d.ts.map +1 -1
  105. package/build/types/platforms/ios/podfile.d.ts +2 -2
  106. package/build/types/platforms/ios/podfile.d.ts.map +1 -1
  107. package/build/types/platforms/ios/targetName.d.ts +2 -2
  108. package/build/types/platforms/ios/targetName.d.ts.map +1 -1
  109. package/build/types/platforms/ios/xcodeTarget.d.ts +4 -2
  110. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  111. package/build/types/platforms/shared/widgetModule.d.ts +12 -3
  112. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -1
  113. package/package.json +5 -5
  114. package/src/apply/index.ts +6 -1
  115. package/src/config/normalize.node.test.ts +105 -0
  116. package/src/config/normalize.ts +217 -45
  117. package/src/config/perConfiguration.ts +49 -0
  118. package/src/config/serverUpdate.ts +160 -0
  119. package/src/config/types.ts +97 -30
  120. package/src/config/widgetKind.node.test.ts +27 -0
  121. package/src/config/widgetKind.ts +27 -0
  122. package/src/dependencies/platformPackages.ts +11 -0
  123. package/src/discovery/ios.ts +104 -60
  124. package/src/discovery/metro.node.test.ts +91 -0
  125. package/src/discovery/metro.ts +54 -0
  126. package/src/index.ts +8 -0
  127. package/src/platforms/android/generated.ts +144 -47
  128. package/src/platforms/android/widgetSizing.ts +112 -0
  129. package/src/platforms/ios/apply.ts +21 -11
  130. package/src/platforms/ios/buildConfigurationValues.ts +105 -0
  131. package/src/platforms/ios/entitlements.ts +55 -31
  132. package/src/platforms/ios/generated.node.test.ts +61 -0
  133. package/src/platforms/ios/generated.ts +137 -38
  134. package/src/platforms/ios/plist.ts +36 -7
  135. package/src/platforms/ios/podfile.ts +2 -2
  136. package/src/platforms/ios/targetName.ts +2 -2
  137. package/src/platforms/ios/xcodeTarget.ts +248 -43
  138. package/src/platforms/shared/widgetModule.ts +22 -140
@@ -2,6 +2,11 @@ import path from 'node:path'
2
2
 
3
3
  import { resolveFromRoot } from '../fs/path'
4
4
  import { CLI_DEFAULTS } from './defaults'
5
+ import { isPerConfigurationMap } from './perConfiguration'
6
+ import { resolveServerUpdateInterval, resolveServerUpdateUrl, validateServerUpdateRefresh } from './serverUpdate'
7
+ import { iosWidgetKind } from './widgetKind'
8
+
9
+ import type { PerConfiguration } from './perConfiguration'
5
10
 
6
11
  import type {
7
12
  AndroidWidgetAppIntentConfig,
@@ -10,6 +15,7 @@ import type {
10
15
  IOSWidgetConfig,
11
16
  LoadedVoltraConfig,
12
17
  NormalizedAndroidWidgetConfig,
18
+ NormalizedWidgetServerUpdateConfig,
13
19
  NormalizedVoltraAndroidConfig,
14
20
  NormalizedVoltraConfig,
15
21
  NormalizedVoltraIOSConfig,
@@ -53,6 +59,33 @@ function assertOptionalString(value: unknown, context: string): asserts value is
53
59
  }
54
60
  }
55
61
 
62
+ function assertOptionalPerConfigurationString(
63
+ value: unknown,
64
+ context: string
65
+ ): asserts value is PerConfiguration<string> | undefined {
66
+ if (value === undefined || typeof value === 'string') {
67
+ return
68
+ }
69
+
70
+ if (!isPerConfigurationMap(value as PerConfiguration<unknown>)) {
71
+ throw new VoltraConfigNormalizationError(
72
+ `${context} must be a string, or an object of strings keyed by build configuration name`
73
+ )
74
+ }
75
+
76
+ const entries = Object.entries(value as Record<string, unknown>)
77
+
78
+ if (entries.length === 0) {
79
+ throw new VoltraConfigNormalizationError(`${context} must not be an empty object`)
80
+ }
81
+
82
+ for (const [buildConfigurationName, configurationValue] of entries) {
83
+ if (typeof configurationValue !== 'string' || !configurationValue.trim()) {
84
+ throw new VoltraConfigNormalizationError(`${context}.${buildConfigurationName} must be a non-empty string`)
85
+ }
86
+ }
87
+ }
88
+
56
89
  function assertOptionalBoolean(value: unknown, context: string): asserts value is boolean | undefined {
57
90
  if (value !== undefined && typeof value !== 'boolean') {
58
91
  throw new VoltraConfigNormalizationError(`${context} must be a boolean`)
@@ -105,6 +138,26 @@ function resolveOptionalPathFromProjectRoot(projectRoot: string, filePath: strin
105
138
  return resolvePathFromProjectRoot(projectRoot, filePath)
106
139
  }
107
140
 
141
+ function resolveOptionalPerConfigurationPath(
142
+ projectRoot: string,
143
+ filePath: PerConfiguration<string> | undefined
144
+ ): PerConfiguration<string> | undefined {
145
+ if (!filePath) {
146
+ return undefined
147
+ }
148
+
149
+ if (!isPerConfigurationMap(filePath)) {
150
+ return resolvePathFromProjectRoot(projectRoot, filePath)
151
+ }
152
+
153
+ return Object.fromEntries(
154
+ Object.entries(filePath).map(([buildConfigurationName, configurationPath]) => [
155
+ buildConfigurationName,
156
+ resolvePathFromProjectRoot(projectRoot, configurationPath),
157
+ ])
158
+ )
159
+ }
160
+
108
161
  function isAbsoluteWidgetPath(value: string): boolean {
109
162
  return path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)
110
163
  }
@@ -294,42 +347,90 @@ function normalizeIOSAppIntent(
294
347
  return { parameters }
295
348
  }
296
349
 
297
- function normalizeServerUpdate(
298
- serverUpdate: { url: string; intervalMinutes?: number; refresh?: boolean },
350
+ /**
351
+ * `appIntent` only reaches the generated project through the `clientRendered` branches, so on a
352
+ * widget with no `entry` every parameter is dropped: no configuration defaults are emitted, the
353
+ * receiver stays payload-driven, and `getWidgetConfiguration` rejects it at runtime. Generate time
354
+ * is the only place that can still say so.
355
+ */
356
+ function warnOnAppIntentWithoutEntry(
357
+ appIntent: { parameters: unknown[] } | undefined,
358
+ hasEntry: boolean,
299
359
  context: string,
300
- defaultIntervalMinutes: number,
301
- defaultRefresh: boolean,
360
+ warnings: string[]
361
+ ): void {
362
+ if (hasEntry || !appIntent || appIntent.parameters.length === 0) {
363
+ return
364
+ }
365
+
366
+ warnings.push(
367
+ `${context}.appIntent is ignored because the widget has no 'entry'. Configuration parameters ` +
368
+ `only apply to Dynamic Widgets. Add an 'entry' to make this one, or drop 'appIntent'.`
369
+ )
370
+ }
371
+
372
+ interface NormalizeServerUpdateOptions {
373
+ context: string
374
+ /** True when the widget has an `entry`, so the response is props rather than a payload. */
375
+ hasEntry: boolean
376
+ defaultIntervalMinutes: number
377
+ defaultRefresh: boolean
302
378
  minimumIntervalMinutes: number
303
- ): { url: string; intervalMinutes: number; refresh: boolean } {
379
+ warnings: string[]
380
+ }
381
+
382
+ function normalizeServerUpdate(
383
+ serverUpdate: { url?: string; intervalMinutes?: number; refresh?: boolean },
384
+ options: NormalizeServerUpdateOptions
385
+ ): NormalizedWidgetServerUpdateConfig {
386
+ const { context, hasEntry, defaultIntervalMinutes, defaultRefresh, minimumIntervalMinutes, warnings } = options
387
+
304
388
  assertObject(serverUpdate, context)
305
- assertNonEmptyString(serverUpdate.url, `${context}.url`)
306
389
 
307
- if (serverUpdate.intervalMinutes !== undefined) {
308
- if (typeof serverUpdate.intervalMinutes !== 'number' || !Number.isFinite(serverUpdate.intervalMinutes)) {
309
- throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be a number`)
310
- }
390
+ const url = resolveServerUpdateUrl(serverUpdate.url, context)
311
391
 
312
- if (!Number.isInteger(serverUpdate.intervalMinutes)) {
313
- throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be an integer`)
314
- }
392
+ if (url.kind === 'invalid') {
393
+ throw new VoltraConfigNormalizationError(url.error)
394
+ }
315
395
 
316
- if (serverUpdate.intervalMinutes < minimumIntervalMinutes) {
317
- throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be at least ${minimumIntervalMinutes}`)
318
- }
396
+ if (url.kind === 'insecure') {
397
+ warnings.push(url.warning)
398
+ }
399
+
400
+ const interval = resolveServerUpdateInterval({
401
+ intervalMinutes: serverUpdate.intervalMinutes,
402
+ context,
403
+ hasEntry,
404
+ defaultIntervalMinutes,
405
+ minimumIntervalMinutes,
406
+ })
407
+
408
+ if (interval.kind === 'invalid') {
409
+ throw new VoltraConfigNormalizationError(interval.error)
319
410
  }
320
411
 
321
- if (serverUpdate.refresh !== undefined && typeof serverUpdate.refresh !== 'boolean') {
322
- throw new VoltraConfigNormalizationError(`${context}.refresh must be a boolean`)
412
+ if (interval.kind === 'clamped') {
413
+ warnings.push(interval.warning)
414
+ }
415
+
416
+ const refreshError = validateServerUpdateRefresh(serverUpdate.refresh, context)
417
+
418
+ if (refreshError) {
419
+ throw new VoltraConfigNormalizationError(refreshError)
323
420
  }
324
421
 
325
422
  return {
326
423
  url: serverUpdate.url,
327
- intervalMinutes: serverUpdate.intervalMinutes ?? defaultIntervalMinutes,
424
+ intervalMinutes: interval.intervalMinutes,
328
425
  refresh: serverUpdate.refresh ?? defaultRefresh,
329
426
  }
330
427
  }
331
428
 
332
- function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig): NormalizedAndroidWidgetConfig {
429
+ function normalizeAndroidWidget(
430
+ projectRoot: string,
431
+ widget: AndroidWidgetConfig,
432
+ warnings: string[]
433
+ ): NormalizedAndroidWidgetConfig {
333
434
  assertObject(widget, 'android.widgets[]')
334
435
  assertNonEmptyString(widget.id, 'android.widgets[].id')
335
436
  assertValidWidgetId(widget.id, 'android.widgets[].id')
@@ -337,6 +438,15 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig
337
438
  assertPositiveInteger(widget.targetCellHeight, `android.widgets[${widget.id}].targetCellHeight`)
338
439
  assertOptionalPositiveInteger(widget.minCellWidth, `android.widgets[${widget.id}].minCellWidth`)
339
440
  assertOptionalPositiveInteger(widget.minCellHeight, `android.widgets[${widget.id}].minCellHeight`)
441
+ assertOptionalPositiveInteger(widget.minWidth, `android.widgets[${widget.id}].minWidth`)
442
+ assertOptionalPositiveInteger(widget.minHeight, `android.widgets[${widget.id}].minHeight`)
443
+ assertOptionalPositiveInteger(widget.minResizeWidth, `android.widgets[${widget.id}].minResizeWidth`)
444
+ assertOptionalPositiveInteger(widget.minResizeHeight, `android.widgets[${widget.id}].minResizeHeight`)
445
+ assertOptionalPositiveInteger(widget.maxResizeWidth, `android.widgets[${widget.id}].maxResizeWidth`)
446
+ assertOptionalPositiveInteger(widget.maxResizeHeight, `android.widgets[${widget.id}].maxResizeHeight`)
447
+
448
+ const androidAppIntent = normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`)
449
+ warnOnAppIntentWithoutEntry(androidAppIntent, widget.entry !== undefined, `android.widgets[${widget.id}]`, warnings)
340
450
 
341
451
  return {
342
452
  ...widget,
@@ -350,24 +460,33 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig
350
460
  ),
351
461
  previewImage: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewImage),
352
462
  previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout),
353
- appIntent: normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`),
463
+ appIntent: androidAppIntent,
354
464
  serverUpdate: widget.serverUpdate
355
- ? normalizeServerUpdate(
356
- widget.serverUpdate,
357
- `android.widgets[${widget.id}].serverUpdate`,
358
- CLI_DEFAULTS.android.serverUpdateIntervalMinutes,
359
- CLI_DEFAULTS.android.serverUpdateRefresh,
360
- 15
361
- )
465
+ ? normalizeServerUpdate(widget.serverUpdate, {
466
+ context: `android.widgets[${widget.id}].serverUpdate`,
467
+ hasEntry: widget.entry !== undefined,
468
+ defaultIntervalMinutes: CLI_DEFAULTS.android.serverUpdateIntervalMinutes,
469
+ defaultRefresh: CLI_DEFAULTS.android.serverUpdateRefresh,
470
+ minimumIntervalMinutes: 15,
471
+ warnings,
472
+ })
362
473
  : undefined,
363
474
  }
364
475
  }
365
476
 
366
- function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): NormalizedIOSWidgetConfig {
477
+ function normalizeIOSWidget(
478
+ projectRoot: string,
479
+ widget: IOSWidgetConfig,
480
+ warnings: string[]
481
+ ): NormalizedIOSWidgetConfig {
367
482
  assertObject(widget, 'ios.widgets[]')
368
483
  assertNonEmptyString(widget.id, 'ios.widgets[].id')
369
484
  assertValidWidgetId(widget.id, 'ios.widgets[].id')
370
485
 
486
+ if (widget.kind !== undefined) {
487
+ assertNonEmptyString(widget.kind, `ios.widgets[${widget.id}].kind`)
488
+ }
489
+
371
490
  if (widget.supportedFamilies !== undefined) {
372
491
  if (!Array.isArray(widget.supportedFamilies)) {
373
492
  throw new VoltraConfigNormalizationError(`ios.widgets[${widget.id}].supportedFamilies must be an array`)
@@ -382,6 +501,9 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma
382
501
  }
383
502
  }
384
503
 
504
+ const iosAppIntent = normalizeIOSAppIntent(widget.appIntent, `ios.widgets[${widget.id}].appIntent`)
505
+ warnOnAppIntentWithoutEntry(iosAppIntent, widget.entry !== undefined, `ios.widgets[${widget.id}]`, warnings)
506
+
385
507
  return {
386
508
  ...widget,
387
509
  displayName: normalizeLabel(widget.displayName, `ios.widgets[${widget.id}].displayName`),
@@ -393,15 +515,16 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma
393
515
  widget.initialStatePath,
394
516
  `ios.widgets[${widget.id}].initialStatePath`
395
517
  ),
396
- appIntent: normalizeIOSAppIntent(widget.appIntent, `ios.widgets[${widget.id}].appIntent`),
518
+ appIntent: iosAppIntent,
397
519
  serverUpdate: widget.serverUpdate
398
- ? normalizeServerUpdate(
399
- widget.serverUpdate,
400
- `ios.widgets[${widget.id}].serverUpdate`,
401
- CLI_DEFAULTS.ios.serverUpdateIntervalMinutes,
402
- CLI_DEFAULTS.ios.serverUpdateRefresh,
403
- 1
404
- )
520
+ ? normalizeServerUpdate(widget.serverUpdate, {
521
+ context: `ios.widgets[${widget.id}].serverUpdate`,
522
+ hasEntry: widget.entry !== undefined,
523
+ defaultIntervalMinutes: CLI_DEFAULTS.ios.serverUpdateIntervalMinutes,
524
+ defaultRefresh: CLI_DEFAULTS.ios.serverUpdateRefresh,
525
+ minimumIntervalMinutes: 1,
526
+ warnings,
527
+ })
405
528
  : undefined,
406
529
  }
407
530
  }
@@ -418,6 +541,25 @@ function assertUniqueWidgetIds(widgetIds: string[], context: string): void {
418
541
  }
419
542
  }
420
543
 
544
+ /**
545
+ * WidgetKit identifies a placed widget by extension bundle id + kind, so two widgets sharing a kind
546
+ * would fight over the same Home Screen instances. Compared after defaulting, which also catches a
547
+ * custom kind that collides with another widget's `Voltra_Widget_<id>`.
548
+ */
549
+ function assertUniqueIOSWidgetKinds(widgets: Pick<IOSWidgetConfig, 'id' | 'kind'>[]): void {
550
+ const seen = new Set<string>()
551
+
552
+ for (const widget of widgets) {
553
+ const kind = iosWidgetKind(widget)
554
+
555
+ if (seen.has(kind)) {
556
+ throw new VoltraConfigNormalizationError(`Duplicate ios widget kind '${kind}'`)
557
+ }
558
+
559
+ seen.add(kind)
560
+ }
561
+ }
562
+
421
563
  function assertValidWidgetId(widgetId: string, context: string): void {
422
564
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widgetId)) {
423
565
  throw new VoltraConfigNormalizationError(
@@ -435,6 +577,7 @@ function assertValidIOSTargetName(targetName: string, context: string): void {
435
577
  }
436
578
 
437
579
  function normalizeAndroidConfig(
580
+ warnings: string[],
438
581
  projectRoot: string,
439
582
  config: LoadedVoltraConfig['config']['android']
440
583
  ): NormalizedVoltraAndroidConfig | undefined {
@@ -459,7 +602,7 @@ function normalizeAndroidConfig(
459
602
  throw new VoltraConfigNormalizationError('android.widgets must be an array')
460
603
  }
461
604
 
462
- const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget))
605
+ const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget, warnings))
463
606
  assertUniqueWidgetIds(
464
607
  widgets.map((widget) => widget.id),
465
608
  'android'
@@ -483,6 +626,7 @@ function normalizeAndroidConfig(
483
626
  }
484
627
 
485
628
  function normalizeIOSConfig(
629
+ warnings: string[],
486
630
  projectRoot: string,
487
631
  config: LoadedVoltraConfig['config']['ios']
488
632
  ): NormalizedVoltraIOSConfig | undefined {
@@ -492,12 +636,12 @@ function normalizeIOSConfig(
492
636
 
493
637
  assertObject(config, 'ios')
494
638
  assertOptionalBoolean(config.enablePushNotifications, 'ios.enablePushNotifications')
495
- assertOptionalString(config.groupIdentifier, 'ios.groupIdentifier')
639
+ assertOptionalPerConfigurationString(config.groupIdentifier, 'ios.groupIdentifier')
496
640
  assertOptionalString(config.deploymentTarget, 'ios.deploymentTarget')
497
641
  assertOptionalString(config.targetName, 'ios.targetName')
498
642
  assertOptionalStringArray(config.fonts, 'ios.fonts')
499
643
  assertOptionalString(config.userImagesPath, 'ios.userImagesPath')
500
- assertOptionalString(config.keychainGroup, 'ios.keychainGroup')
644
+ assertOptionalPerConfigurationString(config.keychainGroup, 'ios.keychainGroup')
501
645
 
502
646
  if (config.project !== undefined) {
503
647
  assertObject(config.project, 'ios.project')
@@ -505,7 +649,7 @@ function normalizeIOSConfig(
505
649
  assertOptionalString(config.project.xcodeprojPath, 'ios.project.xcodeprojPath')
506
650
  assertOptionalString(config.project.mainTargetName, 'ios.project.mainTargetName')
507
651
  assertOptionalString(config.project.infoPlistPath, 'ios.project.infoPlistPath')
508
- assertOptionalString(config.project.entitlementsPath, 'ios.project.entitlementsPath')
652
+ assertOptionalPerConfigurationString(config.project.entitlementsPath, 'ios.project.entitlementsPath')
509
653
  assertOptionalString(config.project.podfilePath, 'ios.project.podfilePath')
510
654
  }
511
655
 
@@ -517,11 +661,12 @@ function normalizeIOSConfig(
517
661
  assertValidIOSTargetName(config.targetName, 'ios.targetName')
518
662
  }
519
663
 
520
- const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget))
664
+ const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget, warnings))
521
665
  assertUniqueWidgetIds(
522
666
  widgets.map((widget) => widget.id),
523
667
  'ios'
524
668
  )
669
+ assertUniqueIOSWidgetKinds(widgets)
525
670
 
526
671
  return {
527
672
  enablePushNotifications: config.enablePushNotifications ?? CLI_DEFAULTS.ios.enablePushNotifications,
@@ -537,7 +682,7 @@ function normalizeIOSConfig(
537
682
  xcodeprojPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.xcodeprojPath),
538
683
  mainTargetName: config.project?.mainTargetName,
539
684
  infoPlistPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.infoPlistPath),
540
- entitlementsPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.entitlementsPath),
685
+ entitlementsPath: resolveOptionalPerConfigurationPath(projectRoot, config.project?.entitlementsPath),
541
686
  podfilePath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.podfilePath),
542
687
  },
543
688
  }
@@ -552,11 +697,38 @@ export function normalizeVoltraConfig(loadedConfig: LoadedVoltraConfig): Normali
552
697
  loadedConfig.config.projectRoot ?? loadedConfig.configDir
553
698
  )
554
699
 
700
+ const warnings: string[] = []
701
+ const android = normalizeAndroidConfig(warnings, projectRoot, loadedConfig.config.android)
702
+ const ios = normalizeIOSConfig(warnings, projectRoot, loadedConfig.config.ios)
703
+
704
+ assertServerDrivenDynamicWidgetsAreSupported(ios)
705
+
555
706
  return {
556
707
  configPath: loadedConfig.configPath,
557
708
  configDir: loadedConfig.configDir,
558
709
  projectRoot,
559
- android: normalizeAndroidConfig(projectRoot, loadedConfig.config.android),
560
- ios: normalizeIOSConfig(projectRoot, loadedConfig.config.ios),
710
+ android,
711
+ ios,
712
+ warnings,
713
+ }
714
+ }
715
+
716
+ /**
717
+ * A Dynamic Widget commits fetched props to the App Group so the widget extension can read them,
718
+ * so a server-driven one without a `groupIdentifier` would fetch and have nowhere to put the
719
+ * result.
720
+ */
721
+ function assertServerDrivenDynamicWidgetsAreSupported(ios: NormalizedVoltraIOSConfig | undefined): void {
722
+ if (ios === undefined || ios.groupIdentifier !== undefined) {
723
+ return
724
+ }
725
+
726
+ for (const widget of ios.widgets) {
727
+ if (widget.entry !== undefined && widget.serverUpdate !== undefined) {
728
+ throw new VoltraConfigNormalizationError(
729
+ `ios.widgets[${widget.id}] has both entry and serverUpdate, which requires ios.groupIdentifier ` +
730
+ 'so fetched props can be shared with the widget extension.'
731
+ )
732
+ }
561
733
  }
562
734
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A value that is either shared by every Xcode build configuration, or given once per build
3
+ * configuration name for apps that ship several environments from the same project.
4
+ *
5
+ * ```ts
6
+ * groupIdentifier: 'group.com.example.app'
7
+ * groupIdentifier: { Debug: 'group.com.example.app.dev', Release: 'group.com.example.app' }
8
+ * ```
9
+ */
10
+ export type PerConfiguration<TValue> = TValue | Record<string, TValue>
11
+
12
+ export function isPerConfigurationMap<TValue>(
13
+ value: PerConfiguration<TValue> | undefined
14
+ ): value is Record<string, TValue> {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
16
+ }
17
+
18
+ /**
19
+ * Pairs a per-build-configuration value with the project's real build configurations, in the
20
+ * project's own order. Keys that name no build configuration, and build configurations the value
21
+ * does not cover, are reported before anything is written to the project.
22
+ */
23
+ export function getValuesByBuildConfiguration<TValue>(
24
+ value: Record<string, TValue>,
25
+ buildConfigurationNames: string[],
26
+ context: string,
27
+ createError: (message: string) => Error
28
+ ): Map<string, TValue> {
29
+ const knownNames = `This project has: ${buildConfigurationNames.join(', ')}.`
30
+ const unknownNames = Object.keys(value).filter((name) => !buildConfigurationNames.includes(name))
31
+
32
+ if (unknownNames.length > 0) {
33
+ throw createError(`${context} names unknown build configurations: ${unknownNames.join(', ')}. ${knownNames}`)
34
+ }
35
+
36
+ const valuesByBuildConfiguration = new Map<string, TValue>()
37
+
38
+ for (const buildConfigurationName of buildConfigurationNames) {
39
+ const configurationValue = value[buildConfigurationName]
40
+
41
+ if (configurationValue === undefined) {
42
+ throw createError(`${context} has no value for build configuration '${buildConfigurationName}'. ${knownNames}`)
43
+ }
44
+
45
+ valuesByBuildConfiguration.set(buildConfigurationName, configurationValue)
46
+ }
47
+
48
+ return valuesByBuildConfiguration
49
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Config rules for `serverUpdate`, shared by the Expo plugins and the `voltra` CLI.
3
+ *
4
+ * `serverUpdate` marks a widget as server-driven for both render engines. On a widget with
5
+ * `entry` the device fetches a JSON object and hands it to the bundled JS as props; without
6
+ * `entry` the server returns a full Voltra payload. Widgets without `entry` keep exactly the
7
+ * rules they had before ADR 0002, so no existing config breaks.
8
+ *
9
+ * `url` is optional. `serverUpdate: {}` means "server-driven, URL supplied at runtime" through
10
+ * `setWidgetServerUpdate`, which covers per-tenant backends whose URL is only known after login.
11
+ *
12
+ * These helpers report problems instead of throwing so each caller can raise its own error type.
13
+ *
14
+ * The `@use-voltra/expo-plugin` package holds the other copy of this module, shared by the
15
+ * Expo config plugins; keep the two in sync.
16
+ */
17
+
18
+ /**
19
+ * Interval floor and default for a widget with `entry`. WorkManager cannot run periodic work
20
+ * more often than every 15 minutes, and WidgetKit stretches timelines requested closer together
21
+ * than five minutes, so a smaller number would only mislead.
22
+ */
23
+ export const DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES = 15
24
+
25
+ /** Hosts allowed over plain `http`, for talking to a dev server from a simulator or emulator. */
26
+ const LOCAL_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '10.0.2.2', '10.0.3.2'])
27
+
28
+ /** Outcome of resolving `serverUpdate.intervalMinutes` against a platform's floor. */
29
+ export type ServerUpdateIntervalResolution =
30
+ | { kind: 'ok'; intervalMinutes: number }
31
+ | { kind: 'clamped'; intervalMinutes: number; warning: string }
32
+ | { kind: 'invalid'; error: string }
33
+
34
+ export interface ResolveServerUpdateIntervalOptions {
35
+ /** Raw `intervalMinutes` from app.json, if the widget set one. */
36
+ intervalMinutes: unknown
37
+ /** Config path used in messages, e.g. `android.widgets[portfolio].serverUpdate`. */
38
+ context: string
39
+ /** True when the widget has an `entry` and therefore renders on device. */
40
+ hasEntry: boolean
41
+ /** Interval used when the widget does not set one. Ignored for widgets with `entry`. */
42
+ defaultIntervalMinutes: number
43
+ /** Platform floor for payload widgets. Ignored for widgets with `entry`. */
44
+ minimumIntervalMinutes: number
45
+ }
46
+
47
+ /**
48
+ * Resolves the interval a widget should be scheduled on.
49
+ *
50
+ * A widget with `entry` is clamped up to {@link DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}
51
+ * with a warning, because a shorter interval is not something either platform can honour. A
52
+ * payload widget keeps the platform's existing rule and is rejected below the floor.
53
+ */
54
+ export function resolveServerUpdateInterval(
55
+ options: ResolveServerUpdateIntervalOptions
56
+ ): ServerUpdateIntervalResolution {
57
+ const { intervalMinutes, context, hasEntry, defaultIntervalMinutes, minimumIntervalMinutes } = options
58
+ const fallback = hasEntry ? DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES : defaultIntervalMinutes
59
+
60
+ if (intervalMinutes === undefined) {
61
+ return { kind: 'ok', intervalMinutes: fallback }
62
+ }
63
+
64
+ if (typeof intervalMinutes !== 'number' || !Number.isFinite(intervalMinutes)) {
65
+ return { kind: 'invalid', error: `${context}.intervalMinutes must be a number` }
66
+ }
67
+
68
+ if (!Number.isInteger(intervalMinutes)) {
69
+ return { kind: 'invalid', error: `${context}.intervalMinutes must be an integer` }
70
+ }
71
+
72
+ if (hasEntry) {
73
+ if (intervalMinutes < DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES) {
74
+ return {
75
+ kind: 'clamped',
76
+ intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES,
77
+ warning:
78
+ `${context}.intervalMinutes is ${intervalMinutes}, below the ` +
79
+ `${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} minute floor for widgets with an entry. ` +
80
+ `Using ${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}.`,
81
+ }
82
+ }
83
+
84
+ return { kind: 'ok', intervalMinutes }
85
+ }
86
+
87
+ if (intervalMinutes < minimumIntervalMinutes) {
88
+ return { kind: 'invalid', error: `${context}.intervalMinutes must be at least ${minimumIntervalMinutes}` }
89
+ }
90
+
91
+ return { kind: 'ok', intervalMinutes }
92
+ }
93
+
94
+ /** Outcome of checking `serverUpdate.url`. */
95
+ export type ServerUpdateUrlResolution =
96
+ | { kind: 'ok' }
97
+ | { kind: 'insecure'; warning: string }
98
+ | { kind: 'invalid'; error: string }
99
+
100
+ /**
101
+ * Checks `serverUpdate.url`. An absent URL is fine — the app supplies it at runtime through
102
+ * `setWidgetServerUpdate`.
103
+ *
104
+ * A URL that is not an absolute `http`/`https` URL is rejected: no platform HTTP stack here
105
+ * accepts one, so such a config could only ever have failed to fetch. Plain `http` to a
106
+ * non-local host is reported as insecure rather than rejected — App Transport Security and
107
+ * Android's cleartext policy already block it in a release build, and rejecting it outright
108
+ * would break configs that point at a LAN dev server.
109
+ */
110
+ export function resolveServerUpdateUrl(url: unknown, context: string): ServerUpdateUrlResolution {
111
+ if (url === undefined) {
112
+ return { kind: 'ok' }
113
+ }
114
+
115
+ if (typeof url !== 'string' || !url.trim()) {
116
+ return { kind: 'invalid', error: `${context}.url must be a non-empty string` }
117
+ }
118
+
119
+ let parsed: URL
120
+
121
+ try {
122
+ parsed = new URL(url)
123
+ } catch {
124
+ return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` }
125
+ }
126
+
127
+ if (parsed.protocol === 'https:') {
128
+ return { kind: 'ok' }
129
+ }
130
+
131
+ if (parsed.protocol !== 'http:') {
132
+ return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` }
133
+ }
134
+
135
+ if (isLocalHttpHost(parsed.hostname)) {
136
+ return { kind: 'ok' }
137
+ }
138
+
139
+ return {
140
+ kind: 'insecure',
141
+ warning:
142
+ `${context}.url uses plain http ('${url}'). Release builds block cleartext traffic, so the ` +
143
+ 'widget will not fetch outside a development build. Use https, or a local dev host ' +
144
+ `(${[...LOCAL_HTTP_HOSTS].join(', ')}).`,
145
+ }
146
+ }
147
+
148
+ /** True for the hosts Voltra allows over plain `http` — dev servers reachable from a simulator. */
149
+ export function isLocalHttpHost(hostname: string): boolean {
150
+ return LOCAL_HTTP_HOSTS.has(hostname.replace(/^\[|\]$/g, ''))
151
+ }
152
+
153
+ /** Validates `serverUpdate.refresh`. Returns an error message, or `undefined` when it is fine. */
154
+ export function validateServerUpdateRefresh(refresh: unknown, context: string): string | undefined {
155
+ if (refresh !== undefined && typeof refresh !== 'boolean') {
156
+ return `${context}.refresh must be a boolean`
157
+ }
158
+
159
+ return undefined
160
+ }