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
@@ -0,0 +1,54 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
3
+
4
+ import type { NormalizedVoltraConfig } from '../config/types'
5
+
6
+ /**
7
+ * A Dynamic Widget renders on device from a per-widget JS bundle that `@use-voltra/metro` produces:
8
+ * the dev server serves it at `/voltra/widgets/<id>.bundle`, and the release bundler reads the same
9
+ * registry. Without the package there is no bundle, and the widget silently draws its prerendered
10
+ * `initialStatePath` forever while its server updates keep fetching — a failure that otherwise shows
11
+ * up only as one `Log.w` line in logcat.
12
+ *
13
+ * Resolution from the project root is the same signal the generated Xcode build phase and Gradle
14
+ * task already use (`platforms/ios/xcodeTarget.ts`, `platforms/android/gradle.ts`), so a project
15
+ * that passes here is a project whose release bundling can run.
16
+ *
17
+ * This deliberately does not try to decide whether the app's Metro config is *wired up* — whether
18
+ * `withVoltra` wraps it. That cannot be read off the config source without warning at correct
19
+ * projects: a config that delegates to a shared preset (`module.exports =
20
+ * require('@myorg/metro-config')(__dirname)`) applies the wrapper somewhere this cannot see, and a
21
+ * config hand-composed from `createVoltraMiddleware` and `createWidgetRegistry` never names
22
+ * `withVoltra` at all. A warning that fires on a working setup costs more than the one it catches.
23
+ */
24
+ export function findMissingMetroPackageWarning(config: NormalizedVoltraConfig): string | undefined {
25
+ if (!hasDynamicWidget(config)) {
26
+ return undefined
27
+ }
28
+
29
+ if (canResolveVoltraMetro(config.projectRoot)) {
30
+ return undefined
31
+ }
32
+
33
+ return (
34
+ `This project declares a widget with an 'entry', but '@use-voltra/metro' cannot be resolved from ` +
35
+ `${config.projectRoot}. Dynamic Widgets render from a bundle only that package produces, so they ` +
36
+ `will keep showing their prerendered initial state. Install '@use-voltra/metro' and wrap the app's ` +
37
+ `Metro config: module.exports = withVoltra(config).`
38
+ )
39
+ }
40
+
41
+ function hasDynamicWidget(config: NormalizedVoltraConfig): boolean {
42
+ const widgets = [...(config.android?.widgets ?? []), ...(config.ios?.widgets ?? [])]
43
+
44
+ return widgets.some((widget) => widget.entry !== undefined)
45
+ }
46
+
47
+ function canResolveVoltraMetro(projectRoot: string): boolean {
48
+ try {
49
+ createRequire(path.join(projectRoot, 'package.json')).resolve('@use-voltra/metro/bundle-widgets')
50
+ return true
51
+ } catch {
52
+ return false
53
+ }
54
+ }
package/src/index.ts CHANGED
@@ -117,6 +117,11 @@ export type { GenerateIOSFilesOptions, GenerateIOSFilesResult } from './platform
117
117
  export { resolveIOSWidgetTargetName } from './platforms/ios/targetName'
118
118
  export { IOSWidgetTargetMutationError, ensureIOSWidgetTarget } from './platforms/ios/xcodeTarget'
119
119
  export { applyIOSPlatform, createIOSPreflightRunner } from './platforms/ios/apply'
120
+ export {
121
+ IOSBuildConfigurationValueError,
122
+ VOLTRA_BUILD_SETTING_PREFIX,
123
+ resolveIOSBuildConfigurationValues,
124
+ } from './platforms/ios/buildConfigurationValues'
120
125
  export { IOSEntitlementsMutationError, ensureEntitlements } from './platforms/ios/entitlements'
121
126
  export type { EnsureEntitlementsOptions, EnsureEntitlementsResult } from './platforms/ios/entitlements'
122
127
  export { IOSInfoPlistMutationError, ensureInfoPlist } from './platforms/ios/plist'
@@ -187,6 +192,8 @@ export type {
187
192
  NormalizedVoltraAndroidConfig,
188
193
  NormalizedVoltraConfig,
189
194
  NormalizedVoltraIOSConfig,
195
+ ResolvedIOSProjectConfig,
196
+ ResolvedVoltraIOSConfig,
190
197
  VoltraAndroidConfig,
191
198
  VoltraConfig,
192
199
  VoltraIOSConfig,
@@ -195,6 +202,7 @@ export type {
195
202
  WidgetLabel,
196
203
  WidgetLocalizedValue,
197
204
  } from './config/types'
205
+ export type { PerConfiguration } from './config/perConfiguration'
198
206
  export type {
199
207
  ApplySummary,
200
208
  PreflightFailureReport,
@@ -4,11 +4,14 @@ import path from 'node:path'
4
4
  import { createRequire } from 'node:module'
5
5
  import { vdConvert } from 'vd-tool'
6
6
 
7
- import { requirePlatformPackage } from '../../dependencies/platformPackages'
7
+ import { getPlatformClientPackageVersion, requirePlatformPackage } from '../../dependencies/platformPackages'
8
8
  import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite'
9
9
  import { normalizeRelativePath, toRelativePath } from '../../fs/path'
10
10
  import { VoltraCliError } from '../../reporting/summary'
11
- import { DYNAMIC_WIDGET_BUILD_INFO, evaluateWidgetModuleExports } from '../shared/widgetModule'
11
+ import { createDynamicWidgetBuildInfo, createGeneratedWidgetModuleLoader } from '../shared/widgetModule'
12
+ import { androidWidgetSizingAttributes, androidWidgetSizingWarnings } from './widgetSizing'
13
+
14
+ import type { WidgetModuleLoader } from '@use-voltra/compiler'
12
15
 
13
16
  import type { AndroidProjectDiscovery } from '../../discovery/android'
14
17
  import type {
@@ -27,6 +30,7 @@ const LOCALIZED_INITIAL_STATE_KEY = '__voltraLocales'
27
30
  const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en'
28
31
  const ANDROID_DYNAMIC_WIDGET_MANIFEST_PATH = path.join('.voltra', 'manifest.android.json')
29
32
  const ANDROID_WIDGET_CONFIG_DEFAULTS_PATH = path.join('assets', 'voltra', 'widget_config_defaults.json')
33
+ const ANDROID_WIDGET_SERVER_DEFAULTS_PATH = path.join('assets', 'voltra', 'widget_server_defaults.json')
30
34
 
31
35
  export interface GenerateAndroidFilesOptions {
32
36
  projectRoot: string
@@ -76,11 +80,18 @@ type DetectedAndroidWidget =
76
80
 
77
81
  export async function generateAndroidFiles(options: GenerateAndroidFilesOptions): Promise<GenerateAndroidFilesResult> {
78
82
  const { projectRoot, android, discovery } = options
83
+ const voltraVersion = getPlatformClientPackageVersion(projectRoot, 'android')
79
84
  const resourceRoot = path.join(discovery.appModuleRoot, 'src', 'main')
80
- const detectedWidgets = detectClientRenderedWidgets(projectRoot, android.widgets)
81
85
  const changes: ReportedChange[] = []
82
86
  const warnings: string[] = []
83
87
  const generatedFiles = new Set<string>()
88
+ const widgetModuleLoader = createGeneratedWidgetModuleLoader(
89
+ projectRoot,
90
+ 'android',
91
+ createAndroidGeneratedFilesError,
92
+ (message) => warnings.push(message)
93
+ )
94
+ const detectedWidgets = detectClientRenderedWidgets(projectRoot, widgetModuleLoader, android.widgets)
84
95
 
85
96
  mergeSingleResult(
86
97
  await writeGeneratedTextFile(
@@ -104,7 +115,13 @@ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions)
104
115
  const fontFiles = await copyAndroidFonts(projectRoot, resourceRoot, android.fonts)
105
116
  mergeResult(fontFiles, changes, warnings, generatedFiles)
106
117
 
107
- const initialStateFiles = await generateAndroidInitialStates(projectRoot, resourceRoot, detectedWidgets)
118
+ const initialStateFiles = await generateAndroidInitialStates(
119
+ projectRoot,
120
+ widgetModuleLoader,
121
+ resourceRoot,
122
+ detectedWidgets,
123
+ voltraVersion
124
+ )
108
125
  mergeResult(initialStateFiles, changes, warnings, generatedFiles)
109
126
 
110
127
  const configDefaultsResult = await generateAndroidConfigDefaults(projectRoot, resourceRoot, detectedWidgets)
@@ -112,6 +129,11 @@ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions)
112
129
  mergeSingleResult(configDefaultsResult, changes, generatedFiles)
113
130
  }
114
131
 
132
+ const serverDefaultsResult = await generateAndroidServerDefaults(projectRoot, resourceRoot, detectedWidgets)
133
+ if (serverDefaultsResult) {
134
+ mergeSingleResult(serverDefaultsResult, changes, generatedFiles)
135
+ }
136
+
115
137
  return {
116
138
  changes,
117
139
  files: [...generatedFiles].sort(),
@@ -216,6 +238,7 @@ async function generateAndroidXmlFiles(
216
238
  const widgetInfoResult = await writeGeneratedTextFile(projectRoot, widgetInfoPath, widgetInfoContent)
217
239
  pushChange(changes, widgetInfoResult.change)
218
240
  generatedFiles.add(widgetInfoResult.relativePath)
241
+ warnings.push(...androidWidgetSizingWarnings(widget, widget.id))
219
242
  }
220
243
 
221
244
  return {
@@ -408,19 +431,21 @@ async function copyAndroidFonts(
408
431
 
409
432
  async function generateAndroidInitialStates(
410
433
  projectRoot: string,
434
+ loader: WidgetModuleLoader,
411
435
  resourceRoot: string,
412
- widgets: DetectedAndroidWidget[]
436
+ widgets: DetectedAndroidWidget[],
437
+ voltraVersion: string
413
438
  ): Promise<GenerateAndroidFilesResult> {
414
439
  const serverWidgets = widgets.filter(
415
440
  (widget): widget is Extract<DetectedAndroidWidget, { clientRendered: false }> => !widget.clientRendered
416
441
  )
417
442
  const prerenderableServerWidgets = serverWidgets.filter((widget) => widget.initialStatePath)
418
443
  const serverStates = await prerenderWidgetStates(
419
- projectRoot,
444
+ loader,
420
445
  prerenderableServerWidgets,
421
446
  loadAndroidWidgetRenderer(projectRoot)
422
447
  )
423
- const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, widgets)
448
+ const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, loader, widgets, voltraVersion)
424
449
  const prerenderedStates = new Map([...serverStates, ...clientStates])
425
450
 
426
451
  if (prerenderedStates.size === 0) {
@@ -443,7 +468,7 @@ async function generateAndroidInitialStates(
443
468
  }
444
469
 
445
470
  async function prerenderWidgetStates(
446
- projectRoot: string,
471
+ loader: WidgetModuleLoader,
447
472
  widgets: NormalizedAndroidWidgetConfig[],
448
473
  renderer: AndroidWidgetRenderer
449
474
  ): Promise<PrerenderedWidgetStates> {
@@ -468,7 +493,7 @@ async function prerenderWidgetStates(
468
493
  throw new AndroidGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`)
469
494
  }
470
495
 
471
- const widgetVariants = evaluateLegacyWidgetModule(projectRoot, modulePath)
496
+ const widgetVariants = evaluateLegacyWidgetModule(loader, modulePath)
472
497
  localeStates.set(localeKey, renderer(widgetVariants))
473
498
  }
474
499
 
@@ -478,11 +503,8 @@ async function prerenderWidgetStates(
478
503
  return prerenderedStates
479
504
  }
480
505
 
481
- function evaluateLegacyWidgetModule(projectRoot: string, filePath: string): AndroidWidgetVariants {
482
- const exports = evaluateWidgetModuleExports(projectRoot, filePath, createAndroidGeneratedFilesError) as {
483
- default?: unknown
484
- }
485
- const widgetVariants = exports.default ?? exports
506
+ function evaluateLegacyWidgetModule(loader: WidgetModuleLoader, filePath: string): AndroidWidgetVariants {
507
+ const widgetVariants = loader.loadDefaultExport(filePath)
486
508
 
487
509
  if (!widgetVariants || typeof widgetVariants !== 'object') {
488
510
  throw new AndroidGeneratedFilesError(`Widget file must export widget variants: ${filePath}`)
@@ -530,12 +552,17 @@ function createDynamicWidgetsManifest(widgets: DetectedAndroidWidget[]): Dynamic
530
552
 
531
553
  function detectClientRenderedWidgets(
532
554
  projectRoot: string,
555
+ loader: WidgetModuleLoader,
533
556
  widgets: NormalizedAndroidWidgetConfig[]
534
557
  ): DetectedAndroidWidget[] {
535
- return widgets.map((widget) => detectSingleWidget(projectRoot, widget))
558
+ return widgets.map((widget) => detectSingleWidget(projectRoot, loader, widget))
536
559
  }
537
560
 
538
- function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidgetConfig): DetectedAndroidWidget {
561
+ function detectSingleWidget(
562
+ projectRoot: string,
563
+ loader: WidgetModuleLoader,
564
+ widget: NormalizedAndroidWidgetConfig
565
+ ): DetectedAndroidWidget {
539
566
  if (!widget.entry) {
540
567
  return {
541
568
  ...widget,
@@ -549,7 +576,7 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidget
549
576
  throw createAndroidGeneratedFilesError(`[voltra] Dynamic Widget "${widget.id}" entry not found at ${widget.entry}`)
550
577
  }
551
578
 
552
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, clientSourcePath)
579
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, clientSourcePath)
553
580
  const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
554
581
 
555
582
  if (typeof widgetFn !== 'function') {
@@ -568,7 +595,9 @@ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidget
568
595
 
569
596
  async function prerenderClientRenderedAndroidWidgets(
570
597
  projectRoot: string,
571
- widgets: DetectedAndroidWidget[]
598
+ loader: WidgetModuleLoader,
599
+ widgets: DetectedAndroidWidget[],
600
+ voltraVersion: string
572
601
  ): Promise<PrerenderedWidgetStates> {
573
602
  const clientWidgets = widgets.filter(
574
603
  (widget): widget is Extract<DetectedAndroidWidget, { clientRendered: true }> => widget.clientRendered
@@ -585,13 +614,13 @@ async function prerenderClientRenderedAndroidWidgets(
585
614
  colorScheme: 'light',
586
615
  locale: 'en-US',
587
616
  configuration: undefined,
588
- build: DYNAMIC_WIDGET_BUILD_INFO,
617
+ build: createDynamicWidgetBuildInfo(voltraVersion),
589
618
  }
590
619
  const prerenderedStates: PrerenderedWidgetStates = new Map()
591
620
 
592
621
  for (const widget of clientWidgets) {
593
622
  try {
594
- const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, widget.clientSourcePath)
623
+ const widgetModule = safelyEvaluateDynamicWidget(loader, widget.id, widget.entry, widget.clientSourcePath)
595
624
  const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
596
625
  const element = widgetFn({}, placeholderEnv)
597
626
  prerenderedStates.set(widget.id, new Map([[DEFAULT_INITIAL_STATE_LOCALE, JSON.stringify(renderer(element))]]))
@@ -612,13 +641,13 @@ async function prerenderClientRenderedAndroidWidgets(
612
641
  }
613
642
 
614
643
  function safelyEvaluateDynamicWidget(
615
- projectRoot: string,
644
+ loader: WidgetModuleLoader,
616
645
  widgetId: string,
617
646
  widgetEntry: string,
618
647
  clientSourcePath: string
619
648
  ): unknown {
620
649
  try {
621
- return evaluateWidgetModuleExports(projectRoot, clientSourcePath, createAndroidGeneratedFilesError)
650
+ return loader.load(clientSourcePath)
622
651
  } catch (error) {
623
652
  if (error instanceof AndroidGeneratedFilesError) {
624
653
  throw error
@@ -670,6 +699,59 @@ async function generateAndroidConfigDefaults(
670
699
  )
671
700
  }
672
701
 
702
+ /**
703
+ * Emits the build-time `serverUpdate` defaults the runtime settings resolver reads as its lowest
704
+ * layer.
705
+ *
706
+ * These used to be Kotlin literals inside each generated receiver. They live in an asset now
707
+ * because the app can override the URL and the interval with `setWidgetServerUpdate`, and a
708
+ * receiver compiled at build time cannot be asked what the interval is today.
709
+ *
710
+ * A widget id present in this file is server-driven. `url` is omitted when app.json declared
711
+ * `serverUpdate` without one, which means the app supplies it at runtime.
712
+ */
713
+ async function generateAndroidServerDefaults(
714
+ projectRoot: string,
715
+ resourceRoot: string,
716
+ widgets: DetectedAndroidWidget[]
717
+ ): Promise<GeneratedFileResult | undefined> {
718
+ const defaults = createWidgetServerDefaults(widgets)
719
+
720
+ if (Object.keys(defaults).length === 0) {
721
+ return undefined
722
+ }
723
+
724
+ return writeGeneratedTextFile(
725
+ projectRoot,
726
+ path.join(resourceRoot, ANDROID_WIDGET_SERVER_DEFAULTS_PATH),
727
+ `${JSON.stringify(defaults, null, 2)}\n`
728
+ )
729
+ }
730
+
731
+ interface WidgetServerDefaults {
732
+ url?: string
733
+ intervalMinutes: number
734
+ refresh: boolean
735
+ }
736
+
737
+ function createWidgetServerDefaults(widgets: DetectedAndroidWidget[]): Record<string, WidgetServerDefaults> {
738
+ const defaults: Record<string, WidgetServerDefaults> = {}
739
+
740
+ for (const widget of widgets) {
741
+ if (!widget.serverUpdate) {
742
+ continue
743
+ }
744
+
745
+ defaults[widget.id] = {
746
+ ...(widget.serverUpdate.url !== undefined ? { url: widget.serverUpdate.url } : {}),
747
+ intervalMinutes: widget.serverUpdate.intervalMinutes,
748
+ refresh: widget.serverUpdate.refresh,
749
+ }
750
+ }
751
+
752
+ return defaults
753
+ }
754
+
673
755
  function createWidgetConfigDefaults(widgets: DetectedAndroidWidget[]): WidgetConfigDefaults {
674
756
  const defaults: WidgetConfigDefaults = {}
675
757
 
@@ -916,15 +998,41 @@ async function getLargeImageWarning(imagePath: string, fileName: string): Promis
916
998
  return `Image '${fileName}' is ${stat.size} bytes. Large Android widget images may not display correctly.`
917
999
  }
918
1000
 
1001
+ /**
1002
+ * `entry` picks the render engine and `serverUpdate` picks where the data comes from, so the two
1003
+ * keys together select one of four base classes. Runtime code never asks "is this server-driven?" —
1004
+ * the answer is baked in here, once, at generate time.
1005
+ */
919
1006
  function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageName: string): string {
920
1007
  const className = `VoltraWidget_${widget.id}Receiver`
921
1008
  const labelForComment = widgetLabelEnglish(widget.displayName)
922
1009
 
1010
+ // A widget with an entry and a serverUpdate renders bundled JS from props fetched in the
1011
+ // background. The scheduling lives in the base class, so the generated receiver stays a name and
1012
+ // an id — the URL and the interval come from widget_server_defaults.json at runtime, where
1013
+ // setWidgetServerUpdate can override them.
1014
+ if (widget.clientRendered && widget.serverUpdate) {
1015
+ return [
1016
+ `package ${packageName}.widget`,
1017
+ '',
1018
+ 'import voltra.dynamicwidget.serverupdate.VoltraServerDrivenClientWidgetReceiver',
1019
+ '',
1020
+ '/**',
1021
+ ` * Auto-generated server-driven Dynamic Widget receiver for ${labelForComment}`,
1022
+ ` * Widget ID: ${widget.id}`,
1023
+ ' */',
1024
+ `class ${className} : VoltraServerDrivenClientWidgetReceiver() {`,
1025
+ ` override val widgetId: String = "${widget.id}"`,
1026
+ '}',
1027
+ '',
1028
+ ].join('\n')
1029
+ }
1030
+
923
1031
  if (widget.clientRendered) {
924
1032
  return [
925
1033
  `package ${packageName}.widget`,
926
1034
  '',
927
- 'import voltra.widget.VoltraClientWidgetReceiver',
1035
+ 'import voltra.dynamicwidget.VoltraClientWidgetReceiver',
928
1036
  '',
929
1037
  '/**',
930
1038
  ` * Auto-generated Dynamic Widget receiver for ${labelForComment}`,
@@ -938,35 +1046,30 @@ function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageNam
938
1046
  }
939
1047
 
940
1048
  if (widget.serverUpdate) {
941
- const refreshEnabled = widget.serverUpdate.refresh === true
942
-
943
1049
  return [
944
1050
  `package ${packageName}.widget`,
945
1051
  '',
946
1052
  'import android.appwidget.AppWidgetManager',
947
1053
  'import android.content.Context',
948
- 'import voltra.widget.VoltraWidgetReceiver',
949
- 'import voltra.widget.VoltraWidgetUpdateScheduler',
1054
+ 'import kotlinx.coroutines.runBlocking',
1055
+ 'import voltra.widget.payload.VoltraPayloadWidgetReceiver',
1056
+ 'import voltra.widget.payload.VoltraWidgetUpdateScheduler',
950
1057
  '',
951
1058
  '/**',
952
1059
  ` * Auto-generated widget receiver for ${labelForComment}`,
953
1060
  ` * Widget ID: ${widget.id}`,
954
- ` * Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes} minutes)`,
955
- ` * Refresh Button: ${String(refreshEnabled)}`,
956
1061
  ' */',
957
- `class ${className} : VoltraWidgetReceiver() {`,
1062
+ `class ${className} : VoltraPayloadWidgetReceiver() {`,
958
1063
  ` override val widgetId: String = "${widget.id}"`,
959
1064
  '',
960
1065
  ' override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {',
961
1066
  ' super.onUpdate(context, appWidgetManager, appWidgetIds)',
962
1067
  '',
963
- ' VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(',
964
- ' context = context,',
965
- ` widgetId = "${widget.id}",`,
966
- ` serverUrl = "${widget.serverUpdate.url}",`,
967
- ` intervalMinutes = ${widget.serverUpdate.intervalMinutes}L,`,
968
- ` refreshEnabled = ${String(refreshEnabled)}`,
969
- ' )',
1068
+ ' // Blocking rather than launching: onReceive must not return before the work is',
1069
+ ' // enqueued, or a widget added while the app is not running loses its schedule.',
1070
+ ' runBlocking {',
1071
+ ` VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(context.applicationContext, "${widget.id}")`,
1072
+ ' }',
970
1073
  ' }',
971
1074
  '',
972
1075
  ' override fun onDeleted(context: Context, appWidgetIds: IntArray) {',
@@ -992,13 +1095,13 @@ function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageNam
992
1095
  return [
993
1096
  `package ${packageName}.widget`,
994
1097
  '',
995
- 'import voltra.widget.VoltraWidgetReceiver',
1098
+ 'import voltra.widget.payload.VoltraPayloadWidgetReceiver',
996
1099
  '',
997
1100
  '/**',
998
1101
  ` * Auto-generated widget receiver for ${labelForComment}`,
999
1102
  ` * Widget ID: ${widget.id}`,
1000
1103
  ' */',
1001
- `class ${className} : VoltraWidgetReceiver() {`,
1104
+ `class ${className} : VoltraPayloadWidgetReceiver() {`,
1002
1105
  ` override val widgetId: String = "${widget.id}"`,
1003
1106
  '}',
1004
1107
  '',
@@ -1010,18 +1113,12 @@ function generateWidgetInfoXml(
1010
1113
  previewImageResourceName?: string,
1011
1114
  previewLayoutResourceName?: string
1012
1115
  ): string {
1013
- const minWidth = widget.minWidth ?? (widget.minCellWidth !== undefined ? widget.minCellWidth * 70 - 30 : undefined)
1014
- const minHeight =
1015
- widget.minHeight ?? (widget.minCellHeight !== undefined ? widget.minCellHeight * 70 - 30 : undefined)
1016
1116
  const resizeMode = widget.resizeMode ?? 'horizontal|vertical'
1017
1117
  const widgetCategory = widget.widgetCategory ?? 'home_screen'
1018
1118
  const lines = [
1019
1119
  '<?xml version="1.0" encoding="utf-8"?>',
1020
- `<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"${
1021
- minWidth !== undefined ? ` android:minWidth="${minWidth}dp"` : ''
1022
- }${minHeight !== undefined ? ` android:minHeight="${minHeight}dp"` : ''}`,
1023
- ` android:targetCellWidth="${widget.targetCellWidth}"`,
1024
- ` android:targetCellHeight="${widget.targetCellHeight}"`,
1120
+ '<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"',
1121
+ ...androidWidgetSizingAttributes(widget).map((attribute) => ` ${attribute}`),
1025
1122
  ' android:updatePeriodMillis="0"',
1026
1123
  ' android:initialLayout="@layout/voltra_widget_placeholder"',
1027
1124
  ` android:resizeMode="${resizeMode}"`,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Android widget sizing.
3
+ *
4
+ * Android 12+ (API 31+) places widgets by `targetCellWidth`/`targetCellHeight`, expressed in
5
+ * launcher grid cells. Android 11 and older ignore those attributes entirely and place widgets by
6
+ * `minWidth`/`minHeight`, expressed in dp, so a widget has to declare both sets to be placed
7
+ * correctly everywhere. Voltra widgets build against the host app's `minSdkVersion` — React
8
+ * Native's template sets 24 — so the pre-Android-12 path is not a legacy edge case.
9
+ * https://developer.android.com/develop/ui/compose/glance/create-app-widget
10
+ *
11
+ * Cell size varies by device, launcher and orientation, so converting cells to dp can only ever be
12
+ * an approximation. These are the figures Google publishes for a 5x4 handset grid, following the
13
+ * accompanying guidance to size widths from the portrait table and heights from the landscape
14
+ * table, because those are the smaller cells on each axis.
15
+ * https://developer.android.com/develop/ui/views/appwidgets/layouts
16
+ *
17
+ * The `voltra` CLI and the `@use-voltra/android-client` Expo plugin generate the same
18
+ * `appwidget-provider` XML from their own copy of this module; keep the two in sync.
19
+ */
20
+
21
+ /** Widget fields that determine the generated `appwidget-provider` sizing attributes. */
22
+ export interface AndroidWidgetSizingInput {
23
+ targetCellWidth: number
24
+ targetCellHeight: number
25
+ minWidth?: number
26
+ minHeight?: number
27
+ minCellWidth?: number
28
+ minCellHeight?: number
29
+ minResizeWidth?: number
30
+ minResizeHeight?: number
31
+ maxResizeWidth?: number
32
+ maxResizeHeight?: number
33
+ }
34
+
35
+ /** Resize bounds, in dp, emitted only when set. `maxResize*` is honoured from Android 12 on. */
36
+ const RESIZE_BOUNDS = ['minResizeWidth', 'minResizeHeight', 'maxResizeWidth', 'maxResizeHeight'] as const
37
+
38
+ function cellsToWidthDp(cells: number): number {
39
+ return cells * 73 - 16
40
+ }
41
+
42
+ function cellsToHeightDp(cells: number): number {
43
+ return cells * 66 - 15
44
+ }
45
+
46
+ function resolveMinimumSize(widget: AndroidWidgetSizingInput): { minWidth: number; minHeight: number } {
47
+ return {
48
+ minWidth: widget.minWidth ?? cellsToWidthDp(widget.minCellWidth ?? widget.targetCellWidth),
49
+ minHeight: widget.minHeight ?? cellsToHeightDp(widget.minCellHeight ?? widget.targetCellHeight),
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Sizing attributes for a single widget, ordered as in Android's own reference example.
55
+ *
56
+ * `minWidth` and `minHeight` are always emitted, so a widget is placed at its intended size on
57
+ * Android 11 and older: an explicit dp value wins, then the deprecated cell count, then the
58
+ * target cell count.
59
+ */
60
+ export function androidWidgetSizingAttributes(widget: AndroidWidgetSizingInput): string[] {
61
+ const { minWidth, minHeight } = resolveMinimumSize(widget)
62
+
63
+ const attributes = [
64
+ `android:minWidth="${minWidth}dp"`,
65
+ `android:minHeight="${minHeight}dp"`,
66
+ `android:targetCellWidth="${widget.targetCellWidth}"`,
67
+ `android:targetCellHeight="${widget.targetCellHeight}"`,
68
+ ]
69
+
70
+ for (const bound of RESIZE_BOUNDS) {
71
+ const value = widget[bound]
72
+ if (value !== undefined) {
73
+ attributes.push(`android:${bound}="${value}dp"`)
74
+ }
75
+ }
76
+
77
+ return attributes
78
+ }
79
+
80
+ /**
81
+ * Resize bounds Android will silently ignore, described for the developer who set them.
82
+ *
83
+ * `minResizeWidth` has no effect when it is greater than `minWidth`, and `maxResizeWidth` has no
84
+ * effect when it is smaller; likewise for the height pair. This reports rather than throws,
85
+ * because `minWidth` is usually the approximation derived from the widget's cell count.
86
+ * https://developer.android.com/reference/android/appwidget/AppWidgetProviderInfo
87
+ */
88
+ export function androidWidgetSizingWarnings(widget: AndroidWidgetSizingInput, widgetId: string): string[] {
89
+ const { minWidth, minHeight } = resolveMinimumSize(widget)
90
+ const warnings: string[] = []
91
+
92
+ const report = (bound: string, value: number, relation: string, minimum: string, minimumValue: number): void => {
93
+ warnings.push(
94
+ `Widget '${widgetId}': ${bound} (${value}dp) is ${relation} ${minimum} (${minimumValue}dp), so Android ignores it.`
95
+ )
96
+ }
97
+
98
+ if (widget.minResizeWidth !== undefined && widget.minResizeWidth > minWidth) {
99
+ report('minResizeWidth', widget.minResizeWidth, 'greater than', 'minWidth', minWidth)
100
+ }
101
+ if (widget.minResizeHeight !== undefined && widget.minResizeHeight > minHeight) {
102
+ report('minResizeHeight', widget.minResizeHeight, 'greater than', 'minHeight', minHeight)
103
+ }
104
+ if (widget.maxResizeWidth !== undefined && widget.maxResizeWidth < minWidth) {
105
+ report('maxResizeWidth', widget.maxResizeWidth, 'smaller than', 'minWidth', minWidth)
106
+ }
107
+ if (widget.maxResizeHeight !== undefined && widget.maxResizeHeight < minHeight) {
108
+ report('maxResizeHeight', widget.maxResizeHeight, 'smaller than', 'minHeight', minHeight)
109
+ }
110
+
111
+ return warnings
112
+ }
@@ -2,6 +2,7 @@ import { discoverIOSProject } from '../../discovery/ios'
2
2
  import { getMissingPlatformPackageMessage, getMissingPlatformPackages } from '../../dependencies/platformPackages'
3
3
  import { VoltraCliError } from '../../reporting/summary'
4
4
 
5
+ import { resolveIOSBuildConfigurationValues } from './buildConfigurationValues'
5
6
  import { ensureEntitlements } from './entitlements'
6
7
  import { generateIOSFiles } from './generated'
7
8
  import { ensureInfoPlist } from './plist'
@@ -52,32 +53,36 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise<P
52
53
  }
53
54
 
54
55
  const discovery = getIOSDiscovery(context.preflight)
56
+ // Values given per build configuration are collapsed once, so every mutator below sees a plain
57
+ // string and the project carries the per-configuration values.
58
+ const { ios, buildSettings } = resolveIOSBuildConfigurationValues(iosConfig, discovery)
55
59
  const generatedResult = await generateIOSFiles({
56
60
  projectRoot: context.config.projectRoot,
57
- ios: iosConfig,
61
+ ios,
58
62
  discovery,
59
63
  })
60
64
  const entitlementsResult = await ensureEntitlements({
61
65
  projectRoot: context.config.projectRoot,
62
- ios: iosConfig,
66
+ ios,
63
67
  discovery,
64
68
  })
65
69
  const infoPlistResult = await ensureInfoPlist({
66
70
  projectRoot: context.config.projectRoot,
67
- ios: iosConfig,
71
+ ios,
68
72
  discovery,
69
73
  })
70
74
  const podfileResult = await ensurePodfileBlock({
71
75
  projectRoot: context.config.projectRoot,
72
- ios: iosConfig,
76
+ ios,
73
77
  discovery,
74
78
  })
75
79
  const xcodeTargetResult = await ensureIOSWidgetTarget({
76
80
  projectRoot: context.config.projectRoot,
77
- ios: iosConfig,
81
+ ios,
78
82
  discovery,
79
83
  generatedFiles: generatedResult.files,
80
84
  previousGeneratedFiles: context.previousState?.files,
85
+ buildSettings,
81
86
  })
82
87
 
83
88
  if (generatedResult.targetName !== xcodeTargetResult.targetName) {
@@ -93,11 +98,10 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise<P
93
98
  }
94
99
 
95
100
  const changes = [
96
- infoPlistResult.change,
97
- entitlementsResult.change,
98
- podfileResult.change,
99
- xcodeTargetResult.change,
100
- ].filter(isDefined)
101
+ ...infoPlistResult.changes,
102
+ ...entitlementsResult.changes,
103
+ ...[podfileResult.change, xcodeTargetResult.change].filter(isDefined),
104
+ ]
101
105
 
102
106
  return {
103
107
  platform: 'ios',
@@ -130,7 +134,13 @@ function isIOSProjectDiscovery(value: unknown): value is IOSProjectDiscovery {
130
134
  candidate.podfilePath,
131
135
  candidate.mainTargetName,
132
136
  candidate.infoPlistPath,
133
- ].every((entry) => typeof entry === 'string' && entry.length > 0) && Array.isArray(candidate.mainTargetCandidates)
137
+ ].every((entry) => typeof entry === 'string' && entry.length > 0) &&
138
+ [
139
+ candidate.mainTargetCandidates,
140
+ candidate.buildConfigurationNames,
141
+ candidate.infoPlistPaths,
142
+ candidate.entitlementsPaths,
143
+ ].every((entry) => Array.isArray(entry))
134
144
  )
135
145
  }
136
146