voltra 2.1.1 → 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 -17
  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
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import type { Stats } from 'node:fs'
5
5
 
6
6
  import { VoltraCliError } from '../reporting/summary'
7
+ import { getValuesByBuildConfiguration, isPerConfigurationMap } from '../config/perConfiguration'
7
8
 
8
9
  import type { NormalizedIOSProjectConfig } from '../config/types'
9
10
 
@@ -19,8 +20,18 @@ export interface IOSProjectDiscovery {
19
20
  podfilePath: string
20
21
  mainTargetName: string
21
22
  mainTargetCandidates: string[]
23
+ /** Build configuration names of the app target, default build configuration first. */
24
+ buildConfigurationNames: string[]
25
+ /** Info.plist of the default build configuration. Always the first entry of `infoPlistPaths`. */
22
26
  infoPlistPath: string
27
+ /** Every distinct Info.plist the app target references, default build configuration first. */
28
+ infoPlistPaths: string[]
29
+ /** Entitlements file of the default build configuration, when the project references one. */
23
30
  entitlementsPath?: string
31
+ /** Every distinct entitlements file the app target references, default build configuration first. */
32
+ entitlementsPaths: string[]
33
+ /** Entitlements file per build configuration, when the config file names one per configuration. */
34
+ entitlementsPathByConfiguration?: Map<string, string>
24
35
  }
25
36
 
26
37
  interface ParsedPbxNativeTarget {
@@ -72,8 +83,13 @@ export async function discoverIOSProject(
72
83
  const mainTargetCandidates = getMainTargetCandidates(parsedProject.targets)
73
84
  const mainTarget = resolveMainTarget(mainTargetCandidates, config.mainTargetName, pbxprojPath)
74
85
  const mainTargetBuildConfigurations = getTargetBuildConfigurations(parsedProject, mainTarget, pbxprojPath)
75
- const infoPlistPath = await resolveInfoPlistPath(iosRoot, config, mainTarget, mainTargetBuildConfigurations)
76
- const entitlementsPath = await resolveEntitlementsPath(iosRoot, config, mainTarget, mainTargetBuildConfigurations)
86
+ const infoPlistPaths = await resolveInfoPlistPaths(iosRoot, config, mainTarget, mainTargetBuildConfigurations)
87
+ const { entitlementsPaths, entitlementsPathByConfiguration } = await resolveEntitlements(
88
+ iosRoot,
89
+ config,
90
+ mainTarget,
91
+ mainTargetBuildConfigurations
92
+ )
77
93
 
78
94
  return {
79
95
  iosRoot,
@@ -82,8 +98,12 @@ export async function discoverIOSProject(
82
98
  podfilePath,
83
99
  mainTargetName: mainTarget.name,
84
100
  mainTargetCandidates: mainTargetCandidates.map((target) => target.name).sort(),
85
- infoPlistPath,
86
- entitlementsPath,
101
+ buildConfigurationNames: mainTargetBuildConfigurations.map((configuration) => configuration.name),
102
+ infoPlistPath: infoPlistPaths[0],
103
+ infoPlistPaths,
104
+ entitlementsPath: entitlementsPaths[0],
105
+ entitlementsPaths,
106
+ entitlementsPathByConfiguration,
87
107
  }
88
108
  }
89
109
 
@@ -343,75 +363,102 @@ function getTargetBuildConfigurations(
343
363
  : buildConfigurations
344
364
  }
345
365
 
346
- async function resolveInfoPlistPath(
366
+ async function resolveInfoPlistPaths(
347
367
  iosRoot: string,
348
368
  config: NormalizedIOSProjectConfig,
349
369
  target: ParsedPbxNativeTarget,
350
370
  buildConfigurations: ParsedXCBuildConfiguration[]
351
- ): Promise<string> {
352
- const infoPlistPath =
353
- config.infoPlistPath ??
354
- resolveConsistentBuildSettingPath(
355
- iosRoot,
356
- target,
357
- buildConfigurations,
358
- 'INFOPLIST_FILE',
359
- (configuration) => configuration.buildSettings.infoPlistFile
360
- )
371
+ ): Promise<string[]> {
372
+ const discoveredPaths = config.infoPlistPath
373
+ ? new Map<string, string[]>()
374
+ : resolveBuildSettingPaths(
375
+ iosRoot,
376
+ target,
377
+ buildConfigurations,
378
+ (configuration) => configuration.buildSettings.infoPlistFile
379
+ )
380
+ const infoPlistPaths = config.infoPlistPath ? [config.infoPlistPath] : [...discoveredPaths.keys()]
361
381
 
362
- if (!infoPlistPath) {
382
+ if (infoPlistPaths.length === 0) {
363
383
  throw new IOSProjectDiscoveryError(
364
384
  `Could not determine Info.plist for target '${target.name}'. Set ios.project.infoPlistPath explicitly.`
365
385
  )
366
386
  }
367
387
 
368
- await ensureFile(
369
- infoPlistPath,
370
- config.infoPlistPath
371
- ? `Configured Info.plist does not exist: ${infoPlistPath}`
372
- : `Discovered Info.plist does not exist: ${infoPlistPath}`
373
- )
388
+ for (const infoPlistPath of infoPlistPaths) {
389
+ await ensureFile(
390
+ infoPlistPath,
391
+ config.infoPlistPath
392
+ ? `Configured Info.plist does not exist: ${infoPlistPath}`
393
+ : `Discovered Info.plist does not exist: ${infoPlistPath}${describeReferencingBuildConfigurations(
394
+ discoveredPaths,
395
+ infoPlistPath
396
+ )}. Set ios.project.infoPlistPath to override discovery.`
397
+ )
398
+ }
399
+
400
+ return infoPlistPaths
401
+ }
374
402
 
375
- return infoPlistPath
403
+ interface ResolvedEntitlements {
404
+ entitlementsPaths: string[]
405
+ entitlementsPathByConfiguration?: Map<string, string>
376
406
  }
377
407
 
378
- async function resolveEntitlementsPath(
408
+ async function resolveEntitlements(
379
409
  iosRoot: string,
380
410
  config: NormalizedIOSProjectConfig,
381
411
  target: ParsedPbxNativeTarget,
382
412
  buildConfigurations: ParsedXCBuildConfiguration[]
383
- ): Promise<string | undefined> {
384
- const entitlementsPath =
385
- config.entitlementsPath ??
386
- resolveConsistentBuildSettingPath(
387
- iosRoot,
388
- target,
389
- buildConfigurations,
390
- 'CODE_SIGN_ENTITLEMENTS',
391
- (configuration) => configuration.buildSettings.codeSignEntitlements
413
+ ): Promise<ResolvedEntitlements> {
414
+ const entitlementsPathByConfiguration = isPerConfigurationMap(config.entitlementsPath)
415
+ ? getValuesByBuildConfiguration(
416
+ config.entitlementsPath,
417
+ buildConfigurations.map((configuration) => configuration.name),
418
+ 'ios.project.entitlementsPath',
419
+ (message) => new IOSProjectDiscoveryError(message)
420
+ )
421
+ : undefined
422
+ const discoveredPaths = config.entitlementsPath
423
+ ? new Map<string, string[]>()
424
+ : resolveBuildSettingPaths(
425
+ iosRoot,
426
+ target,
427
+ buildConfigurations,
428
+ (configuration) => configuration.buildSettings.codeSignEntitlements
429
+ )
430
+ const entitlementsPaths = entitlementsPathByConfiguration
431
+ ? [...new Set(entitlementsPathByConfiguration.values())]
432
+ : typeof config.entitlementsPath === 'string'
433
+ ? [config.entitlementsPath]
434
+ : [...discoveredPaths.keys()]
435
+
436
+ for (const entitlementsPath of entitlementsPaths) {
437
+ await ensureFile(
438
+ entitlementsPath,
439
+ config.entitlementsPath
440
+ ? `Configured entitlements file does not exist: ${entitlementsPath}`
441
+ : `Discovered entitlements file does not exist: ${entitlementsPath}${describeReferencingBuildConfigurations(
442
+ discoveredPaths,
443
+ entitlementsPath
444
+ )}. Set ios.project.entitlementsPath to override discovery.`
392
445
  )
393
-
394
- if (!entitlementsPath) {
395
- return undefined
396
446
  }
397
447
 
398
- await ensureFile(
399
- entitlementsPath,
400
- config.entitlementsPath
401
- ? `Configured entitlements file does not exist: ${entitlementsPath}`
402
- : `Discovered entitlements file does not exist: ${entitlementsPath}`
403
- )
404
-
405
- return entitlementsPath
448
+ return { entitlementsPaths, entitlementsPathByConfiguration }
406
449
  }
407
450
 
408
- function resolveConsistentBuildSettingPath(
451
+ /**
452
+ * Collects the distinct paths a build setting resolves to across the target's build configurations.
453
+ * Apps that ship several environments point each configuration at its own file, so divergence is
454
+ * expected rather than an error.
455
+ */
456
+ function resolveBuildSettingPaths(
409
457
  iosRoot: string,
410
458
  target: ParsedPbxNativeTarget,
411
459
  buildConfigurations: ParsedXCBuildConfiguration[],
412
- settingName: string,
413
460
  getSettingValue: (configuration: ParsedXCBuildConfiguration) => string | undefined
414
- ): string | undefined {
461
+ ): Map<string, string[]> {
415
462
  const resolvedPaths = new Map<string, string[]>()
416
463
 
417
464
  for (const configuration of buildConfigurations) {
@@ -423,25 +470,22 @@ function resolveConsistentBuildSettingPath(
423
470
 
424
471
  const resolvedPath = resolveXcodePathValue(settingValue, iosRoot, target, configuration)
425
472
  const configurationNames = resolvedPaths.get(resolvedPath) ?? []
473
+
426
474
  configurationNames.push(configuration.name)
427
475
  resolvedPaths.set(resolvedPath, configurationNames)
428
476
  }
429
477
 
430
- if (resolvedPaths.size === 0) {
431
- return undefined
432
- }
478
+ return resolvedPaths
479
+ }
433
480
 
434
- if (resolvedPaths.size > 1) {
435
- throw new IOSProjectDiscoveryError(
436
- `Target '${target.name}' resolves ${settingName} to multiple paths: ${[...resolvedPaths.entries()]
437
- .map(([resolvedPath, configurationNames]) => `${resolvedPath} (${configurationNames.join(', ')})`)
438
- .join('; ')}. Set ios.project.${
439
- settingName === 'INFOPLIST_FILE' ? 'infoPlistPath' : 'entitlementsPath'
440
- } explicitly.`
441
- )
442
- }
481
+ /** Names the build configurations that reference a path, so a missing file can be traced back. */
482
+ function describeReferencingBuildConfigurations(
483
+ pathsByBuildConfiguration: Map<string, string[]>,
484
+ filePath: string
485
+ ): string {
486
+ const configurationNames = pathsByBuildConfiguration.get(filePath)
443
487
 
444
- return [...resolvedPaths.keys()][0]
488
+ return configurationNames?.length ? ` (referenced by ${configurationNames.join(', ')})` : ''
445
489
  }
446
490
 
447
491
  function resolveXcodePathValue(
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}"`,