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,155 @@
1
+ import path from 'node:path'
2
+
3
+ import { normalizeRelativePath, toRelativePath } from '../../fs/path'
4
+ import { readTextFile, writeTextFile } from '../../fs/readWrite'
5
+
6
+ import type { AndroidProjectDiscovery } from '../../discovery/android'
7
+ import type { ReportedChange } from '../../reporting/summary'
8
+
9
+ const MARKER = '// @voltra-widget-bundling'
10
+ const END_MARKER = '// @voltra-widget-bundling-end'
11
+
12
+ export interface EnsureAndroidGradleWidgetBundlingOptions {
13
+ projectRoot: string
14
+ discovery: AndroidProjectDiscovery
15
+ hasDynamicWidgets: boolean
16
+ }
17
+
18
+ export interface EnsureAndroidGradleWidgetBundlingResult {
19
+ change?: ReportedChange
20
+ warnings?: string[]
21
+ }
22
+
23
+ export async function ensureAndroidGradleWidgetBundling(
24
+ options: EnsureAndroidGradleWidgetBundlingOptions
25
+ ): Promise<EnsureAndroidGradleWidgetBundlingResult> {
26
+ const { projectRoot, discovery, hasDynamicWidgets } = options
27
+ const isKotlinDsl = discovery.buildGradlePath.endsWith('.kts')
28
+ const projectRootRelativeToAppModule =
29
+ normalizeRelativePath(path.relative(discovery.appModuleRoot, projectRoot)) || '.'
30
+
31
+ if (isKotlinDsl) {
32
+ if (!hasDynamicWidgets) {
33
+ return {}
34
+ }
35
+
36
+ return {
37
+ warnings: ['Voltra: app/build.gradle is not Groovy - skipping Dynamic Widget release bundling wiring.'],
38
+ }
39
+ }
40
+
41
+ const previousContent = await readTextFile(discovery.buildGradlePath)
42
+ const removal = removeDynamicWidgetBundlingSnippet(previousContent, projectRootRelativeToAppModule)
43
+ const nextContent = hasDynamicWidgets
44
+ ? addDynamicWidgetBundlingSnippet(removal.contents, projectRootRelativeToAppModule)
45
+ : removal.contents
46
+
47
+ if (nextContent === previousContent) {
48
+ return removal.warning ? { warnings: [removal.warning] } : {}
49
+ }
50
+
51
+ await writeTextFile(discovery.buildGradlePath, nextContent)
52
+
53
+ return {
54
+ change: {
55
+ kind: 'updated',
56
+ path: toRelativePath(projectRoot, discovery.buildGradlePath),
57
+ },
58
+ warnings: removal.warning ? [removal.warning] : undefined,
59
+ }
60
+ }
61
+
62
+ export function addDynamicWidgetBundlingSnippet(contents: string, projectRootRelativeToAppModule: string): string {
63
+ if (contents.includes(MARKER)) {
64
+ return contents
65
+ }
66
+
67
+ return `${contents.trimEnd()}\n\n${createDynamicWidgetBundlingSnippet(projectRootRelativeToAppModule)}\n`
68
+ }
69
+
70
+ export function removeDynamicWidgetBundlingSnippet(
71
+ contents: string,
72
+ projectRootRelativeToAppModule: string
73
+ ): { contents: string; warning?: string } {
74
+ const range = findManagedSnippetRange(contents)
75
+
76
+ if (range) {
77
+ return {
78
+ contents: collapseRemovedRange(contents, range.start, range.end),
79
+ }
80
+ }
81
+
82
+ const legacySnippet = createLegacyDynamicWidgetBundlingSnippet(projectRootRelativeToAppModule)
83
+ const legacyIndex = contents.indexOf(legacySnippet)
84
+
85
+ if (legacyIndex >= 0) {
86
+ return {
87
+ contents: collapseRemovedRange(contents, legacyIndex, legacyIndex + legacySnippet.length),
88
+ }
89
+ }
90
+
91
+ if (!contents.includes(MARKER)) {
92
+ return { contents }
93
+ }
94
+
95
+ return {
96
+ contents,
97
+ warning:
98
+ 'Voltra: found legacy Dynamic Widget Gradle marker without managed end marker. Preserved trailing Gradle content; remove old @voltra-widget-bundling block manually if no longer needed.',
99
+ }
100
+ }
101
+
102
+ function createDynamicWidgetBundlingSnippet(projectRootRelativeToAppModule: string): string {
103
+ return `
104
+ ${MARKER}
105
+ def voltraProjectRoot = file("${projectRootRelativeToAppModule}")
106
+ def voltraWidgetAssetsDir = file("\${buildDir}/generated/voltra/assets")
107
+ android.sourceSets.getByName("main").assets.srcDir(voltraWidgetAssetsDir)
108
+
109
+ def voltraBundleWidgets = tasks.register("voltraBundleWidgets", Exec) {
110
+ description = "Bake Dynamic Widget Voltra bundles into assets (release)."
111
+ workingDir voltraProjectRoot
112
+ environment "VOLTRA_PROJECT_ROOT", voltraProjectRoot.absolutePath
113
+ environment "VOLTRA_OUT_DIR", new File(voltraWidgetAssetsDir, "voltra").absolutePath
114
+ environment "VOLTRA_BUNDLER_RESOLVER", "const m=require('module'),p=require('path');process.stdout.write(m.createRequire(p.join(process.argv[1],'package.json')).resolve('@use-voltra/metro/bundle-widgets'))"
115
+ commandLine "bash", "-c", 'BUNDLER="$(node -e "$VOLTRA_BUNDLER_RESOLVER" "$VOLTRA_PROJECT_ROOT")"; if [ -z "$BUNDLER" ]; then echo "error: Voltra could not resolve @use-voltra/metro/bundle-widgets from $VOLTRA_PROJECT_ROOT - install @use-voltra/metro so release widgets can be baked." >&2; exit 1; fi; node "$BUNDLER" --platform android --out-dir "$VOLTRA_OUT_DIR" --project-root "$VOLTRA_PROJECT_ROOT"'
116
+ outputs.upToDateWhen { false }
117
+ }
118
+
119
+ android.applicationVariants.all { variant ->
120
+ if (variant.buildType.name == "release") {
121
+ tasks.named("merge\${variant.name.capitalize()}Assets").configure { dependsOn voltraBundleWidgets }
122
+ }
123
+ }
124
+ ${END_MARKER}
125
+ `.trim()
126
+ }
127
+
128
+ function createLegacyDynamicWidgetBundlingSnippet(projectRootRelativeToAppModule: string): string {
129
+ return createDynamicWidgetBundlingSnippet(projectRootRelativeToAppModule).replace(`\n${END_MARKER}`, '')
130
+ }
131
+
132
+ function findManagedSnippetRange(contents: string): { start: number; end: number } | null {
133
+ const markerIndex = contents.indexOf(MARKER)
134
+ if (markerIndex < 0) {
135
+ return null
136
+ }
137
+
138
+ const endMarkerIndex = contents.indexOf(END_MARKER, markerIndex)
139
+ if (endMarkerIndex < 0) {
140
+ return null
141
+ }
142
+
143
+ const lineEndIndex = contents.indexOf('\n', endMarkerIndex)
144
+ return {
145
+ start: markerIndex,
146
+ end: lineEndIndex >= 0 ? lineEndIndex + 1 : contents.length,
147
+ }
148
+ }
149
+
150
+ function collapseRemovedRange(contents: string, start: number, end: number): string {
151
+ const before = contents.slice(0, start).trimEnd()
152
+ const after = contents.slice(end).replace(/^\s+/, '')
153
+ const merged = [before, after].filter(Boolean).join('\n\n')
154
+ return merged ? `${merged}\n` : ''
155
+ }
@@ -0,0 +1,379 @@
1
+ import { Builder, parseStringPromise } from 'xml2js'
2
+
3
+ import { readTextFile, writeTextFile } from '../../fs/readWrite'
4
+ import { toRelativePath } from '../../fs/path'
5
+ import { VoltraCliError } from '../../reporting/summary'
6
+
7
+ import type { NormalizedVoltraAndroidConfig } from '../../config/types'
8
+ import type { AndroidProjectDiscovery } from '../../discovery/android'
9
+ import type { ReportedChange } from '../../reporting/summary'
10
+
11
+ const XML_BUILDER = new Builder({
12
+ headless: false,
13
+ renderOpts: {
14
+ pretty: true,
15
+ indent: ' ',
16
+ newline: '\n',
17
+ },
18
+ })
19
+
20
+ const APPWIDGET_UPDATE_ACTION = 'android.appwidget.action.APPWIDGET_UPDATE'
21
+ const APPWIDGET_PROVIDER_METADATA = 'android.appwidget.provider'
22
+ const ONGOING_NOTIFICATION_RECEIVER = 'voltra.VoltraOngoingNotificationDismissedReceiver'
23
+ const POST_NOTIFICATIONS_PERMISSION = 'android.permission.POST_NOTIFICATIONS'
24
+ const POST_PROMOTED_NOTIFICATIONS_PERMISSION = 'android.permission.POST_PROMOTED_NOTIFICATIONS'
25
+ const WIDGET_RECEIVER_NAME_PREFIX = '.widget.VoltraWidget_'
26
+ const WIDGET_RECEIVER_NAME_SUFFIX = 'Receiver'
27
+ const WIDGET_INFO_RESOURCE_PREFIX = '@xml/voltra_widget_'
28
+ const WIDGET_INFO_RESOURCE_SUFFIX = '_info'
29
+
30
+ export interface EnsureAndroidManifestOptions {
31
+ projectRoot: string
32
+ android: NormalizedVoltraAndroidConfig
33
+ discovery: AndroidProjectDiscovery
34
+ }
35
+
36
+ export interface EnsureAndroidManifestResult {
37
+ change?: ReportedChange
38
+ }
39
+
40
+ interface AndroidManifestDocument {
41
+ manifest?: AndroidManifestRoot
42
+ }
43
+
44
+ interface AndroidManifestRoot {
45
+ $?: Record<string, string>
46
+ application?: AndroidManifestApplication[]
47
+ 'uses-permission'?: AndroidManifestPermission[]
48
+ }
49
+
50
+ interface AndroidManifestPermission {
51
+ $?: Record<string, string>
52
+ }
53
+
54
+ interface AndroidManifestApplication {
55
+ $?: Record<string, string>
56
+ receiver?: AndroidManifestReceiver[]
57
+ }
58
+
59
+ interface AndroidManifestReceiver {
60
+ $?: Record<string, string>
61
+ 'intent-filter'?: AndroidManifestIntentFilter[]
62
+ 'meta-data'?: AndroidManifestMetaData[]
63
+ }
64
+
65
+ interface AndroidManifestIntentFilter {
66
+ action?: AndroidManifestAction[]
67
+ }
68
+
69
+ interface AndroidManifestAction {
70
+ $?: Record<string, string>
71
+ }
72
+
73
+ interface AndroidManifestMetaData {
74
+ $?: Record<string, string>
75
+ }
76
+
77
+ export class AndroidManifestMutationError extends VoltraCliError {
78
+ constructor(message: string) {
79
+ super(message, 'VOLTRA_ANDROID_MANIFEST_MUTATION_FAILED')
80
+ this.name = 'AndroidManifestMutationError'
81
+ }
82
+ }
83
+
84
+ export async function ensureAndroidManifest(
85
+ options: EnsureAndroidManifestOptions
86
+ ): Promise<EnsureAndroidManifestResult> {
87
+ const { projectRoot, android, discovery } = options
88
+ const manifestPath = discovery.manifestPath
89
+ const previousContent = await readTextFile(manifestPath)
90
+ const manifestDocument = await parseAndroidManifest(previousContent, manifestPath)
91
+ const manifest = manifestDocument.manifest
92
+
93
+ if (!manifest) {
94
+ throw new AndroidManifestMutationError(`Android manifest root is missing in ${manifestPath}`)
95
+ }
96
+
97
+ const application = getMainApplication(manifest, manifestPath)
98
+ const permissions = manifest['uses-permission'] ?? []
99
+ manifest['uses-permission'] = permissions
100
+
101
+ reconcileNotificationPermissions(permissions, android.enableNotifications)
102
+
103
+ const receivers = application.receiver ?? []
104
+ application.receiver = receivers
105
+
106
+ reconcileNotificationReceiver(receivers, android.enableNotifications)
107
+ removeStaleWidgetReceivers(
108
+ receivers,
109
+ android.widgets.map((widget) => widget.id)
110
+ )
111
+
112
+ if (android.enableNotifications) {
113
+ ensureNotificationReceiver(receivers)
114
+ }
115
+
116
+ for (const widget of android.widgets) {
117
+ ensureWidgetReceiver(receivers, widget.id)
118
+ }
119
+
120
+ const nextContent = `${XML_BUILDER.buildObject(manifestDocument)}\n`
121
+
122
+ if (nextContent === previousContent) {
123
+ return {}
124
+ }
125
+
126
+ await writeTextFile(manifestPath, nextContent)
127
+
128
+ return {
129
+ change: {
130
+ kind: 'updated',
131
+ path: toRelativePath(projectRoot, manifestPath),
132
+ },
133
+ }
134
+ }
135
+
136
+ async function parseAndroidManifest(content: string, manifestPath: string): Promise<AndroidManifestDocument> {
137
+ try {
138
+ return (await parseStringPromise(content)) as AndroidManifestDocument
139
+ } catch (error: unknown) {
140
+ throw new AndroidManifestMutationError(
141
+ `Failed to parse AndroidManifest.xml at ${manifestPath}: ${
142
+ error instanceof Error ? error.message : String(error)
143
+ }`
144
+ )
145
+ }
146
+ }
147
+
148
+ function getMainApplication(manifest: AndroidManifestRoot, manifestPath: string): AndroidManifestApplication {
149
+ const applications = manifest.application ?? []
150
+
151
+ if (applications.length === 0) {
152
+ throw new AndroidManifestMutationError(
153
+ `Android manifest does not contain an <application> element: ${manifestPath}`
154
+ )
155
+ }
156
+
157
+ if (applications.length > 1) {
158
+ throw new AndroidManifestMutationError(`Android manifest contains multiple <application> elements: ${manifestPath}`)
159
+ }
160
+
161
+ return applications[0]
162
+ }
163
+
164
+ function ensurePermission(permissions: AndroidManifestPermission[], permissionName: string): void {
165
+ const matchingPermissions = permissions.filter((permission) => permission.$?.['android:name'] === permissionName)
166
+
167
+ if (matchingPermissions.length === 0) {
168
+ permissions.push(createPermission(permissionName))
169
+ return
170
+ }
171
+
172
+ const primaryPermission = matchingPermissions[0]
173
+ primaryPermission.$ = {
174
+ ...primaryPermission.$,
175
+ 'android:name': permissionName,
176
+ }
177
+
178
+ removeDuplicateEntries(permissions, (permission) => permission.$?.['android:name'] === permissionName)
179
+ }
180
+
181
+ function reconcileNotificationPermissions(permissions: AndroidManifestPermission[], enabled: boolean): void {
182
+ if (enabled) {
183
+ ensurePermission(permissions, POST_NOTIFICATIONS_PERMISSION)
184
+ ensurePermission(permissions, POST_PROMOTED_NOTIFICATIONS_PERMISSION)
185
+ }
186
+ }
187
+
188
+ function ensureNotificationReceiver(receivers: AndroidManifestReceiver[]): void {
189
+ const receiver = findReceiverByName(receivers, ONGOING_NOTIFICATION_RECEIVER)
190
+
191
+ if (receiver) {
192
+ receiver.$ = {
193
+ ...receiver.$,
194
+ 'android:name': ONGOING_NOTIFICATION_RECEIVER,
195
+ 'android:exported': 'false',
196
+ }
197
+ removeDuplicateEntries(receivers, (candidate) => candidate.$?.['android:name'] === ONGOING_NOTIFICATION_RECEIVER)
198
+ return
199
+ }
200
+
201
+ receivers.push(createReceiver(ONGOING_NOTIFICATION_RECEIVER, 'false'))
202
+ }
203
+
204
+ function reconcileNotificationReceiver(receivers: AndroidManifestReceiver[], enabled: boolean): void {
205
+ if (enabled) {
206
+ ensureNotificationReceiver(receivers)
207
+ return
208
+ }
209
+
210
+ removeEntries(receivers, (receiver) => receiver.$?.['android:name'] === ONGOING_NOTIFICATION_RECEIVER)
211
+ }
212
+
213
+ function ensureWidgetReceiver(receivers: AndroidManifestReceiver[], widgetId: string): void {
214
+ const receiverName = `.widget.VoltraWidget_${widgetId}Receiver`
215
+ const metadataResource = `@xml/voltra_widget_${widgetId}_info`
216
+ const labelResource = `@string/voltra_widget_${widgetId}_label`
217
+ const receiver = findReceiverByName(receivers, receiverName)
218
+
219
+ if (receiver) {
220
+ receiver.$ = {
221
+ ...receiver.$,
222
+ 'android:name': receiverName,
223
+ 'android:exported': 'true',
224
+ 'android:label': labelResource,
225
+ }
226
+ ensureAppWidgetUpdateIntentFilter(receiver)
227
+ ensureReceiverMetadata(receiver, metadataResource)
228
+ removeDuplicateEntries(receivers, (candidate) => candidate.$?.['android:name'] === receiverName)
229
+ return
230
+ }
231
+
232
+ const nextReceiver = createReceiver(receiverName, 'true', labelResource)
233
+ ensureAppWidgetUpdateIntentFilter(nextReceiver)
234
+ ensureReceiverMetadata(nextReceiver, metadataResource)
235
+ receivers.push(nextReceiver)
236
+ }
237
+
238
+ function removeStaleWidgetReceivers(receivers: AndroidManifestReceiver[], widgetIds: string[]): void {
239
+ const nextWidgetIds = new Set(widgetIds)
240
+
241
+ removeEntries(receivers, (receiver) => {
242
+ const receiverName = receiver.$?.['android:name']
243
+
244
+ if (
245
+ !receiverName ||
246
+ !receiverName.startsWith(WIDGET_RECEIVER_NAME_PREFIX) ||
247
+ !receiverName.endsWith(WIDGET_RECEIVER_NAME_SUFFIX)
248
+ ) {
249
+ return false
250
+ }
251
+
252
+ const widgetId = receiverName.slice(WIDGET_RECEIVER_NAME_PREFIX.length, -WIDGET_RECEIVER_NAME_SUFFIX.length)
253
+
254
+ if (!widgetId || nextWidgetIds.has(widgetId)) {
255
+ return false
256
+ }
257
+
258
+ const metadataResource = getReceiverMetadataResource(receiver)
259
+ return metadataResource === undefined || isWidgetMetadataResource(metadataResource)
260
+ })
261
+ }
262
+
263
+ function ensureAppWidgetUpdateIntentFilter(receiver: AndroidManifestReceiver): void {
264
+ const intentFilters = receiver['intent-filter'] ?? []
265
+ receiver['intent-filter'] = intentFilters
266
+
267
+ const existingFilter = intentFilters.find((intentFilter) =>
268
+ (intentFilter.action ?? []).some((action) => action.$?.['android:name'] === APPWIDGET_UPDATE_ACTION)
269
+ )
270
+
271
+ if (existingFilter) {
272
+ existingFilter.action = [{ $: { 'android:name': APPWIDGET_UPDATE_ACTION } }]
273
+ removeDuplicateEntries(intentFilters, (intentFilter) =>
274
+ (intentFilter.action ?? []).some((action) => action.$?.['android:name'] === APPWIDGET_UPDATE_ACTION)
275
+ )
276
+ return
277
+ }
278
+
279
+ intentFilters.push({
280
+ action: [
281
+ {
282
+ $: {
283
+ 'android:name': APPWIDGET_UPDATE_ACTION,
284
+ },
285
+ },
286
+ ],
287
+ })
288
+ }
289
+
290
+ function ensureReceiverMetadata(receiver: AndroidManifestReceiver, metadataResource: string): void {
291
+ const metadataEntries = receiver['meta-data'] ?? []
292
+ receiver['meta-data'] = metadataEntries
293
+
294
+ const providerMetadata = metadataEntries.find(
295
+ (metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA
296
+ )
297
+
298
+ if (providerMetadata) {
299
+ providerMetadata.$ = {
300
+ ...providerMetadata.$,
301
+ 'android:name': APPWIDGET_PROVIDER_METADATA,
302
+ 'android:resource': metadataResource,
303
+ }
304
+ removeDuplicateEntries(metadataEntries, (metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA)
305
+ return
306
+ }
307
+
308
+ metadataEntries.push({
309
+ $: {
310
+ 'android:name': APPWIDGET_PROVIDER_METADATA,
311
+ 'android:resource': metadataResource,
312
+ },
313
+ })
314
+ }
315
+
316
+ function findReceiverByName(
317
+ receivers: AndroidManifestReceiver[],
318
+ receiverName: string
319
+ ): AndroidManifestReceiver | undefined {
320
+ return receivers.find((receiver) => receiver.$?.['android:name'] === receiverName)
321
+ }
322
+
323
+ function getReceiverMetadataResource(receiver: AndroidManifestReceiver): string | undefined {
324
+ return receiver['meta-data']?.find((metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA)?.$?.[
325
+ 'android:resource'
326
+ ]
327
+ }
328
+
329
+ function isWidgetMetadataResource(resource: string): boolean {
330
+ return resource.startsWith(WIDGET_INFO_RESOURCE_PREFIX) && resource.endsWith(WIDGET_INFO_RESOURCE_SUFFIX)
331
+ }
332
+
333
+ function createPermission(permissionName: string): AndroidManifestPermission {
334
+ return {
335
+ $: {
336
+ 'android:name': permissionName,
337
+ },
338
+ }
339
+ }
340
+
341
+ function createReceiver(receiverName: string, exported: 'true' | 'false', label?: string): AndroidManifestReceiver {
342
+ return {
343
+ $: {
344
+ 'android:name': receiverName,
345
+ 'android:exported': exported,
346
+ ...(label ? { 'android:label': label } : {}),
347
+ },
348
+ }
349
+ }
350
+
351
+ function removeDuplicateEntries<TEntry>(entries: TEntry[], isDuplicate: (entry: TEntry) => boolean): void {
352
+ let foundPrimary = false
353
+
354
+ for (let index = 0; index < entries.length; ) {
355
+ if (!isDuplicate(entries[index])) {
356
+ index += 1
357
+ continue
358
+ }
359
+
360
+ if (!foundPrimary) {
361
+ foundPrimary = true
362
+ index += 1
363
+ continue
364
+ }
365
+
366
+ entries.splice(index, 1)
367
+ }
368
+ }
369
+
370
+ function removeEntries<TEntry>(entries: TEntry[], shouldRemove: (entry: TEntry) => boolean): void {
371
+ for (let index = 0; index < entries.length; ) {
372
+ if (!shouldRemove(entries[index])) {
373
+ index += 1
374
+ continue
375
+ }
376
+
377
+ entries.splice(index, 1)
378
+ }
379
+ }
@@ -0,0 +1,139 @@
1
+ import { discoverIOSProject } from '../../discovery/ios'
2
+ import { getMissingPlatformPackageMessage, getMissingPlatformPackages } from '../../dependencies/platformPackages'
3
+ import { VoltraCliError } from '../../reporting/summary'
4
+
5
+ import { ensureEntitlements } from './entitlements'
6
+ import { generateIOSFiles } from './generated'
7
+ import { ensureInfoPlist } from './plist'
8
+ import { ensurePodfileBlock } from './podfile'
9
+ import { ensureIOSWidgetTarget } from './xcodeTarget'
10
+
11
+ import type { NormalizedVoltraConfig } from '../../config/types'
12
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
13
+ import type { PlatformApplyContext, PlatformApplyResult } from '../../apply'
14
+ import type { ApplyPreflightContext, PlatformPreflightResult, PlatformPreflightRunner } from '../../apply/preflight'
15
+
16
+ export function createIOSPreflightRunner(config: NormalizedVoltraConfig): PlatformPreflightRunner<IOSProjectDiscovery> {
17
+ return async (_context: ApplyPreflightContext): Promise<PlatformPreflightResult<IOSProjectDiscovery>> => {
18
+ const iosConfig = config.ios
19
+
20
+ if (!iosConfig) {
21
+ return {
22
+ platform: 'ios',
23
+ issues: [{ message: 'iOS config is missing.' }],
24
+ }
25
+ }
26
+
27
+ const missingPackages = getMissingPlatformPackages(config.projectRoot, 'ios')
28
+
29
+ if (missingPackages.length > 0) {
30
+ return {
31
+ platform: 'ios',
32
+ issues: [{ message: getMissingPlatformPackageMessage('ios', missingPackages) }],
33
+ }
34
+ }
35
+
36
+ return {
37
+ platform: 'ios',
38
+ context: await discoverIOSProject(config.projectRoot, iosConfig.project),
39
+ }
40
+ }
41
+ }
42
+
43
+ export async function applyIOSPlatform(context: PlatformApplyContext): Promise<PlatformApplyResult> {
44
+ if (context.platform !== 'ios') {
45
+ throw new VoltraCliError(`iOS apply runner received unexpected platform: ${context.platform}.`)
46
+ }
47
+
48
+ const iosConfig = context.config.ios
49
+
50
+ if (!iosConfig) {
51
+ throw new VoltraCliError('iOS config is missing.')
52
+ }
53
+
54
+ const discovery = getIOSDiscovery(context.preflight)
55
+ const generatedResult = await generateIOSFiles({
56
+ projectRoot: context.config.projectRoot,
57
+ ios: iosConfig,
58
+ discovery,
59
+ })
60
+ const entitlementsResult = await ensureEntitlements({
61
+ projectRoot: context.config.projectRoot,
62
+ ios: iosConfig,
63
+ discovery,
64
+ })
65
+ const infoPlistResult = await ensureInfoPlist({
66
+ projectRoot: context.config.projectRoot,
67
+ ios: iosConfig,
68
+ discovery,
69
+ })
70
+ const podfileResult = await ensurePodfileBlock({
71
+ projectRoot: context.config.projectRoot,
72
+ ios: iosConfig,
73
+ discovery,
74
+ })
75
+ const xcodeTargetResult = await ensureIOSWidgetTarget({
76
+ projectRoot: context.config.projectRoot,
77
+ ios: iosConfig,
78
+ discovery,
79
+ generatedFiles: generatedResult.files,
80
+ previousGeneratedFiles: context.previousState?.files,
81
+ })
82
+
83
+ if (generatedResult.targetName !== xcodeTargetResult.targetName) {
84
+ throw new VoltraCliError(
85
+ `iOS generated files and Xcode target mutation resolved different widget target names: ${generatedResult.targetName} vs ${xcodeTargetResult.targetName}.`
86
+ )
87
+ }
88
+
89
+ if (generatedResult.targetName !== podfileResult.targetName) {
90
+ throw new VoltraCliError(
91
+ `iOS generated files and Podfile block resolved different widget target names: ${generatedResult.targetName} vs ${podfileResult.targetName}.`
92
+ )
93
+ }
94
+
95
+ const changes = [
96
+ infoPlistResult.change,
97
+ entitlementsResult.change,
98
+ podfileResult.change,
99
+ xcodeTargetResult.change,
100
+ ].filter(isDefined)
101
+
102
+ return {
103
+ platform: 'ios',
104
+ changes: [...changes, ...generatedResult.changes],
105
+ generatedFiles: generatedResult.files,
106
+ warnings: [...(generatedResult.warnings ?? []), ...(podfileResult.warnings ?? [])],
107
+ }
108
+ }
109
+
110
+ function getIOSDiscovery(value: unknown): IOSProjectDiscovery {
111
+ if (!isIOSProjectDiscovery(value)) {
112
+ throw new VoltraCliError('iOS preflight did not provide a valid discovery result.')
113
+ }
114
+
115
+ return value
116
+ }
117
+
118
+ function isIOSProjectDiscovery(value: unknown): value is IOSProjectDiscovery {
119
+ if (!value || typeof value !== 'object') {
120
+ return false
121
+ }
122
+
123
+ const candidate = value as Partial<IOSProjectDiscovery>
124
+
125
+ return (
126
+ [
127
+ candidate.iosRoot,
128
+ candidate.xcodeprojPath,
129
+ candidate.pbxprojPath,
130
+ candidate.podfilePath,
131
+ candidate.mainTargetName,
132
+ candidate.infoPlistPath,
133
+ ].every((entry) => typeof entry === 'string' && entry.length > 0) && Array.isArray(candidate.mainTargetCandidates)
134
+ )
135
+ }
136
+
137
+ function isDefined<TValue>(value: TValue | undefined): value is TValue {
138
+ return value !== undefined
139
+ }