voltra 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/build/cjs/apply/index.js +4 -1
  2. package/build/cjs/apply/index.js.map +1 -1
  3. package/build/cjs/config/normalize.js +131 -29
  4. package/build/cjs/config/normalize.js.map +1 -1
  5. package/build/cjs/config/perConfiguration.js +29 -0
  6. package/build/cjs/config/perConfiguration.js.map +1 -0
  7. package/build/cjs/config/serverUpdate.js +119 -0
  8. package/build/cjs/config/serverUpdate.js.map +1 -0
  9. package/build/cjs/config/widgetKind.js +25 -0
  10. package/build/cjs/config/widgetKind.js.map +1 -0
  11. package/build/cjs/dependencies/platformPackages.js +9 -0
  12. package/build/cjs/dependencies/platformPackages.js.map +1 -1
  13. package/build/cjs/discovery/ios.js +50 -31
  14. package/build/cjs/discovery/ios.js.map +1 -1
  15. package/build/cjs/index.js +6 -2
  16. package/build/cjs/index.js.map +1 -1
  17. package/build/cjs/platforms/android/generated.js +98 -40
  18. package/build/cjs/platforms/android/generated.js.map +1 -1
  19. package/build/cjs/platforms/android/widgetSizing.js +89 -0
  20. package/build/cjs/platforms/android/widgetSizing.js.map +1 -0
  21. package/build/cjs/platforms/ios/apply.js +21 -11
  22. package/build/cjs/platforms/ios/apply.js.map +1 -1
  23. package/build/cjs/platforms/ios/buildConfigurationValues.js +73 -0
  24. package/build/cjs/platforms/ios/buildConfigurationValues.js.map +1 -0
  25. package/build/cjs/platforms/ios/entitlements.js +34 -19
  26. package/build/cjs/platforms/ios/entitlements.js.map +1 -1
  27. package/build/cjs/platforms/ios/generated.js +79 -32
  28. package/build/cjs/platforms/ios/generated.js.map +1 -1
  29. package/build/cjs/platforms/ios/plist.js +22 -4
  30. package/build/cjs/platforms/ios/plist.js.map +1 -1
  31. package/build/cjs/platforms/ios/targetName.js.map +1 -1
  32. package/build/cjs/platforms/ios/xcodeTarget.js +166 -23
  33. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  34. package/build/cjs/platforms/shared/widgetModule.js +18 -145
  35. package/build/cjs/platforms/shared/widgetModule.js.map +1 -1
  36. package/build/esm/apply/index.js +4 -1
  37. package/build/esm/apply/index.js.map +1 -1
  38. package/build/esm/config/normalize.js +131 -29
  39. package/build/esm/config/normalize.js.map +1 -1
  40. package/build/esm/config/perConfiguration.js +25 -0
  41. package/build/esm/config/perConfiguration.js.map +1 -0
  42. package/build/esm/config/serverUpdate.js +112 -0
  43. package/build/esm/config/serverUpdate.js.map +1 -0
  44. package/build/esm/config/widgetKind.js +20 -0
  45. package/build/esm/config/widgetKind.js.map +1 -0
  46. package/build/esm/dependencies/platformPackages.js +8 -0
  47. package/build/esm/dependencies/platformPackages.js.map +1 -1
  48. package/build/esm/discovery/ios.js +50 -31
  49. package/build/esm/discovery/ios.js.map +1 -1
  50. package/build/esm/index.js +1 -0
  51. package/build/esm/index.js.map +1 -1
  52. package/build/esm/platforms/android/generated.js +100 -42
  53. package/build/esm/platforms/android/generated.js.map +1 -1
  54. package/build/esm/platforms/android/widgetSizing.js +85 -0
  55. package/build/esm/platforms/android/widgetSizing.js.map +1 -0
  56. package/build/esm/platforms/ios/apply.js +21 -11
  57. package/build/esm/platforms/ios/apply.js.map +1 -1
  58. package/build/esm/platforms/ios/buildConfigurationValues.js +68 -0
  59. package/build/esm/platforms/ios/buildConfigurationValues.js.map +1 -0
  60. package/build/esm/platforms/ios/entitlements.js +34 -19
  61. package/build/esm/platforms/ios/entitlements.js.map +1 -1
  62. package/build/esm/platforms/ios/generated.js +80 -33
  63. package/build/esm/platforms/ios/generated.js.map +1 -1
  64. package/build/esm/platforms/ios/plist.js +22 -4
  65. package/build/esm/platforms/ios/plist.js.map +1 -1
  66. package/build/esm/platforms/ios/targetName.js.map +1 -1
  67. package/build/esm/platforms/ios/xcodeTarget.js +166 -23
  68. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  69. package/build/esm/platforms/shared/widgetModule.js +16 -107
  70. package/build/esm/platforms/shared/widgetModule.js.map +1 -1
  71. package/build/types/apply/index.d.ts.map +1 -1
  72. package/build/types/config/normalize.d.ts.map +1 -1
  73. package/build/types/config/perConfiguration.d.ts +18 -0
  74. package/build/types/config/perConfiguration.d.ts.map +1 -0
  75. package/build/types/config/serverUpdate.d.ts +80 -0
  76. package/build/types/config/serverUpdate.d.ts.map +1 -0
  77. package/build/types/config/types.d.ts +91 -30
  78. package/build/types/config/types.d.ts.map +1 -1
  79. package/build/types/config/widgetKind.d.ts +11 -0
  80. package/build/types/config/widgetKind.d.ts.map +1 -0
  81. package/build/types/dependencies/platformPackages.d.ts +1 -0
  82. package/build/types/dependencies/platformPackages.d.ts.map +1 -1
  83. package/build/types/discovery/ios.d.ts +10 -0
  84. package/build/types/discovery/ios.d.ts.map +1 -1
  85. package/build/types/index.d.ts +3 -1
  86. package/build/types/index.d.ts.map +1 -1
  87. package/build/types/platforms/android/generated.d.ts.map +1 -1
  88. package/build/types/platforms/android/widgetSizing.d.ts +50 -0
  89. package/build/types/platforms/android/widgetSizing.d.ts.map +1 -0
  90. package/build/types/platforms/ios/apply.d.ts.map +1 -1
  91. package/build/types/platforms/ios/buildConfigurationValues.d.ts +33 -0
  92. package/build/types/platforms/ios/buildConfigurationValues.d.ts.map +1 -0
  93. package/build/types/platforms/ios/entitlements.d.ts +4 -4
  94. package/build/types/platforms/ios/entitlements.d.ts.map +1 -1
  95. package/build/types/platforms/ios/generated.d.ts +14 -2
  96. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  97. package/build/types/platforms/ios/plist.d.ts +3 -3
  98. package/build/types/platforms/ios/plist.d.ts.map +1 -1
  99. package/build/types/platforms/ios/podfile.d.ts +2 -2
  100. package/build/types/platforms/ios/podfile.d.ts.map +1 -1
  101. package/build/types/platforms/ios/targetName.d.ts +2 -2
  102. package/build/types/platforms/ios/targetName.d.ts.map +1 -1
  103. package/build/types/platforms/ios/xcodeTarget.d.ts +4 -2
  104. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  105. package/build/types/platforms/shared/widgetModule.d.ts +12 -3
  106. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -1
  107. package/package.json +5 -5
  108. package/src/apply/index.ts +4 -1
  109. package/src/config/normalize.node.test.ts +61 -0
  110. package/src/config/normalize.ts +188 -44
  111. package/src/config/perConfiguration.ts +49 -0
  112. package/src/config/serverUpdate.ts +160 -0
  113. package/src/config/types.ts +97 -30
  114. package/src/config/widgetKind.node.test.ts +27 -0
  115. package/src/config/widgetKind.ts +27 -0
  116. package/src/dependencies/platformPackages.ts +11 -0
  117. package/src/discovery/ios.ts +104 -60
  118. package/src/index.ts +8 -0
  119. package/src/platforms/android/generated.ts +144 -47
  120. package/src/platforms/android/widgetSizing.ts +112 -0
  121. package/src/platforms/ios/apply.ts +21 -11
  122. package/src/platforms/ios/buildConfigurationValues.ts +105 -0
  123. package/src/platforms/ios/entitlements.ts +55 -31
  124. package/src/platforms/ios/generated.node.test.ts +61 -0
  125. package/src/platforms/ios/generated.ts +137 -38
  126. package/src/platforms/ios/plist.ts +36 -7
  127. package/src/platforms/ios/podfile.ts +2 -2
  128. package/src/platforms/ios/targetName.ts +2 -2
  129. package/src/platforms/ios/xcodeTarget.ts +248 -43
  130. package/src/platforms/shared/widgetModule.ts +22 -140
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Android widget sizing.
3
+ *
4
+ * Android 12+ (API 31+) places widgets by `targetCellWidth`/`targetCellHeight`, expressed in
5
+ * launcher grid cells. Android 11 and older ignore those attributes entirely and place widgets by
6
+ * `minWidth`/`minHeight`, expressed in dp, so a widget has to declare both sets to be placed
7
+ * correctly everywhere. Voltra widgets build against the host app's `minSdkVersion` — React
8
+ * Native's template sets 24 — so the pre-Android-12 path is not a legacy edge case.
9
+ * https://developer.android.com/develop/ui/compose/glance/create-app-widget
10
+ *
11
+ * Cell size varies by device, launcher and orientation, so converting cells to dp can only ever be
12
+ * an approximation. These are the figures Google publishes for a 5x4 handset grid, following the
13
+ * accompanying guidance to size widths from the portrait table and heights from the landscape
14
+ * table, because those are the smaller cells on each axis.
15
+ * https://developer.android.com/develop/ui/views/appwidgets/layouts
16
+ *
17
+ * The `voltra` CLI and the `@use-voltra/android-client` Expo plugin generate the same
18
+ * `appwidget-provider` XML from their own copy of this module; keep the two in sync.
19
+ */
20
+
21
+ /** Widget fields that determine the generated `appwidget-provider` sizing attributes. */
22
+ export interface AndroidWidgetSizingInput {
23
+ targetCellWidth: number
24
+ targetCellHeight: number
25
+ minWidth?: number
26
+ minHeight?: number
27
+ minCellWidth?: number
28
+ minCellHeight?: number
29
+ minResizeWidth?: number
30
+ minResizeHeight?: number
31
+ maxResizeWidth?: number
32
+ maxResizeHeight?: number
33
+ }
34
+
35
+ /** Resize bounds, in dp, emitted only when set. `maxResize*` is honoured from Android 12 on. */
36
+ const RESIZE_BOUNDS = ['minResizeWidth', 'minResizeHeight', 'maxResizeWidth', 'maxResizeHeight'] as const
37
+
38
+ function cellsToWidthDp(cells: number): number {
39
+ return cells * 73 - 16
40
+ }
41
+
42
+ function cellsToHeightDp(cells: number): number {
43
+ return cells * 66 - 15
44
+ }
45
+
46
+ function resolveMinimumSize(widget: AndroidWidgetSizingInput): { minWidth: number; minHeight: number } {
47
+ return {
48
+ minWidth: widget.minWidth ?? cellsToWidthDp(widget.minCellWidth ?? widget.targetCellWidth),
49
+ minHeight: widget.minHeight ?? cellsToHeightDp(widget.minCellHeight ?? widget.targetCellHeight),
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Sizing attributes for a single widget, ordered as in Android's own reference example.
55
+ *
56
+ * `minWidth` and `minHeight` are always emitted, so a widget is placed at its intended size on
57
+ * Android 11 and older: an explicit dp value wins, then the deprecated cell count, then the
58
+ * target cell count.
59
+ */
60
+ export function androidWidgetSizingAttributes(widget: AndroidWidgetSizingInput): string[] {
61
+ const { minWidth, minHeight } = resolveMinimumSize(widget)
62
+
63
+ const attributes = [
64
+ `android:minWidth="${minWidth}dp"`,
65
+ `android:minHeight="${minHeight}dp"`,
66
+ `android:targetCellWidth="${widget.targetCellWidth}"`,
67
+ `android:targetCellHeight="${widget.targetCellHeight}"`,
68
+ ]
69
+
70
+ for (const bound of RESIZE_BOUNDS) {
71
+ const value = widget[bound]
72
+ if (value !== undefined) {
73
+ attributes.push(`android:${bound}="${value}dp"`)
74
+ }
75
+ }
76
+
77
+ return attributes
78
+ }
79
+
80
+ /**
81
+ * Resize bounds Android will silently ignore, described for the developer who set them.
82
+ *
83
+ * `minResizeWidth` has no effect when it is greater than `minWidth`, and `maxResizeWidth` has no
84
+ * effect when it is smaller; likewise for the height pair. This reports rather than throws,
85
+ * because `minWidth` is usually the approximation derived from the widget's cell count.
86
+ * https://developer.android.com/reference/android/appwidget/AppWidgetProviderInfo
87
+ */
88
+ export function androidWidgetSizingWarnings(widget: AndroidWidgetSizingInput, widgetId: string): string[] {
89
+ const { minWidth, minHeight } = resolveMinimumSize(widget)
90
+ const warnings: string[] = []
91
+
92
+ const report = (bound: string, value: number, relation: string, minimum: string, minimumValue: number): void => {
93
+ warnings.push(
94
+ `Widget '${widgetId}': ${bound} (${value}dp) is ${relation} ${minimum} (${minimumValue}dp), so Android ignores it.`
95
+ )
96
+ }
97
+
98
+ if (widget.minResizeWidth !== undefined && widget.minResizeWidth > minWidth) {
99
+ report('minResizeWidth', widget.minResizeWidth, 'greater than', 'minWidth', minWidth)
100
+ }
101
+ if (widget.minResizeHeight !== undefined && widget.minResizeHeight > minHeight) {
102
+ report('minResizeHeight', widget.minResizeHeight, 'greater than', 'minHeight', minHeight)
103
+ }
104
+ if (widget.maxResizeWidth !== undefined && widget.maxResizeWidth < minWidth) {
105
+ report('maxResizeWidth', widget.maxResizeWidth, 'smaller than', 'minWidth', minWidth)
106
+ }
107
+ if (widget.maxResizeHeight !== undefined && widget.maxResizeHeight < minHeight) {
108
+ report('maxResizeHeight', widget.maxResizeHeight, 'smaller than', 'minHeight', minHeight)
109
+ }
110
+
111
+ return warnings
112
+ }
@@ -2,6 +2,7 @@ import { discoverIOSProject } from '../../discovery/ios'
2
2
  import { getMissingPlatformPackageMessage, getMissingPlatformPackages } from '../../dependencies/platformPackages'
3
3
  import { VoltraCliError } from '../../reporting/summary'
4
4
 
5
+ import { resolveIOSBuildConfigurationValues } from './buildConfigurationValues'
5
6
  import { ensureEntitlements } from './entitlements'
6
7
  import { generateIOSFiles } from './generated'
7
8
  import { ensureInfoPlist } from './plist'
@@ -52,32 +53,36 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise<P
52
53
  }
53
54
 
54
55
  const discovery = getIOSDiscovery(context.preflight)
56
+ // Values given per build configuration are collapsed once, so every mutator below sees a plain
57
+ // string and the project carries the per-configuration values.
58
+ const { ios, buildSettings } = resolveIOSBuildConfigurationValues(iosConfig, discovery)
55
59
  const generatedResult = await generateIOSFiles({
56
60
  projectRoot: context.config.projectRoot,
57
- ios: iosConfig,
61
+ ios,
58
62
  discovery,
59
63
  })
60
64
  const entitlementsResult = await ensureEntitlements({
61
65
  projectRoot: context.config.projectRoot,
62
- ios: iosConfig,
66
+ ios,
63
67
  discovery,
64
68
  })
65
69
  const infoPlistResult = await ensureInfoPlist({
66
70
  projectRoot: context.config.projectRoot,
67
- ios: iosConfig,
71
+ ios,
68
72
  discovery,
69
73
  })
70
74
  const podfileResult = await ensurePodfileBlock({
71
75
  projectRoot: context.config.projectRoot,
72
- ios: iosConfig,
76
+ ios,
73
77
  discovery,
74
78
  })
75
79
  const xcodeTargetResult = await ensureIOSWidgetTarget({
76
80
  projectRoot: context.config.projectRoot,
77
- ios: iosConfig,
81
+ ios,
78
82
  discovery,
79
83
  generatedFiles: generatedResult.files,
80
84
  previousGeneratedFiles: context.previousState?.files,
85
+ buildSettings,
81
86
  })
82
87
 
83
88
  if (generatedResult.targetName !== xcodeTargetResult.targetName) {
@@ -93,11 +98,10 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise<P
93
98
  }
94
99
 
95
100
  const changes = [
96
- infoPlistResult.change,
97
- entitlementsResult.change,
98
- podfileResult.change,
99
- xcodeTargetResult.change,
100
- ].filter(isDefined)
101
+ ...infoPlistResult.changes,
102
+ ...entitlementsResult.changes,
103
+ ...[podfileResult.change, xcodeTargetResult.change].filter(isDefined),
104
+ ]
101
105
 
102
106
  return {
103
107
  platform: 'ios',
@@ -130,7 +134,13 @@ function isIOSProjectDiscovery(value: unknown): value is IOSProjectDiscovery {
130
134
  candidate.podfilePath,
131
135
  candidate.mainTargetName,
132
136
  candidate.infoPlistPath,
133
- ].every((entry) => typeof entry === 'string' && entry.length > 0) && Array.isArray(candidate.mainTargetCandidates)
137
+ ].every((entry) => typeof entry === 'string' && entry.length > 0) &&
138
+ [
139
+ candidate.mainTargetCandidates,
140
+ candidate.buildConfigurationNames,
141
+ candidate.infoPlistPaths,
142
+ candidate.entitlementsPaths,
143
+ ].every((entry) => Array.isArray(entry))
134
144
  )
135
145
  }
136
146
 
@@ -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
+ })