@use-voltra/ios-client 2.1.1 → 2.2.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 (131) hide show
  1. package/README.md +61 -8
  2. package/build/commonjs/dynamic-widget/api.js +15 -0
  3. package/build/commonjs/dynamic-widget/api.js.map +1 -0
  4. package/build/commonjs/index.js +15 -7
  5. package/build/commonjs/index.js.map +1 -1
  6. package/build/commonjs/native/NativeVoltra.js.map +1 -1
  7. package/build/module/dynamic-widget/api.js +10 -0
  8. package/build/module/dynamic-widget/api.js.map +1 -0
  9. package/build/module/index.js +1 -0
  10. package/build/module/index.js.map +1 -1
  11. package/build/module/native/NativeVoltra.js.map +1 -1
  12. package/build/typescript/commonjs/dynamic-widget/api.d.ts +8 -0
  13. package/build/typescript/commonjs/dynamic-widget/api.d.ts.map +1 -0
  14. package/build/typescript/commonjs/index.d.ts +1 -0
  15. package/build/typescript/commonjs/index.d.ts.map +1 -1
  16. package/build/typescript/commonjs/native/NativeVoltra.d.ts +1 -0
  17. package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
  18. package/build/typescript/module/dynamic-widget/api.d.ts +8 -0
  19. package/build/typescript/module/dynamic-widget/api.d.ts.map +1 -0
  20. package/build/typescript/module/index.d.ts +1 -0
  21. package/build/typescript/module/index.d.ts.map +1 -1
  22. package/build/typescript/module/native/NativeVoltra.d.ts +1 -0
  23. package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
  24. package/expo-plugin/build/cjs/index.js +3 -1
  25. package/expo-plugin/build/cjs/index.js.map +1 -1
  26. package/expo-plugin/build/cjs/ios-widget/index.js +1 -1
  27. package/expo-plugin/build/cjs/ios-widget/index.js.map +1 -1
  28. package/expo-plugin/build/cjs/ios-widget/podfile.js +38 -12
  29. package/expo-plugin/build/cjs/ios-widget/podfile.js.map +1 -1
  30. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js +143 -107
  31. package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
  32. package/expo-plugin/build/cjs/ios-widget/xcode/configurationList.js +30 -22
  33. package/expo-plugin/build/cjs/ios-widget/xcode/configurationList.js.map +1 -1
  34. package/expo-plugin/build/cjs/ios-widget/xcode/fileReferences.js +50 -0
  35. package/expo-plugin/build/cjs/ios-widget/xcode/fileReferences.js.map +1 -0
  36. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js +45 -43
  37. package/expo-plugin/build/cjs/ios-widget/xcode/groups.js.map +1 -1
  38. package/expo-plugin/build/cjs/ios-widget/xcode/index.js +77 -91
  39. package/expo-plugin/build/cjs/ios-widget/xcode/index.js.map +1 -1
  40. package/expo-plugin/build/cjs/ios-widget/xcode/mainAppSettings.js +41 -39
  41. package/expo-plugin/build/cjs/ios-widget/xcode/mainAppSettings.js.map +1 -1
  42. package/expo-plugin/build/cjs/ios-widget/xcode/productFile.js +2 -2
  43. package/expo-plugin/build/cjs/ios-widget/xcode/productFile.js.map +1 -1
  44. package/expo-plugin/build/cjs/ios-widget/xcode/target.js +29 -63
  45. package/expo-plugin/build/cjs/ios-widget/xcode/target.js.map +1 -1
  46. package/expo-plugin/build/esm/index.js +3 -1
  47. package/expo-plugin/build/esm/index.js.map +1 -1
  48. package/expo-plugin/build/esm/ios-widget/index.js +1 -1
  49. package/expo-plugin/build/esm/ios-widget/index.js.map +1 -1
  50. package/expo-plugin/build/esm/ios-widget/podfile.js +36 -11
  51. package/expo-plugin/build/esm/ios-widget/podfile.js.map +1 -1
  52. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js +143 -106
  53. package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
  54. package/expo-plugin/build/esm/ios-widget/xcode/configurationList.js +31 -22
  55. package/expo-plugin/build/esm/ios-widget/xcode/configurationList.js.map +1 -1
  56. package/expo-plugin/build/esm/ios-widget/xcode/fileReferences.js +45 -0
  57. package/expo-plugin/build/esm/ios-widget/xcode/fileReferences.js.map +1 -0
  58. package/expo-plugin/build/esm/ios-widget/xcode/groups.js +45 -42
  59. package/expo-plugin/build/esm/ios-widget/xcode/groups.js.map +1 -1
  60. package/expo-plugin/build/esm/ios-widget/xcode/index.js +81 -96
  61. package/expo-plugin/build/esm/ios-widget/xcode/index.js.map +1 -1
  62. package/expo-plugin/build/esm/ios-widget/xcode/mainAppSettings.js +41 -39
  63. package/expo-plugin/build/esm/ios-widget/xcode/mainAppSettings.js.map +1 -1
  64. package/expo-plugin/build/esm/ios-widget/xcode/productFile.js +3 -2
  65. package/expo-plugin/build/esm/ios-widget/xcode/productFile.js.map +1 -1
  66. package/expo-plugin/build/esm/ios-widget/xcode/target.js +28 -62
  67. package/expo-plugin/build/esm/ios-widget/xcode/target.js.map +1 -1
  68. package/expo-plugin/build/types/index.d.ts.map +1 -1
  69. package/expo-plugin/build/types/ios-widget/podfile.d.ts +19 -1
  70. package/expo-plugin/build/types/ios-widget/podfile.d.ts.map +1 -1
  71. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +3 -7
  72. package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
  73. package/expo-plugin/build/types/ios-widget/xcode/configurationList.d.ts +15 -10
  74. package/expo-plugin/build/types/ios-widget/xcode/configurationList.d.ts.map +1 -1
  75. package/expo-plugin/build/types/ios-widget/xcode/fileReferences.d.ts +16 -0
  76. package/expo-plugin/build/types/ios-widget/xcode/fileReferences.d.ts.map +1 -0
  77. package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts +0 -4
  78. package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts.map +1 -1
  79. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +22 -3
  80. package/expo-plugin/build/types/ios-widget/xcode/index.d.ts.map +1 -1
  81. package/expo-plugin/build/types/ios-widget/xcode/mainAppSettings.d.ts +16 -4
  82. package/expo-plugin/build/types/ios-widget/xcode/mainAppSettings.d.ts.map +1 -1
  83. package/expo-plugin/build/types/ios-widget/xcode/productFile.d.ts +0 -4
  84. package/expo-plugin/build/types/ios-widget/xcode/productFile.d.ts.map +1 -1
  85. package/expo-plugin/build/types/ios-widget/xcode/target.d.ts +11 -26
  86. package/expo-plugin/build/types/ios-widget/xcode/target.d.ts.map +1 -1
  87. package/expo-plugin/src/index.ts +5 -1
  88. package/expo-plugin/src/ios-widget/index.ts +1 -1
  89. package/expo-plugin/src/ios-widget/podfile.ts +46 -13
  90. package/expo-plugin/src/ios-widget/xcode/__fixtures__/fresh.pbxproj +197 -0
  91. package/expo-plugin/src/ios-widget/xcode/__fixtures__/with-existing-extension.pbxproj +321 -0
  92. package/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj +203 -0
  93. package/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +357 -0
  94. package/expo-plugin/src/ios-widget/xcode/__tests__/pbxConsistency.ts +130 -0
  95. package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +151 -152
  96. package/expo-plugin/src/ios-widget/xcode/configurationList.ts +52 -35
  97. package/expo-plugin/src/ios-widget/xcode/fileReferences.ts +51 -0
  98. package/expo-plugin/src/ios-widget/xcode/groups.ts +52 -54
  99. package/expo-plugin/src/ios-widget/xcode/index.ts +107 -119
  100. package/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts +59 -54
  101. package/expo-plugin/src/ios-widget/xcode/productFile.ts +3 -2
  102. package/expo-plugin/src/ios-widget/xcode/target.ts +31 -83
  103. package/ios/Package.swift +7 -1
  104. package/ios/app/NativeVoltra.mm +19 -0
  105. package/ios/app/VoltraModule.swift +18 -0
  106. package/ios/app/VoltraModuleImpl.swift +16 -0
  107. package/ios/shared/DynamicWidgetPropsCodec.swift +51 -0
  108. package/ios/shared/DynamicWidgetPropsStorage.swift +53 -0
  109. package/ios/shared/DynamicWidgetPropsStore.swift +50 -0
  110. package/ios/shared/DynamicWidgetRenderCoordinator.swift +31 -0
  111. package/ios/shared/DynamicWidgetUpdater.swift +30 -0
  112. package/ios/shared/VoltraConstants.swift +4 -0
  113. package/ios/shared/VoltraJSRenderer.swift +3 -1
  114. package/ios/shared/VoltraNode.swift +6 -2
  115. package/ios/shared/VoltraWidgetDefaults.swift +9 -1
  116. package/ios/target/VoltraClientWidgetRuntime.swift +17 -5
  117. package/ios/target/VoltraHomeWidget.swift +6 -2
  118. package/ios/target/VoltraWidget.swift +3 -1
  119. package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +3 -1
  120. package/ios/ui/Helpers/VoltraProgressDriver.swift +6 -2
  121. package/ios/ui/Layout/VoltraFlexStackLayout.swift +9 -3
  122. package/ios/ui/Style/JSColorParser.swift +24 -8
  123. package/ios/ui/Style/JSGradientParser.swift +36 -12
  124. package/ios/ui/Style/JSStyleParser.swift +36 -12
  125. package/ios/ui/Style/LayoutStyle.swift +12 -4
  126. package/ios/ui/Views/VoltraChart.swift +52 -16
  127. package/ios/ui/Views/VoltraLinearGradient.swift +6 -2
  128. package/package.json +5 -5
  129. package/src/dynamic-widget/api.ts +25 -0
  130. package/src/index.ts +1 -0
  131. package/src/native/NativeVoltra.ts +1 -0
@@ -2,6 +2,7 @@ import { XcodeProject } from '@expo/config-plugins'
2
2
  import * as util from 'util'
3
3
 
4
4
  import type { IOSWidgetExtensionFiles } from '../../types'
5
+ import { ensureWidgetFileReference, normalizeRef } from './fileReferences'
5
6
 
6
7
  const pbxFile = require('xcode/lib/pbxFile')
7
8
 
@@ -98,71 +99,77 @@ export function ensureWidgetBundleScriptPhase(xcodeProject: XcodeProject, target
98
99
  }
99
100
  }
100
101
 
101
- export interface AddBuildPhasesOptions {
102
+ export interface EnsureBuildPhasesOptions {
102
103
  targetUuid: string
104
+ targetName: string
103
105
  groupName: string
104
106
  productFile: {
105
107
  uuid: string
108
+ fileRef?: string
106
109
  target?: string
107
110
  basename: string
108
111
  group: string
109
112
  }
110
113
  widgetFiles: IOSWidgetExtensionFiles
111
- }
112
-
113
- export interface EnsureBuildPhasesOptions extends AddBuildPhasesOptions {
114
114
  mainTargetUuid?: string
115
115
  }
116
116
 
117
117
  /**
118
- * Adds all required build phases for the widget extension target.
118
+ * Finds a `PBXCopyFilesBuildPhase` on the given target that embeds app extensions
119
+ * (`dstSubfolderSpec == 13`), regardless of its display name. Returns the phase object or null.
120
+ *
121
+ * A project that already contains another app extension (Share/Watch/expo-live-activity) will have
122
+ * such a phase — typically named "Embed App Extensions" — and the widget's product must be added to
123
+ * it rather than to a second, duplicate embed phase (issue #87).
119
124
  */
120
- export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhasesOptions): void {
121
- const { targetUuid, groupName, productFile, widgetFiles } = options
122
- const buildPath = `""`
123
- const folderType = 'app_extension'
124
-
125
- const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles
126
- const resourcePaths = [...assetDirectories, ...localizedStringResources]
127
-
128
- // Sources build phase
129
- xcodeProject.addBuildPhase(
130
- [...swiftFiles, ...intentFiles],
131
- 'PBXSourcesBuildPhase',
132
- 'Sources',
133
- targetUuid,
134
- folderType,
135
- buildPath
136
- )
137
-
138
- // Copy files build phase
139
- xcodeProject.addBuildPhase(
140
- [],
141
- 'PBXCopyFilesBuildPhase',
142
- groupName,
143
- xcodeProject.getFirstTarget().uuid,
144
- folderType,
145
- buildPath
146
- )
125
+ function findExistingEmbedExtensionsPhase(xcodeProject: XcodeProject, targetUuid: string): any | null {
126
+ const nativeTargets = xcodeProject.pbxNativeTargetSection()
127
+ const target = nativeTargets[targetUuid]
128
+ if (!target?.buildPhases) {
129
+ return null
130
+ }
147
131
 
148
- xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, productFile.target).files.push({
149
- value: productFile.uuid,
150
- comment: util.format('%s in %s', productFile.basename, productFile.group),
151
- })
152
- xcodeProject.addToPbxBuildFileSection(productFile)
132
+ const copyFilesSection = xcodeProject.hash.project.objects['PBXCopyFilesBuildPhase'] || {}
133
+ for (const entry of target.buildPhases) {
134
+ const phase = copyFilesSection[normalizeRef(entry.value)]
135
+ if (phase && String(phase.dstSubfolderSpec) === '13') {
136
+ return phase
137
+ }
138
+ }
139
+ return null
140
+ }
153
141
 
154
- // Frameworks build phase
155
- xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath)
142
+ /**
143
+ * Finds a target's build phase of the given pbx section type by walking the target's own
144
+ * `buildPhases` list and checking membership in the type's object section; the first match wins.
145
+ *
146
+ * This is deliberately semantic: pbxproj comments are decorative and other tools strip them, and
147
+ * the xcode lib's `buildPhaseObject` both relies on comments and silently falls back to a
148
+ * project-wide search that can return another target's phase (e.g. the main app's "Sources").
149
+ * The embed phase is the one exception with extra semantics — see
150
+ * {@link findExistingEmbedExtensionsPhase} (`dstSubfolderSpec == 13`).
151
+ */
152
+ function findTargetPhaseByType(xcodeProject: XcodeProject, targetUuid: string, phaseType: string): any | null {
153
+ const target = xcodeProject.pbxNativeTargetSection()[targetUuid]
154
+ if (!target?.buildPhases) {
155
+ return null
156
+ }
156
157
 
157
- // Resources build phase
158
- xcodeProject.addBuildPhase([...resourcePaths], 'PBXResourcesBuildPhase', 'Resources', targetUuid)
158
+ const phaseSection = xcodeProject.hash.project.objects[phaseType] || {}
159
+ for (const entry of target.buildPhases) {
160
+ const phase = phaseSection[normalizeRef(entry.value)]
161
+ if (phase) {
162
+ return phase
163
+ }
164
+ }
165
+ return null
159
166
  }
160
167
 
161
168
  /**
162
169
  * Ensures all required build phases and files exist for the widget extension target.
163
170
  */
164
171
  export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBuildPhasesOptions): void {
165
- const { targetUuid, groupName, productFile, widgetFiles } = options
172
+ const { targetUuid, targetName, groupName, productFile, widgetFiles } = options
166
173
  const buildPath = `""`
167
174
  const folderType = 'app_extension'
168
175
  const mainTargetUuid = options.mainTargetUuid ?? xcodeProject.getFirstTarget().uuid
@@ -170,60 +177,55 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui
170
177
  const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles
171
178
  const resourcePaths = [...assetDirectories, ...localizedStringResources]
172
179
 
173
- dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources')
174
- dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks')
175
- dedupeBuildPhasesByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName)
180
+ dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase')
181
+ dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase')
182
+ dedupeEmbedPhasesForTarget(xcodeProject, mainTargetUuid)
176
183
 
177
184
  // Sources build phase
178
- let sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid)
185
+ let sourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXSourcesBuildPhase')
179
186
  if (!sourcesPhase) {
180
- xcodeProject.addBuildPhase(
181
- [...swiftFiles, ...intentFiles],
182
- 'PBXSourcesBuildPhase',
183
- 'Sources',
184
- targetUuid,
185
- folderType,
186
- buildPath
187
- )
188
- sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid)
187
+ xcodeProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetUuid, folderType, buildPath)
188
+ sourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXSourcesBuildPhase')
189
189
  }
190
190
  if (sourcesPhase) {
191
- ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles])
191
+ ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles], targetName)
192
192
  }
193
193
 
194
- // Copy files build phase (embed extension into main app)
195
- let copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid)
194
+ // Copy files build phase (embed extension into main app) — the embed phase is matched purely by
195
+ // its `dstSubfolderSpec == 13` semantics, both before and after creation
196
+ let copyFilesPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid)
196
197
  if (!copyFilesPhase) {
197
198
  xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath)
198
- copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid)
199
+ copyFilesPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid)
199
200
  }
200
201
  if (copyFilesPhase) {
201
202
  ensureCopyFilesPhaseProduct(xcodeProject, copyFilesPhase, productFile)
202
203
  }
203
204
 
204
205
  // Frameworks build phase
205
- const frameworksPhase = xcodeProject.buildPhaseObject('PBXFrameworksBuildPhase', 'Frameworks', targetUuid)
206
+ const frameworksPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase')
206
207
  if (!frameworksPhase) {
207
208
  xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath)
208
209
  }
209
210
 
210
211
  // Resources build phase
211
- let resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid)
212
+ let resourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXResourcesBuildPhase')
212
213
  if (!resourcesPhase) {
213
- xcodeProject.addBuildPhase([...resourcePaths], 'PBXResourcesBuildPhase', 'Resources', targetUuid)
214
- resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid)
214
+ xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetUuid)
215
+ resourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXResourcesBuildPhase')
215
216
  }
216
217
  if (resourcesPhase) {
217
- ensureBuildPhaseFiles(xcodeProject, resourcesPhase, [...resourcePaths])
218
+ ensureBuildPhaseFiles(xcodeProject, resourcesPhase, resourcePaths, targetName)
218
219
  }
219
220
  }
220
221
 
221
- function dedupeBuildPhasesForTarget(
222
- xcodeProject: XcodeProject,
223
- targetUuid: string,
224
- phaseType: string,
225
- preferredComment: string
226
- ): void {
222
+ /**
223
+ * Drops duplicate build phases of one pbx section type from a target, keeping the first phase in
224
+ * the target's `buildPhases` order. Membership in the type's object section is the only criterion —
225
+ * comments are decorative and other tools strip them. De-referenced phase objects are deleted so no
226
+ * orphans remain.
227
+ */
228
+ function dedupeBuildPhasesForTarget(xcodeProject: XcodeProject, targetUuid: string, phaseType: string): void {
227
229
  const nativeTargets = xcodeProject.pbxNativeTargetSection()
228
230
  const target = nativeTargets[targetUuid]
229
231
  if (!target?.buildPhases) {
@@ -231,114 +233,97 @@ function dedupeBuildPhasesForTarget(
231
233
  }
232
234
 
233
235
  const phaseSection = xcodeProject.hash.project.objects[phaseType] || {}
234
- const matching = target.buildPhases.filter((entry: any) => phaseSection[entry.value])
236
+ const matching = target.buildPhases.filter((entry: any) => phaseSection[normalizeRef(entry.value)])
235
237
  if (matching.length <= 1) {
236
238
  return
237
239
  }
238
240
 
239
- const keep = matching.find((entry: any) => entry.comment === preferredComment) ?? matching[0]
240
- keep.comment = preferredComment
241
-
241
+ const keep = matching[0]
242
+ const removed = matching.filter((entry: any) => entry.value !== keep.value)
242
243
  target.buildPhases = target.buildPhases.filter((entry: any) => {
243
- if (!phaseSection[entry.value]) {
244
+ if (!phaseSection[normalizeRef(entry.value)]) {
244
245
  return true
245
246
  }
246
247
  return entry.value === keep.value
247
248
  })
249
+ deleteOrphanPhaseObjects(phaseSection, removed)
248
250
  }
249
251
 
250
- function dedupeBuildPhasesByComment(
251
- xcodeProject: XcodeProject,
252
- targetUuid: string,
253
- phaseType: string,
254
- comment: string
255
- ): void {
252
+ /**
253
+ * Drops duplicate embed-app-extensions copy-files phases (`dstSubfolderSpec == 13`) from a target,
254
+ * keeping the first one in the target's `buildPhases` order. Other copy-files phases (different
255
+ * `dstSubfolderSpec`) are left untouched. De-referenced phase objects are deleted so no orphans
256
+ * remain.
257
+ */
258
+ function dedupeEmbedPhasesForTarget(xcodeProject: XcodeProject, targetUuid: string): void {
256
259
  const nativeTargets = xcodeProject.pbxNativeTargetSection()
257
260
  const target = nativeTargets[targetUuid]
258
261
  if (!target?.buildPhases) {
259
262
  return
260
263
  }
261
264
 
262
- const phaseSection = xcodeProject.hash.project.objects[phaseType] || {}
263
- const matching = target.buildPhases.filter((entry: any) => phaseSection[entry.value] && entry.comment === comment)
265
+ const phaseSection = xcodeProject.hash.project.objects['PBXCopyFilesBuildPhase'] || {}
266
+ const isEmbedEntry = (entry: any) => {
267
+ const phase = phaseSection[normalizeRef(entry.value)]
268
+ return phase && String(phase.dstSubfolderSpec) === '13'
269
+ }
270
+
271
+ const matching = target.buildPhases.filter(isEmbedEntry)
264
272
  if (matching.length <= 1) {
265
273
  return
266
274
  }
267
275
 
268
276
  const keep = matching[0]
277
+ const removed = matching.filter((entry: any) => entry.value !== keep.value)
269
278
  target.buildPhases = target.buildPhases.filter((entry: any) => {
270
- if (!phaseSection[entry.value] || entry.comment !== comment) {
279
+ if (!isEmbedEntry(entry)) {
271
280
  return true
272
281
  }
273
282
  return entry.value === keep.value
274
283
  })
284
+ deleteOrphanPhaseObjects(phaseSection, removed)
275
285
  }
276
286
 
277
- function normalizePath(value: string | undefined): string {
278
- if (!value) {
279
- return ''
280
- }
281
- return value.replace(/^"|"$/g, '')
282
- }
283
-
284
- function findFileReferenceKey(xcodeProject: XcodeProject, filePath: string): string | null {
285
- const fileReferenceSection = xcodeProject.pbxFileReferenceSection()
286
- const normalizedPath = normalizePath(filePath)
287
-
288
- for (const key of Object.keys(fileReferenceSection)) {
289
- if (/_comment$/.test(key)) {
290
- continue
291
- }
292
- const entry = fileReferenceSection[key]
293
- const entryPath = normalizePath(entry?.path)
294
- if (entryPath === normalizedPath) {
295
- return key
296
- }
297
- }
298
-
299
- return null
300
- }
301
-
302
- function findBuildFileKeyByFileRef(xcodeProject: XcodeProject, fileRef: string): string | null {
303
- const buildFileSection = xcodeProject.pbxBuildFileSection()
304
- for (const key of Object.keys(buildFileSection)) {
305
- if (/_comment$/.test(key)) {
306
- continue
307
- }
308
- const entry = buildFileSection[key]
309
- if (entry?.fileRef === fileRef) {
310
- return key
311
- }
287
+ /**
288
+ * Deletes de-referenced build-phase objects (and their `_comment` keys) from a phase section, so
289
+ * dropping duplicate phase refs from a target does not leave orphan objects that fail the
290
+ * `[Xcodeproj]` consistency check.
291
+ */
292
+ function deleteOrphanPhaseObjects(phaseSection: Record<string, any>, removedEntries: any[]): void {
293
+ for (const entry of removedEntries) {
294
+ const key = normalizeRef(entry.value)
295
+ delete phaseSection[key]
296
+ delete phaseSection[`${key}_comment`]
312
297
  }
313
- return null
314
298
  }
315
299
 
316
- function ensureFileReference(xcodeProject: XcodeProject, filePath: string) {
317
- const existingFileRef = findFileReferenceKey(xcodeProject, filePath)
300
+ /**
301
+ * Resolves (or creates) the PBXBuildFile for a widget file within a specific build phase.
302
+ *
303
+ * The lookup is scoped to the given phase's `files`: a PBXBuildFile is only reused when it is
304
+ * already part of THIS phase. This prevents adopting a build file that belongs to another target's
305
+ * phase — the same PBXFileReference (e.g. an asset catalog) may legitimately need its own build
306
+ * file per phase, and sharing one produces the "no parent for object" corruption (issue #87). When
307
+ * no match exists a new PBXBuildFile with a fresh UUID is created.
308
+ */
309
+ function ensureBuildFile(xcodeProject: XcodeProject, filePath: string, buildPhase: any, targetName: string) {
310
+ const fileRef = ensureWidgetFileReference(xcodeProject, filePath, targetName)
318
311
  const file = new pbxFile(filePath)
319
312
 
320
- if (existingFileRef) {
321
- return { fileRef: existingFileRef, basename: file.basename, group: file.group }
322
- }
323
-
324
- file.fileRef = xcodeProject.generateUuid()
325
- xcodeProject.addToPbxFileReferenceSection(file)
326
- return { fileRef: file.fileRef, basename: file.basename, group: file.group }
327
- }
328
-
329
- function ensureBuildFile(xcodeProject: XcodeProject, filePath: string) {
330
- const fileReference = ensureFileReference(xcodeProject, filePath)
331
- const existingBuildFile = findBuildFileKeyByFileRef(xcodeProject, fileReference.fileRef)
332
-
333
- if (existingBuildFile) {
334
- return { uuid: existingBuildFile, ...fileReference }
313
+ if (buildPhase?.files) {
314
+ const buildFileSection = xcodeProject.pbxBuildFileSection()
315
+ const existingInPhase = buildPhase.files.find((entry: any) => {
316
+ const buildFile = buildFileSection[normalizeRef(entry.value)]
317
+ return buildFile?.fileRef === fileRef
318
+ })
319
+ if (existingInPhase) {
320
+ return { uuid: normalizeRef(existingInPhase.value), fileRef, basename: file.basename, group: file.group }
321
+ }
335
322
  }
336
323
 
337
- const file = new pbxFile(filePath)
338
- file.uuid = xcodeProject.generateUuid()
339
- file.fileRef = fileReference.fileRef
340
- xcodeProject.addToPbxBuildFileSection(file)
341
- return { uuid: file.uuid, ...fileReference, group: file.group }
324
+ const uuid = xcodeProject.generateUuid()
325
+ xcodeProject.addToPbxBuildFileSection({ uuid, fileRef, basename: file.basename, group: file.group } as any)
326
+ return { uuid, fileRef, basename: file.basename, group: file.group }
342
327
  }
343
328
 
344
329
  function buildPhaseHasFile(xcodeProject: XcodeProject, buildPhase: any, fileRef: string): boolean {
@@ -348,23 +333,28 @@ function buildPhaseHasFile(xcodeProject: XcodeProject, buildPhase: any, fileRef:
348
333
  const buildFileSection = xcodeProject.pbxBuildFileSection()
349
334
 
350
335
  return buildPhase.files.some((entry: any) => {
351
- const buildFile = buildFileSection[entry.value]
336
+ const buildFile = buildFileSection[normalizeRef(entry.value)]
352
337
  return buildFile?.fileRef === fileRef
353
338
  })
354
339
  }
355
340
 
356
- function ensureBuildPhaseFiles(xcodeProject: XcodeProject, buildPhase: any, filePaths: string[]): void {
341
+ function ensureBuildPhaseFiles(
342
+ xcodeProject: XcodeProject,
343
+ buildPhase: any,
344
+ filePaths: string[],
345
+ targetName: string
346
+ ): void {
357
347
  if (!buildPhase.files) {
358
348
  buildPhase.files = []
359
349
  }
360
350
 
361
351
  for (const filePath of filePaths) {
362
- const fileReference = ensureFileReference(xcodeProject, filePath)
363
- if (buildPhaseHasFile(xcodeProject, buildPhase, fileReference.fileRef)) {
352
+ const fileRef = ensureWidgetFileReference(xcodeProject, filePath, targetName)
353
+ if (buildPhaseHasFile(xcodeProject, buildPhase, fileRef)) {
364
354
  continue
365
355
  }
366
356
 
367
- const buildFile = ensureBuildFile(xcodeProject, filePath)
357
+ const buildFile = ensureBuildFile(xcodeProject, filePath, buildPhase, targetName)
368
358
  buildPhase.files.push({
369
359
  value: buildFile.uuid,
370
360
  comment: util.format('%s in %s', buildFile.basename, buildFile.group),
@@ -377,18 +367,27 @@ function ensureCopyFilesPhaseProduct(xcodeProject: XcodeProject, buildPhase: any
377
367
  buildPhase.files = []
378
368
  }
379
369
 
380
- const alreadyExists = buildPhase.files.some((entry: any) => entry.value === productFile.uuid)
381
- if (alreadyExists) {
370
+ const buildFileSection = xcodeProject.pbxBuildFileSection()
371
+ const alreadyInPhase = buildPhase.files.some((entry: any) => {
372
+ const buildFile = buildFileSection[normalizeRef(entry.value)]
373
+ return buildFile?.fileRef === productFile.fileRef
374
+ })
375
+ if (alreadyInPhase) {
382
376
  return
383
377
  }
384
378
 
385
- const buildFileSection = xcodeProject.pbxBuildFileSection()
386
- if (!buildFileSection[productFile.uuid]) {
379
+ // Reuse the product's build-file UUID only if it isn't already registered by another phase;
380
+ // otherwise mint a fresh UUID so the two phases never share one PBXBuildFile (issue #87).
381
+ let useUuid = productFile.uuid
382
+ if (buildFileSection[productFile.uuid]) {
383
+ useUuid = xcodeProject.generateUuid()
384
+ xcodeProject.addToPbxBuildFileSection({ ...productFile, uuid: useUuid })
385
+ } else {
387
386
  xcodeProject.addToPbxBuildFileSection(productFile)
388
387
  }
389
388
 
390
389
  buildPhase.files.push({
391
- value: productFile.uuid,
390
+ value: useUuid,
392
391
  comment: util.format('%s in %s', productFile.basename, productFile.group),
393
392
  })
394
393
  }
@@ -1,34 +1,52 @@
1
1
  import { XcodeProject } from '@expo/config-plugins'
2
2
 
3
3
  import { IOS } from '../../constants'
4
+ import type { MainAppSigningSettings, MainAppTargetSettings } from './mainAppSettings'
4
5
 
5
- export interface AddConfigurationListOptions {
6
+ export interface EnsureConfigurationListOptions {
6
7
  targetName: string
7
8
  bundleIdentifier: string
8
9
  deploymentTarget: string
9
- codeSignStyle?: string
10
- developmentTeam?: string
11
- provisioningProfileSpecifier?: string
10
+ /**
11
+ * App marketing version (CFBundleShortVersionString). App Store Connect expects the appex
12
+ * version to match the app's, so this becomes the widget's MARKETING_VERSION.
13
+ */
14
+ version?: string
15
+ /** App build number; becomes the widget's CURRENT_PROJECT_VERSION. */
16
+ buildNumber?: string
17
+ /** Main app signing settings, applied per configuration name (Debug→Debug, Release→Release). */
18
+ mainAppSettings?: MainAppTargetSettings | null
12
19
  }
13
20
 
14
- function createCommonBuildSettings(options: AddConfigurationListOptions) {
15
- const {
16
- targetName,
17
- bundleIdentifier,
18
- deploymentTarget,
19
- codeSignStyle,
20
- developmentTeam,
21
- provisioningProfileSpecifier,
22
- } = options
23
-
24
- const commonBuildSettings: Record<string, string> = {
21
+ /**
22
+ * Resolves the main app signing settings for a widget configuration name: an exact name match
23
+ * wins, otherwise the first available configuration's settings are used as fallback.
24
+ */
25
+ function signingForConfiguration(
26
+ mainAppSettings: MainAppTargetSettings | null | undefined,
27
+ configurationName: string
28
+ ): MainAppSigningSettings {
29
+ if (!mainAppSettings) {
30
+ return {}
31
+ }
32
+ return mainAppSettings.byConfigurationName[configurationName] ?? mainAppSettings.fallback
33
+ }
34
+
35
+ function createBuildSettings(options: EnsureConfigurationListOptions, configurationName: string) {
36
+ const { targetName, bundleIdentifier, deploymentTarget, version, buildNumber, mainAppSettings } = options
37
+ const { codeSignStyle, developmentTeam, provisioningProfileSpecifier } = signingForConfiguration(
38
+ mainAppSettings,
39
+ configurationName
40
+ )
41
+
42
+ const buildSettings: Record<string, string> = {
25
43
  PRODUCT_NAME: `"$(TARGET_NAME)"`,
26
44
  SWIFT_VERSION: IOS.SWIFT_VERSION,
27
45
  TARGETED_DEVICE_FAMILY: `"${IOS.DEVICE_FAMILY}"`,
28
46
  INFOPLIST_FILE: `${targetName}/Info.plist`,
29
47
  INFOPLIST_OUTPUT_FORMAT: `"xml"`,
30
- CURRENT_PROJECT_VERSION: `"1"`,
31
- MARKETING_VERSION: `"1.0"`,
48
+ CURRENT_PROJECT_VERSION: `"${buildNumber ?? '1'}"`,
49
+ MARKETING_VERSION: `"${version ?? '1.0'}"`,
32
50
  IPHONEOS_DEPLOYMENT_TARGET: `"${deploymentTarget}"`,
33
51
  PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
34
52
  SWIFT_OPTIMIZATION_LEVEL: `"-Onone"`,
@@ -37,41 +55,37 @@ function createCommonBuildSettings(options: AddConfigurationListOptions) {
37
55
  ASSETCATALOG_COMPILER_APPICON_NAME: '""',
38
56
  }
39
57
 
40
- // Synchronize code signing settings from main app target
58
+ // Synchronize code signing settings from the main app target's matching configuration
41
59
  if (codeSignStyle) {
42
- commonBuildSettings.CODE_SIGN_STYLE = `"${codeSignStyle}"`
60
+ buildSettings.CODE_SIGN_STYLE = `"${codeSignStyle}"`
43
61
  }
44
62
  if (developmentTeam) {
45
- commonBuildSettings.DEVELOPMENT_TEAM = `"${developmentTeam}"`
63
+ buildSettings.DEVELOPMENT_TEAM = `"${developmentTeam}"`
46
64
  }
47
65
  if (provisioningProfileSpecifier) {
48
- commonBuildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${provisioningProfileSpecifier}"`
66
+ buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${provisioningProfileSpecifier}"`
49
67
  }
50
68
 
51
- return commonBuildSettings
69
+ return buildSettings
52
70
  }
53
71
 
54
72
  /**
55
- * Adds the XCConfigurationList for the widget extension target.
73
+ * Adds a fresh XCConfigurationList for the widget extension target. Internal create-only fallback
74
+ * for {@link ensureXCConfigurationList}; not part of the public pipeline.
56
75
  */
57
- export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigurationListOptions) {
76
+ function addXCConfigurationList(xcodeProject: XcodeProject, options: EnsureConfigurationListOptions) {
58
77
  const { targetName } = options
59
- const commonBuildSettings = createCommonBuildSettings(options)
60
78
 
61
79
  const buildConfigurationsList = [
62
80
  {
63
81
  name: 'Debug',
64
82
  isa: 'XCBuildConfiguration',
65
- buildSettings: {
66
- ...commonBuildSettings,
67
- },
83
+ buildSettings: createBuildSettings(options, 'Debug'),
68
84
  },
69
85
  {
70
86
  name: 'Release',
71
87
  isa: 'XCBuildConfiguration',
72
- buildSettings: {
73
- ...commonBuildSettings,
74
- },
88
+ buildSettings: createBuildSettings(options, 'Release'),
75
89
  },
76
90
  ]
77
91
 
@@ -85,11 +99,13 @@ export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddC
85
99
  }
86
100
 
87
101
  /**
88
- * Ensures an existing XCConfigurationList is updated, or adds a new one if missing.
102
+ * Ensures an existing XCConfigurationList is updated, or adds a new one if missing. Each widget
103
+ * configuration receives the build settings derived for its own name, so Debug and Release signing
104
+ * stay in sync with the main app's respective configurations.
89
105
  */
90
106
  export function ensureXCConfigurationList(
91
107
  xcodeProject: XcodeProject,
92
- options: AddConfigurationListOptions,
108
+ options: EnsureConfigurationListOptions,
93
109
  existingConfigurationListId?: string | { value?: string }
94
110
  ) {
95
111
  const configurationListId =
@@ -103,7 +119,6 @@ export function ensureXCConfigurationList(
103
119
  return addXCConfigurationList(xcodeProject, options)
104
120
  }
105
121
 
106
- const commonBuildSettings = createCommonBuildSettings(options)
107
122
  const buildConfigurations = configurationList.buildConfigurations
108
123
  const buildConfigurationSection = xcodeProject.pbxXCBuildConfigurationSection()
109
124
 
@@ -117,9 +132,11 @@ export function ensureXCConfigurationList(
117
132
  continue
118
133
  }
119
134
 
135
+ const configurationName =
136
+ typeof buildConfiguration.name === 'string' ? buildConfiguration.name.replace(/^"|"$/g, '') : ''
120
137
  buildConfiguration.buildSettings = {
121
138
  ...(buildConfiguration.buildSettings ?? {}),
122
- ...commonBuildSettings,
139
+ ...createBuildSettings(options, configurationName),
123
140
  }
124
141
  }
125
142
 
@@ -0,0 +1,51 @@
1
+ import { XcodeProject } from '@expo/config-plugins'
2
+
3
+ const pbxFile = require('xcode/lib/pbxFile')
4
+
5
+ /** Strips surrounding quotes from a pbx path/name value. */
6
+ export function normalizePath(value: string | undefined): string {
7
+ if (!value) {
8
+ return ''
9
+ }
10
+ return value.replace(/^"|"$/g, '')
11
+ }
12
+
13
+ /** Strips a trailing `/* comment *\/` and whitespace, yielding the bare UUID of a pbx reference. */
14
+ export function normalizeRef(value: unknown): string {
15
+ return String(value)
16
+ .replace(/\/\*.*?\*\//g, '')
17
+ .trim()
18
+ }
19
+
20
+ /**
21
+ * Resolves the PBXFileReference UUID for a widget file, scoped to the widget's own PBXGroup.
22
+ *
23
+ * pbxproj paths are group-relative, so a bare path like `Assets.xcassets` collides with any other
24
+ * extension's identically named file. Scoping the lookup to the children of the widget group
25
+ * (identified by `targetName`) keeps the widget's references distinct from those, which is what
26
+ * prevents the shared-fileRef corruption behind issues #87/#32. When the widget group does not
27
+ * exist yet, a fresh reference is created.
28
+ */
29
+ export function ensureWidgetFileReference(xcodeProject: XcodeProject, filePath: string, targetName: string): string {
30
+ const file = new pbxFile(filePath)
31
+ const fileReferenceSection = xcodeProject.pbxFileReferenceSection()
32
+ const group = xcodeProject.pbxGroupByName(targetName)
33
+
34
+ if (group?.children) {
35
+ for (const child of group.children) {
36
+ const refKey = normalizeRef(child.value)
37
+ const entry = fileReferenceSection[refKey]
38
+ if (!entry) {
39
+ continue
40
+ }
41
+ const entryPath = normalizePath(entry.path)
42
+ if (entryPath === file.path || entryPath === normalizePath(filePath)) {
43
+ return refKey
44
+ }
45
+ }
46
+ }
47
+
48
+ file.fileRef = xcodeProject.generateUuid()
49
+ xcodeProject.addToPbxFileReferenceSection(file)
50
+ return file.fileRef
51
+ }