voltra 2.0.0-rc.4 → 2.1.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.
- package/build/cjs/config/normalize.js +93 -0
- package/build/cjs/config/normalize.js.map +1 -1
- package/build/cjs/index.js +5 -2
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/platforms/android/apply.js +11 -2
- package/build/cjs/platforms/android/apply.js.map +1 -1
- package/build/cjs/platforms/android/generated.js +152 -130
- package/build/cjs/platforms/android/generated.js.map +1 -1
- package/build/cjs/platforms/android/gradle.js +120 -0
- package/build/cjs/platforms/android/gradle.js.map +1 -0
- package/build/cjs/platforms/ios/generated.js +246 -142
- package/build/cjs/platforms/ios/generated.js.map +1 -1
- package/build/cjs/platforms/ios/xcodeTarget.js +68 -0
- package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
- package/build/cjs/platforms/shared/widgetModule.js +151 -0
- package/build/cjs/platforms/shared/widgetModule.js.map +1 -0
- package/build/esm/config/normalize.js +93 -0
- package/build/esm/config/normalize.js.map +1 -1
- package/build/esm/index.js +1 -0
- package/build/esm/index.js.map +1 -1
- package/build/esm/platforms/android/apply.js +11 -2
- package/build/esm/platforms/android/apply.js.map +1 -1
- package/build/esm/platforms/android/generated.js +152 -97
- package/build/esm/platforms/android/generated.js.map +1 -1
- package/build/esm/platforms/android/gradle.js +112 -0
- package/build/esm/platforms/android/gradle.js.map +1 -0
- package/build/esm/platforms/ios/generated.js +246 -109
- package/build/esm/platforms/ios/generated.js.map +1 -1
- package/build/esm/platforms/ios/xcodeTarget.js +69 -1
- package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
- package/build/esm/platforms/shared/widgetModule.js +111 -0
- package/build/esm/platforms/shared/widgetModule.js.map +1 -0
- package/build/types/config/normalize.d.ts.map +1 -1
- package/build/types/config/types.d.ts +32 -0
- package/build/types/config/types.d.ts.map +1 -1
- package/build/types/index.d.ts +3 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/platforms/android/apply.d.ts.map +1 -1
- package/build/types/platforms/android/generated.d.ts.map +1 -1
- package/build/types/platforms/android/gradle.d.ts +18 -0
- package/build/types/platforms/android/gradle.d.ts.map +1 -0
- package/build/types/platforms/ios/generated.d.ts.map +1 -1
- package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
- package/build/types/platforms/shared/widgetModule.d.ts +9 -0
- package/build/types/platforms/shared/widgetModule.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/apply/index.ts +309 -0
- package/src/apply/preflight.ts +172 -0
- package/src/bin.ts +14 -0
- package/src/commands/apply.ts +114 -0
- package/src/config/defaults.ts +34 -0
- package/src/config/load.ts +100 -0
- package/src/config/normalize.ts +562 -0
- package/src/config/types.ts +317 -0
- package/src/cosmiconfig.d.ts +21 -0
- package/src/dependencies/platformPackages.ts +75 -0
- package/src/discovery/android.ts +233 -0
- package/src/discovery/ios.ts +579 -0
- package/src/fs/path.ts +25 -0
- package/src/fs/readWrite.ts +102 -0
- package/src/git/status.ts +205 -0
- package/src/index.ts +204 -0
- package/src/platforms/android/apply.ts +106 -0
- package/src/platforms/android/generated.ts +1284 -0
- package/src/platforms/android/gradle.ts +155 -0
- package/src/platforms/android/manifest.ts +379 -0
- package/src/platforms/ios/apply.ts +139 -0
- package/src/platforms/ios/deploymentTarget.ts +47 -0
- package/src/platforms/ios/entitlements.ts +146 -0
- package/src/platforms/ios/generated.ts +1233 -0
- package/src/platforms/ios/mainAppEntitlements.ts +11 -0
- package/src/platforms/ios/plist.ts +321 -0
- package/src/platforms/ios/podfile.ts +255 -0
- package/src/platforms/ios/targetName.ts +11 -0
- package/src/platforms/ios/xcode.ts +181 -0
- package/src/platforms/ios/xcodeTarget.ts +899 -0
- package/src/platforms/shared/widgetModule.ts +153 -0
- package/src/reporting/clack.ts +67 -0
- package/src/reporting/summary.ts +116 -0
- package/src/state/diff.ts +21 -0
- package/src/state/files.ts +52 -0
- package/src/state/load.ts +66 -0
- package/src/state/save.ts +31 -0
- 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
|
+
}
|