voltra 2.0.0 → 2.1.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 (84) hide show
  1. package/build/cjs/config/normalize.js +93 -0
  2. package/build/cjs/config/normalize.js.map +1 -1
  3. package/build/cjs/index.js +5 -2
  4. package/build/cjs/index.js.map +1 -1
  5. package/build/cjs/platforms/android/apply.js +11 -2
  6. package/build/cjs/platforms/android/apply.js.map +1 -1
  7. package/build/cjs/platforms/android/generated.js +152 -130
  8. package/build/cjs/platforms/android/generated.js.map +1 -1
  9. package/build/cjs/platforms/android/gradle.js +120 -0
  10. package/build/cjs/platforms/android/gradle.js.map +1 -0
  11. package/build/cjs/platforms/ios/generated.js +246 -142
  12. package/build/cjs/platforms/ios/generated.js.map +1 -1
  13. package/build/cjs/platforms/ios/xcodeTarget.js +68 -0
  14. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  15. package/build/cjs/platforms/shared/widgetModule.js +151 -0
  16. package/build/cjs/platforms/shared/widgetModule.js.map +1 -0
  17. package/build/esm/config/normalize.js +93 -0
  18. package/build/esm/config/normalize.js.map +1 -1
  19. package/build/esm/index.js +1 -0
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/platforms/android/apply.js +11 -2
  22. package/build/esm/platforms/android/apply.js.map +1 -1
  23. package/build/esm/platforms/android/generated.js +152 -97
  24. package/build/esm/platforms/android/generated.js.map +1 -1
  25. package/build/esm/platforms/android/gradle.js +112 -0
  26. package/build/esm/platforms/android/gradle.js.map +1 -0
  27. package/build/esm/platforms/ios/generated.js +246 -109
  28. package/build/esm/platforms/ios/generated.js.map +1 -1
  29. package/build/esm/platforms/ios/xcodeTarget.js +69 -1
  30. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  31. package/build/esm/platforms/shared/widgetModule.js +111 -0
  32. package/build/esm/platforms/shared/widgetModule.js.map +1 -0
  33. package/build/types/config/normalize.d.ts.map +1 -1
  34. package/build/types/config/types.d.ts +32 -0
  35. package/build/types/config/types.d.ts.map +1 -1
  36. package/build/types/index.d.ts +3 -1
  37. package/build/types/index.d.ts.map +1 -1
  38. package/build/types/platforms/android/apply.d.ts.map +1 -1
  39. package/build/types/platforms/android/generated.d.ts.map +1 -1
  40. package/build/types/platforms/android/gradle.d.ts +18 -0
  41. package/build/types/platforms/android/gradle.d.ts.map +1 -0
  42. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  43. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  44. package/build/types/platforms/shared/widgetModule.d.ts +9 -0
  45. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -0
  46. package/package.json +2 -1
  47. package/src/apply/index.ts +309 -0
  48. package/src/apply/preflight.ts +172 -0
  49. package/src/bin.ts +14 -0
  50. package/src/commands/apply.ts +114 -0
  51. package/src/config/defaults.ts +34 -0
  52. package/src/config/load.ts +100 -0
  53. package/src/config/normalize.ts +562 -0
  54. package/src/config/types.ts +317 -0
  55. package/src/cosmiconfig.d.ts +21 -0
  56. package/src/dependencies/platformPackages.ts +75 -0
  57. package/src/discovery/android.ts +233 -0
  58. package/src/discovery/ios.ts +579 -0
  59. package/src/fs/path.ts +25 -0
  60. package/src/fs/readWrite.ts +102 -0
  61. package/src/git/status.ts +205 -0
  62. package/src/index.ts +204 -0
  63. package/src/platforms/android/apply.ts +106 -0
  64. package/src/platforms/android/generated.ts +1284 -0
  65. package/src/platforms/android/gradle.ts +155 -0
  66. package/src/platforms/android/manifest.ts +379 -0
  67. package/src/platforms/ios/apply.ts +139 -0
  68. package/src/platforms/ios/deploymentTarget.ts +47 -0
  69. package/src/platforms/ios/entitlements.ts +146 -0
  70. package/src/platforms/ios/generated.ts +1233 -0
  71. package/src/platforms/ios/mainAppEntitlements.ts +11 -0
  72. package/src/platforms/ios/plist.ts +321 -0
  73. package/src/platforms/ios/podfile.ts +255 -0
  74. package/src/platforms/ios/targetName.ts +11 -0
  75. package/src/platforms/ios/xcode.ts +181 -0
  76. package/src/platforms/ios/xcodeTarget.ts +899 -0
  77. package/src/platforms/shared/widgetModule.ts +153 -0
  78. package/src/reporting/clack.ts +67 -0
  79. package/src/reporting/summary.ts +116 -0
  80. package/src/state/diff.ts +21 -0
  81. package/src/state/files.ts +52 -0
  82. package/src/state/load.ts +66 -0
  83. package/src/state/save.ts +31 -0
  84. package/src/xml2js.d.ts +23 -0
@@ -0,0 +1,899 @@
1
+ import path from 'node:path'
2
+
3
+ import {
4
+ PBXFileReference,
5
+ PBXFrameworksBuildPhase,
6
+ PBXGroup,
7
+ PBXNativeTarget,
8
+ PBXResourcesBuildPhase,
9
+ PBXShellScriptBuildPhase,
10
+ PBXSourcesBuildPhase,
11
+ XCBuildConfiguration,
12
+ XCConfigurationList,
13
+ } from '@bacons/xcode'
14
+
15
+ import { normalizeRelativePath, toRelativePath } from '../../fs/path'
16
+ import { pathExists } from '../../fs/readWrite'
17
+ import { VoltraCliError } from '../../reporting/summary'
18
+ import { resolveIOSWidgetTargetName } from './targetName'
19
+ import { ensureMainGroupChild, openIOSXcodeProject, saveIOSXcodeProject } from './xcode'
20
+ import { resolveMainAppEntitlementsPath } from './mainAppEntitlements'
21
+ import { needsEntitlementsMutation } from './entitlements'
22
+ import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, maxIOSDeploymentTarget } from './deploymentTarget'
23
+
24
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
25
+ import type { NormalizedVoltraIOSConfig } from '../../config/types'
26
+ import type { ReportedChange } from '../../reporting/summary'
27
+ import type { IOSXcodeProjectContext } from './xcode'
28
+ import type { BuildSettings } from '@bacons/xcode/build/json/types'
29
+
30
+ const IOS_APP_EXTENSION_PRODUCT_TYPE = 'com.apple.product-type.app-extension'
31
+ const PRODUCT_FILE_TYPE = 'wrapper.app-extension'
32
+ const SWIFT_FILE_TYPE = 'sourcecode.swift'
33
+ const STRINGS_FILE_TYPE = 'text.plist.strings'
34
+ const PLIST_FILE_TYPE = 'text.plist.xml'
35
+ const ASSET_CATALOG_FILE_TYPE = 'folder.assetcatalog'
36
+ const COPY_FILES_PHASE_NAME = 'Embed Foundation Extensions'
37
+ const DYNAMIC_WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Widgets'
38
+ const SOURCE_EXTENSIONS = new Set(['.swift'])
39
+ const RESOURCE_EXTENSIONS = new Set(['.xcassets', '.strings', '.ttf', '.otf', '.woff', '.woff2'])
40
+ const DYNAMIC_WIDGET_BUNDLE_SHELL_SCRIPT = `if [[ "$CONFIGURATION" == *Debug* ]]; then
41
+ echo "Voltra: Debug build - Dynamic Widgets load from Metro, skipping bundling"
42
+ exit 0
43
+ fi
44
+
45
+ if [[ -f "$SRCROOT/.xcode.env" ]]; then
46
+ source "$SRCROOT/.xcode.env"
47
+ fi
48
+ if [[ -f "$SRCROOT/.xcode.env.local" ]]; then
49
+ source "$SRCROOT/.xcode.env.local"
50
+ fi
51
+
52
+ export PROJECT_ROOT="\${PROJECT_ROOT:-$SRCROOT/..}"
53
+ NODE_BINARY="\${NODE_BINARY:-node}"
54
+
55
+ BUNDLER="$("$NODE_BINARY" - "$PROJECT_ROOT" <<'NODE'
56
+ const { createRequire } = require('node:module')
57
+ const path = require('node:path')
58
+
59
+ const projectRoot = process.argv[2]
60
+ const requireFromProject = createRequire(path.join(projectRoot, 'package.json'))
61
+
62
+ try {
63
+ const resolved = requireFromProject.resolve('@use-voltra/metro/bundle-widgets')
64
+ process.stdout.write(resolved)
65
+ } catch (error) {
66
+ console.error(
67
+ 'error: Voltra widget bundler could not resolve @use-voltra/metro from ' +
68
+ projectRoot +
69
+ '. Install @use-voltra/metro in app project so release widgets can be baked.'
70
+ )
71
+ console.error(error && error.message ? error.message : String(error))
72
+ process.exit(1)
73
+ }
74
+ NODE
75
+ )"
76
+ if [[ -z "$BUNDLER" ]]; then
77
+ echo "Voltra: resolver returned empty string for @use-voltra/metro/bundle-widgets" >&2
78
+ exit 1
79
+ fi
80
+
81
+ echo "Voltra: widget bundler resolved to $BUNDLER"
82
+ "$NODE_BINARY" "$BUNDLER" --out-dir "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" --platform ios --project-root "$PROJECT_ROOT"
83
+ `
84
+
85
+ export interface EnsureIOSWidgetTargetOptions {
86
+ projectRoot: string
87
+ ios: NormalizedVoltraIOSConfig
88
+ discovery: IOSProjectDiscovery
89
+ generatedFiles: string[]
90
+ previousGeneratedFiles?: string[]
91
+ }
92
+
93
+ export interface EnsureIOSWidgetTargetResult {
94
+ change?: ReportedChange
95
+ targetName: string
96
+ }
97
+
98
+ export class IOSWidgetTargetMutationError extends VoltraCliError {
99
+ constructor(message: string) {
100
+ super(message, 'VOLTRA_IOS_XCODE_TARGET_FAILED')
101
+ this.name = 'IOSWidgetTargetMutationError'
102
+ }
103
+ }
104
+
105
+ export async function ensureIOSWidgetTarget(
106
+ options: EnsureIOSWidgetTargetOptions
107
+ ): Promise<EnsureIOSWidgetTargetResult> {
108
+ const { projectRoot, ios, discovery, generatedFiles, previousGeneratedFiles } = options
109
+ const targetName = resolveIOSWidgetTargetName(ios, discovery)
110
+ const context = openIOSXcodeProject(discovery)
111
+ const beforeSerialized = JSON.stringify(context.project.toJSON())
112
+ const productPath = `${targetName}.appex`
113
+ const nextGeneratedFiles = normalizeGeneratedFilePaths(generatedFiles, projectRoot, discovery)
114
+ const previousWidgetFiles = normalizeGeneratedFilePaths(previousGeneratedFiles ?? [], projectRoot, discovery)
115
+ const staleTargetNames = getStaleWidgetTargetNames(previousWidgetFiles, targetName)
116
+ const bundleIdentifier = resolveBundleIdentifier(context, discovery, targetName)
117
+ const codeSigning = getMainAppCodeSigningSettings(context)
118
+ const mainAppEntitlementsPath = await getMainAppEntitlementsBuildSetting(discovery, ios)
119
+
120
+ removeStaleWidgetTargets(context, staleTargetNames)
121
+ ensureMainAppEntitlementsBuildSetting(context, mainAppEntitlementsPath)
122
+ ensureMainAppDeploymentTarget(context)
123
+ ensureWidgetTarget(context, targetName, bundleIdentifier, ios.deploymentTarget, codeSigning)
124
+
125
+ const widgetTarget = getWidgetTarget(context, targetName)
126
+ const widgetGroup = ensureWidgetGroup(context, targetName)
127
+ const productFile = ensureProductFile(context, targetName, productPath)
128
+
129
+ sanitizeWidgetGroupChildren(widgetGroup)
130
+
131
+ widgetTarget.props.productReference = productFile
132
+ widgetTarget.props.productType = IOS_APP_EXTENSION_PRODUCT_TYPE
133
+ widgetTarget.props.productName = targetName
134
+
135
+ ensureTargetDependency(context, widgetTarget)
136
+ ensureTargetAttributes(context, widgetTarget)
137
+ ensureDynamicWidgetBundlePhase(
138
+ widgetTarget,
139
+ ios.widgets.some((widget) => widget.entry !== undefined)
140
+ )
141
+ removeStaleGeneratedFileReferences(context, widgetTarget, widgetGroup, previousWidgetFiles, nextGeneratedFiles)
142
+ ensureBuildPhases(context, widgetTarget, productFile, nextGeneratedFiles)
143
+ ensureWidgetGroupFiles(context, widgetGroup, targetName, nextGeneratedFiles)
144
+ removeEmptyWidgetGroups(context, staleTargetNames)
145
+
146
+ const changePath = toRelativePath(projectRoot, discovery.pbxprojPath)
147
+ const afterSerialized = JSON.stringify(context.project.toJSON())
148
+
149
+ if (beforeSerialized !== afterSerialized) {
150
+ await saveIOSXcodeProject(context)
151
+ }
152
+
153
+ return {
154
+ change: beforeSerialized === afterSerialized ? undefined : { kind: 'updated', path: changePath },
155
+ targetName,
156
+ }
157
+ }
158
+
159
+ function ensureWidgetTarget(
160
+ context: IOSXcodeProjectContext,
161
+ targetName: string,
162
+ bundleIdentifier: string,
163
+ deploymentTarget: string,
164
+ codeSigning: MainAppCodeSigningSettings
165
+ ): PBXNativeTarget {
166
+ const existingTarget = getWidgetTargetOptional(context, targetName)
167
+
168
+ if (existingTarget) {
169
+ ensureBuildConfigurations(existingTarget, targetName, bundleIdentifier, deploymentTarget, codeSigning)
170
+ return existingTarget
171
+ }
172
+
173
+ const buildConfigurationList = createBuildConfigurationList(
174
+ context,
175
+ targetName,
176
+ bundleIdentifier,
177
+ deploymentTarget,
178
+ codeSigning
179
+ )
180
+ const target = context.project.rootObject.createNativeTarget({
181
+ buildConfigurationList,
182
+ name: targetName,
183
+ productType: IOS_APP_EXTENSION_PRODUCT_TYPE,
184
+ })
185
+
186
+ target.props.productName = targetName
187
+ target.getSourcesBuildPhase()
188
+ target.getResourcesBuildPhase()
189
+ target.getFrameworksBuildPhase()
190
+ return target
191
+ }
192
+
193
+ function createBuildConfigurationList(
194
+ context: IOSXcodeProjectContext,
195
+ targetName: string,
196
+ bundleIdentifier: string,
197
+ deploymentTarget: string,
198
+ codeSigning: MainAppCodeSigningSettings
199
+ ): XCConfigurationList {
200
+ const configs = context.mainAppTarget.buildConfigurations.all.map((config) => {
201
+ return XCBuildConfiguration.create(context.project, {
202
+ name: config.props.name,
203
+ buildSettings: buildWidgetBuildSettings(
204
+ targetName,
205
+ bundleIdentifier,
206
+ deploymentTarget,
207
+ codeSigning,
208
+ config.props.name
209
+ ),
210
+ })
211
+ })
212
+
213
+ return XCConfigurationList.create(context.project, {
214
+ buildConfigurations: configs,
215
+ defaultConfigurationName: context.mainAppTarget.buildConfigurations.default.props.name,
216
+ })
217
+ }
218
+
219
+ function ensureBuildConfigurations(
220
+ target: PBXNativeTarget,
221
+ targetName: string,
222
+ bundleIdentifier: string,
223
+ minimumDeploymentTarget: string,
224
+ codeSigning: MainAppCodeSigningSettings
225
+ ): void {
226
+ const configurationList = target.props.buildConfigurationList
227
+
228
+ if (!configurationList) {
229
+ throw new IOSWidgetTargetMutationError(
230
+ `Widget target '${target.props.name}' is missing a build configuration list.`
231
+ )
232
+ }
233
+
234
+ for (const config of configurationList.props.buildConfigurations) {
235
+ const deploymentTarget = resolveBuildConfigurationDeploymentTarget(config, minimumDeploymentTarget)
236
+
237
+ Object.assign(
238
+ config.props.buildSettings,
239
+ buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name)
240
+ )
241
+ }
242
+ }
243
+
244
+ function buildWidgetBuildSettings(
245
+ targetName: string,
246
+ bundleIdentifier: string,
247
+ deploymentTarget: string,
248
+ codeSigning: MainAppCodeSigningSettings,
249
+ configurationName: string
250
+ ): BuildSettings & Record<string, string | undefined> {
251
+ const buildSettings: BuildSettings & Record<string, string | undefined> = {
252
+ ASSETCATALOG_COMPILER_APPICON_NAME: '',
253
+ CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`,
254
+ CURRENT_PROJECT_VERSION: '1',
255
+ INFOPLIST_FILE: `${targetName}/Info.plist`,
256
+ INFOPLIST_OUTPUT_FORMAT: 'xml',
257
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
258
+ MARKETING_VERSION: '1.0',
259
+ OTHER_SWIFT_FLAGS: `$(inherited) -D EXPO_CONFIGURATION_${configurationName.toUpperCase()}`,
260
+ PRODUCT_BUNDLE_IDENTIFIER: bundleIdentifier,
261
+ PRODUCT_NAME: '$(TARGET_NAME)',
262
+ SWIFT_OPTIMIZATION_LEVEL: '-Onone',
263
+ SWIFT_VERSION: '5.0',
264
+ TARGETED_DEVICE_FAMILY: '1,2',
265
+ ...(codeSigning.codeSignStyle ? { CODE_SIGN_STYLE: codeSigning.codeSignStyle } : {}),
266
+ ...(codeSigning.developmentTeam ? { DEVELOPMENT_TEAM: codeSigning.developmentTeam } : {}),
267
+ }
268
+
269
+ buildSettings.APPLICATION_EXTENSION_API_ONLY = 'YES'
270
+ buildSettings.INFOPLIST_OUTPUT_FORMAT = 'xml'
271
+
272
+ if (codeSigning.provisioningProfileSpecifier) {
273
+ buildSettings.PROVISIONING_PROFILE_SPECIFIER = codeSigning.provisioningProfileSpecifier
274
+ }
275
+
276
+ return buildSettings
277
+ }
278
+
279
+ function getWidgetTarget(context: IOSXcodeProjectContext, targetName: string): PBXNativeTarget {
280
+ const target = getWidgetTargetOptional(context, targetName)
281
+
282
+ if (!target) {
283
+ throw new IOSWidgetTargetMutationError(
284
+ `Xcode project does not contain widget target '${targetName}' after mutation.`
285
+ )
286
+ }
287
+
288
+ return target
289
+ }
290
+
291
+ function getWidgetTargetOptional(context: IOSXcodeProjectContext, targetName: string): PBXNativeTarget | undefined {
292
+ return context.project.rootObject.props.targets.find((target): target is PBXNativeTarget => {
293
+ return (
294
+ PBXNativeTarget.is(target) &&
295
+ target.props.name === targetName &&
296
+ target.props.productType === IOS_APP_EXTENSION_PRODUCT_TYPE
297
+ )
298
+ })
299
+ }
300
+
301
+ function ensureWidgetGroup(context: IOSXcodeProjectContext, targetName: string): PBXGroup {
302
+ const existingGroup = context.mainGroup.getChildGroups().find((group) => group.getDisplayName() === targetName)
303
+ if (existingGroup) {
304
+ existingGroup.props.path = targetName
305
+ return existingGroup
306
+ }
307
+
308
+ return ensureMainGroupChild(context, targetName)
309
+ }
310
+
311
+ function ensureProductFile(context: IOSXcodeProjectContext, targetName: string, productPath: string): PBXFileReference {
312
+ const existingProduct = [...context.project.values()].find((object): object is PBXFileReference => {
313
+ return (
314
+ PBXFileReference.is(object) &&
315
+ stripQuotes(object.props.path) === productPath &&
316
+ object.props.sourceTree === 'BUILT_PRODUCTS_DIR'
317
+ )
318
+ })
319
+
320
+ if (existingProduct) {
321
+ existingProduct.props.explicitFileType = PRODUCT_FILE_TYPE
322
+ existingProduct.props.path = productPath
323
+ existingProduct.props.sourceTree = 'BUILT_PRODUCTS_DIR'
324
+ return existingProduct
325
+ }
326
+
327
+ return context.productsGroup.createNewProductRefForTarget(targetName, 'appExtension')
328
+ }
329
+
330
+ function ensureTargetDependency(context: IOSXcodeProjectContext, widgetTarget: PBXNativeTarget): void {
331
+ context.mainAppTarget.target.addDependency(widgetTarget)
332
+
333
+ const copyFilesPhase = context.mainAppTarget.getCopyFilesBuildPhaseFor(widgetTarget)
334
+ copyFilesPhase.ensureDefaultsForTarget(widgetTarget)
335
+ copyFilesPhase.props.name = COPY_FILES_PHASE_NAME
336
+
337
+ const productReference = widgetTarget.props.productReference
338
+ if (!productReference) {
339
+ throw new IOSWidgetTargetMutationError(`Widget target '${widgetTarget.props.name}' is missing a product reference.`)
340
+ }
341
+
342
+ copyFilesPhase.ensureFile({ fileRef: productReference })
343
+ }
344
+
345
+ function ensureTargetAttributes(context: IOSXcodeProjectContext, widgetTarget: PBXNativeTarget): void {
346
+ const attributes = context.project.rootObject.props.attributes
347
+ const targetAttributes = (attributes.TargetAttributes ??= {}) as Record<string, { LastSwiftMigration?: string }>
348
+ targetAttributes[widgetTarget.uuid] ??= { LastSwiftMigration: '1250' }
349
+ }
350
+
351
+ function ensureDynamicWidgetBundlePhase(widgetTarget: PBXNativeTarget, enabled: boolean): void {
352
+ const matchingPhases = widgetTarget.props.buildPhases.filter(
353
+ (phase): phase is PBXShellScriptBuildPhase =>
354
+ PBXShellScriptBuildPhase.is(phase) && stripQuotes(phase.props.name) === DYNAMIC_WIDGET_BUNDLE_PHASE_NAME
355
+ )
356
+
357
+ if (!enabled) {
358
+ for (const phase of matchingPhases) {
359
+ phase.removeFromProject()
360
+ }
361
+ return
362
+ }
363
+ const primaryPhase =
364
+ matchingPhases[0] ??
365
+ widgetTarget.createBuildPhase(PBXShellScriptBuildPhase, {
366
+ name: DYNAMIC_WIDGET_BUNDLE_PHASE_NAME,
367
+ shellPath: '/bin/sh',
368
+ shellScript: DYNAMIC_WIDGET_BUNDLE_SHELL_SCRIPT,
369
+ })
370
+
371
+ primaryPhase.props.name = DYNAMIC_WIDGET_BUNDLE_PHASE_NAME
372
+ primaryPhase.props.shellPath = '/bin/sh'
373
+ primaryPhase.props.shellScript = DYNAMIC_WIDGET_BUNDLE_SHELL_SCRIPT
374
+ primaryPhase.props.alwaysOutOfDate = 1
375
+
376
+ for (const duplicatePhase of matchingPhases.slice(1)) {
377
+ duplicatePhase.removeFromProject()
378
+ }
379
+ }
380
+
381
+ function removeStaleWidgetTargets(context: IOSXcodeProjectContext, staleTargetNames: string[]): void {
382
+ for (const staleTargetName of staleTargetNames) {
383
+ const staleTarget = getWidgetTargetOptional(context, staleTargetName)
384
+ if (!staleTarget) {
385
+ continue
386
+ }
387
+
388
+ staleTarget.removeFromProject()
389
+ }
390
+ }
391
+
392
+ function ensureBuildPhases(
393
+ context: IOSXcodeProjectContext,
394
+ widgetTarget: PBXNativeTarget,
395
+ productFile: PBXFileReference,
396
+ generatedFiles: string[]
397
+ ): void {
398
+ const sources = widgetTarget.getSourcesBuildPhase()
399
+ const resources = widgetTarget.getResourcesBuildPhase()
400
+ widgetTarget.getFrameworksBuildPhase()
401
+
402
+ const fileReferences = getBuildPhaseFileReferences(context, generatedFiles)
403
+
404
+ for (const fileReference of fileReferences) {
405
+ const relativePath = getReferenceRelativePath(context, fileReference)
406
+
407
+ if (isSourceFile(relativePath)) {
408
+ sources.ensureFile({ fileRef: fileReference })
409
+ continue
410
+ }
411
+
412
+ if (isResourceFile(relativePath)) {
413
+ resources.ensureFile({ fileRef: fileReference })
414
+ }
415
+ }
416
+
417
+ const copyFilesPhase = context.mainAppTarget.getCopyFilesBuildPhaseFor(widgetTarget)
418
+ copyFilesPhase.ensureDefaultsForTarget(widgetTarget)
419
+ copyFilesPhase.props.name = COPY_FILES_PHASE_NAME
420
+ copyFilesPhase.ensureFile({ fileRef: productFile })
421
+ }
422
+
423
+ function getBuildPhaseFileReferences(context: IOSXcodeProjectContext, generatedFiles: string[]): PBXFileReference[] {
424
+ const references = new Map<string, PBXFileReference>()
425
+
426
+ for (const file of generatedFiles) {
427
+ const buildPhasePath = getBuildPhaseReferencePath(file)
428
+ if (!buildPhasePath || references.has(buildPhasePath)) {
429
+ continue
430
+ }
431
+
432
+ references.set(buildPhasePath, ensureGeneratedFileReference(context, buildPhasePath))
433
+ }
434
+
435
+ return [...references.values()]
436
+ }
437
+
438
+ function removeStaleGeneratedFileReferences(
439
+ context: IOSXcodeProjectContext,
440
+ widgetTarget: PBXNativeTarget,
441
+ widgetGroup: PBXGroup,
442
+ previousGeneratedFiles: string[],
443
+ generatedFiles: string[]
444
+ ): void {
445
+ const staleReferencePaths = getStaleReferencePaths(previousGeneratedFiles, generatedFiles)
446
+
447
+ if (staleReferencePaths.size === 0) {
448
+ return
449
+ }
450
+
451
+ const staleReferences = [...context.project.values()].filter((object): object is PBXFileReference => {
452
+ if (!PBXFileReference.is(object)) {
453
+ return false
454
+ }
455
+
456
+ const relativePath = getReferenceRelativePath(context, object)
457
+ return staleReferencePaths.has(relativePath)
458
+ })
459
+
460
+ for (const reference of staleReferences) {
461
+ removeFileReferenceFromTargetBuildPhases(widgetTarget, reference)
462
+ removeFileReferenceFromGroupTree(widgetGroup, reference)
463
+ reference.removeFromProject()
464
+ }
465
+ }
466
+
467
+ function ensureWidgetGroupFiles(
468
+ context: IOSXcodeProjectContext,
469
+ widgetGroup: PBXGroup,
470
+ targetName: string,
471
+ generatedFiles: string[]
472
+ ): void {
473
+ const groupedReferencePaths = new Set<string>()
474
+
475
+ for (const file of generatedFiles) {
476
+ const groupReferencePath = getGroupReferencePath(file)
477
+ if (!groupReferencePath || groupedReferencePaths.has(groupReferencePath)) {
478
+ continue
479
+ }
480
+
481
+ groupedReferencePaths.add(groupReferencePath)
482
+ const reference = ensureGeneratedFileReference(context, groupReferencePath)
483
+ const relativeToTarget = getPathRelativeToTarget(groupReferencePath, targetName)
484
+
485
+ if (!relativeToTarget) {
486
+ continue
487
+ }
488
+
489
+ if (relativeToTarget.includes('/')) {
490
+ const parentGroup = ensureParentGroup(widgetGroup, relativeToTarget)
491
+ removeGroupReference(widgetGroup, reference)
492
+ ensureGroupContainsReference(parentGroup, reference)
493
+ continue
494
+ }
495
+
496
+ ensureGroupContainsReference(widgetGroup, reference)
497
+ }
498
+ }
499
+
500
+ function removeEmptyWidgetGroups(context: IOSXcodeProjectContext, staleTargetNames: string[]): void {
501
+ for (const staleTargetName of staleTargetNames) {
502
+ const staleGroup = context.mainGroup.getChildGroups().find((group) => group.getDisplayName() === staleTargetName)
503
+
504
+ if (!staleGroup || staleGroup.props.children.length > 0) {
505
+ continue
506
+ }
507
+
508
+ staleGroup.removeFromProject()
509
+ }
510
+ }
511
+
512
+ function removeFileReferenceFromTargetBuildPhases(target: PBXNativeTarget, reference: PBXFileReference): void {
513
+ for (const phase of [
514
+ target.getSourcesBuildPhase(),
515
+ target.getResourcesBuildPhase(),
516
+ target.getFrameworksBuildPhase(),
517
+ ]) {
518
+ removeBuildPhaseReference(phase, reference)
519
+ }
520
+ }
521
+
522
+ function removeBuildPhaseReference(
523
+ phase: PBXSourcesBuildPhase | PBXResourcesBuildPhase | PBXFrameworksBuildPhase,
524
+ reference: PBXFileReference
525
+ ): void {
526
+ if (phase.includesFile(reference)) {
527
+ phase.removeFileReference(reference)
528
+ }
529
+ }
530
+
531
+ function ensureGeneratedFileReference(context: IOSXcodeProjectContext, relativeFilePath: string): PBXFileReference {
532
+ const absolutePath = path.join(context.project.getProjectRoot(), relativeFilePath)
533
+ const existingReference = context.project.getReferenceForPath(absolutePath)
534
+
535
+ if (existingReference) {
536
+ applyFileType(existingReference, relativeFilePath)
537
+ return existingReference
538
+ }
539
+
540
+ const targetName = path.dirname(relativeFilePath).split(path.sep)[0]
541
+ const widgetGroup = ensureWidgetGroup(context, targetName)
542
+ const pathWithinGroup = getPathRelativeToTarget(relativeFilePath, targetName)
543
+
544
+ if (!pathWithinGroup) {
545
+ throw new IOSWidgetTargetMutationError(`Generated iOS file is outside widget target directory: ${relativeFilePath}`)
546
+ }
547
+
548
+ const parentGroup = ensureParentGroup(widgetGroup, pathWithinGroup)
549
+ const fileReference = parentGroup.createFile({ path: path.basename(pathWithinGroup) })
550
+ applyFileType(fileReference, relativeFilePath)
551
+ return fileReference
552
+ }
553
+
554
+ function ensureParentGroup(rootGroup: PBXGroup, relativePath: string): PBXGroup {
555
+ const directories = path.dirname(relativePath)
556
+ if (directories === '.' || directories === '') {
557
+ return rootGroup
558
+ }
559
+
560
+ const group = rootGroup.mkdir(directories.split(path.sep), { recursive: true })
561
+ if (!group) {
562
+ throw new IOSWidgetTargetMutationError(`Failed to create Xcode group path for ${relativePath}`)
563
+ }
564
+
565
+ return group
566
+ }
567
+
568
+ function ensureGroupContainsReference(group: PBXGroup, reference: PBXFileReference): void {
569
+ const alreadyPresent = group.props.children.some((child) => child.uuid === reference.uuid)
570
+ if (!alreadyPresent) {
571
+ group.props.children.push(reference)
572
+ }
573
+ }
574
+
575
+ function removeGroupReference(group: PBXGroup, reference: PBXFileReference): void {
576
+ group.props.children = group.props.children.filter((child) => child.uuid !== reference.uuid)
577
+ }
578
+
579
+ function applyFileType(reference: PBXFileReference, relativePath: string): void {
580
+ const extension = path.extname(relativePath)
581
+
582
+ if (extension === '.swift') {
583
+ reference.setLastKnownFileType(SWIFT_FILE_TYPE)
584
+ return
585
+ }
586
+
587
+ if (extension === '.plist') {
588
+ reference.setLastKnownFileType(PLIST_FILE_TYPE)
589
+ return
590
+ }
591
+
592
+ if (extension === '.entitlements') {
593
+ reference.setLastKnownFileType('text.plist.entitlements')
594
+ return
595
+ }
596
+
597
+ if (extension === '.strings') {
598
+ reference.setLastKnownFileType(STRINGS_FILE_TYPE)
599
+ return
600
+ }
601
+
602
+ if (extension === '.xcassets') {
603
+ reference.setLastKnownFileType(ASSET_CATALOG_FILE_TYPE)
604
+ return
605
+ }
606
+ }
607
+
608
+ function isSourceFile(relativePath: string): boolean {
609
+ return SOURCE_EXTENSIONS.has(path.extname(relativePath))
610
+ }
611
+
612
+ function isResourceFile(relativePath: string): boolean {
613
+ const extension = path.extname(relativePath)
614
+ return RESOURCE_EXTENSIONS.has(extension) || relativePath.endsWith('.xcassets')
615
+ }
616
+
617
+ function getBuildPhaseReferencePath(relativePath: string): string {
618
+ const normalizedPath = normalizeRelativePath(relativePath)
619
+ const assetCatalogIndex = normalizedPath.indexOf('/Assets.xcassets/')
620
+
621
+ if (assetCatalogIndex >= 0) {
622
+ return normalizedPath.slice(0, assetCatalogIndex + '/Assets.xcassets'.length)
623
+ }
624
+
625
+ return normalizedPath
626
+ }
627
+
628
+ function getStaleReferencePaths(previousGeneratedFiles: string[], generatedFiles: string[]): Set<string> {
629
+ const currentReferencePaths = new Set(
630
+ generatedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)])
631
+ )
632
+ const previousReferencePaths = new Set(
633
+ previousGeneratedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)])
634
+ )
635
+
636
+ return new Set([...previousReferencePaths].filter((referencePath) => !currentReferencePaths.has(referencePath)))
637
+ }
638
+
639
+ function getGroupReferencePath(relativePath: string): string {
640
+ const normalizedPath = normalizeRelativePath(relativePath)
641
+ const assetCatalogIndex = normalizedPath.indexOf('/Assets.xcassets/')
642
+
643
+ if (assetCatalogIndex >= 0) {
644
+ return normalizedPath.slice(0, assetCatalogIndex + '/Assets.xcassets'.length)
645
+ }
646
+
647
+ return normalizedPath
648
+ }
649
+
650
+ function getReferenceRelativePath(context: IOSXcodeProjectContext, reference: PBXFileReference): string {
651
+ const segments = [stripQuotes(reference.props.path ?? '')].filter((segment) => segment.length > 0)
652
+ let parent = getPreferredParentGroup(reference)
653
+
654
+ while (parent && parent.uuid !== context.mainGroup.uuid) {
655
+ const parentPath = stripQuotes(parent.props.path ?? parent.props.name ?? '')
656
+ if (parentPath.length > 0) {
657
+ segments.unshift(parentPath)
658
+ }
659
+
660
+ parent = getPreferredParentGroup(parent)
661
+ }
662
+
663
+ if (segments.length === 0) {
664
+ throw new IOSWidgetTargetMutationError(`Unable to resolve Xcode file reference path for ${reference.uuid}`)
665
+ }
666
+
667
+ return normalizeRelativePath(segments.join('/'))
668
+ }
669
+
670
+ function getPreferredParentGroup(object: PBXFileReference | PBXGroup): PBXGroup | undefined {
671
+ const parentGroups = object.getReferrers().filter((referrer): referrer is PBXGroup => PBXGroup.is(referrer))
672
+
673
+ if (parentGroups.length <= 1) {
674
+ return parentGroups[0]
675
+ }
676
+
677
+ return [...parentGroups].sort((left, right) => getGroupSpecificity(right) - getGroupSpecificity(left))[0]
678
+ }
679
+
680
+ function getGroupSpecificity(group: PBXGroup): number {
681
+ const identifier = group.props.path ?? group.props.name ?? group.getDisplayName()
682
+
683
+ if (identifier.endsWith('.imageset')) {
684
+ return 4
685
+ }
686
+
687
+ if (identifier.endsWith('.xcassets') || identifier.endsWith('.lproj')) {
688
+ return 3
689
+ }
690
+
691
+ return 1
692
+ }
693
+
694
+ function sanitizeWidgetGroupChildren(widgetGroup: PBXGroup): void {
695
+ const staleChildren = widgetGroup.props.children.filter((child) => {
696
+ const identifier = stripQuotes(
697
+ 'path' in child && typeof child.props.path === 'string' ? child.props.path : child.getDisplayName()
698
+ )
699
+
700
+ if (identifier.endsWith('.imageset')) {
701
+ return true
702
+ }
703
+
704
+ return PBXGroup.is(child) && identifier.endsWith('.xcassets')
705
+ })
706
+
707
+ widgetGroup.props.children = widgetGroup.props.children.filter((child) => !staleChildren.includes(child))
708
+
709
+ for (const child of staleChildren) {
710
+ if (PBXGroup.is(child)) {
711
+ removeGroupTree(child)
712
+ continue
713
+ }
714
+
715
+ child.removeFromProject()
716
+ }
717
+ }
718
+
719
+ function removeGroupTree(group: PBXGroup): void {
720
+ const childGroups = [...group.getChildGroups()]
721
+ const childFiles = group.props.children.filter((child): child is PBXFileReference => PBXFileReference.is(child))
722
+
723
+ for (const childGroup of childGroups) {
724
+ removeGroupTree(childGroup)
725
+ }
726
+
727
+ for (const childFile of childFiles) {
728
+ childFile.removeFromProject()
729
+ }
730
+
731
+ group.removeFromProject()
732
+ }
733
+
734
+ function removeFileReferenceFromGroupTree(group: PBXGroup, reference: PBXFileReference): void {
735
+ group.props.children = group.props.children.filter((child) => child.uuid !== reference.uuid)
736
+
737
+ for (const childGroup of group.getChildGroups()) {
738
+ removeFileReferenceFromGroupTree(childGroup, reference)
739
+ }
740
+ }
741
+
742
+ function getPathRelativeToTarget(relativePath: string, targetName: string): string | null {
743
+ const normalizedPath = normalizeRelativePath(relativePath)
744
+ if (!normalizedPath.startsWith(`${targetName}/`)) {
745
+ return null
746
+ }
747
+
748
+ return normalizedPath.slice(targetName.length + 1)
749
+ }
750
+
751
+ function getStaleWidgetTargetNames(previousGeneratedFiles: string[], targetName: string): string[] {
752
+ return [
753
+ ...new Set(
754
+ previousGeneratedFiles
755
+ .map(getWidgetTargetNameFromGeneratedPath)
756
+ .filter((candidate): candidate is string => candidate !== undefined && candidate !== targetName)
757
+ ),
758
+ ].sort()
759
+ }
760
+
761
+ function getWidgetTargetNameFromGeneratedPath(relativePath: string): string | undefined {
762
+ const normalizedPath = normalizeRelativePath(relativePath)
763
+ const [targetName] = normalizedPath.split(path.sep, 1)
764
+
765
+ return typeof targetName === 'string' && targetName.length > 0 ? targetName : undefined
766
+ }
767
+
768
+ function normalizeGeneratedFilePaths(
769
+ generatedFiles: string[],
770
+ projectRoot: string,
771
+ discovery: IOSProjectDiscovery
772
+ ): string[] {
773
+ const iosRootRelativePath = normalizeRelativePath(path.relative(projectRoot, discovery.iosRoot))
774
+ const iosRootRelativePrefix = iosRootRelativePath === '.' ? '' : `${iosRootRelativePath}/`
775
+
776
+ return [
777
+ ...new Set(generatedFiles.map((file) => toIOSProjectRelativePath(file, iosRootRelativePrefix)).filter(isDefined)),
778
+ ].sort()
779
+ }
780
+
781
+ function toIOSProjectRelativePath(relativeFilePath: string, iosRootRelativePrefix: string): string | undefined {
782
+ const normalizedPath = normalizeRelativePath(relativeFilePath)
783
+
784
+ if (iosRootRelativePrefix.length === 0) {
785
+ return normalizedPath
786
+ }
787
+
788
+ if (normalizedPath.startsWith(iosRootRelativePrefix)) {
789
+ return normalizedPath.slice(iosRootRelativePrefix.length)
790
+ }
791
+
792
+ return undefined
793
+ }
794
+
795
+ function resolveBundleIdentifier(
796
+ context: IOSXcodeProjectContext,
797
+ discovery: IOSProjectDiscovery,
798
+ targetName: string
799
+ ): string {
800
+ const mainTargetBundleIdentifier =
801
+ context.mainAppTarget.buildConfigurations.default.resolveBuildSetting('PRODUCT_BUNDLE_IDENTIFIER')
802
+
803
+ if (typeof mainTargetBundleIdentifier !== 'string' || mainTargetBundleIdentifier.length === 0) {
804
+ throw new IOSWidgetTargetMutationError(
805
+ `Main app target '${discovery.mainTargetName}' is missing PRODUCT_BUNDLE_IDENTIFIER in ${discovery.pbxprojPath}`
806
+ )
807
+ }
808
+
809
+ return `${stripQuotes(mainTargetBundleIdentifier)}.${sanitizeBundleIdentifierSegment(targetName)}`
810
+ }
811
+
812
+ function sanitizeBundleIdentifierSegment(targetName: string): string {
813
+ const sanitized = targetName.replace(/[^A-Za-z0-9-]/g, '-')
814
+ return sanitized.replace(/-+/g, '-').replace(/^-+|-+$/g, '')
815
+ }
816
+
817
+ function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainAppCodeSigningSettings {
818
+ const buildSettings = context.mainAppTarget.buildConfigurations.default.props.buildSettings ?? {}
819
+
820
+ return {
821
+ codeSignStyle: readBuildSettingString(buildSettings.CODE_SIGN_STYLE),
822
+ developmentTeam: readBuildSettingString(buildSettings.DEVELOPMENT_TEAM),
823
+ provisioningProfileSpecifier: readBuildSettingString(
824
+ (buildSettings as unknown as { PROVISIONING_PROFILE_SPECIFIER?: unknown }).PROVISIONING_PROFILE_SPECIFIER
825
+ ),
826
+ }
827
+ }
828
+
829
+ async function getMainAppEntitlementsBuildSetting(
830
+ discovery: IOSProjectDiscovery,
831
+ ios: NormalizedVoltraIOSConfig
832
+ ): Promise<string | undefined> {
833
+ if (discovery.entitlementsPath) {
834
+ return normalizeRelativePath(path.relative(discovery.iosRoot, discovery.entitlementsPath))
835
+ }
836
+
837
+ if (!needsEntitlementsMutation(ios)) {
838
+ return undefined
839
+ }
840
+
841
+ const entitlementsPath = resolveMainAppEntitlementsPath(discovery)
842
+
843
+ if (!(await pathExists(entitlementsPath))) {
844
+ return undefined
845
+ }
846
+
847
+ return normalizeRelativePath(path.relative(discovery.iosRoot, entitlementsPath))
848
+ }
849
+
850
+ function ensureMainAppEntitlementsBuildSetting(
851
+ context: IOSXcodeProjectContext,
852
+ entitlementsPath: string | undefined
853
+ ): void {
854
+ for (const config of context.mainAppTarget.buildConfigurations.all) {
855
+ if (entitlementsPath) {
856
+ config.props.buildSettings.CODE_SIGN_ENTITLEMENTS = entitlementsPath
857
+ continue
858
+ }
859
+
860
+ delete config.props.buildSettings.CODE_SIGN_ENTITLEMENTS
861
+ }
862
+ }
863
+
864
+ function ensureMainAppDeploymentTarget(context: IOSXcodeProjectContext): void {
865
+ for (const config of context.mainAppTarget.buildConfigurations.all) {
866
+ config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = resolveBuildConfigurationDeploymentTarget(
867
+ config,
868
+ VOLTRA_MIN_IOS_DEPLOYMENT_TARGET
869
+ )
870
+ }
871
+ }
872
+
873
+ function resolveBuildConfigurationDeploymentTarget(
874
+ config: XCBuildConfiguration,
875
+ minimumDeploymentTarget: string
876
+ ): string {
877
+ const currentDeploymentTarget = readBuildSettingString(config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET)
878
+ return currentDeploymentTarget
879
+ ? maxIOSDeploymentTarget(currentDeploymentTarget, minimumDeploymentTarget)
880
+ : minimumDeploymentTarget
881
+ }
882
+
883
+ interface MainAppCodeSigningSettings {
884
+ codeSignStyle?: string
885
+ developmentTeam?: string
886
+ provisioningProfileSpecifier?: string
887
+ }
888
+
889
+ function readBuildSettingString(value: unknown): string | undefined {
890
+ return typeof value === 'string' ? stripQuotes(value) : undefined
891
+ }
892
+
893
+ function stripQuotes(value: string | undefined): string {
894
+ return value?.replace(/^"|"$/g, '') ?? ''
895
+ }
896
+
897
+ function isDefined<TValue>(value: TValue | undefined): value is TValue {
898
+ return value !== undefined
899
+ }