voltra 2.2.0 → 2.3.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.
Files changed (130) hide show
  1. package/build/cjs/apply/index.js +4 -1
  2. package/build/cjs/apply/index.js.map +1 -1
  3. package/build/cjs/config/normalize.js +131 -29
  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/index.js +6 -2
  16. package/build/cjs/index.js.map +1 -1
  17. package/build/cjs/platforms/android/generated.js +98 -40
  18. package/build/cjs/platforms/android/generated.js.map +1 -1
  19. package/build/cjs/platforms/android/widgetSizing.js +89 -0
  20. package/build/cjs/platforms/android/widgetSizing.js.map +1 -0
  21. package/build/cjs/platforms/ios/apply.js +21 -11
  22. package/build/cjs/platforms/ios/apply.js.map +1 -1
  23. package/build/cjs/platforms/ios/buildConfigurationValues.js +73 -0
  24. package/build/cjs/platforms/ios/buildConfigurationValues.js.map +1 -0
  25. package/build/cjs/platforms/ios/entitlements.js +34 -19
  26. package/build/cjs/platforms/ios/entitlements.js.map +1 -1
  27. package/build/cjs/platforms/ios/generated.js +79 -32
  28. package/build/cjs/platforms/ios/generated.js.map +1 -1
  29. package/build/cjs/platforms/ios/plist.js +22 -4
  30. package/build/cjs/platforms/ios/plist.js.map +1 -1
  31. package/build/cjs/platforms/ios/targetName.js.map +1 -1
  32. package/build/cjs/platforms/ios/xcodeTarget.js +166 -23
  33. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  34. package/build/cjs/platforms/shared/widgetModule.js +18 -145
  35. package/build/cjs/platforms/shared/widgetModule.js.map +1 -1
  36. package/build/esm/apply/index.js +4 -1
  37. package/build/esm/apply/index.js.map +1 -1
  38. package/build/esm/config/normalize.js +131 -29
  39. package/build/esm/config/normalize.js.map +1 -1
  40. package/build/esm/config/perConfiguration.js +25 -0
  41. package/build/esm/config/perConfiguration.js.map +1 -0
  42. package/build/esm/config/serverUpdate.js +112 -0
  43. package/build/esm/config/serverUpdate.js.map +1 -0
  44. package/build/esm/config/widgetKind.js +20 -0
  45. package/build/esm/config/widgetKind.js.map +1 -0
  46. package/build/esm/dependencies/platformPackages.js +8 -0
  47. package/build/esm/dependencies/platformPackages.js.map +1 -1
  48. package/build/esm/discovery/ios.js +50 -31
  49. package/build/esm/discovery/ios.js.map +1 -1
  50. package/build/esm/index.js +1 -0
  51. package/build/esm/index.js.map +1 -1
  52. package/build/esm/platforms/android/generated.js +100 -42
  53. package/build/esm/platforms/android/generated.js.map +1 -1
  54. package/build/esm/platforms/android/widgetSizing.js +85 -0
  55. package/build/esm/platforms/android/widgetSizing.js.map +1 -0
  56. package/build/esm/platforms/ios/apply.js +21 -11
  57. package/build/esm/platforms/ios/apply.js.map +1 -1
  58. package/build/esm/platforms/ios/buildConfigurationValues.js +68 -0
  59. package/build/esm/platforms/ios/buildConfigurationValues.js.map +1 -0
  60. package/build/esm/platforms/ios/entitlements.js +34 -19
  61. package/build/esm/platforms/ios/entitlements.js.map +1 -1
  62. package/build/esm/platforms/ios/generated.js +80 -33
  63. package/build/esm/platforms/ios/generated.js.map +1 -1
  64. package/build/esm/platforms/ios/plist.js +22 -4
  65. package/build/esm/platforms/ios/plist.js.map +1 -1
  66. package/build/esm/platforms/ios/targetName.js.map +1 -1
  67. package/build/esm/platforms/ios/xcodeTarget.js +166 -23
  68. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  69. package/build/esm/platforms/shared/widgetModule.js +16 -107
  70. package/build/esm/platforms/shared/widgetModule.js.map +1 -1
  71. package/build/types/apply/index.d.ts.map +1 -1
  72. package/build/types/config/normalize.d.ts.map +1 -1
  73. package/build/types/config/perConfiguration.d.ts +18 -0
  74. package/build/types/config/perConfiguration.d.ts.map +1 -0
  75. package/build/types/config/serverUpdate.d.ts +80 -0
  76. package/build/types/config/serverUpdate.d.ts.map +1 -0
  77. package/build/types/config/types.d.ts +91 -30
  78. package/build/types/config/types.d.ts.map +1 -1
  79. package/build/types/config/widgetKind.d.ts +11 -0
  80. package/build/types/config/widgetKind.d.ts.map +1 -0
  81. package/build/types/dependencies/platformPackages.d.ts +1 -0
  82. package/build/types/dependencies/platformPackages.d.ts.map +1 -1
  83. package/build/types/discovery/ios.d.ts +10 -0
  84. package/build/types/discovery/ios.d.ts.map +1 -1
  85. package/build/types/index.d.ts +3 -1
  86. package/build/types/index.d.ts.map +1 -1
  87. package/build/types/platforms/android/generated.d.ts.map +1 -1
  88. package/build/types/platforms/android/widgetSizing.d.ts +50 -0
  89. package/build/types/platforms/android/widgetSizing.d.ts.map +1 -0
  90. package/build/types/platforms/ios/apply.d.ts.map +1 -1
  91. package/build/types/platforms/ios/buildConfigurationValues.d.ts +33 -0
  92. package/build/types/platforms/ios/buildConfigurationValues.d.ts.map +1 -0
  93. package/build/types/platforms/ios/entitlements.d.ts +4 -4
  94. package/build/types/platforms/ios/entitlements.d.ts.map +1 -1
  95. package/build/types/platforms/ios/generated.d.ts +14 -2
  96. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  97. package/build/types/platforms/ios/plist.d.ts +3 -3
  98. package/build/types/platforms/ios/plist.d.ts.map +1 -1
  99. package/build/types/platforms/ios/podfile.d.ts +2 -2
  100. package/build/types/platforms/ios/podfile.d.ts.map +1 -1
  101. package/build/types/platforms/ios/targetName.d.ts +2 -2
  102. package/build/types/platforms/ios/targetName.d.ts.map +1 -1
  103. package/build/types/platforms/ios/xcodeTarget.d.ts +4 -2
  104. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  105. package/build/types/platforms/shared/widgetModule.d.ts +12 -3
  106. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -1
  107. package/package.json +5 -5
  108. package/src/apply/index.ts +4 -1
  109. package/src/config/normalize.node.test.ts +61 -0
  110. package/src/config/normalize.ts +188 -44
  111. package/src/config/perConfiguration.ts +49 -0
  112. package/src/config/serverUpdate.ts +160 -0
  113. package/src/config/types.ts +97 -30
  114. package/src/config/widgetKind.node.test.ts +27 -0
  115. package/src/config/widgetKind.ts +27 -0
  116. package/src/dependencies/platformPackages.ts +11 -0
  117. package/src/discovery/ios.ts +104 -60
  118. package/src/index.ts +8 -0
  119. package/src/platforms/android/generated.ts +144 -47
  120. package/src/platforms/android/widgetSizing.ts +112 -0
  121. package/src/platforms/ios/apply.ts +21 -11
  122. package/src/platforms/ios/buildConfigurationValues.ts +105 -0
  123. package/src/platforms/ios/entitlements.ts +55 -31
  124. package/src/platforms/ios/generated.node.test.ts +61 -0
  125. package/src/platforms/ios/generated.ts +137 -38
  126. package/src/platforms/ios/plist.ts +36 -7
  127. package/src/platforms/ios/podfile.ts +2 -2
  128. package/src/platforms/ios/targetName.ts +2 -2
  129. package/src/platforms/ios/xcodeTarget.ts +248 -43
  130. package/src/platforms/shared/widgetModule.ts +22 -140
@@ -3,20 +3,23 @@ import fsPromises from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { createRequire } from 'node:module'
5
5
 
6
- import { requirePlatformPackage } from '../../dependencies/platformPackages'
6
+ import { getPlatformClientPackageVersion, requirePlatformPackage } from '../../dependencies/platformPackages'
7
7
  import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite'
8
8
  import { normalizeRelativePath, toRelativePath } from '../../fs/path'
9
9
  import { VoltraCliError } from '../../reporting/summary'
10
- import { DYNAMIC_WIDGET_BUILD_INFO, evaluateWidgetModuleExports } from '../shared/widgetModule'
10
+ import { createDynamicWidgetBuildInfo, createGeneratedWidgetModuleLoader } from '../shared/widgetModule'
11
+ import { iosWidgetKindOverrides, iosWidgetKind } from '../../config/widgetKind'
11
12
  import { buildPlistXml, parsePlistFile } from './plist'
12
13
  import { resolveIOSWidgetTargetName } from './targetName'
13
14
 
15
+ import type { WidgetModuleLoader } from '@use-voltra/compiler'
16
+
14
17
  import type { IOSProjectDiscovery } from '../../discovery/ios'
15
18
  import type {
16
19
  IOSWidgetAppIntentParameter,
17
20
  IOSWidgetFamily,
18
21
  NormalizedIOSWidgetConfig,
19
- NormalizedVoltraIOSConfig,
22
+ ResolvedVoltraIOSConfig,
20
23
  WidgetLabel,
21
24
  } from '../../config/types'
22
25
  import type { ReportedChange } from '../../reporting/summary'
@@ -99,7 +102,7 @@ const GENERATED_INITIAL_STATE_LOCALE_HELPER = `private enum VoltraGeneratedIniti
99
102
 
100
103
  export interface GenerateIOSFilesOptions {
101
104
  projectRoot: string
102
- ios: NormalizedVoltraIOSConfig
105
+ ios: ResolvedVoltraIOSConfig
103
106
  discovery: IOSProjectDiscovery
104
107
  }
105
108
 
@@ -135,7 +138,7 @@ type DynamicWidgetManifest = {
135
138
  widgets: Array<{ id: string; entry: string }>
136
139
  }
137
140
 
138
- type DetectedIOSWidget =
141
+ export type DetectedIOSWidget =
139
142
  | (NormalizedIOSWidgetConfig & { clientRendered: false })
140
143
  | (NormalizedIOSWidgetConfig & { entry: string; clientRendered: true; clientSourcePath: string })
141
144
 
@@ -152,13 +155,20 @@ function createGeneratedFilesError(message: string): IOSGeneratedFilesError {
152
155
 
153
156
  export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise<GenerateIOSFilesResult> {
154
157
  const { projectRoot, ios, discovery } = options
158
+ const voltraVersion = getPlatformClientPackageVersion(projectRoot, 'ios')
155
159
  const targetName = resolveIOSWidgetTargetName(ios, discovery)
156
160
  const targetPath = path.join(discovery.iosRoot, targetName)
157
- const detectedWidgets = detectClientRenderedWidgets(projectRoot, ios.widgets)
158
161
  const mainAppMetadata = await readMainAppMetadata(discovery.infoPlistPath)
159
162
  const changes: ReportedChange[] = []
160
- const warnings: string[] = []
163
+ const warnings: string[] = [...(await getDivergentMainAppMetadataWarnings(discovery, mainAppMetadata))]
161
164
  const generatedFiles = new Set<string>()
165
+ const widgetModuleLoader = createGeneratedWidgetModuleLoader(
166
+ projectRoot,
167
+ 'ios',
168
+ createGeneratedFilesError,
169
+ (message) => warnings.push(message)
170
+ )
171
+ const detectedWidgets = detectClientRenderedWidgets(projectRoot, widgetModuleLoader, ios.widgets)
162
172
 
163
173
  mergeSingleResult(
164
174
  await writeGeneratedTextFile(
@@ -176,7 +186,8 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis
176
186
  targetName,
177
187
  ios,
178
188
  mainAppMetadata,
179
- detectedWidgets
189
+ detectedWidgets,
190
+ voltraVersion
180
191
  )
181
192
  mergeSingleResult(infoPlistResult, changes, generatedFiles)
182
193
 
@@ -189,7 +200,12 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis
189
200
  const fontsResult = await copyIOSFonts(projectRoot, targetPath, ios.fonts)
190
201
  mergeResult(fontsResult, changes, warnings, generatedFiles)
191
202
 
192
- const initialStatesResult = await generateInitialStatesSwift(projectRoot, detectedWidgets)
203
+ const initialStatesResult = await generateInitialStatesSwift(
204
+ projectRoot,
205
+ widgetModuleLoader,
206
+ detectedWidgets,
207
+ voltraVersion
208
+ )
193
209
  mergeSingleResult(
194
210
  await writeGeneratedTextFile(
195
211
  projectRoot,
@@ -223,15 +239,22 @@ async function generateInfoPlistFile(
223
239
  projectRoot: string,
224
240
  targetPath: string,
225
241
  targetName: string,
226
- ios: NormalizedVoltraIOSConfig,
242
+ ios: ResolvedVoltraIOSConfig,
227
243
  mainAppMetadata: MainAppMetadata,
228
- widgets: DetectedIOSWidget[]
244
+ widgets: DetectedIOSWidget[],
245
+ voltraVersion: string
229
246
  ): Promise<GeneratedFileResult> {
230
247
  const plistPath = path.join(targetPath, 'Info.plist')
231
248
  const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort()
232
249
  const serverWidgets = widgets.filter((widget) => widget.serverUpdate)
233
250
  const hasClientRenderedWidget = widgets.some((widget) => widget.clientRendered)
234
- const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url]))
251
+ // Keys of the intervals dictionary are the set of server-driven widget ids; a URL appears only
252
+ // when app.json set one, because it may instead arrive at runtime via setWidgetServerUpdate.
253
+ const serverUrls = Object.fromEntries(
254
+ serverWidgets
255
+ .filter((widget) => widget.serverUpdate?.url !== undefined)
256
+ .map((widget) => [widget.id, widget.serverUpdate?.url])
257
+ )
235
258
  const serverIntervals = Object.fromEntries(
236
259
  serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])
237
260
  )
@@ -257,6 +280,10 @@ async function generateInfoPlistFile(
257
280
  UIAppFonts: fontNames.length > 0 ? fontNames : undefined,
258
281
  Voltra_AppGroupIdentifier: ios.groupIdentifier,
259
282
  Voltra_KeychainGroup: ios.keychainGroup,
283
+ Voltra_Version: voltraVersion,
284
+ // The extension resolves its own kinds too, and Bundle.main there is the extension bundle,
285
+ // so the map has to be written here as well as into the app's Info.plist.
286
+ Voltra_WidgetKinds: iosWidgetKindOverrides(widgets),
260
287
  Voltra_WidgetServerIntervals: Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined,
261
288
  Voltra_WidgetServerRefresh: Object.keys(serverRefresh).length > 0 ? serverRefresh : undefined,
262
289
  NSAppTransportSecurity: createWidgetAppTransportSecurity(hasClientRenderedWidget, serverWidgets.length > 0),
@@ -296,7 +323,7 @@ async function generateEntitlementsFile(
296
323
  projectRoot: string,
297
324
  targetPath: string,
298
325
  targetName: string,
299
- ios: NormalizedVoltraIOSConfig
326
+ ios: ResolvedVoltraIOSConfig
300
327
  ): Promise<GeneratedFileResult> {
301
328
  const entitlementsPath = path.join(targetPath, `${targetName}.entitlements`)
302
329
  const entitlements = buildPlistXml(
@@ -418,6 +445,41 @@ async function generateLocalizedWidgetStrings(
418
445
  }
419
446
  }
420
447
 
448
+ /**
449
+ * The widget extension has a single Info.plist, so its version has to come from one of the app's.
450
+ * An app whose build configurations disagree on the version would ship an extension that matches
451
+ * only some of them, which App Store validation rejects, so say so rather than picking silently.
452
+ */
453
+ async function getDivergentMainAppMetadataWarnings(
454
+ discovery: IOSProjectDiscovery,
455
+ mainAppMetadata: MainAppMetadata
456
+ ): Promise<string[]> {
457
+ const warnings: string[] = []
458
+
459
+ for (const infoPlistPath of discovery.infoPlistPaths.slice(1)) {
460
+ const metadata = await readMainAppMetadata(infoPlistPath)
461
+
462
+ if (
463
+ metadata.shortVersionString === mainAppMetadata.shortVersionString &&
464
+ metadata.buildNumber === mainAppMetadata.buildNumber
465
+ ) {
466
+ continue
467
+ }
468
+
469
+ warnings.push(
470
+ `${toRelativePath(discovery.iosRoot, infoPlistPath)} sets version ${metadata.shortVersionString} (${
471
+ metadata.buildNumber
472
+ }) while ${toRelativePath(discovery.iosRoot, discovery.infoPlistPath)} sets ${
473
+ mainAppMetadata.shortVersionString
474
+ } (${
475
+ mainAppMetadata.buildNumber
476
+ }). The widget extension is generated with the version of the default build configuration. Set ios.project.infoPlistPath to choose a different one.`
477
+ )
478
+ }
479
+
480
+ return warnings
481
+ }
482
+
421
483
  async function readMainAppMetadata(infoPlistPath: string): Promise<MainAppMetadata> {
422
484
  const dict = await parsePlistFile(
423
485
  infoPlistPath,
@@ -435,17 +497,22 @@ async function readMainAppMetadata(infoPlistPath: string): Promise<MainAppMetada
435
497
  }
436
498
  }
437
499
 
438
- async function generateInitialStatesSwift(projectRoot: string, widgets: DetectedIOSWidget[]): Promise<string> {
500
+ async function generateInitialStatesSwift(
501
+ projectRoot: string,
502
+ loader: WidgetModuleLoader,
503
+ widgets: DetectedIOSWidget[],
504
+ voltraVersion: string
505
+ ): Promise<string> {
439
506
  const serverWidgets = widgets.filter(
440
507
  (widget): widget is Extract<DetectedIOSWidget, { clientRendered: false }> => !widget.clientRendered
441
508
  )
442
509
  const prerenderableServerWidgets = serverWidgets.filter((widget) => widget.initialStatePath)
443
510
  const serverStates = await prerenderWidgetStates(
444
- projectRoot,
511
+ loader,
445
512
  prerenderableServerWidgets,
446
513
  loadIOSWidgetRenderer(projectRoot)
447
514
  )
448
- const clientStates = await prerenderClientRenderedWidgets(projectRoot, widgets)
515
+ const clientStates = await prerenderClientRenderedWidgets(projectRoot, loader, widgets, voltraVersion)
449
516
  const prerenderedStates = new Map([...serverStates, ...clientStates])
450
517
 
451
518
  if (prerenderedStates.size === 0) {
@@ -519,7 +586,7 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string {
519
586
  appIntentWidgets.length > 0 ? 'import AppIntents' : undefined,
520
587
  'import SwiftUI',
521
588
  'import WidgetKit',
522
- 'import VoltraWidget',
589
+ 'import VoltraRuntime',
523
590
  ]
524
591
  .filter((value): value is string => value !== undefined)
525
592
  .join('\n')
@@ -585,9 +652,14 @@ function generateWidgetStruct(widget: DetectedIOSWidget): string {
585
652
  const familiesSwift = widget.supportedFamilies.map((family) => IOS_WIDGET_FAMILY_MAP[family]).join(', ')
586
653
  const displayNameExpr = createSwiftLabelExpression(widget.id, 'displayName', widget.displayName)
587
654
  const descriptionExpr = createSwiftLabelExpression(widget.id, 'description', widget.description)
655
+ // `entry` picks the render engine and `serverUpdate` picks where the data comes from, so the two
656
+ // keys together select one of four providers. Runtime code never asks "is this server-driven?" —
657
+ // the answer is baked in here, once, at generate time.
588
658
  const providerAndContent = widget.clientRendered
589
659
  ? [
590
- ' provider: VoltraClientWidgetProvider(',
660
+ ` provider: ${
661
+ widget.serverUpdate ? 'VoltraDynamicWidgetServerUpdateProvider' : 'VoltraClientWidgetProvider'
662
+ }(`,
591
663
  ' widgetId: widgetId,',
592
664
  ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)',
593
665
  ' )',
@@ -614,7 +686,7 @@ function generateWidgetStruct(widget: DetectedIOSWidget): string {
614
686
  '',
615
687
  ' public var body: some WidgetConfiguration {',
616
688
  ' StaticConfiguration(',
617
- ` kind: ${JSON.stringify(`Voltra_Widget_${widget.id}`)},`,
689
+ ` kind: ${JSON.stringify(iosWidgetKind(widget))},`,
618
690
  providerAndContent,
619
691
  ' }',
620
692
  ` .configurationDisplayName(${displayNameExpr})`,
@@ -664,6 +736,22 @@ function generateClientAppIntentWidgetCode(
664
736
  const defaultDictionary = createSwiftDictionaryLiteral(
665
737
  widget.appIntent.parameters.map((parameter) => `${JSON.stringify(parameter.name)}: ${swiftDefaultValue(parameter)}`)
666
738
  )
739
+ // A server-driven Dynamic Widget fetches on every timeline request and schedules the next one
740
+ // from its resolved interval; a plain one has nothing to ask again for, so its policy is .never.
741
+ const timelineBody = widget.serverUpdate
742
+ ? [
743
+ ' return await VoltraDynamicWidgetServerUpdateProvider.timeline(',
744
+ ' widgetId: widgetId,',
745
+ ' family: context.family,',
746
+ ` configuration: ${configuredDictionary}`,
747
+ ' )',
748
+ ' }',
749
+ ]
750
+ : [
751
+ ` let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`,
752
+ ' return Timeline(entries: [entry], policy: .never)',
753
+ ' }',
754
+ ]
667
755
 
668
756
  return [
669
757
  `@available(iOS 17.0, *)`,
@@ -693,10 +781,8 @@ function generateClientAppIntentWidgetCode(
693
781
  ` await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`,
694
782
  ' }',
695
783
  '',
696
- ` func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline<VoltraClientWidgetEntry> {`,
697
- ` let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`,
698
- ' return Timeline(entries: [entry], policy: .never)',
699
- ' }',
784
+ ` func timeline(for configuration: ${intentName}, in context: Context) async -> Timeline<VoltraClientWidgetEntry> {`,
785
+ ...timelineBody,
700
786
  '}',
701
787
  '',
702
788
  `@available(iOS 17.0, *)`,
@@ -707,7 +793,7 @@ function generateClientAppIntentWidgetCode(
707
793
  '',
708
794
  ' public var body: some WidgetConfiguration {',
709
795
  ' AppIntentConfiguration(',
710
- ` kind: ${JSON.stringify(`Voltra_Widget_${widget.id}`)},`,
796
+ ` kind: ${JSON.stringify(iosWidgetKind(widget))},`,
711
797
  ` intent: ${intentName}.self,`,
712
798
  ` provider: ${providerName}()`,
713
799
  ' ) { entry in',
@@ -751,7 +837,7 @@ function createSwiftDictionaryLiteral(entries: string[]): string {
751
837
  }
752
838
 
753
839
  async function prerenderWidgetStates(
754
- projectRoot: string,
840
+ loader: WidgetModuleLoader,
755
841
  widgets: NormalizedIOSWidgetConfig[],
756
842
  renderer: IOSWidgetRenderer
757
843
  ): Promise<PrerenderedWidgetStates> {
@@ -773,7 +859,7 @@ async function prerenderWidgetStates(
773
859
  throw new IOSGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`)
774
860
  }
775
861
 
776
- const widgetVariants = evaluateLegacyWidgetModule(projectRoot, modulePath)
862
+ const widgetVariants = evaluateLegacyWidgetModule(loader, modulePath)
777
863
  localeStates.set(localeKey, renderer(widgetVariants))
778
864
  }
779
865
 
@@ -783,9 +869,8 @@ async function prerenderWidgetStates(
783
869
  return prerenderedStates
784
870
  }
785
871
 
786
- function evaluateLegacyWidgetModule(projectRoot: string, filePath: string): WidgetVariants {
787
- const exports = evaluateWidgetModuleExports(projectRoot, filePath, createGeneratedFilesError) as { default?: unknown }
788
- const widgetVariants = exports.default ?? exports
872
+ function evaluateLegacyWidgetModule(loader: WidgetModuleLoader, filePath: string): WidgetVariants {
873
+ const widgetVariants = loader.loadDefaultExport(filePath)
789
874
 
790
875
  if (!widgetVariants || typeof widgetVariants !== 'object') {
791
876
  throw new IOSGeneratedFilesError(`Widget file must export widget variants: ${filePath}`)
@@ -827,11 +912,19 @@ function createDynamicWidgetsManifest(widgets: DetectedIOSWidget[]): DynamicWidg
827
912
  }
828
913
  }
829
914
 
830
- function detectClientRenderedWidgets(projectRoot: string, widgets: NormalizedIOSWidgetConfig[]): DetectedIOSWidget[] {
831
- return widgets.map((widget) => detectSingleWidget(projectRoot, widget))
915
+ function detectClientRenderedWidgets(
916
+ projectRoot: string,
917
+ loader: WidgetModuleLoader,
918
+ widgets: NormalizedIOSWidgetConfig[]
919
+ ): DetectedIOSWidget[] {
920
+ return widgets.map((widget) => detectSingleWidget(projectRoot, loader, widget))
832
921
  }
833
922
 
834
- function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConfig): DetectedIOSWidget {
923
+ function detectSingleWidget(
924
+ projectRoot: string,
925
+ loader: WidgetModuleLoader,
926
+ widget: NormalizedIOSWidgetConfig
927
+ ): DetectedIOSWidget {
835
928
  if (!widget.entry) {
836
929
  return {
837
930
  ...widget,
@@ -845,7 +938,7 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConf
845
938
  throw createGeneratedFilesError(`[voltra] Dynamic Widget "${widget.id}" entry not found at ${widget.entry}`)
846
939
  }
847
940
 
848
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, clientSourcePath)
941
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, clientSourcePath)
849
942
  const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
850
943
 
851
944
  if (typeof widgetFn !== 'function') {
@@ -864,7 +957,9 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConf
864
957
 
865
958
  async function prerenderClientRenderedWidgets(
866
959
  projectRoot: string,
867
- widgets: DetectedIOSWidget[]
960
+ loader: WidgetModuleLoader,
961
+ widgets: DetectedIOSWidget[],
962
+ voltraVersion: string
868
963
  ): Promise<PrerenderedWidgetStates> {
869
964
  const clientWidgets = widgets.filter(
870
965
  (widget): widget is Extract<DetectedIOSWidget, { clientRendered: true }> => widget.clientRendered
@@ -883,13 +978,13 @@ async function prerenderClientRenderedWidgets(
883
978
  widgetRenderingMode: 'fullColor',
884
979
  showsWidgetContainerBackground: true,
885
980
  configuration: undefined,
886
- build: DYNAMIC_WIDGET_BUILD_INFO,
981
+ build: createDynamicWidgetBuildInfo(voltraVersion),
887
982
  }
888
983
  const prerenderedStates: PrerenderedWidgetStates = new Map()
889
984
 
890
985
  for (const widget of clientWidgets) {
891
986
  try {
892
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, widget.clientSourcePath)
987
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, widget.clientSourcePath)
893
988
  const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
894
989
  const element = widgetFn({}, placeholderEnv)
895
990
  prerenderedStates.set(widget.id, new Map([[DEFAULT_INITIAL_STATE_LOCALE, JSON.stringify(renderer(element))]]))
@@ -910,13 +1005,13 @@ async function prerenderClientRenderedWidgets(
910
1005
  }
911
1006
 
912
1007
  function safelyEvaluateDynamicWidget(
913
- projectRoot: string,
1008
+ loader: WidgetModuleLoader,
914
1009
  widgetId: string,
915
1010
  widgetEntry: string,
916
1011
  clientSourcePath: string
917
1012
  ): unknown {
918
1013
  try {
919
- return evaluateWidgetModuleExports(projectRoot, clientSourcePath, createGeneratedFilesError)
1014
+ return loader.load(clientSourcePath)
920
1015
  } catch (error) {
921
1016
  if (error instanceof IOSGeneratedFilesError) {
922
1017
  throw error
@@ -1231,3 +1326,7 @@ function mergeSingleResult(result: GeneratedFileResult, changes: ReportedChange[
1231
1326
 
1232
1327
  generatedFiles.add(normalizeRelativePath(result.relativePath))
1233
1328
  }
1329
+
1330
+ export const __test__ = {
1331
+ generateWidgetBundleSwift,
1332
+ }
@@ -1,11 +1,12 @@
1
1
  import { parseStringPromise } from 'xml2js'
2
2
 
3
+ import { iosWidgetKindOverrides } from '../../config/widgetKind'
3
4
  import { readTextFile, writeTextFile } from '../../fs/readWrite'
4
5
  import { toRelativePath } from '../../fs/path'
5
6
  import { VoltraCliError } from '../../reporting/summary'
6
7
 
7
8
  import type { IOSProjectDiscovery } from '../../discovery/ios'
8
- import type { NormalizedVoltraIOSConfig } from '../../config/types'
9
+ import type { ResolvedVoltraIOSConfig } from '../../config/types'
9
10
  import type { ReportedChange } from '../../reporting/summary'
10
11
 
11
12
  interface OrderedPlistNode {
@@ -23,12 +24,12 @@ type PlistErrorFactory = (message: string) => Error
23
24
 
24
25
  export interface EnsureInfoPlistOptions {
25
26
  projectRoot: string
26
- ios: NormalizedVoltraIOSConfig
27
+ ios: ResolvedVoltraIOSConfig
27
28
  discovery: IOSProjectDiscovery
28
29
  }
29
30
 
30
31
  export interface EnsureInfoPlistResult {
31
- change?: ReportedChange
32
+ changes: ReportedChange[]
32
33
  }
33
34
 
34
35
  export class IOSInfoPlistMutationError extends VoltraCliError {
@@ -40,7 +41,26 @@ export class IOSInfoPlistMutationError extends VoltraCliError {
40
41
 
41
42
  export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise<EnsureInfoPlistResult> {
42
43
  const { projectRoot, ios, discovery } = options
43
- const infoPlist = await parsePlistFile(discovery.infoPlistPath, 'main app Info.plist', createInfoPlistError)
44
+ const changes: ReportedChange[] = []
45
+
46
+ // Build configurations of a multi-environment app can each reference their own Info.plist.
47
+ for (const infoPlistPath of discovery.infoPlistPaths) {
48
+ const change = await ensureSingleInfoPlist(projectRoot, ios, infoPlistPath)
49
+
50
+ if (change) {
51
+ changes.push(change)
52
+ }
53
+ }
54
+
55
+ return { changes }
56
+ }
57
+
58
+ async function ensureSingleInfoPlist(
59
+ projectRoot: string,
60
+ ios: ResolvedVoltraIOSConfig,
61
+ infoPlistPath: string
62
+ ): Promise<ReportedChange | undefined> {
63
+ const infoPlist = await parsePlistFile(infoPlistPath, 'main app Info.plist', createInfoPlistError)
44
64
 
45
65
  infoPlist.NSSupportsLiveActivities = true
46
66
  infoPlist.NSSupportsLiveActivitiesFrequentUpdates = false
@@ -52,8 +72,18 @@ export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise<
52
72
  const widgetIds = ios.widgets.map((widget) => widget.id)
53
73
  setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetIds', widgetIds.length > 0 ? widgetIds : undefined)
54
74
 
75
+ // Widgets that pin a custom WidgetKit kind, so VoltraWidgetKind can map ids to kinds and back.
76
+ setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetKinds', iosWidgetKindOverrides(ios.widgets))
77
+
78
+ // Every server-driven widget gets an interval, so this dictionary's keys are the set of
79
+ // server-driven widget ids the runtime settings store validates against. A URL is written only
80
+ // when app.json set one; otherwise the app supplies it with setWidgetServerUpdate.
55
81
  const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate)
56
- const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url]))
82
+ const serverUrls = Object.fromEntries(
83
+ serverWidgets
84
+ .filter((widget) => widget.serverUpdate?.url !== undefined)
85
+ .map((widget) => [widget.id, widget.serverUpdate?.url])
86
+ )
57
87
  const serverIntervals = Object.fromEntries(
58
88
  serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])
59
89
  )
@@ -70,9 +100,8 @@ export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise<
70
100
  )
71
101
 
72
102
  const nextContent = buildPlistXml(infoPlist, createInfoPlistError)
73
- const change = await writePlistIfChanged(projectRoot, discovery.infoPlistPath, nextContent)
74
103
 
75
- return { change }
104
+ return await writePlistIfChanged(projectRoot, infoPlistPath, nextContent)
76
105
  }
77
106
 
78
107
  export async function parsePlistFile(
@@ -8,7 +8,7 @@ import { resolveIOSWidgetTargetName } from './targetName'
8
8
  import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, isIOSDeploymentTargetAtLeast } from './deploymentTarget'
9
9
 
10
10
  import type { IOSProjectDiscovery } from '../../discovery/ios'
11
- import type { NormalizedVoltraIOSConfig } from '../../config/types'
11
+ import type { ResolvedVoltraIOSConfig } from '../../config/types'
12
12
  import type { ReportedChange } from '../../reporting/summary'
13
13
 
14
14
  const VOLTRA_MANAGED_BLOCK_START = '# >>> VOLTRA MANAGED BLOCK: widget extension target'
@@ -18,7 +18,7 @@ const LEGACY_BLOCK_NOTICE = '# DO NOT MODIFY THIS FILE - IT IS AUTO-GENERATED BY
18
18
 
19
19
  export interface EnsurePodfileBlockOptions {
20
20
  projectRoot: string
21
- ios: NormalizedVoltraIOSConfig
21
+ ios: ResolvedVoltraIOSConfig
22
22
  discovery: IOSProjectDiscovery
23
23
  }
24
24
 
@@ -1,7 +1,7 @@
1
1
  import type { IOSProjectDiscovery } from '../../discovery/ios'
2
- import type { NormalizedVoltraIOSConfig } from '../../config/types'
2
+ import type { ResolvedVoltraIOSConfig } from '../../config/types'
3
3
 
4
- export function resolveIOSWidgetTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string {
4
+ export function resolveIOSWidgetTargetName(ios: ResolvedVoltraIOSConfig, discovery: IOSProjectDiscovery): string {
5
5
  if (ios.targetName) {
6
6
  return ios.targetName
7
7
  }