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
@@ -1,4 +1,5 @@
1
1
  import type { CLI_DEFAULTS } from './defaults'
2
+ import type { PerConfiguration } from './perConfiguration'
2
3
 
3
4
  export type VoltraPlatform = 'android' | 'ios'
4
5
 
@@ -15,8 +16,11 @@ export type WidgetLabel = string | WidgetLocalizedValue
15
16
  export type WidgetInitialStatePath = string | WidgetLocalizedValue
16
17
 
17
18
  export interface AndroidWidgetServerUpdateConfig {
18
- /** Server endpoint that returns widget state updates. */
19
- url: string
19
+ /**
20
+ * Server endpoint that returns widget state updates. Optional — omit it to mark the widget
21
+ * server-driven and supply the URL at runtime with `setWidgetServerUpdate`.
22
+ */
23
+ url?: string
20
24
  /** Refresh interval, in minutes, for fetching server updates. */
21
25
  intervalMinutes?: number
22
26
  /** Whether fetched updates should trigger an immediate widget refresh. */
@@ -44,14 +48,32 @@ export interface AndroidWidgetConfig {
44
48
  displayName: WidgetLabel
45
49
  /** User-facing widget description shown by the launcher. */
46
50
  description: WidgetLabel
47
- /** Minimum widget width in dp. */
51
+ /** Minimum widget width in dp. Only affects Android 11 and older. */
48
52
  minWidth?: number
49
- /** Minimum widget height in dp. */
53
+ /** Minimum widget height in dp. Only affects Android 11 and older. */
50
54
  minHeight?: number
51
- /** Minimum widget width in launcher grid cells. */
55
+ /**
56
+ * Minimum widget width in launcher grid cells.
57
+ *
58
+ * @deprecated Use `minWidth` instead. The value is approximated in dp for Android 11 and
59
+ * older.
60
+ */
52
61
  minCellWidth?: number
53
- /** Minimum widget height in launcher grid cells. */
62
+ /**
63
+ * Minimum widget height in launcher grid cells.
64
+ *
65
+ * @deprecated Use `minHeight` instead. The value is approximated in dp for Android 11 and
66
+ * older.
67
+ */
54
68
  minCellHeight?: number
69
+ /** Minimum width, in dp, the widget can be resized down to. */
70
+ minResizeWidth?: number
71
+ /** Minimum height, in dp, the widget can be resized down to. */
72
+ minResizeHeight?: number
73
+ /** Maximum width, in dp, the widget can be resized up to. Honoured on Android 12 and newer. */
74
+ maxResizeWidth?: number
75
+ /** Maximum height, in dp, the widget can be resized up to. Honoured on Android 12 and newer. */
76
+ maxResizeHeight?: number
55
77
  /** Default widget width in launcher grid cells. */
56
78
  targetCellWidth: number
57
79
  /** Default widget height in launcher grid cells. */
@@ -84,8 +106,11 @@ export type IOSWidgetFamily =
84
106
  | 'accessoryInline'
85
107
 
86
108
  export interface IOSWidgetServerUpdateConfig {
87
- /** Server endpoint that returns widget state updates. */
88
- url: string
109
+ /**
110
+ * Server endpoint that returns widget state updates. Optional — omit it to mark the widget
111
+ * server-driven and supply the URL at runtime with `setWidgetServerUpdate`.
112
+ */
113
+ url?: string
89
114
  /** Refresh interval, in minutes, for fetching server updates. */
90
115
  intervalMinutes?: number
91
116
  /** Whether fetched updates should trigger an immediate widget refresh. */
@@ -109,6 +134,12 @@ export interface IOSWidgetAppIntentConfig {
109
134
  export interface IOSWidgetConfig {
110
135
  /** Stable widget identifier used in generated files and registrations. */
111
136
  id: string
137
+ /**
138
+ * WidgetKit `kind` of the generated widget. Defaults to `Voltra_Widget_<id>`.
139
+ * Pin it to the kind of a pre-Voltra widget so already placed instances survive the migration
140
+ * (WidgetKit identifies a placed widget by extension bundle id + kind).
141
+ */
142
+ kind?: string
112
143
  /** User-facing widget name shown in iOS widget configuration UI. */
113
144
  displayName: WidgetLabel
114
145
  /** User-facing widget description shown in iOS widget configuration UI. */
@@ -145,8 +176,11 @@ export interface IOSProjectOverrides {
145
176
  mainTargetName?: string
146
177
  /** Explicit path to the app target Info.plist file. */
147
178
  infoPlistPath?: string
148
- /** Explicit path to the main app entitlements file. */
149
- entitlementsPath?: string
179
+ /**
180
+ * Explicit path to the main app entitlements file, or one path per Xcode build configuration
181
+ * name when each environment has its own.
182
+ */
183
+ entitlementsPath?: PerConfiguration<string>
150
184
  /** Explicit path to the Podfile. */
151
185
  podfilePath?: string
152
186
  }
@@ -175,8 +209,11 @@ export interface VoltraAndroidConfig {
175
209
  export interface VoltraIOSConfig {
176
210
  /** Whether to enable push-notification-related iOS setup for widgets and Live Activities. */
177
211
  enablePushNotifications?: boolean
178
- /** App Group identifier used to share data between the app and widget extension. */
179
- groupIdentifier?: string
212
+ /**
213
+ * App Group identifier used to share data between the app and widget extension, or one per Xcode
214
+ * build configuration name when each environment has its own.
215
+ */
216
+ groupIdentifier?: PerConfiguration<string>
180
217
  /** iOS widgets to generate and register. */
181
218
  widgets?: IOSWidgetConfig[]
182
219
  /** Minimum iOS deployment target for generated widget targets. */
@@ -187,8 +224,11 @@ export interface VoltraIOSConfig {
187
224
  fonts?: string[]
188
225
  /** Directory containing user-provided images for iOS widgets. */
189
226
  userImagesPath?: string
190
- /** Keychain access group shared by the app and extension. */
191
- keychainGroup?: string
227
+ /**
228
+ * Keychain access group shared by the app and extension, or one per Xcode build configuration
229
+ * name when each environment has its own.
230
+ */
231
+ keychainGroup?: PerConfiguration<string>
192
232
  /** Native iOS project discovery overrides. */
193
233
  project?: IOSProjectOverrides
194
234
  }
@@ -211,28 +251,27 @@ export interface LoadedVoltraConfig {
211
251
  configDir: string
212
252
  }
213
253
 
214
- export interface NormalizedAndroidWidgetServerUpdateConfig {
215
- /** Server endpoint that returns widget state updates. */
216
- url: string
254
+ /**
255
+ * Build-time server-update defaults after normalization. The device treats these as the lowest
256
+ * settings layer; `setWidgetServerUpdate` overrides `url` and `intervalMinutes` at runtime.
257
+ */
258
+ export interface NormalizedWidgetServerUpdateConfig {
259
+ /** Server endpoint, when app.json set one. Absent means "URL supplied at runtime". */
260
+ url?: string
217
261
  /** Refresh interval, in minutes, for fetching server updates. */
218
262
  intervalMinutes: number
219
- /** Whether fetched updates should trigger an immediate widget refresh. */
263
+ /** Whether the widget draws a refresh button. Build-time only: it is generated UI structure. */
220
264
  refresh: boolean
221
265
  }
222
266
 
267
+ export type NormalizedAndroidWidgetServerUpdateConfig = NormalizedWidgetServerUpdateConfig
268
+
223
269
  export interface NormalizedAndroidWidgetConfig extends Omit<AndroidWidgetConfig, 'serverUpdate'> {
224
270
  /** Server-driven update settings after defaults have been applied. */
225
271
  serverUpdate?: NormalizedAndroidWidgetServerUpdateConfig
226
272
  }
227
273
 
228
- export interface NormalizedIOSWidgetServerUpdateConfig {
229
- /** Server endpoint that returns widget state updates. */
230
- url: string
231
- /** Refresh interval, in minutes, for fetching server updates. */
232
- intervalMinutes: number
233
- /** Whether fetched updates should trigger an immediate widget refresh. */
234
- refresh: boolean
235
- }
274
+ export type NormalizedIOSWidgetServerUpdateConfig = NormalizedWidgetServerUpdateConfig
236
275
 
237
276
  export interface NormalizedIOSWidgetConfig extends Omit<IOSWidgetConfig, 'serverUpdate' | 'supportedFamilies'> {
238
277
  /** Supported iOS widget families after defaults have been applied. */
@@ -261,8 +300,8 @@ export interface NormalizedIOSProjectConfig {
261
300
  mainTargetName?: string
262
301
  /** Absolute path to the Info.plist file, if overridden. */
263
302
  infoPlistPath?: string
264
- /** Absolute path to the entitlements file, if overridden. */
265
- entitlementsPath?: string
303
+ /** Absolute path to the entitlements file, or one per build configuration name, if overridden. */
304
+ entitlementsPath?: PerConfiguration<string>
266
305
  /** Absolute path to the Podfile, if overridden. */
267
306
  podfilePath?: string
268
307
  }
@@ -284,7 +323,7 @@ export interface NormalizedVoltraIOSConfig {
284
323
  /** Whether iOS push-notification-related setup should be applied. */
285
324
  enablePushNotifications: boolean
286
325
  /** App Group identifier used to share data between the app and extension. */
287
- groupIdentifier?: string
326
+ groupIdentifier?: PerConfiguration<string>
288
327
  /** iOS widgets after validation and normalization. */
289
328
  widgets: NormalizedIOSWidgetConfig[]
290
329
  /** Effective iOS deployment target for generated widget targets. */
@@ -296,11 +335,37 @@ export interface NormalizedVoltraIOSConfig {
296
335
  /** Absolute path to the iOS user images directory. */
297
336
  userImagesPath: string
298
337
  /** Keychain access group shared by the app and extension. */
299
- keychainGroup?: string
338
+ keychainGroup?: PerConfiguration<string>
300
339
  /** Normalized iOS native project discovery overrides. */
301
340
  project: NormalizedIOSProjectConfig
302
341
  }
303
342
 
343
+ /**
344
+ * iOS project overrides after per-build-configuration values have been resolved against the Xcode
345
+ * project.
346
+ */
347
+ export type ResolvedIOSProjectConfig = Omit<NormalizedIOSProjectConfig, 'entitlementsPath'> & {
348
+ /** Absolute path to the entitlements file of the default build configuration, if overridden. */
349
+ entitlementsPath?: string
350
+ }
351
+
352
+ /**
353
+ * iOS config as the platform mutators consume it: every per-build-configuration value has been
354
+ * collapsed to a single string, either the configured value or a reference to a build setting
355
+ * Voltra writes per build configuration.
356
+ */
357
+ export type ResolvedVoltraIOSConfig = Omit<
358
+ NormalizedVoltraIOSConfig,
359
+ 'groupIdentifier' | 'keychainGroup' | 'project'
360
+ > & {
361
+ /** App Group identifier used to share data between the app and extension. */
362
+ groupIdentifier?: string
363
+ /** Keychain access group shared by the app and extension. */
364
+ keychainGroup?: string
365
+ /** Resolved iOS native project discovery overrides. */
366
+ project: ResolvedIOSProjectConfig
367
+ }
368
+
304
369
  export interface NormalizedVoltraConfig {
305
370
  /** Absolute path to the loaded config file, when the config came from a file. */
306
371
  configPath?: string
@@ -312,6 +377,8 @@ export interface NormalizedVoltraConfig {
312
377
  android?: NormalizedVoltraAndroidConfig
313
378
  /** Normalized iOS-specific Voltra configuration. */
314
379
  ios?: NormalizedVoltraIOSConfig
380
+ /** Non-fatal config problems, surfaced in the `voltra apply` summary. */
381
+ warnings?: string[]
315
382
  }
316
383
 
317
384
  export type CliDefaults = typeof CLI_DEFAULTS
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, test } from 'node:test'
3
+
4
+ import { iosWidgetKind, iosWidgetKindOverrides } from './widgetKind.ts'
5
+
6
+ describe('iosWidgetKind', () => {
7
+ test('defaults to the prefixed widget id', () => {
8
+ assert.equal(iosWidgetKind({ id: 'weather' }), 'Voltra_Widget_weather')
9
+ })
10
+
11
+ test('uses the pinned kind when the widget has one', () => {
12
+ assert.equal(iosWidgetKind({ id: 'streak', kind: 'StreakWidget' }), 'StreakWidget')
13
+ })
14
+ })
15
+
16
+ describe('iosWidgetKindOverrides', () => {
17
+ test('is undefined when no widget pins a kind, so the Info.plist key stays absent', () => {
18
+ assert.equal(iosWidgetKindOverrides([{ id: 'weather' }, { id: 'streak' }]), undefined)
19
+ assert.equal(iosWidgetKindOverrides([]), undefined)
20
+ })
21
+
22
+ test('maps only the widgets that pin a kind', () => {
23
+ const overrides = iosWidgetKindOverrides([{ id: 'weather' }, { id: 'streak', kind: 'StreakWidget' }])
24
+
25
+ assert.deepEqual(overrides, { streak: 'StreakWidget' })
26
+ })
27
+ })
@@ -0,0 +1,27 @@
1
+ import type { IOSWidgetConfig } from './types'
2
+
3
+ /** Default prefix of a widget's WidgetKit `kind`: `Voltra_Widget_<id>`. Mirrors `VoltraStorageKeys.widgetKindPrefix`. */
4
+ export const IOS_WIDGET_KIND_PREFIX = 'Voltra_Widget_'
5
+
6
+ /** WidgetKit `kind` of a widget: the `kind` option when set, else `Voltra_Widget_<id>`. */
7
+ export function iosWidgetKind(widget: Pick<IOSWidgetConfig, 'id' | 'kind'>): string {
8
+ return widget.kind ?? `${IOS_WIDGET_KIND_PREFIX}${widget.id}`
9
+ }
10
+
11
+ /**
12
+ * `{ id: kind }` for the widgets that pin a custom `kind`, or undefined when none does.
13
+ * Written to both Info.plists (`Voltra_WidgetKinds`) so native code maps ids to kinds and back.
14
+ */
15
+ export function iosWidgetKindOverrides(
16
+ widgets: Pick<IOSWidgetConfig, 'id' | 'kind'>[]
17
+ ): Record<string, string> | undefined {
18
+ const overrides: Record<string, string> = {}
19
+
20
+ for (const widget of widgets) {
21
+ if (widget.kind !== undefined) {
22
+ overrides[widget.id] = widget.kind
23
+ }
24
+ }
25
+
26
+ return Object.keys(overrides).length > 0 ? overrides : undefined
27
+ }
@@ -36,6 +36,17 @@ export function requirePlatformPackage<TPackage>(projectRoot: string, platform:
36
36
  return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage
37
37
  }
38
38
 
39
+ export function getPlatformClientPackageVersion(projectRoot: string, platform: VoltraPlatform): string {
40
+ const packageName = getPlatformClientPackageName(platform)
41
+ const manifest = createProjectRequire(projectRoot)(`${packageName}/package.json`) as { version?: unknown }
42
+
43
+ if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
44
+ throw new Error(`Package ${packageName} does not declare a valid version.`)
45
+ }
46
+
47
+ return manifest.version
48
+ }
49
+
39
50
  export function getMissingPlatformPackageMessage(
40
51
  platform: VoltraPlatform,
41
52
  packageNames = getRequiredPlatformPackageNames(platform)
@@ -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(
@@ -0,0 +1,91 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { describe, test } from 'node:test'
6
+
7
+ import { findMissingMetroPackageWarning } from './metro.ts'
8
+
9
+ import type { NormalizedVoltraConfig } from '../config/types.ts'
10
+
11
+ /**
12
+ * A project root with its own `package.json`, so `createRequire` has an anchor, and optionally a
13
+ * `node_modules/@use-voltra/metro` that resolves the `bundle-widgets` subpath the same way the real
14
+ * package does.
15
+ */
16
+ async function createProject({ withMetroInstalled }: { withMetroInstalled: boolean }): Promise<string> {
17
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'voltra-metro-'))
18
+ await fs.writeFile(path.join(projectRoot, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0' }))
19
+
20
+ if (withMetroInstalled) {
21
+ const packageRoot = path.join(projectRoot, 'node_modules', '@use-voltra', 'metro')
22
+ await fs.mkdir(path.join(packageRoot, 'build', 'cjs', 'bin'), { recursive: true })
23
+ await fs.writeFile(path.join(packageRoot, 'build', 'cjs', 'bin', 'bundle-widgets.js'), 'module.exports = {}\n')
24
+ await fs.writeFile(
25
+ path.join(packageRoot, 'package.json'),
26
+ JSON.stringify({
27
+ name: '@use-voltra/metro',
28
+ version: '2.3.0',
29
+ exports: { './bundle-widgets': './build/cjs/bin/bundle-widgets.js' },
30
+ })
31
+ )
32
+ }
33
+
34
+ return projectRoot
35
+ }
36
+
37
+ function configWith(projectRoot: string, entry: string | undefined): NormalizedVoltraConfig {
38
+ return {
39
+ projectRoot,
40
+ android: {
41
+ project: {},
42
+ widgets: [
43
+ {
44
+ id: 'portfolio',
45
+ displayName: 'Portfolio',
46
+ description: 'Your holdings',
47
+ targetCellWidth: 2,
48
+ targetCellHeight: 2,
49
+ entry,
50
+ },
51
+ ],
52
+ fonts: [],
53
+ },
54
+ warnings: [],
55
+ } as unknown as NormalizedVoltraConfig
56
+ }
57
+
58
+ describe('metro package detection', () => {
59
+ test('warns when a Dynamic Widget is declared and the package is not installed', async () => {
60
+ const projectRoot = await createProject({ withMetroInstalled: false })
61
+
62
+ const warning = findMissingMetroPackageWarning(configWith(projectRoot, './widgets/portfolio.tsx'))
63
+
64
+ assert.match(warning ?? '', /@use-voltra\/metro/)
65
+ assert.match(warning ?? '', /prerendered initial state/)
66
+ })
67
+
68
+ test('stays quiet when the package resolves', async () => {
69
+ const projectRoot = await createProject({ withMetroInstalled: true })
70
+
71
+ assert.equal(findMissingMetroPackageWarning(configWith(projectRoot, './widgets/portfolio.tsx')), undefined)
72
+ })
73
+
74
+ test('stays quiet when no widget has an entry, however Metro is set up', async () => {
75
+ const projectRoot = await createProject({ withMetroInstalled: false })
76
+
77
+ assert.equal(findMissingMetroPackageWarning(configWith(projectRoot, undefined)), undefined)
78
+ })
79
+
80
+ test('does not warn at a project whose Metro config never names withVoltra', async () => {
81
+ // A config delegating to a shared preset, or hand-composed from createVoltraMiddleware, is
82
+ // correctly wired and must stay quiet — the package resolving is the whole signal.
83
+ const projectRoot = await createProject({ withMetroInstalled: true })
84
+ await fs.writeFile(
85
+ path.join(projectRoot, 'metro.config.js'),
86
+ "module.exports = require('@myorg/metro-config')(__dirname)\n"
87
+ )
88
+
89
+ assert.equal(findMissingMetroPackageWarning(configWith(projectRoot, './widgets/portfolio.tsx')), undefined)
90
+ })
91
+ })