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,105 @@
1
+ import { getValuesByBuildConfiguration, isPerConfigurationMap } from '../../config/perConfiguration'
2
+ import { VoltraCliError } from '../../reporting/summary'
3
+
4
+ import type { PerConfiguration } from '../../config/perConfiguration'
5
+ import type { NormalizedVoltraIOSConfig, ResolvedVoltraIOSConfig } from '../../config/types'
6
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
7
+
8
+ /**
9
+ * Prefix of the user-defined build settings Voltra writes. Settings under it are Voltra's to add
10
+ * and remove, so a value that stops being per build configuration leaves nothing behind.
11
+ */
12
+ export const VOLTRA_BUILD_SETTING_PREFIX = 'VOLTRA_'
13
+
14
+ const APP_GROUP_IDENTIFIER_SETTING = `${VOLTRA_BUILD_SETTING_PREFIX}APP_GROUP_IDENTIFIER`
15
+ const KEYCHAIN_GROUP_SETTING = `${VOLTRA_BUILD_SETTING_PREFIX}KEYCHAIN_GROUP`
16
+
17
+ /**
18
+ * The settings Voltra writes and is therefore allowed to remove. Anything else a project happens to
19
+ * name `VOLTRA_*` belongs to the user and is left alone.
20
+ */
21
+ export const VOLTRA_OWNED_BUILD_SETTINGS: readonly string[] = [APP_GROUP_IDENTIFIER_SETTING, KEYCHAIN_GROUP_SETTING]
22
+
23
+ export interface IOSBuildConfigurationValues {
24
+ /** iOS config with every per-build-configuration value collapsed to a single string. */
25
+ ios: ResolvedVoltraIOSConfig
26
+ /** User-defined build settings to write, keyed by build configuration name. */
27
+ buildSettings: Map<string, Record<string, string>>
28
+ }
29
+
30
+ export class IOSBuildConfigurationValueError extends VoltraCliError {
31
+ constructor(message: string) {
32
+ super(message, 'VOLTRA_IOS_BUILD_CONFIGURATION_VALUE_FAILED')
33
+ this.name = 'IOSBuildConfigurationValueError'
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Collapses per-build-configuration values into the single strings the platform mutators consume.
39
+ *
40
+ * A plain string resolves to itself and writes no build settings. A value given per build
41
+ * configuration becomes a user-defined build setting holding one value per configuration, and
42
+ * resolves to a reference to that setting, which Xcode expands per build into the entitlements and
43
+ * Info.plist files. The app and the generated widget extension both read the value that way, so
44
+ * nothing downstream has to know which environment is being built.
45
+ */
46
+ export function resolveIOSBuildConfigurationValues(
47
+ ios: NormalizedVoltraIOSConfig,
48
+ discovery: IOSProjectDiscovery
49
+ ): IOSBuildConfigurationValues {
50
+ const buildSettings = new Map<string, Record<string, string>>()
51
+
52
+ const resolve = (
53
+ settingName: string,
54
+ value: PerConfiguration<string> | undefined,
55
+ context: string
56
+ ): string | undefined => {
57
+ if (!isPerConfigurationMap(value)) {
58
+ return value
59
+ }
60
+
61
+ const valuesByBuildConfiguration = getValuesByBuildConfiguration(
62
+ value,
63
+ discovery.buildConfigurationNames,
64
+ context,
65
+ (message) => new IOSBuildConfigurationValueError(message)
66
+ )
67
+
68
+ for (const [buildConfigurationName, configurationValue] of valuesByBuildConfiguration) {
69
+ const configurationSettings = buildSettings.get(buildConfigurationName) ?? {}
70
+ configurationSettings[settingName] = configurationValue
71
+ buildSettings.set(buildConfigurationName, configurationSettings)
72
+ }
73
+
74
+ return `$(${settingName})`
75
+ }
76
+
77
+ return {
78
+ ios: {
79
+ ...ios,
80
+ groupIdentifier: resolve(APP_GROUP_IDENTIFIER_SETTING, ios.groupIdentifier, 'ios.groupIdentifier'),
81
+ keychainGroup: resolve(KEYCHAIN_GROUP_SETTING, ios.keychainGroup, 'ios.keychainGroup'),
82
+ project: {
83
+ ...ios.project,
84
+ entitlementsPath: resolveEntitlementsPath(ios.project.entitlementsPath, discovery),
85
+ },
86
+ },
87
+ buildSettings,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The entitlements file of the default build configuration. Unlike a value written into a file, a
93
+ * per-configuration entitlements path is a build setting of its own, so it is applied to the Xcode
94
+ * project directly rather than through a reference.
95
+ */
96
+ function resolveEntitlementsPath(
97
+ entitlementsPath: PerConfiguration<string> | undefined,
98
+ discovery: IOSProjectDiscovery
99
+ ): string | undefined {
100
+ if (!isPerConfigurationMap(entitlementsPath)) {
101
+ return entitlementsPath
102
+ }
103
+
104
+ return discovery.entitlementsPathByConfiguration?.get(discovery.buildConfigurationNames[0])
105
+ }
@@ -6,7 +6,7 @@ import { buildPlistXml, parsePlistFile } from './plist'
6
6
  import { resolveMainAppEntitlementsPath } from './mainAppEntitlements'
7
7
 
8
8
  import type { IOSProjectDiscovery } from '../../discovery/ios'
9
- import type { NormalizedVoltraIOSConfig } from '../../config/types'
9
+ import type { ResolvedVoltraIOSConfig } from '../../config/types'
10
10
  import type { ReportedChange } from '../../reporting/summary'
11
11
 
12
12
  interface PreviousVoltraEntitlementValues {
@@ -17,12 +17,12 @@ interface PreviousVoltraEntitlementValues {
17
17
 
18
18
  export interface EnsureEntitlementsOptions {
19
19
  projectRoot: string
20
- ios: NormalizedVoltraIOSConfig
20
+ ios: ResolvedVoltraIOSConfig
21
21
  discovery: IOSProjectDiscovery
22
22
  }
23
23
 
24
24
  export interface EnsureEntitlementsResult {
25
- change?: ReportedChange
25
+ changes: ReportedChange[]
26
26
  }
27
27
 
28
28
  export class IOSEntitlementsMutationError extends VoltraCliError {
@@ -34,44 +34,68 @@ export class IOSEntitlementsMutationError extends VoltraCliError {
34
34
 
35
35
  export async function ensureEntitlements(options: EnsureEntitlementsOptions): Promise<EnsureEntitlementsResult> {
36
36
  const { projectRoot, ios, discovery } = options
37
- const entitlementsPath = resolveMainAppEntitlementsPath(discovery)
37
+ const entitlementsPaths = resolveEntitlementsPathsToMutate(ios, discovery)
38
38
 
39
- if (!discovery.entitlementsPath) {
40
- if (!needsEntitlementsMutation(ios)) {
41
- return {}
42
- }
39
+ if (entitlementsPaths.length === 0) {
40
+ return { changes: [] }
43
41
  }
44
42
 
45
- const entitlements: Record<string, unknown> = (await pathExists(entitlementsPath))
46
- ? await parsePlistFile(entitlementsPath, 'main app entitlements', createEntitlementsError)
47
- : {}
48
43
  const previousVoltraValues = await readPreviousVoltraEntitlementValues(discovery.infoPlistPath)
44
+ const changes: ReportedChange[] = []
45
+
46
+ for (const entitlementsPath of entitlementsPaths) {
47
+ const entitlements: Record<string, unknown> = (await pathExists(entitlementsPath))
48
+ ? await parsePlistFile(entitlementsPath, 'main app entitlements', createEntitlementsError)
49
+ : {}
50
+
51
+ ensureStringArrayValue(
52
+ entitlements,
53
+ 'com.apple.security.application-groups',
54
+ ios.groupIdentifier,
55
+ previousVoltraValues.appGroupIdentifier
56
+ )
57
+ ensureStringArrayValue(
58
+ entitlements,
59
+ 'keychain-access-groups',
60
+ ios.keychainGroup,
61
+ previousVoltraValues.keychainGroup
62
+ )
63
+
64
+ if (ios.enablePushNotifications && entitlements['aps-environment'] === undefined) {
65
+ entitlements['aps-environment'] = 'development'
66
+ } else if (
67
+ !ios.enablePushNotifications &&
68
+ previousVoltraValues.pushNotificationsEnabled &&
69
+ entitlements['aps-environment'] === 'development'
70
+ ) {
71
+ delete entitlements['aps-environment']
72
+ }
49
73
 
50
- ensureStringArrayValue(
51
- entitlements,
52
- 'com.apple.security.application-groups',
53
- ios.groupIdentifier,
54
- previousVoltraValues.appGroupIdentifier
55
- )
56
- ensureStringArrayValue(entitlements, 'keychain-access-groups', ios.keychainGroup, previousVoltraValues.keychainGroup)
57
-
58
- if (ios.enablePushNotifications && entitlements['aps-environment'] === undefined) {
59
- entitlements['aps-environment'] = 'development'
60
- } else if (
61
- !ios.enablePushNotifications &&
62
- previousVoltraValues.pushNotificationsEnabled &&
63
- entitlements['aps-environment'] === 'development'
64
- ) {
65
- delete entitlements['aps-environment']
74
+ const nextContent = buildPlistXml(entitlements, createEntitlementsError)
75
+ const change = await writeEntitlementsIfChanged(projectRoot, entitlementsPath, nextContent)
76
+
77
+ if (change) {
78
+ changes.push(change)
79
+ }
66
80
  }
67
81
 
68
- const nextContent = buildPlistXml(entitlements, createEntitlementsError)
69
- const change = await writeEntitlementsIfChanged(projectRoot, entitlementsPath, nextContent)
82
+ return { changes }
83
+ }
84
+
85
+ /**
86
+ * Every entitlements file the app target references, so each build configuration of a
87
+ * multi-environment app keeps its own file. Falls back to the standard path when the project
88
+ * references none and Voltra has values to write.
89
+ */
90
+ function resolveEntitlementsPathsToMutate(ios: ResolvedVoltraIOSConfig, discovery: IOSProjectDiscovery): string[] {
91
+ if (discovery.entitlementsPaths.length > 0) {
92
+ return discovery.entitlementsPaths
93
+ }
70
94
 
71
- return { change }
95
+ return needsEntitlementsMutation(ios) ? [resolveMainAppEntitlementsPath(discovery)] : []
72
96
  }
73
97
 
74
- export function needsEntitlementsMutation(ios: NormalizedVoltraIOSConfig): boolean {
98
+ export function needsEntitlementsMutation(ios: ResolvedVoltraIOSConfig): boolean {
75
99
  return ios.enablePushNotifications || ios.groupIdentifier !== undefined || ios.keychainGroup !== undefined
76
100
  }
77
101
 
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, test } from 'node:test'
3
+
4
+ import { __test__ } from './generated.ts'
5
+
6
+ import type { DetectedIOSWidget } from './generated.ts'
7
+
8
+ describe('generateWidgetBundleSwift', () => {
9
+ test('imports the compiled VoltraRuntime module, not the VoltraWidget pod name', () => {
10
+ const swift = __test__.generateWidgetBundleSwift([])
11
+
12
+ assert.ok(swift.includes('import VoltraRuntime'))
13
+ assert.ok(!swift.includes('import VoltraWidget'))
14
+ })
15
+
16
+ test('imports VoltraRuntime when widgets are configured too', () => {
17
+ const widget: DetectedIOSWidget = {
18
+ id: 'weather',
19
+ displayName: 'Weather',
20
+ description: 'Shows weather',
21
+ supportedFamilies: ['systemSmall'],
22
+ clientRendered: false,
23
+ }
24
+
25
+ const swift = __test__.generateWidgetBundleSwift([widget])
26
+
27
+ assert.ok(swift.includes('import VoltraRuntime'))
28
+ assert.ok(!swift.includes('import VoltraWidget'))
29
+ })
30
+
31
+ test('defaults the WidgetKit kind to the prefixed widget id', () => {
32
+ const widget: DetectedIOSWidget = {
33
+ id: 'weather',
34
+ displayName: 'Weather',
35
+ description: 'Shows weather',
36
+ supportedFamilies: ['systemSmall'],
37
+ clientRendered: false,
38
+ }
39
+
40
+ const swift = __test__.generateWidgetBundleSwift([widget])
41
+
42
+ assert.ok(swift.includes('kind: "Voltra_Widget_weather"'))
43
+ })
44
+
45
+ test('uses the `kind` option as the WidgetKit kind when set', () => {
46
+ // A widget migrated from a hand-written extension keeps the kind its placed instances use.
47
+ const widget: DetectedIOSWidget = {
48
+ id: 'streak',
49
+ kind: 'StreakWidget',
50
+ displayName: 'Streak',
51
+ description: 'Your daily streak',
52
+ supportedFamilies: ['systemSmall'],
53
+ clientRendered: false,
54
+ }
55
+
56
+ const swift = __test__.generateWidgetBundleSwift([widget])
57
+
58
+ assert.ok(swift.includes('kind: "StreakWidget"'))
59
+ assert.ok(!swift.includes('Voltra_Widget_streak'))
60
+ })
61
+ })
@@ -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
+ }