@mpgd/cli 0.33.2 → 0.35.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/dist/capacitor-shell-starter.d.ts +40 -0
- package/dist/capacitor-shell-starter.js +2130 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +171 -8
- package/dist/kit-upgrade.d.ts +39 -0
- package/dist/kit-upgrade.js +486 -0
- package/dist/native-shell-identity.d.ts +19 -0
- package/dist/native-shell-identity.js +490 -0
- package/dist/production-target-readiness.d.ts +1 -0
- package/dist/production-target-readiness.js +1 -1
- package/package.json +16 -13
- package/templates/phaser-game/package.json +1 -0
- package/templates/phaser-game/src/styles.css +6 -4
- package/templates/phaser-game/vite.runtime-target-config.ts +5 -0
|
@@ -0,0 +1,2130 @@
|
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { DOMParser } from '@xmldom/xmldom';
|
|
7
|
+
import { isNonPublicServiceHostname } from './production-target-readiness.js';
|
|
8
|
+
import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
|
|
9
|
+
const shellPath = 'apps/mobile-capacitor';
|
|
10
|
+
const webPath = `${shellPath}/www`;
|
|
11
|
+
const referenceShellPath = '${MPGD_KIT_PATH}/apps/mobile-capacitor';
|
|
12
|
+
const providerIdPattern = /^[a-z][a-z0-9-]*$/u;
|
|
13
|
+
const appIdPattern = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/u;
|
|
14
|
+
const javaKeywords = new Set(`
|
|
15
|
+
abstract assert boolean break byte case catch char class const continue default do double
|
|
16
|
+
else enum extends final finally float for goto if implements import instanceof int interface
|
|
17
|
+
long native new package private protected public return short static strictfp super switch
|
|
18
|
+
synchronized this throw throws transient try void volatile while true false null record sealed
|
|
19
|
+
permits var yield
|
|
20
|
+
`.trim().split(/\s+/u));
|
|
21
|
+
/**
|
|
22
|
+
* Produce the complete game-owned shell change list before writing anything.
|
|
23
|
+
* Existing Android/iOS projects, signing files, and custom controllers are
|
|
24
|
+
* never rewritten by this initializer.
|
|
25
|
+
*/
|
|
26
|
+
export function planCapacitorShellStarter(input) {
|
|
27
|
+
if (!existsSync(input.gameRoot)) {
|
|
28
|
+
throw new Error(`Game root does not exist: ${input.gameRoot}`);
|
|
29
|
+
}
|
|
30
|
+
const gameRoot = realpathSync(input.gameRoot);
|
|
31
|
+
if (!lstatSync(gameRoot).isDirectory()) {
|
|
32
|
+
throw new Error('Capacitor shell game root must be a directory.');
|
|
33
|
+
}
|
|
34
|
+
if (!appIdPattern.test(input.appId)) {
|
|
35
|
+
throw new Error('Capacitor app ID must be a lowercase reverse-domain identifier.');
|
|
36
|
+
}
|
|
37
|
+
if (input.appId.split('.').some((segment) => javaKeywords.has(segment))) {
|
|
38
|
+
throw new Error('Capacitor app ID cannot contain Java keyword segments.');
|
|
39
|
+
}
|
|
40
|
+
if (input.displayName.trim() !== input.displayName
|
|
41
|
+
|| input.displayName.length === 0 || input.displayName.length > 80) {
|
|
42
|
+
throw new Error('Capacitor display name must be 1-80 characters without outer whitespace.');
|
|
43
|
+
}
|
|
44
|
+
if (/\$\(|\$\{/u.test(input.displayName)) {
|
|
45
|
+
throw new Error('Capacitor display name cannot contain Xcode build-setting expansions.');
|
|
46
|
+
}
|
|
47
|
+
if (/[\t\n]/u.test(input.displayName)) {
|
|
48
|
+
throw new Error('Capacitor display name cannot contain native whitespace controls.');
|
|
49
|
+
}
|
|
50
|
+
for (const scalar of input.displayName) {
|
|
51
|
+
const point = scalar.codePointAt(0) ?? 0;
|
|
52
|
+
if (point !== 0x9 && point !== 0xa
|
|
53
|
+
&& !(point >= 0x20 && point <= 0xd7ff)
|
|
54
|
+
&& !(point >= 0xe000 && point <= 0xfffd)
|
|
55
|
+
&& !(point >= 0x10000 && point <= 0x10ffff)) {
|
|
56
|
+
throw new Error('Capacitor display name contains a character invalid in native XML.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const providerIds = [...new Set(input.providerIds ?? [])].sort();
|
|
60
|
+
if (providerIds.some((id) => !providerIdPattern.test(id))) {
|
|
61
|
+
throw new Error('Capacitor provider IDs must be lowercase hyphenated names.');
|
|
62
|
+
}
|
|
63
|
+
const backendUrl = input.backendUrl === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: normalizeBackendUrl(input.backendUrl);
|
|
66
|
+
const iconSource = input.iconSource === undefined
|
|
67
|
+
? undefined
|
|
68
|
+
: relativeGameFile(gameRoot, input.iconSource);
|
|
69
|
+
for (const managedPath of [
|
|
70
|
+
'mpgd.targets.json',
|
|
71
|
+
shellPath,
|
|
72
|
+
`${shellPath}/package.json`,
|
|
73
|
+
`${shellPath}/capacitor.config.ts`,
|
|
74
|
+
`${shellPath}/mpgd.native-shell.json`,
|
|
75
|
+
`${shellPath}/README.md`,
|
|
76
|
+
`${shellPath}/www/index.html`,
|
|
77
|
+
`${shellPath}/android`,
|
|
78
|
+
`${shellPath}/ios`,
|
|
79
|
+
]) {
|
|
80
|
+
safeDestination(gameRoot, managedPath);
|
|
81
|
+
}
|
|
82
|
+
const gamePackageFile = path.join(gameRoot, 'package.json');
|
|
83
|
+
if (!existsSync(gamePackageFile)) {
|
|
84
|
+
throw new Error('Game root must contain package.json.');
|
|
85
|
+
}
|
|
86
|
+
const gamePackage = readJsonObject(gamePackageFile);
|
|
87
|
+
const gamePackageName = requireString(gamePackage.name, 'game package name');
|
|
88
|
+
const gameDependencies = requireObject(gamePackage.dependencies, 'game dependencies');
|
|
89
|
+
const capacitorVersion = requireString(gameDependencies['@capacitor/core'], '@capacitor/core');
|
|
90
|
+
const appVersion = requireString(gameDependencies['@capacitor/app'], '@capacitor/app');
|
|
91
|
+
if (!/^\d+\.\d+\.\d+$/u.test(capacitorVersion)
|
|
92
|
+
|| !/^\d+\.\d+\.\d+$/u.test(appVersion)) {
|
|
93
|
+
throw new Error('Capacitor shell requires exact @capacitor/core and @capacitor/app versions.');
|
|
94
|
+
}
|
|
95
|
+
if (!capacitorVersion.startsWith('8.') || !appVersion.startsWith('8.')) {
|
|
96
|
+
throw new Error('Capacitor shell requires Capacitor 8.x for core and app.');
|
|
97
|
+
}
|
|
98
|
+
const adapterFile = path.join(gameRoot, 'node_modules/@mpgd/adapter-capacitor/package.json');
|
|
99
|
+
if (!existsSync(adapterFile)) {
|
|
100
|
+
throw new Error('Install game dependencies before initializing the Capacitor shell.');
|
|
101
|
+
}
|
|
102
|
+
const adapter = readJsonObject(adapterFile);
|
|
103
|
+
const adapterDependencies = requireObject(adapter.dependencies, 'Capacitor adapter dependencies');
|
|
104
|
+
const pluginRequirement = requireString(adapterDependencies['@mpgd/capacitor-game-services'], '@mpgd/capacitor-game-services');
|
|
105
|
+
const pluginVersion = resolveInstalledPluginVersion(gameRoot, pluginRequirement);
|
|
106
|
+
const targetsFile = path.join(gameRoot, 'mpgd.targets.json');
|
|
107
|
+
if (!existsSync(targetsFile)) {
|
|
108
|
+
throw new Error('Game root must contain mpgd.targets.json.');
|
|
109
|
+
}
|
|
110
|
+
const targets = readJsonObject(targetsFile);
|
|
111
|
+
const cliPackage = readJsonObject(fileURLToPath(new URL('../package.json', import.meta.url)));
|
|
112
|
+
const cliDevelopmentDependencies = requireObject(cliPackage.devDependencies, 'CLI dev dependencies');
|
|
113
|
+
const typescriptVersion = requireString(cliDevelopmentDependencies.typescript, 'CLI TypeScript version');
|
|
114
|
+
const targetMap = requireObject(targets.targets, 'targets');
|
|
115
|
+
for (const [targetName, kind, identityKey] of [
|
|
116
|
+
['android', 'capacitor-android', 'packageId'],
|
|
117
|
+
['ios', 'capacitor-ios', 'bundleId'],
|
|
118
|
+
]) {
|
|
119
|
+
if (targetMap[targetName] === undefined) {
|
|
120
|
+
throw new Error(`Capacitor initialization requires the canonical ${targetName} target name used by the build CLI.`);
|
|
121
|
+
}
|
|
122
|
+
const target = requireObject(targetMap[targetName], `${targetName} target`);
|
|
123
|
+
if (target.kind !== kind) {
|
|
124
|
+
throw new Error(`${targetName} must be a ${kind} target.`);
|
|
125
|
+
}
|
|
126
|
+
const configuredGame = requireString(target.gameApp, `${targetName}.gameApp`);
|
|
127
|
+
if (path.resolve(gameRoot, configuredGame) !== gameRoot) {
|
|
128
|
+
throw new Error(`${targetName}.gameApp must select this game root.`);
|
|
129
|
+
}
|
|
130
|
+
if (requireString(target.adapter, `${targetName}.adapter`) !== 'capacitor') {
|
|
131
|
+
throw new Error(`${targetName}.adapter must be capacitor.`);
|
|
132
|
+
}
|
|
133
|
+
const configuredShell = requireString(target.shellApp, `${targetName}.shellApp`);
|
|
134
|
+
if (configuredShell !== shellPath && configuredShell !== referenceShellPath) {
|
|
135
|
+
throw new Error(`${targetName} already points at another shell; refusing to replace it.`);
|
|
136
|
+
}
|
|
137
|
+
const metadata = target.metadata === undefined
|
|
138
|
+
? {}
|
|
139
|
+
: requireObject(target.metadata, `${targetName}.metadata`);
|
|
140
|
+
if (metadata[identityKey] !== undefined && metadata[identityKey] !== input.appId) {
|
|
141
|
+
throw new Error(`${targetName} ${identityKey} conflicts with the requested app ID.`);
|
|
142
|
+
}
|
|
143
|
+
if (metadata.displayName !== undefined && metadata.displayName !== input.displayName) {
|
|
144
|
+
throw new Error(`${targetName} display name conflicts with the requested name.`);
|
|
145
|
+
}
|
|
146
|
+
const expectedArtifact = targetName === 'android' ? 'aab' : 'ipa';
|
|
147
|
+
if (target.artifact === undefined) {
|
|
148
|
+
target.artifact = expectedArtifact;
|
|
149
|
+
}
|
|
150
|
+
else if (target.artifact !== expectedArtifact) {
|
|
151
|
+
throw new Error(`${targetName}.artifact must be ${expectedArtifact} for this native shell.`);
|
|
152
|
+
}
|
|
153
|
+
target.shellApp = shellPath;
|
|
154
|
+
target.webDir = webPath;
|
|
155
|
+
target.metadata = { ...metadata, [identityKey]: input.appId, displayName: input.displayName };
|
|
156
|
+
if (iconSource !== undefined) {
|
|
157
|
+
const icon = target.icon === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: requireObject(target.icon, `${targetName}.icon`);
|
|
160
|
+
target.icon = { ...icon, source: iconSource };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const packageFile = path.join(gameRoot, shellPath, 'package.json');
|
|
164
|
+
const configFile = path.join(gameRoot, shellPath, 'capacitor.config.ts');
|
|
165
|
+
const manifestFile = path.join(gameRoot, shellPath, 'mpgd.native-shell.json');
|
|
166
|
+
const packageContent = readExisting(packageFile);
|
|
167
|
+
const configContent = readExisting(configFile);
|
|
168
|
+
const manifestContent = readExisting(manifestFile);
|
|
169
|
+
const existingManifest = manifestContent === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: parseJsonObject(manifestContent, 'existing native shell manifest');
|
|
172
|
+
if (existingManifest !== undefined && existingManifest.schemaVersion !== 1) {
|
|
173
|
+
throw new Error('Existing native shell manifest schema is unsupported; refusing to downgrade it.');
|
|
174
|
+
}
|
|
175
|
+
if (existingManifest !== undefined
|
|
176
|
+
&& (existingManifest.appId !== input.appId
|
|
177
|
+
|| existingManifest.displayName !== input.displayName)) {
|
|
178
|
+
throw new Error('Existing native shell identity differs; refusing to overwrite it.');
|
|
179
|
+
}
|
|
180
|
+
const requestedProviderIds = input.providerIds === undefined
|
|
181
|
+
&& Array.isArray(existingManifest?.requestedProviderIds)
|
|
182
|
+
? existingManifest.requestedProviderIds : providerIds;
|
|
183
|
+
const requestedBackendUrl = backendUrl ?? (typeof existingManifest?.backendUrl === 'string'
|
|
184
|
+
? existingManifest.backendUrl : undefined);
|
|
185
|
+
if (packageContent !== undefined) {
|
|
186
|
+
assertCompatibleExistingPackage(packageContent, capacitorVersion, appVersion, pluginVersion, typescriptVersion);
|
|
187
|
+
}
|
|
188
|
+
if (configContent !== undefined) {
|
|
189
|
+
const safeConfig = requireStaticCapacitorConfig(configContent);
|
|
190
|
+
const existingAppId = readCapacitorConfigLiteral(safeConfig, 'appId');
|
|
191
|
+
if (existingAppId === undefined) {
|
|
192
|
+
throw new Error('Existing Capacitor shell app ID could not be read safely.');
|
|
193
|
+
}
|
|
194
|
+
if (existingAppId !== input.appId) {
|
|
195
|
+
throw new Error('Existing Capacitor shell app ID differs; refusing to overwrite it.');
|
|
196
|
+
}
|
|
197
|
+
const existingAppName = readCapacitorConfigLiteral(safeConfig, 'appName');
|
|
198
|
+
if (existingAppName === undefined) {
|
|
199
|
+
throw new Error('Existing Capacitor shell display name could not be read safely.');
|
|
200
|
+
}
|
|
201
|
+
if (existingAppName !== input.displayName) {
|
|
202
|
+
throw new Error('Existing Capacitor shell display name differs; refusing to overwrite it.');
|
|
203
|
+
}
|
|
204
|
+
const existingWebDir = readCapacitorConfigLiteral(safeConfig, 'webDir');
|
|
205
|
+
if (existingWebDir !== 'www') {
|
|
206
|
+
throw new Error('Existing Capacitor shell webDir differs; refusing to overwrite it.');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const requestedFiles = [
|
|
210
|
+
{ path: 'mpgd.targets.json', content: json(targets) },
|
|
211
|
+
{
|
|
212
|
+
path: `${shellPath}/package.json`,
|
|
213
|
+
content: packageContent ?? json({
|
|
214
|
+
name: `${gamePackageName}-native-shell`,
|
|
215
|
+
private: true,
|
|
216
|
+
version: '0.0.0',
|
|
217
|
+
type: 'module',
|
|
218
|
+
scripts: {
|
|
219
|
+
cap: 'cap',
|
|
220
|
+
'sync:android': 'cap sync android',
|
|
221
|
+
'sync:ios': 'cap sync ios',
|
|
222
|
+
},
|
|
223
|
+
dependencies: {
|
|
224
|
+
'@capacitor/android': capacitorVersion,
|
|
225
|
+
'@capacitor/app': appVersion,
|
|
226
|
+
'@capacitor/core': capacitorVersion,
|
|
227
|
+
'@capacitor/ios': capacitorVersion,
|
|
228
|
+
'@mpgd/capacitor-game-services': pluginVersion,
|
|
229
|
+
},
|
|
230
|
+
devDependencies: {
|
|
231
|
+
'@capacitor/cli': capacitorVersion,
|
|
232
|
+
typescript: typescriptVersion,
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
path: `${shellPath}/capacitor.config.ts`,
|
|
238
|
+
content: configContent ?? [
|
|
239
|
+
"import type { CapacitorConfig } from '@capacitor/cli';",
|
|
240
|
+
'',
|
|
241
|
+
'const config: CapacitorConfig = {',
|
|
242
|
+
` appId: ${JSON.stringify(input.appId)},`,
|
|
243
|
+
` appName: ${JSON.stringify(input.displayName)},`,
|
|
244
|
+
" webDir: 'www',",
|
|
245
|
+
" server: { androidScheme: 'https' },",
|
|
246
|
+
" ios: { contentInset: 'automatic' },",
|
|
247
|
+
' android: { allowMixedContent: false },',
|
|
248
|
+
'};',
|
|
249
|
+
'',
|
|
250
|
+
'export default config;',
|
|
251
|
+
'',
|
|
252
|
+
].join('\n'),
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
path: `${shellPath}/mpgd.native-shell.json`,
|
|
256
|
+
content: json({
|
|
257
|
+
schemaVersion: 1,
|
|
258
|
+
appId: input.appId,
|
|
259
|
+
displayName: input.displayName,
|
|
260
|
+
requestedProviderIds,
|
|
261
|
+
...(requestedBackendUrl === undefined ? {} : { backendUrl: requestedBackendUrl }),
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
path: `${shellPath}/README.md`,
|
|
266
|
+
content: readExisting(path.join(gameRoot, shellPath, 'README.md')) ?? [
|
|
267
|
+
'# Game-owned Capacitor shell',
|
|
268
|
+
'',
|
|
269
|
+
'This private shell belongs to this game. The initializer installs game',
|
|
270
|
+
'dependencies and uses the pinned Capacitor CLI to add missing Android',
|
|
271
|
+
'and iOS projects. Rerun it after an interrupted dependency install.',
|
|
272
|
+
'If cap add left an incomplete native directory, repair or remove only',
|
|
273
|
+
'that game-owned directory before retrying; it is never overwritten.',
|
|
274
|
+
'Existing native files, signing settings, and custom',
|
|
275
|
+
'ViewControllers are never replaced by mpgd target init capacitor.',
|
|
276
|
+
'',
|
|
277
|
+
'requestedProviderIds is selection metadata only: it does not install an',
|
|
278
|
+
'SDK or report a feature as available. Register provider modules explicitly.',
|
|
279
|
+
'Production builds require a real game-owned backend when grants are enabled.',
|
|
280
|
+
'',
|
|
281
|
+
].join('\n'),
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
path: `${shellPath}/www/index.html`,
|
|
285
|
+
content: readExisting(path.join(gameRoot, shellPath, 'www/index.html')) ?? [
|
|
286
|
+
'<!doctype html>',
|
|
287
|
+
'<html lang="en"><head><meta charset="utf-8"><title>Native shell setup</title></head>',
|
|
288
|
+
'<body>Run mpgd target build to replace this setup page with the game bundle.</body></html>',
|
|
289
|
+
'',
|
|
290
|
+
].join('\n'),
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
if (requestedBackendUrl !== undefined) {
|
|
294
|
+
safeDestination(gameRoot, '.env.production');
|
|
295
|
+
safeDestination(gameRoot, '.env.production.local');
|
|
296
|
+
const validatedBackendUrl = normalizeBackendUrl(requestedBackendUrl);
|
|
297
|
+
const localEnv = readExisting(path.join(gameRoot, '.env.production.local')) ?? '';
|
|
298
|
+
const localDefinitions = [...localEnv.replace(/^\uFEFF/u, '').matchAll(/^[ \t]*(?:export[ \t]+)?VITE_MPGD_GAME_SERVICES_URL[ \t]*=[ \t]*(.*)$/gmu)];
|
|
299
|
+
if (localDefinitions.length > 1) {
|
|
300
|
+
throw new Error('Duplicate production-local Game Services URLs are not allowed.');
|
|
301
|
+
}
|
|
302
|
+
if (localDefinitions[0]?.[1] !== undefined
|
|
303
|
+
&& normalizeBackendUrl(readDotenvValue(localDefinitions[0][1] ?? ''))
|
|
304
|
+
!== validatedBackendUrl) {
|
|
305
|
+
throw new Error('Production-local Game Services URL conflicts with the requested shell.');
|
|
306
|
+
}
|
|
307
|
+
const envFile = path.join(gameRoot, '.env.production');
|
|
308
|
+
const current = readExisting(envFile) ?? '';
|
|
309
|
+
const definitions = [...current.replace(/^\uFEFF/u, '').matchAll(/^[ \t]*(?:export[ \t]+)?VITE_MPGD_GAME_SERVICES_URL[ \t]*=[ \t]*(.*)$/gmu)];
|
|
310
|
+
if (definitions.length > 1) {
|
|
311
|
+
throw new Error('Duplicate production Game Services URLs are not allowed.');
|
|
312
|
+
}
|
|
313
|
+
const existing = definitions[0]?.[1] === undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: readDotenvValue(definitions[0][1] ?? '');
|
|
316
|
+
if (existing !== undefined && normalizeBackendUrl(existing) !== validatedBackendUrl) {
|
|
317
|
+
throw new Error('Existing production Game Services URL differs; refusing to overwrite it.');
|
|
318
|
+
}
|
|
319
|
+
requestedFiles.push({
|
|
320
|
+
path: '.env.production',
|
|
321
|
+
content: existing === undefined
|
|
322
|
+
? appendEnvLine(current, `VITE_MPGD_GAME_SERVICES_URL=${validatedBackendUrl}`)
|
|
323
|
+
: current,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const nativePlatformsToAdd = ['android', 'ios'].filter((platform) => {
|
|
327
|
+
const nativeDirectory = path.join(gameRoot, shellPath, platform);
|
|
328
|
+
if (!existsSync(nativeDirectory)) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
assertNativePlatformComplete(gameRoot, platform, input.appId, input.displayName);
|
|
332
|
+
return false;
|
|
333
|
+
});
|
|
334
|
+
const smokePath = `${shellPath}/ios/App/App/Info-Smoke.plist`;
|
|
335
|
+
safeDestination(gameRoot, smokePath);
|
|
336
|
+
if (readExisting(path.join(gameRoot, smokePath)) === undefined) {
|
|
337
|
+
requestedFiles.push({ path: smokePath, content: smokeInfoPlist(input.displayName) });
|
|
338
|
+
}
|
|
339
|
+
const files = requestedFiles.filter((file) => readExisting(path.join(gameRoot, file.path)) !== file.content);
|
|
340
|
+
return {
|
|
341
|
+
gameRoot,
|
|
342
|
+
input: {
|
|
343
|
+
...input,
|
|
344
|
+
gameRoot,
|
|
345
|
+
...(input.providerIds === undefined ? {} : { providerIds: [...input.providerIds] }),
|
|
346
|
+
},
|
|
347
|
+
files,
|
|
348
|
+
changedFiles: files.map((file) => file.path),
|
|
349
|
+
nativePlatformsToAdd,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
export function applyCapacitorShellStarter(plan, installStagedFile = renameSync) {
|
|
353
|
+
const staged = plan.files.filter((file) => !plan.nativePlatformsToAdd.some((platform) => file.path.startsWith(`${shellPath}/${platform}/`))).map((file) => {
|
|
354
|
+
const destination = safeDestination(plan.gameRoot, file.path);
|
|
355
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
356
|
+
assertNotSymlink(destination);
|
|
357
|
+
const previous = existsSync(destination) ? lstatSync(destination) : undefined;
|
|
358
|
+
if (previous !== undefined && !previous.isFile()) {
|
|
359
|
+
throw new Error(`Managed shell destination must be a file: ${destination}`);
|
|
360
|
+
}
|
|
361
|
+
const temporary = `${destination}.mpgd-${randomUUID()}.tmp`;
|
|
362
|
+
return {
|
|
363
|
+
destination,
|
|
364
|
+
temporary,
|
|
365
|
+
previousMode: previous === undefined ? undefined : previous.mode & 0o7777,
|
|
366
|
+
content: file.content,
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
const installed = [];
|
|
370
|
+
try {
|
|
371
|
+
for (const file of staged) {
|
|
372
|
+
writeFileSync(file.temporary, file.content, {
|
|
373
|
+
flag: 'wx',
|
|
374
|
+
mode: file.previousMode ?? 0o666,
|
|
375
|
+
});
|
|
376
|
+
if (file.previousMode !== undefined) {
|
|
377
|
+
chmodSync(file.temporary, file.previousMode);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const file of staged) {
|
|
381
|
+
const entry = { destination: file.destination };
|
|
382
|
+
if (file.previousMode !== undefined) {
|
|
383
|
+
entry.backup = `${file.destination}.mpgd-${randomUUID()}.backup`;
|
|
384
|
+
renameSync(file.destination, entry.backup);
|
|
385
|
+
}
|
|
386
|
+
installed.push(entry);
|
|
387
|
+
installStagedFile(file.temporary, file.destination);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
const rollbackErrors = [];
|
|
392
|
+
for (const entry of installed.reverse()) {
|
|
393
|
+
try {
|
|
394
|
+
if (existsSync(entry.destination)) {
|
|
395
|
+
unlinkSync(entry.destination);
|
|
396
|
+
}
|
|
397
|
+
if (entry.backup !== undefined && existsSync(entry.backup)) {
|
|
398
|
+
renameSync(entry.backup, entry.destination);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (rollbackError) {
|
|
402
|
+
rollbackErrors.push(rollbackError);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (rollbackErrors.length > 0) {
|
|
406
|
+
throw new AggregateError([error, ...rollbackErrors], 'Capacitor shell rollback failed.');
|
|
407
|
+
}
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
for (const file of staged) {
|
|
412
|
+
if (existsSync(file.temporary)) {
|
|
413
|
+
unlinkSync(file.temporary);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const entry of installed) {
|
|
418
|
+
if (entry.backup !== undefined) {
|
|
419
|
+
unlinkSync(entry.backup);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function safeDestination(gameRoot, relative) {
|
|
424
|
+
if (relative === '' || path.isAbsolute(relative)) {
|
|
425
|
+
throw new Error('Managed shell path must be relative to the game.');
|
|
426
|
+
}
|
|
427
|
+
const canonicalRoot = realpathSync(gameRoot);
|
|
428
|
+
const destination = path.resolve(canonicalRoot, relative);
|
|
429
|
+
const within = path.relative(canonicalRoot, destination);
|
|
430
|
+
if (within === '' || within.startsWith('..') || path.isAbsolute(within)) {
|
|
431
|
+
throw new Error('Managed shell path must stay inside the game.');
|
|
432
|
+
}
|
|
433
|
+
let current = canonicalRoot;
|
|
434
|
+
for (const segment of within.split(path.sep)) {
|
|
435
|
+
current = path.join(current, segment);
|
|
436
|
+
assertNotSymlink(current);
|
|
437
|
+
}
|
|
438
|
+
return destination;
|
|
439
|
+
}
|
|
440
|
+
/** Materialize only missing native projects after the non-destructive plan is applied. */
|
|
441
|
+
export function materializeCapacitorShellStarter(plan, runner = { run: runCommand }) {
|
|
442
|
+
applyCapacitorShellStarter(plan);
|
|
443
|
+
// Native projects are generated by the pinned Capacitor CLI within the
|
|
444
|
+
// game workspace. An existing game may exclude the new shell from its
|
|
445
|
+
// workspace globs. Always retry its standalone install: a CLI shim alone
|
|
446
|
+
// does not prove that an interrupted dependency install completed.
|
|
447
|
+
runner.run('pnpm', ['install', '--no-frozen-lockfile'], plan.gameRoot);
|
|
448
|
+
runner.run('pnpm', ['--dir', shellPath, 'install', '--ignore-workspace', '--no-frozen-lockfile'], plan.gameRoot);
|
|
449
|
+
for (const platform of plan.nativePlatformsToAdd) {
|
|
450
|
+
if (!existsSync(path.join(plan.gameRoot, shellPath, platform))) {
|
|
451
|
+
runner.run('pnpm', ['--dir', shellPath, 'cap', 'add', platform], plan.gameRoot);
|
|
452
|
+
}
|
|
453
|
+
const manifest = readJsonObject(path.join(plan.gameRoot, shellPath, 'mpgd.native-shell.json'));
|
|
454
|
+
if (platform === 'ios') {
|
|
455
|
+
materializeIosArchiveScheme(plan.gameRoot);
|
|
456
|
+
const relative = `${shellPath}/ios/App/App/Info-Smoke.plist`;
|
|
457
|
+
const destination = safeDestination(plan.gameRoot, relative);
|
|
458
|
+
if (!existsSync(destination)) {
|
|
459
|
+
const planned = plan.files.find((file) => file.path === relative);
|
|
460
|
+
if (planned === undefined) {
|
|
461
|
+
throw new Error('New ios project simulator Info.plist was not planned.');
|
|
462
|
+
}
|
|
463
|
+
writeFileSync(destination, planned.content, { flag: 'wx' });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
assertNativePlatformComplete(plan.gameRoot, platform, requireString(manifest.appId, 'shell app ID'), requireString(manifest.displayName, 'shell display name'));
|
|
467
|
+
}
|
|
468
|
+
const afterInstall = planCapacitorShellStarter(plan.input);
|
|
469
|
+
if (afterInstall.changedFiles.length > 0 || afterInstall.nativePlatformsToAdd.length > 0) {
|
|
470
|
+
throw new Error('Capacitor shell changed during installation; rerun the initializer.');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function materializeIosArchiveScheme(gameRoot) {
|
|
474
|
+
const schemeRelative = `${shellPath}/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme`;
|
|
475
|
+
const schemeFile = safeDestination(gameRoot, schemeRelative);
|
|
476
|
+
if (existsSync(schemeFile)) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const projectRelative = `${shellPath}/ios/App/App.xcodeproj/project.pbxproj`;
|
|
480
|
+
const projectFile = safeDestination(gameRoot, projectRelative);
|
|
481
|
+
const appTargetId = readIosAppTargetId(readFileSync(projectFile, 'utf8'));
|
|
482
|
+
const scheme = [
|
|
483
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
484
|
+
'<Scheme LastUpgradeVersion="2600" version="1.7">',
|
|
485
|
+
'<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">',
|
|
486
|
+
'<BuildActionEntries>',
|
|
487
|
+
'<BuildActionEntry buildForTesting="YES" buildForRunning="YES"',
|
|
488
|
+
' buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">',
|
|
489
|
+
`<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="${appTargetId}"`,
|
|
490
|
+
' BuildableName="App.app" BlueprintName="App"',
|
|
491
|
+
' ReferencedContainer="container:App.xcodeproj"/>',
|
|
492
|
+
'</BuildActionEntry>',
|
|
493
|
+
'</BuildActionEntries>',
|
|
494
|
+
'</BuildAction>',
|
|
495
|
+
'<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>',
|
|
496
|
+
'</Scheme>',
|
|
497
|
+
].join('\n');
|
|
498
|
+
mkdirSync(path.dirname(schemeFile), { recursive: true });
|
|
499
|
+
writeFileSync(schemeFile, `${scheme}\n`, { flag: 'wx' });
|
|
500
|
+
}
|
|
501
|
+
function assertNativePlatformComplete(gameRoot, platform, expectedAppId, expectedDisplayName) {
|
|
502
|
+
const nativeDirectory = path.join(gameRoot, shellPath, platform);
|
|
503
|
+
if (!existsSync(nativeDirectory) || !lstatSync(nativeDirectory).isDirectory()) {
|
|
504
|
+
throw new Error(`Existing ${platform} project is incomplete; repair or remove only that game-owned directory before retrying.`);
|
|
505
|
+
}
|
|
506
|
+
const required = platform === 'android'
|
|
507
|
+
? requireOneNativeFile(nativeDirectory, ['app/build.gradle', 'app/build.gradle.kts'], platform)
|
|
508
|
+
: requireOneNativeFile(nativeDirectory, ['App/App.xcodeproj/project.pbxproj'], platform);
|
|
509
|
+
if (platform === 'android') {
|
|
510
|
+
const rootBuild = requireOneNativeFile(nativeDirectory, ['build.gradle', 'build.gradle.kts'], platform);
|
|
511
|
+
const settings = requireOneNativeFile(nativeDirectory, ['settings.gradle', 'settings.gradle.kts'], platform);
|
|
512
|
+
const rootSource = stripGradleComments(readFileSync(path.join(nativeDirectory, rootBuild), 'utf8'));
|
|
513
|
+
const settingsSource = stripGradleComments(readFileSync(path.join(nativeDirectory, settings), 'utf8'));
|
|
514
|
+
assertAndroidSettingsAppProject(settingsSource);
|
|
515
|
+
const rootCode = maskGradleStrings(rootSource);
|
|
516
|
+
const settingsCode = maskGradleStrings(settingsSource);
|
|
517
|
+
if (/\b(?:afterEvaluate|projectsEvaluated)\b/u.test(rootCode)
|
|
518
|
+
&& /\b(?:project|subprojects|allprojects|android)\b/u.test(rootCode)) {
|
|
519
|
+
throw new Error('Existing android project has unsupported root Gradle app callbacks.');
|
|
520
|
+
}
|
|
521
|
+
if (/\b(?:beforeProject|afterProject|beforeEvaluate|afterEvaluate|projectsEvaluated)\b/u
|
|
522
|
+
.test(settingsCode)) {
|
|
523
|
+
throw new Error('Existing android project has unsupported settings Gradle callbacks.');
|
|
524
|
+
}
|
|
525
|
+
const visited = new Set();
|
|
526
|
+
const inspectedScripts = new Set();
|
|
527
|
+
for (const script of [settings, rootBuild, required]) {
|
|
528
|
+
const ownerDirectory = script === required
|
|
529
|
+
? path.join(nativeDirectory, 'app')
|
|
530
|
+
: nativeDirectory;
|
|
531
|
+
assertAppliedGradleScripts(nativeDirectory, script, visited, inspectedScripts, script === settings, ownerDirectory);
|
|
532
|
+
}
|
|
533
|
+
for (const script of inspectedScripts) {
|
|
534
|
+
const source = stripGradleComments(readFileSync(path.join(nativeDirectory, script), 'utf8'));
|
|
535
|
+
if (hasAndroidDisplayNameResourceOverride(source)) {
|
|
536
|
+
throw new Error('Existing android project generates an unsupported app_name resource.');
|
|
537
|
+
}
|
|
538
|
+
if (hasAndroidResourceSourceSetOverride(source)) {
|
|
539
|
+
throw new Error('Existing android project custom resource sourceSets are unsupported.');
|
|
540
|
+
}
|
|
541
|
+
if (hasAndroidManifestSourceSetOverride(source)) {
|
|
542
|
+
throw new Error('Existing android project custom manifest sourceSets are unsupported.');
|
|
543
|
+
}
|
|
544
|
+
if (hasGradleTaskAction(source)) {
|
|
545
|
+
throw new Error('Existing android project Gradle task actions are unsupported.');
|
|
546
|
+
}
|
|
547
|
+
if (script === required) {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (/\bproductFlavors\b/u.test(maskGradleStrings(source))) {
|
|
551
|
+
throw new Error('Existing android project product flavors are unsupported by the builder.');
|
|
552
|
+
}
|
|
553
|
+
if (hasGradleIdentityMutation(source)) {
|
|
554
|
+
throw new Error(`Existing android project applied Gradle script changes identity: ${script}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
const hasSpm = isNativeFile(nativeDirectory, 'App/CapApp-SPM/Package.swift');
|
|
560
|
+
if (!hasSpm) {
|
|
561
|
+
throw new Error('Existing ios project is incomplete; SPM files are required by the builder.');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const additional = platform === 'android'
|
|
565
|
+
? [
|
|
566
|
+
'gradlew',
|
|
567
|
+
'gradle/wrapper/gradle-wrapper.jar',
|
|
568
|
+
'gradle/wrapper/gradle-wrapper.properties',
|
|
569
|
+
'app/src/main/AndroidManifest.xml',
|
|
570
|
+
]
|
|
571
|
+
: ['App/App/AppDelegate.swift'];
|
|
572
|
+
if (additional.some((relative) => !isNativeFile(nativeDirectory, relative))) {
|
|
573
|
+
throw new Error(`Existing ${platform} project is incomplete; required native files are missing.`);
|
|
574
|
+
}
|
|
575
|
+
if (platform === 'android' && process.platform !== 'win32'
|
|
576
|
+
&& (lstatSync(path.join(nativeDirectory, 'gradlew')).mode & 0o111) === 0) {
|
|
577
|
+
throw new Error('Existing android project Gradle wrapper is not executable.');
|
|
578
|
+
}
|
|
579
|
+
const content = readFileSync(path.join(nativeDirectory, required), 'utf8');
|
|
580
|
+
assertNativeShellIdentity(platform, content, expectedAppId);
|
|
581
|
+
if (platform === 'ios') {
|
|
582
|
+
assertIosReleaseProductName(content);
|
|
583
|
+
assertIosReleaseInfoPlistExpansion(content);
|
|
584
|
+
assertIosReleasePackagingSettings(content);
|
|
585
|
+
const infoPlist = readIosReleaseInfoPlist(content);
|
|
586
|
+
const infoRelative = path.join('App', infoPlist);
|
|
587
|
+
if (!isNativeFile(nativeDirectory, infoRelative)) {
|
|
588
|
+
throw new Error('Existing ios project Release Info.plist is missing or unsafe.');
|
|
589
|
+
}
|
|
590
|
+
const compiledSources = assertReferencedIosFiles(nativeDirectory, content, infoRelative);
|
|
591
|
+
assertNativeDisplayName(nativeDirectory, platform, expectedDisplayName, infoRelative);
|
|
592
|
+
assertIosReleasePlistIdentity(readFileSync(path.join(nativeDirectory, infoRelative), 'utf8'));
|
|
593
|
+
assertNoIosLocalizedDisplayNameOverride(nativeDirectory, content);
|
|
594
|
+
assertIosArchiveScheme(path.join(gameRoot, shellPath));
|
|
595
|
+
const smokeRelative = 'App/App/Info-Smoke.plist';
|
|
596
|
+
if (existsSync(path.join(nativeDirectory, smokeRelative))) {
|
|
597
|
+
if (!isNativeFile(nativeDirectory, smokeRelative)) {
|
|
598
|
+
throw new Error('Existing ios project simulator Info.plist is unsafe.');
|
|
599
|
+
}
|
|
600
|
+
const smoke = readFileSync(path.join(nativeDirectory, smokeRelative), 'utf8');
|
|
601
|
+
assertSmokeInfoPlist(smoke, expectedDisplayName);
|
|
602
|
+
assertSceneDelegateFiles(nativeDirectory, smoke, 'simulator', compiledSources);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
const generatedSmoke = smokeInfoPlist(expectedDisplayName);
|
|
606
|
+
assertSceneDelegateFiles(nativeDirectory, generatedSmoke, 'simulator', compiledSources);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
if (/\bproductFlavors\b/u.test(maskGradleStrings(stripGradleComments(content)))) {
|
|
611
|
+
throw new Error('Existing android project product flavors are unsupported by the builder.');
|
|
612
|
+
}
|
|
613
|
+
assertAndroidManifestResources(nativeDirectory);
|
|
614
|
+
assertNativeDisplayName(nativeDirectory, platform, expectedDisplayName);
|
|
615
|
+
assertAndroidLauncherClasses(nativeDirectory, content);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function assertAndroidLauncherClasses(nativeDirectory, gradle) {
|
|
619
|
+
const application = readAndroidApplication(path.join(nativeDirectory, 'app/src/main/AndroidManifest.xml'));
|
|
620
|
+
if (application === undefined) {
|
|
621
|
+
throw new Error('Existing android project launcher activity is missing.');
|
|
622
|
+
}
|
|
623
|
+
assertAndroidLauncherEnabled(application, 'main application');
|
|
624
|
+
const manifestRoot = application.parentNode;
|
|
625
|
+
const manifestPackage = manifestRoot?.getAttribute('package');
|
|
626
|
+
const namespace = /\bnamespace\s*(?:=\s*)?["']([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+)["']/u
|
|
627
|
+
.exec(stripGradleComments(gradle))?.[1]
|
|
628
|
+
?? (manifestPackage === null || manifestPackage === '' ? undefined : manifestPackage);
|
|
629
|
+
const releaseFile = path.join(nativeDirectory, 'app/src/release/AndroidManifest.xml');
|
|
630
|
+
const release = existsSync(releaseFile) ? readAndroidApplication(releaseFile, false) : undefined;
|
|
631
|
+
if (release !== undefined) {
|
|
632
|
+
assertAndroidLauncherEnabled(release, 'Release application');
|
|
633
|
+
}
|
|
634
|
+
assertAndroidApplicationClasses(nativeDirectory, application, release, namespace);
|
|
635
|
+
const launchers = [
|
|
636
|
+
...readAndroidLaunchers(application).map((element) => ({ element, release: false })),
|
|
637
|
+
...(release === undefined ? [] : readAndroidLaunchers(release)
|
|
638
|
+
.map((element) => ({ element, release: true }))),
|
|
639
|
+
];
|
|
640
|
+
if (launchers.length === 0) {
|
|
641
|
+
throw new Error('Existing android project launcher activity is missing.');
|
|
642
|
+
}
|
|
643
|
+
for (const { element: launcher, release: releaseLauncher } of launchers) {
|
|
644
|
+
assertAndroidLauncherEnabled(launcher, 'launcher');
|
|
645
|
+
const isAlias = launcher.tagName === 'activity-alias';
|
|
646
|
+
const attribute = isAlias ? 'android:targetActivity' : 'android:name';
|
|
647
|
+
const name = launcher.getAttribute(attribute) ?? undefined;
|
|
648
|
+
if (name === undefined || (name.startsWith('.') || !name.includes('.'))
|
|
649
|
+
&& namespace === undefined) {
|
|
650
|
+
throw new Error('Existing android project launcher class cannot be resolved.');
|
|
651
|
+
}
|
|
652
|
+
let qualified = name;
|
|
653
|
+
if (name.startsWith('.')) {
|
|
654
|
+
qualified = `${namespace}${name}`;
|
|
655
|
+
}
|
|
656
|
+
else if (!name.includes('.')) {
|
|
657
|
+
qualified = `${namespace}.${name}`;
|
|
658
|
+
}
|
|
659
|
+
if (!/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+$/u.test(qualified)) {
|
|
660
|
+
throw new Error('Existing android project launcher class cannot be resolved.');
|
|
661
|
+
}
|
|
662
|
+
const launcherName = qualifyAndroidActivityName(launcher.getAttribute('android:name'), namespace);
|
|
663
|
+
const inheritedExported = releaseLauncher
|
|
664
|
+
? readAndroidLaunchers(application).find((item) => qualifyAndroidActivityName(item.getAttribute('android:name'), namespace) === launcherName)?.getAttribute('android:exported')
|
|
665
|
+
: null;
|
|
666
|
+
if ((launcher.getAttribute('android:exported') ?? inheritedExported) !== 'true') {
|
|
667
|
+
throw new Error('Existing android project launcher must declare android:exported="true".');
|
|
668
|
+
}
|
|
669
|
+
if (isAlias) {
|
|
670
|
+
const targetInRelease = release === undefined ? [] : childElements(release)
|
|
671
|
+
.filter((activity) => activity.tagName === 'activity');
|
|
672
|
+
const changedTarget = targetInRelease.some((activity) => qualifyAndroidActivityName(activity.getAttribute('android:name'), namespace) === qualified
|
|
673
|
+
&& hasAndroidMergerDirectiveDeep(activity));
|
|
674
|
+
if (changedTarget) {
|
|
675
|
+
throw new Error(`Existing android launcher alias target ${qualified} is changed by Release.`);
|
|
676
|
+
}
|
|
677
|
+
const activities = childElements(application)
|
|
678
|
+
.concat(release === undefined ? [] : childElements(release));
|
|
679
|
+
const declared = activities.some((activity) => {
|
|
680
|
+
const activityName = activity.getAttribute('android:name');
|
|
681
|
+
if (activity.tagName !== 'activity' || activityName === null
|
|
682
|
+
|| hasAndroidMergerDirective(activity)) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
const resolved = qualifyAndroidActivityName(activityName, namespace);
|
|
686
|
+
return resolved === qualified;
|
|
687
|
+
});
|
|
688
|
+
if (!declared) {
|
|
689
|
+
throw new Error(`Existing android launcher alias target ${qualified} is undeclared.`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const sourceSets = releaseLauncher ? ['main', 'release'] : ['main'];
|
|
693
|
+
const sources = [...new Set(sourceSets.flatMap((sourceSet) => ['java', 'kotlin'].flatMap((language) => ['.java', '.kt'].flatMap((extension) => listNativeFiles(nativeDirectory, `app/src/${sourceSet}/${language}`, extension)))))];
|
|
694
|
+
const validActivity = resolvesAndroidClass(nativeDirectory, qualified, sources, new Set(), androidActivityBases, true);
|
|
695
|
+
if (!validActivity) {
|
|
696
|
+
throw new Error(`Existing android project launcher class ${qualified} is missing or not an Android Activity.`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const androidActivityBases = new Set([
|
|
701
|
+
'android.app.Activity',
|
|
702
|
+
'com.getcapacitor.BridgeActivity',
|
|
703
|
+
'androidx.activity.ComponentActivity',
|
|
704
|
+
'androidx.appcompat.app.AppCompatActivity',
|
|
705
|
+
'androidx.fragment.app.FragmentActivity',
|
|
706
|
+
]);
|
|
707
|
+
const androidApplicationBases = new Set([
|
|
708
|
+
'android.app.Application',
|
|
709
|
+
'androidx.multidex.MultiDexApplication',
|
|
710
|
+
]);
|
|
711
|
+
function assertAndroidApplicationClasses(nativeDirectory, main, release, namespace) {
|
|
712
|
+
const variants = [
|
|
713
|
+
{ application: main, sourceSets: ['main'] },
|
|
714
|
+
...(release === undefined ? [] : [
|
|
715
|
+
{ application: release, sourceSets: ['main', 'release'] },
|
|
716
|
+
]),
|
|
717
|
+
];
|
|
718
|
+
for (const { application, sourceSets } of variants) {
|
|
719
|
+
const name = application.getAttribute('android:name');
|
|
720
|
+
if (name === null) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const qualified = qualifyAndroidActivityName(name, namespace);
|
|
724
|
+
if (qualified === undefined) {
|
|
725
|
+
throw new Error('Existing android project Application class cannot be resolved.');
|
|
726
|
+
}
|
|
727
|
+
const sources = [...new Set(sourceSets.flatMap((sourceSet) => ['java', 'kotlin'].flatMap((language) => ['.java', '.kt'].flatMap((extension) => listNativeFiles(nativeDirectory, `app/src/${sourceSet}/${language}`, extension)))))];
|
|
728
|
+
const validApplication = resolvesAndroidClass(nativeDirectory, qualified, sources, new Set(), androidApplicationBases, true);
|
|
729
|
+
if (!validApplication) {
|
|
730
|
+
throw new Error(`Existing android project Application class ${qualified} is missing or invalid.`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function resolvesAndroidClass(nativeDirectory, qualified, sourceFiles, visited, bases, manifestClass = false) {
|
|
735
|
+
if (bases.has(qualified)) {
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
if (visited.has(qualified)) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
visited.add(qualified);
|
|
742
|
+
const separator = qualified.lastIndexOf('.');
|
|
743
|
+
const packageName = qualified.slice(0, separator);
|
|
744
|
+
const className = qualified.slice(separator + 1);
|
|
745
|
+
for (const relative of sourceFiles) {
|
|
746
|
+
if (!isNativeFile(nativeDirectory, relative)) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const source = stripSourceCommentsAndStrings(readFileSync(path.join(nativeDirectory, relative), 'utf8'));
|
|
750
|
+
const declaredPackage = /\bpackage\s+([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+)\s*;?/u
|
|
751
|
+
.exec(source)?.[1];
|
|
752
|
+
if (declaredPackage !== packageName) {
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
const declaration = readTopLevelAndroidSuperclass(source, className);
|
|
756
|
+
if (declaration === undefined) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (manifestClass && (relative.endsWith('.java')
|
|
760
|
+
? !/\bpublic\b/u.test(declaration.modifiers)
|
|
761
|
+
|| /\babstract\b/u.test(declaration.modifiers)
|
|
762
|
+
: /\b(?:abstract|private|protected)\b/u.test(declaration.modifiers))) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
const base = declaration.base;
|
|
766
|
+
const imported = [...source.matchAll(/\bimport\s+([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+)\s*;?/gu)]
|
|
767
|
+
.map((match) => match[1] ?? '')
|
|
768
|
+
.find((name) => name.endsWith(`.${base}`));
|
|
769
|
+
const resolved = base.includes('.') ? base : (imported ?? `${packageName}.${base}`);
|
|
770
|
+
if (resolvesAndroidClass(nativeDirectory, resolved, sourceFiles, visited, bases)) {
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
function readTopLevelAndroidSuperclass(source, className) {
|
|
777
|
+
const expression = new RegExp(`\\bclass\\s+${className}\\b([^{};\\r\\n]*)(?:\\{|$)`, 'gmu');
|
|
778
|
+
for (const match of source.matchAll(expression)) {
|
|
779
|
+
let depth = 0;
|
|
780
|
+
for (const character of source.slice(0, match.index)) {
|
|
781
|
+
depth += character === '{' ? 1 : character === '}' ? -1 : 0;
|
|
782
|
+
}
|
|
783
|
+
if (depth !== 0) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const header = match[1] ?? '';
|
|
787
|
+
const base = /\bextends\s+([A-Za-z_][\w.]*)/u.exec(header)?.[1]
|
|
788
|
+
?? /:\s*([A-Za-z_][\w.]*)\s*\(/u.exec(header)?.[1];
|
|
789
|
+
if (base === undefined) {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
const before = source.slice(0, match.index);
|
|
793
|
+
const modifiers = before.slice(Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.lastIndexOf('}')) + 1);
|
|
794
|
+
return { base, modifiers };
|
|
795
|
+
}
|
|
796
|
+
return undefined;
|
|
797
|
+
}
|
|
798
|
+
function qualifyAndroidActivityName(name, namespace) {
|
|
799
|
+
if (name === null) {
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
if (name.startsWith('.')) {
|
|
803
|
+
return namespace === undefined ? undefined : `${namespace}${name}`;
|
|
804
|
+
}
|
|
805
|
+
return name.includes('.') ? name : namespace === undefined ? undefined : `${namespace}.${name}`;
|
|
806
|
+
}
|
|
807
|
+
function listNativeFiles(nativeDirectory, relativeRoot, extension) {
|
|
808
|
+
const pending = [relativeRoot];
|
|
809
|
+
const files = [];
|
|
810
|
+
while (pending.length > 0) {
|
|
811
|
+
const relative = pending.pop() ?? '';
|
|
812
|
+
const full = path.join(nativeDirectory, relative);
|
|
813
|
+
if (!existsSync(full) || lstatSync(full).isSymbolicLink()) {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
for (const entry of readdirSync(full, { withFileTypes: true })) {
|
|
817
|
+
if (entry.isSymbolicLink()) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const child = path.join(relative, entry.name);
|
|
821
|
+
if (entry.isDirectory()) {
|
|
822
|
+
pending.push(child);
|
|
823
|
+
}
|
|
824
|
+
else if (entry.isFile() && entry.name.endsWith(extension)) {
|
|
825
|
+
files.push(child);
|
|
826
|
+
if (files.length > 10_000) {
|
|
827
|
+
throw new Error('Existing native project has too many source files to inspect.');
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return files;
|
|
833
|
+
}
|
|
834
|
+
function assertAndroidLauncherEnabled(element, label) {
|
|
835
|
+
for (const attribute of ['android:enabled', 'android:exported']) {
|
|
836
|
+
const value = element.getAttribute(attribute);
|
|
837
|
+
if (value !== null && value !== 'true') {
|
|
838
|
+
throw new Error(`Existing android project ${label} ${attribute} is unsupported.`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function stripSourceCommentsAndStrings(source) {
|
|
843
|
+
let output = '';
|
|
844
|
+
let quote;
|
|
845
|
+
let blockComment = false;
|
|
846
|
+
let lineComment = false;
|
|
847
|
+
let rawString = false;
|
|
848
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
849
|
+
const character = source[index] ?? '';
|
|
850
|
+
const next = source[index + 1] ?? '';
|
|
851
|
+
if (lineComment) {
|
|
852
|
+
if (character === '\n') {
|
|
853
|
+
lineComment = false;
|
|
854
|
+
output += '\n';
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
output += ' ';
|
|
858
|
+
}
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
if (blockComment) {
|
|
862
|
+
if (character === '*' && next === '/') {
|
|
863
|
+
blockComment = false;
|
|
864
|
+
output += ' ';
|
|
865
|
+
index += 1;
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
output += character === '\n' ? '\n' : ' ';
|
|
869
|
+
}
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (rawString) {
|
|
873
|
+
if (source.startsWith('"""', index)) {
|
|
874
|
+
rawString = false;
|
|
875
|
+
output += ' ';
|
|
876
|
+
index += 2;
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
output += character === '\n' ? '\n' : ' ';
|
|
880
|
+
}
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (quote !== undefined) {
|
|
884
|
+
if (character === '\\' && next !== '') {
|
|
885
|
+
output += ' ';
|
|
886
|
+
index += 1;
|
|
887
|
+
}
|
|
888
|
+
else if (character === quote) {
|
|
889
|
+
quote = undefined;
|
|
890
|
+
output += ' ';
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
output += character === '\n' ? '\n' : ' ';
|
|
894
|
+
}
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
if (character === '/' && next === '/') {
|
|
898
|
+
lineComment = true;
|
|
899
|
+
output += ' ';
|
|
900
|
+
index += 1;
|
|
901
|
+
}
|
|
902
|
+
else if (character === '/' && next === '*') {
|
|
903
|
+
blockComment = true;
|
|
904
|
+
output += ' ';
|
|
905
|
+
index += 1;
|
|
906
|
+
}
|
|
907
|
+
else if (source.startsWith('"""', index)) {
|
|
908
|
+
rawString = true;
|
|
909
|
+
output += ' ';
|
|
910
|
+
index += 2;
|
|
911
|
+
}
|
|
912
|
+
else if (character === '"' || character === "'") {
|
|
913
|
+
quote = character;
|
|
914
|
+
output += ' ';
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
output += character;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return output;
|
|
921
|
+
}
|
|
922
|
+
function assertSmokeInfoPlist(source, expectedDisplayName) {
|
|
923
|
+
const values = parsePlistDictionary(source, 'simulator');
|
|
924
|
+
const required = [
|
|
925
|
+
['CFBundleDisplayName', expectedDisplayName],
|
|
926
|
+
['CFBundleExecutable', '$(EXECUTABLE_NAME)'],
|
|
927
|
+
['CFBundleIdentifier', '$(PRODUCT_BUNDLE_IDENTIFIER)'],
|
|
928
|
+
['CFBundleName', '$(PRODUCT_NAME)'],
|
|
929
|
+
['CFBundlePackageType', 'APPL'],
|
|
930
|
+
['CFBundleShortVersionString', '$(MARKETING_VERSION)'],
|
|
931
|
+
['CFBundleVersion', '$(CURRENT_PROJECT_VERSION)'],
|
|
932
|
+
];
|
|
933
|
+
for (const [key, expected] of required) {
|
|
934
|
+
const actual = values.get(key);
|
|
935
|
+
if (actual?.tagName !== 'string' || actual.textContent !== expected) {
|
|
936
|
+
throw new Error(`Existing ios project simulator Info.plist ${key} differs or is missing.`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if ([...values.keys()].some((key) => ['UIMainStoryboardFile', 'UILaunchStoryboardName']
|
|
940
|
+
.some((name) => key === name || key.startsWith(`${name}~`) || key.startsWith(`${name}-`)))) {
|
|
941
|
+
throw new Error('Existing ios project simulator Info.plist references excluded storyboards.');
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
function parseXml(source, label) {
|
|
945
|
+
try {
|
|
946
|
+
return new DOMParser({
|
|
947
|
+
onError(_level, message) { throw new Error(message); },
|
|
948
|
+
}).parseFromString(source, 'application/xml');
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
throw new Error(`Existing ${label} is malformed.`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function childElements(element) {
|
|
955
|
+
return Array.from(element.childNodes)
|
|
956
|
+
.filter((node) => node.nodeType === 1);
|
|
957
|
+
}
|
|
958
|
+
function parsePlistDictionary(source, label) {
|
|
959
|
+
const plist = parseXml(source, `ios project ${label} Info.plist`).documentElement;
|
|
960
|
+
const children = plist === null ? [] : childElements(plist);
|
|
961
|
+
const dictionary = children[0];
|
|
962
|
+
if (plist?.tagName !== 'plist' || children.length !== 1
|
|
963
|
+
|| dictionary?.tagName !== 'dict' || hasNonWhitespaceText(plist)) {
|
|
964
|
+
throw new Error(`Existing ios project ${label} Info.plist is malformed.`);
|
|
965
|
+
}
|
|
966
|
+
return parsePlistEntries(dictionary, label);
|
|
967
|
+
}
|
|
968
|
+
/** Fail closed when a Release plist hardcodes values independently of Xcode build settings. */
|
|
969
|
+
export function assertIosReleasePlistIdentity(source) {
|
|
970
|
+
const values = parsePlistDictionary(source, 'Release');
|
|
971
|
+
const required = [
|
|
972
|
+
['CFBundleExecutable', '$(EXECUTABLE_NAME)'],
|
|
973
|
+
['CFBundleIdentifier', '$(PRODUCT_BUNDLE_IDENTIFIER)'],
|
|
974
|
+
['CFBundleName', '$(PRODUCT_NAME)'],
|
|
975
|
+
['CFBundlePackageType', 'APPL'],
|
|
976
|
+
['CFBundleShortVersionString', '$(MARKETING_VERSION)'],
|
|
977
|
+
['CFBundleVersion', '$(CURRENT_PROJECT_VERSION)'],
|
|
978
|
+
];
|
|
979
|
+
for (const [key, expected] of required) {
|
|
980
|
+
const value = values.get(key);
|
|
981
|
+
if (value?.tagName !== 'string' || value.textContent !== expected) {
|
|
982
|
+
throw new Error(`Existing ios project Release Info.plist ${key} differs or is missing.`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function parsePlistEntries(dictionary, label) {
|
|
987
|
+
const children = childElements(dictionary);
|
|
988
|
+
if (children.length % 2 !== 0 || hasNonWhitespaceText(dictionary)) {
|
|
989
|
+
throw new Error(`Existing ios project ${label} Info.plist is malformed.`);
|
|
990
|
+
}
|
|
991
|
+
const entries = new Map();
|
|
992
|
+
for (let index = 0; index < children.length; index += 2) {
|
|
993
|
+
const key = children[index];
|
|
994
|
+
const value = children[index + 1];
|
|
995
|
+
const name = key?.textContent ?? '';
|
|
996
|
+
if (key?.tagName !== 'key' || childElements(key).length > 0
|
|
997
|
+
|| value === undefined || name.length === 0
|
|
998
|
+
|| entries.has(name)) {
|
|
999
|
+
throw new Error(`Existing ios project ${label} Info.plist is malformed.`);
|
|
1000
|
+
}
|
|
1001
|
+
assertPlistValue(value, label);
|
|
1002
|
+
entries.set(name, value);
|
|
1003
|
+
}
|
|
1004
|
+
return entries;
|
|
1005
|
+
}
|
|
1006
|
+
function assertPlistValue(value, label) {
|
|
1007
|
+
if (value.tagName === 'dict') {
|
|
1008
|
+
parsePlistEntries(value, label);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (value.tagName === 'array') {
|
|
1012
|
+
if (hasNonWhitespaceText(value)) {
|
|
1013
|
+
throw new Error(`Existing ios project ${label} Info.plist is malformed.`);
|
|
1014
|
+
}
|
|
1015
|
+
for (const child of childElements(value)) {
|
|
1016
|
+
assertPlistValue(child, label);
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (childElements(value).length === 0) {
|
|
1021
|
+
const text = value.textContent ?? '';
|
|
1022
|
+
if (value.tagName === 'string') {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (value.tagName === 'integer' && /^-?\d+$/u.test(text)) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (value.tagName === 'real'
|
|
1029
|
+
&& /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u.test(text)
|
|
1030
|
+
&& Number.isFinite(Number(text))) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (value.tagName === 'date'
|
|
1034
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(text)
|
|
1035
|
+
&& Number.isFinite(Date.parse(text))) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (value.tagName === 'data'
|
|
1039
|
+
&& /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u
|
|
1040
|
+
.test(text.replace(/\s+/gu, ''))) {
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if ((value.tagName === 'true' || value.tagName === 'false') && text === '') {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
throw new Error(`Existing ios project ${label} Info.plist has an unsupported value node.`);
|
|
1048
|
+
}
|
|
1049
|
+
function hasNonWhitespaceText(element) {
|
|
1050
|
+
return Array.from(element.childNodes).some((node) => (node.nodeType === 3 || node.nodeType === 4)
|
|
1051
|
+
&& (node.textContent?.trim().length ?? 0) > 0);
|
|
1052
|
+
}
|
|
1053
|
+
function assertReferencedIosFiles(nativeDirectory, project, infoRelative) {
|
|
1054
|
+
const compiledSources = assertAppBuildPhaseInputs(nativeDirectory, project);
|
|
1055
|
+
const references = [
|
|
1056
|
+
[/\/\*\s*SceneDelegate\.swift in Sources\s*\*\//u, 'App/App/SceneDelegate.swift'],
|
|
1057
|
+
[/\/\*\s*Main\.storyboard in Resources\s*\*\//u, 'App/App/Base.lproj/Main.storyboard'],
|
|
1058
|
+
[
|
|
1059
|
+
/\/\*\s*LaunchScreen\.storyboard in Resources\s*\*\//u,
|
|
1060
|
+
'App/App/Base.lproj/LaunchScreen.storyboard',
|
|
1061
|
+
],
|
|
1062
|
+
[/\/\*\s*Assets\.xcassets in Resources\s*\*\//u, 'App/App/Assets.xcassets/Contents.json'],
|
|
1063
|
+
];
|
|
1064
|
+
for (const [marker, relative] of references) {
|
|
1065
|
+
if (marker.test(project) && !isNativeFile(nativeDirectory, relative)) {
|
|
1066
|
+
throw new Error(`Existing ios project is incomplete; referenced app file ${relative} is missing.`);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const info = readFileSync(path.join(nativeDirectory, infoRelative), 'utf8');
|
|
1070
|
+
assertSceneDelegateFiles(nativeDirectory, info, 'Release', compiledSources);
|
|
1071
|
+
return compiledSources;
|
|
1072
|
+
}
|
|
1073
|
+
function assertAppBuildPhaseInputs(nativeDirectory, project) {
|
|
1074
|
+
const parentGroups = readPbxParentGroups(project);
|
|
1075
|
+
const compiledSources = new Set();
|
|
1076
|
+
const targetId = readIosAppTargetId(project);
|
|
1077
|
+
assertNoIosTargetScriptPhases(project, targetId, new Set());
|
|
1078
|
+
const target = readPbxObjectBody(project, targetId);
|
|
1079
|
+
const phaseIds = readPbxIds(target, 'buildPhases');
|
|
1080
|
+
for (const phaseId of phaseIds) {
|
|
1081
|
+
const phase = readPbxObjectBody(project, phaseId);
|
|
1082
|
+
if (/\bisa\s*=\s*PBXShellScriptBuildPhase\s*;/u.test(phase)) {
|
|
1083
|
+
throw new Error('Existing ios project App shell script build phases are unsupported.');
|
|
1084
|
+
}
|
|
1085
|
+
const isSources = /\bisa\s*=\s*PBXSourcesBuildPhase\s*;/u.test(phase);
|
|
1086
|
+
if (/\bisa\s*=\s*PBXFrameworksBuildPhase\s*;/u.test(phase)) {
|
|
1087
|
+
const linked = readPbxIds(phase, 'files').map((buildId) => {
|
|
1088
|
+
const build = readPbxObjectBody(project, buildId);
|
|
1089
|
+
return /\bfileRef\s*=\s*([A-F0-9]+)\b/u.exec(build)?.[1];
|
|
1090
|
+
});
|
|
1091
|
+
const otherProducts = [...project.matchAll(/\b([A-F0-9]+)\s*(?:\/\*[^*]*\*\/)?\s*=\s*\{([^{}]*)\};/gu)].filter((match) => match[1] !== targetId
|
|
1092
|
+
&& /\bisa\s*=\s*PBXNativeTarget\s*;/u.test(match[2] ?? ''))
|
|
1093
|
+
.map((match) => /\bproductReference\s*=\s*([A-F0-9]+)\b/u.exec(match[2] ?? '')?.[1]);
|
|
1094
|
+
if (linked.some((reference) => reference !== undefined
|
|
1095
|
+
&& otherProducts.includes(reference))) {
|
|
1096
|
+
throw new Error('Existing ios project implicit App target dependencies are unsupported.');
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (!isSources && !/\bisa\s*=\s*PBXResourcesBuildPhase\s*;/u.test(phase)) {
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
for (const buildId of readPbxIds(phase, 'files')) {
|
|
1103
|
+
const build = readPbxObjectBody(project, buildId);
|
|
1104
|
+
const referenceId = /\bfileRef\s*=\s*([A-F0-9]+)\b/u.exec(build)?.[1];
|
|
1105
|
+
if (referenceId === undefined) {
|
|
1106
|
+
throw new Error('Existing ios project App build input has no file reference.');
|
|
1107
|
+
}
|
|
1108
|
+
const files = assertPbxFileReference(nativeDirectory, project, referenceId, parentGroups);
|
|
1109
|
+
if (isSources) {
|
|
1110
|
+
files.forEach((file) => compiledSources.add(file));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return compiledSources;
|
|
1115
|
+
}
|
|
1116
|
+
function assertNoIosTargetScriptPhases(project, targetId, visited) {
|
|
1117
|
+
if (visited.has(targetId)) {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
visited.add(targetId);
|
|
1121
|
+
const target = readPbxObjectBody(project, targetId);
|
|
1122
|
+
if (!/\bisa\s*=\s*PBX(?:Native|Aggregate)Target\s*;/u.test(target)) {
|
|
1123
|
+
throw new Error('Existing ios project App target dependency is unsupported.');
|
|
1124
|
+
}
|
|
1125
|
+
if (readPbxIds(target, 'buildRules').length > 0) {
|
|
1126
|
+
throw new Error('Existing ios project App executable build rules are unsupported.');
|
|
1127
|
+
}
|
|
1128
|
+
for (const phaseId of readPbxIds(target, 'buildPhases')) {
|
|
1129
|
+
const phase = readPbxObjectBody(project, phaseId);
|
|
1130
|
+
if (/\bisa\s*=\s*PBXShellScriptBuildPhase\s*;/u.test(phase)) {
|
|
1131
|
+
throw new Error('Existing ios project App shell script build phases are unsupported.');
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
for (const dependencyId of readPbxIds(target, 'dependencies')) {
|
|
1135
|
+
const dependency = readPbxObjectBody(project, dependencyId);
|
|
1136
|
+
const childId = /\btarget\s*=\s*([A-F0-9]+)\b/u.exec(dependency)?.[1];
|
|
1137
|
+
if (!/\bisa\s*=\s*PBXTargetDependency\s*;/u.test(dependency)
|
|
1138
|
+
|| childId === undefined) {
|
|
1139
|
+
throw new Error('Existing ios project App target dependency is unsupported.');
|
|
1140
|
+
}
|
|
1141
|
+
assertNoIosTargetScriptPhases(project, childId, visited);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function readPbxObjectBody(project, id) {
|
|
1145
|
+
const marker = new RegExp(`\\b${id}\\s*(?:/\\*[^*]*\\*/)?\\s*=\\s*\\{([\\s\\S]*?)\\};`, 'u');
|
|
1146
|
+
const body = marker.exec(project)?.[1];
|
|
1147
|
+
if (body === undefined) {
|
|
1148
|
+
throw new Error(`Existing ios project build object ${id} is missing.`);
|
|
1149
|
+
}
|
|
1150
|
+
return body;
|
|
1151
|
+
}
|
|
1152
|
+
function readPbxIds(object, key) {
|
|
1153
|
+
const list = new RegExp(`\\b${key}\\s*=\\s*\\(([^)]*)\\)\\s*;`, 'u').exec(object)?.[1];
|
|
1154
|
+
const uncommented = list?.replace(/\/\*[\s\S]*?\*\//gu, '') ?? '';
|
|
1155
|
+
return [...uncommented.matchAll(/\b([A-F0-9]{8,24})\b/gu)]
|
|
1156
|
+
.map((match) => match[1] ?? '');
|
|
1157
|
+
}
|
|
1158
|
+
function readPbxParentGroups(project) {
|
|
1159
|
+
const parents = new Map();
|
|
1160
|
+
const groupObjectPattern = /\b([A-F0-9]+)\s*(?:\/\*[^*]*\*\/)?\s*=\s*\{([\s\S]*?)\};/gu;
|
|
1161
|
+
const objects = project.matchAll(groupObjectPattern);
|
|
1162
|
+
for (const match of objects) {
|
|
1163
|
+
const id = match[1] ?? '';
|
|
1164
|
+
const body = match[2] ?? '';
|
|
1165
|
+
if (!/\bisa\s*=\s*PBX(?:Variant)?Group\s*;/u.test(body)) {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
for (const child of readPbxIds(body, 'children')) {
|
|
1169
|
+
if (parents.has(child)) {
|
|
1170
|
+
throw new Error(`Existing ios project build input ${child} has ambiguous groups.`);
|
|
1171
|
+
}
|
|
1172
|
+
parents.set(child, id);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return parents;
|
|
1176
|
+
}
|
|
1177
|
+
function readPbxGroupPath(project, parents, referenceId) {
|
|
1178
|
+
const chain = [];
|
|
1179
|
+
const seen = new Set();
|
|
1180
|
+
let parent = parents.get(referenceId);
|
|
1181
|
+
if (parent === undefined) {
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
while (parent !== undefined) {
|
|
1185
|
+
if (seen.has(parent)) {
|
|
1186
|
+
throw new Error('Existing ios project build input group cycle is unsupported.');
|
|
1187
|
+
}
|
|
1188
|
+
seen.add(parent);
|
|
1189
|
+
chain.unshift(parent);
|
|
1190
|
+
parent = parents.get(parent);
|
|
1191
|
+
}
|
|
1192
|
+
const parts = [];
|
|
1193
|
+
for (const groupId of chain) {
|
|
1194
|
+
const group = readPbxObjectBody(project, groupId);
|
|
1195
|
+
const sourceTree = /\bsourceTree\s*=\s*(?:"([^"]+)"|([^;]+))\s*;/u.exec(group);
|
|
1196
|
+
const tree = (sourceTree?.[1] ?? sourceTree?.[2])?.trim();
|
|
1197
|
+
if (tree === 'SOURCE_ROOT') {
|
|
1198
|
+
parts.length = 0;
|
|
1199
|
+
}
|
|
1200
|
+
else if (tree !== undefined && tree !== '<group>') {
|
|
1201
|
+
throw new Error('Existing ios project build input group sourceTree is unsupported.');
|
|
1202
|
+
}
|
|
1203
|
+
const groupPath = /\bpath\s*=\s*(?:"([^"]+)"|([^;]+))\s*;/u.exec(group);
|
|
1204
|
+
const value = (groupPath?.[1] ?? groupPath?.[2])?.trim();
|
|
1205
|
+
if (value !== undefined) {
|
|
1206
|
+
if (value.includes('$') || path.isAbsolute(value)) {
|
|
1207
|
+
throw new Error('Existing ios project build input group path is unsupported.');
|
|
1208
|
+
}
|
|
1209
|
+
parts.push(value);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return parts.join('/');
|
|
1213
|
+
}
|
|
1214
|
+
function assertPbxFileReference(nativeDirectory, project, referenceId, parentGroups) {
|
|
1215
|
+
const reference = readPbxObjectBody(project, referenceId);
|
|
1216
|
+
if (/\bisa\s*=\s*PBXVariantGroup\s*;/u.test(reference)) {
|
|
1217
|
+
const files = [];
|
|
1218
|
+
for (const child of readPbxIds(reference, 'children')) {
|
|
1219
|
+
files.push(...assertPbxFileReference(nativeDirectory, project, child, parentGroups));
|
|
1220
|
+
}
|
|
1221
|
+
return files;
|
|
1222
|
+
}
|
|
1223
|
+
if (!/\bisa\s*=\s*PBXFileReference\s*;/u.test(reference)) {
|
|
1224
|
+
throw new Error('Existing ios project App build input has an unsupported file reference.');
|
|
1225
|
+
}
|
|
1226
|
+
const relative = /\bpath\s*=\s*(?:"([^"]+)"|([^;]+))\s*;/u.exec(reference);
|
|
1227
|
+
const filePath = (relative?.[1] ?? relative?.[2])?.trim();
|
|
1228
|
+
if (filePath === undefined || filePath.includes('$') || path.isAbsolute(filePath)) {
|
|
1229
|
+
throw new Error('Existing ios project App build input path is unsupported.');
|
|
1230
|
+
}
|
|
1231
|
+
if (filePath === 'public' || filePath === 'capacitor.config.json') {
|
|
1232
|
+
return []; // cap sync creates these inputs in the staged shell.
|
|
1233
|
+
}
|
|
1234
|
+
const sourceTree = /\bsourceTree\s*=\s*(?:"([^"]+)"|([^;]+))\s*;/u.exec(reference);
|
|
1235
|
+
const tree = (sourceTree?.[1] ?? sourceTree?.[2])?.trim();
|
|
1236
|
+
if (tree !== undefined && tree !== '<group>' && tree !== 'SOURCE_ROOT') {
|
|
1237
|
+
throw new Error('Existing ios project App build input sourceTree is unsupported.');
|
|
1238
|
+
}
|
|
1239
|
+
const groupPath = readPbxGroupPath(project, parentGroups, referenceId);
|
|
1240
|
+
let candidates;
|
|
1241
|
+
if (tree === 'SOURCE_ROOT') {
|
|
1242
|
+
candidates = [`App/${filePath}`];
|
|
1243
|
+
}
|
|
1244
|
+
else if (groupPath === undefined) {
|
|
1245
|
+
candidates = [`App/App/${filePath}`, `App/${filePath}`];
|
|
1246
|
+
}
|
|
1247
|
+
else {
|
|
1248
|
+
candidates = [`App/${groupPath}/${filePath}`];
|
|
1249
|
+
}
|
|
1250
|
+
const resolved = candidates.find((candidate) => isNativeEntry(nativeDirectory, candidate));
|
|
1251
|
+
if (resolved === undefined) {
|
|
1252
|
+
throw new Error(`Existing ios project App build input ${filePath} is missing.`);
|
|
1253
|
+
}
|
|
1254
|
+
return [resolved];
|
|
1255
|
+
}
|
|
1256
|
+
function isNativeEntry(directory, relative) {
|
|
1257
|
+
const file = path.resolve(directory, relative);
|
|
1258
|
+
const within = path.relative(directory, file);
|
|
1259
|
+
if (within === '' || within.startsWith('..') || path.isAbsolute(within)) {
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
let current = directory;
|
|
1263
|
+
for (const segment of within.split(path.sep)) {
|
|
1264
|
+
current = path.join(current, segment);
|
|
1265
|
+
if (!existsSync(current) || lstatSync(current).isSymbolicLink()) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return lstatSync(file).isFile() || lstatSync(file).isDirectory();
|
|
1270
|
+
}
|
|
1271
|
+
function assertSceneDelegateFiles(nativeDirectory, source, label, compiledSources) {
|
|
1272
|
+
const values = parsePlistDictionary(source, label);
|
|
1273
|
+
const delegates = [];
|
|
1274
|
+
const visit = (value) => {
|
|
1275
|
+
if (value.tagName === 'dict') {
|
|
1276
|
+
for (const [key, child] of parsePlistEntries(value, label)) {
|
|
1277
|
+
if (key === 'UISceneDelegateClassName') {
|
|
1278
|
+
if (child.tagName !== 'string') {
|
|
1279
|
+
throw new Error(`Existing ios project ${label} scene delegate is invalid.`);
|
|
1280
|
+
}
|
|
1281
|
+
delegates.push(child.textContent ?? '');
|
|
1282
|
+
}
|
|
1283
|
+
else {
|
|
1284
|
+
visit(child);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
else if (value.tagName === 'array') {
|
|
1289
|
+
childElements(value).forEach(visit);
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
[...values.values()].forEach(visit);
|
|
1293
|
+
const sources = listNativeFiles(nativeDirectory, 'App/App', '.swift');
|
|
1294
|
+
for (const delegate of delegates) {
|
|
1295
|
+
const className = /^(?:\$\(PRODUCT_MODULE_NAME\)|[A-Za-z_]\w*)\.([A-Za-z_]\w*)$/u
|
|
1296
|
+
.exec(delegate)?.[1];
|
|
1297
|
+
if (className === undefined || !sources.some((relative) => {
|
|
1298
|
+
if (!compiledSources.has(relative)) {
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
const text = stripSourceCommentsAndStrings(readFileSync(path.join(nativeDirectory, relative), 'utf8'));
|
|
1302
|
+
return hasUsableTopLevelSwiftSceneDelegate(text, className);
|
|
1303
|
+
})) {
|
|
1304
|
+
throw new Error(`Existing ios project ${label} scene delegate ${delegate} is missing from App Sources.`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
function hasUsableTopLevelSwiftSceneDelegate(source, className) {
|
|
1309
|
+
const declaration = new RegExp(`\\bclass\\s+${className}\\b([^{};]*)\\{`, 'gu');
|
|
1310
|
+
let cursor = 0;
|
|
1311
|
+
let depth = 0;
|
|
1312
|
+
for (const match of source.matchAll(declaration)) {
|
|
1313
|
+
const index = match.index ?? 0;
|
|
1314
|
+
for (; cursor < index; cursor += 1) {
|
|
1315
|
+
depth += source[cursor] === '{' ? 1 : source[cursor] === '}' ? -1 : 0;
|
|
1316
|
+
}
|
|
1317
|
+
const inheritance = match[1] ?? '';
|
|
1318
|
+
const supported = /:\s*(?:UIResponder|NSObject)\s*,\s*(?:UIWindowSceneDelegate|UISceneDelegate)(?:\s*,|\s*$)/u
|
|
1319
|
+
.test(inheritance.trim());
|
|
1320
|
+
if (depth === 0 && supported) {
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
function assertAndroidManifestResources(nativeDirectory) {
|
|
1327
|
+
const manifestFiles = ['main', 'release']
|
|
1328
|
+
.map((sourceSet) => path.join(nativeDirectory, `app/src/${sourceSet}/AndroidManifest.xml`))
|
|
1329
|
+
.filter((file) => existsSync(file));
|
|
1330
|
+
const resourceRoots = ['main', 'release']
|
|
1331
|
+
.map((sourceSet) => path.join(nativeDirectory, `app/src/${sourceSet}/res`));
|
|
1332
|
+
const references = manifestFiles.flatMap((file) => {
|
|
1333
|
+
const manifest = readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/gu, '');
|
|
1334
|
+
return [...manifest.matchAll(/(?<![\w+])@([a-z]+)\/([A-Za-z_][A-Za-z0-9_.]*)/gu)];
|
|
1335
|
+
});
|
|
1336
|
+
for (const reference of references) {
|
|
1337
|
+
const type = reference[1] ?? '';
|
|
1338
|
+
const name = reference[2] ?? '';
|
|
1339
|
+
const found = resourceRoots.some((resourceRoot) => {
|
|
1340
|
+
if (!existsSync(resourceRoot)) {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
const folders = readdirSync(resourceRoot, { withFileTypes: true })
|
|
1344
|
+
.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
1345
|
+
return folders.some((folder) => {
|
|
1346
|
+
const densityFallback = (type === 'mipmap' || type === 'drawable')
|
|
1347
|
+
&& new RegExp(`^${type}-(?:ldpi|mdpi|hdpi|xhdpi|xxhdpi|xxxhdpi|nodpi|anydpi)$`, 'u')
|
|
1348
|
+
.test(folder);
|
|
1349
|
+
if (folder === type || densityFallback) {
|
|
1350
|
+
return readdirSync(path.join(resourceRoot, folder)).some((file) => file === `${name}.xml` || file.startsWith(`${name}.`));
|
|
1351
|
+
}
|
|
1352
|
+
if (folder !== 'values') {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
return readdirSync(path.join(resourceRoot, folder)).some((file) => file.endsWith('.xml') && readAndroidValueResource(path.join(resourceRoot, folder, file), type, name).length > 0);
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
if (!found) {
|
|
1359
|
+
throw new Error(`Existing android project manifest resource @${type}/${name} is missing.`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function smokeInfoPlist(displayName) {
|
|
1364
|
+
const name = displayName.replace(/&/gu, '&').replace(/</gu, '<')
|
|
1365
|
+
.replace(/>/gu, '>').replace(/"/gu, '"').replace(/'/gu, ''');
|
|
1366
|
+
return [
|
|
1367
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1368
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1369
|
+
'<plist version="1.0"><dict>',
|
|
1370
|
+
'<key>CFBundleDisplayName</key>', `<string>${name}</string>`,
|
|
1371
|
+
'<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>',
|
|
1372
|
+
'<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>',
|
|
1373
|
+
'<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>',
|
|
1374
|
+
'<key>CFBundlePackageType</key><string>APPL</string>',
|
|
1375
|
+
'<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>',
|
|
1376
|
+
'<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>',
|
|
1377
|
+
'<key>UIApplicationSceneManifest</key><dict>',
|
|
1378
|
+
'<key>UIApplicationSupportsMultipleScenes</key><false/>',
|
|
1379
|
+
'<key>UISceneConfigurations</key><dict>',
|
|
1380
|
+
'<key>UIWindowSceneSessionRoleApplication</key><array><dict>',
|
|
1381
|
+
'<key>UISceneConfigurationName</key><string>Default Configuration</string>',
|
|
1382
|
+
'<key>UISceneDelegateClassName</key><string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>',
|
|
1383
|
+
'</dict></array></dict></dict>',
|
|
1384
|
+
'<key>UILaunchScreen</key><dict/>',
|
|
1385
|
+
'</dict></plist>',
|
|
1386
|
+
'',
|
|
1387
|
+
].join('\n');
|
|
1388
|
+
}
|
|
1389
|
+
function assertAppliedGradleScripts(nativeDirectory, script, visited, inspectedScripts, isSettingsScript, ownerDirectory) {
|
|
1390
|
+
const visitKey = `${ownerDirectory}\0${isSettingsScript}\0${script}`;
|
|
1391
|
+
if (visited.has(visitKey)) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
visited.add(visitKey);
|
|
1395
|
+
inspectedScripts.add(script);
|
|
1396
|
+
if (!isNativeFile(nativeDirectory, script)) {
|
|
1397
|
+
throw new Error(`Existing android project is incomplete; applied Gradle script ${script} is missing.`);
|
|
1398
|
+
}
|
|
1399
|
+
const source = stripGradleComments(readFileSync(path.join(nativeDirectory, script), 'utf8'));
|
|
1400
|
+
const code = maskGradleStrings(source);
|
|
1401
|
+
if (/(?:^|[\s;{])apply\s*\{/u.test(code)) {
|
|
1402
|
+
throw new Error('Existing android project has an unsupported Gradle apply expression.');
|
|
1403
|
+
}
|
|
1404
|
+
if (isSettingsScript) {
|
|
1405
|
+
assertAndroidSettingsNoAppRemap(source);
|
|
1406
|
+
}
|
|
1407
|
+
const expressions = [
|
|
1408
|
+
/\bapply\s+from\s*:\s*["']([^"']+)["']/gu,
|
|
1409
|
+
/\bapply\s*\(\s*from\s*=\s*["']([^"']+)["']\s*\)/gu,
|
|
1410
|
+
/\bapply\s+from\s*:\s*file\s*\(\s*["']([^"']+)["']\s*\)/gu,
|
|
1411
|
+
/\bapply\s*\(\s*from\s*=\s*file\s*\(\s*["']([^"']+)["']\s*\)\s*\)/gu,
|
|
1412
|
+
];
|
|
1413
|
+
const matched = new Set();
|
|
1414
|
+
for (const expression of expressions) {
|
|
1415
|
+
for (const match of source.matchAll(expression)) {
|
|
1416
|
+
if (match.index === undefined || code.slice(match.index, match.index + 5) !== 'apply') {
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
matched.add(match.index);
|
|
1420
|
+
const requested = match[1] ?? '';
|
|
1421
|
+
// Gradle resolves nested script paths against the owning project or settings directory.
|
|
1422
|
+
const resolved = path.resolve(ownerDirectory, requested);
|
|
1423
|
+
const relative = path.relative(nativeDirectory, resolved);
|
|
1424
|
+
if (requested.includes('$') || relative === '' || relative.startsWith('..')
|
|
1425
|
+
|| path.isAbsolute(relative)) {
|
|
1426
|
+
throw new Error('Existing android project has an unsupported applied Gradle script path.');
|
|
1427
|
+
}
|
|
1428
|
+
assertAppliedGradleScripts(nativeDirectory, relative, visited, inspectedScripts, isSettingsScript, ownerDirectory);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const applyExpressions = [/\bapply\s+from\s*:/gu, /\bapply\s*\(/gu];
|
|
1432
|
+
if (applyExpressions.some((expression) => [...source.matchAll(expression)]
|
|
1433
|
+
.some((match) => match.index !== undefined
|
|
1434
|
+
&& code.slice(match.index, match.index + 5) === 'apply'
|
|
1435
|
+
&& !matched.has(match.index)))) {
|
|
1436
|
+
throw new Error('Existing android project has an unsupported Gradle apply expression.');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
function assertNativeDisplayName(nativeDirectory, platform, expectedDisplayName, iosInfoRelative) {
|
|
1440
|
+
const relative = iosInfoRelative ?? 'App/App/Info.plist';
|
|
1441
|
+
if (platform === 'ios' && !isNativeFile(nativeDirectory, relative)) {
|
|
1442
|
+
throw new Error(`Existing ${platform} project is incomplete; native display name resource is missing.`);
|
|
1443
|
+
}
|
|
1444
|
+
const source = platform === 'ios'
|
|
1445
|
+
? readFileSync(path.join(nativeDirectory, relative), 'utf8')
|
|
1446
|
+
: '';
|
|
1447
|
+
const labels = platform === 'ios'
|
|
1448
|
+
? [parsePlistDictionary(source, 'Release').get('CFBundleDisplayName')]
|
|
1449
|
+
.filter((value) => value?.tagName === 'string')
|
|
1450
|
+
.map((value) => value?.textContent ?? '')
|
|
1451
|
+
: [];
|
|
1452
|
+
if (platform === 'ios' && (labels.length !== 1 || labels[0] !== expectedDisplayName)) {
|
|
1453
|
+
throw new Error(`Existing ${platform} project display name differs or cannot be read safely.`);
|
|
1454
|
+
}
|
|
1455
|
+
if (platform === 'android') {
|
|
1456
|
+
const main = readAndroidApplication(path.join(nativeDirectory, 'app/src/main/AndroidManifest.xml'));
|
|
1457
|
+
if (main === undefined) {
|
|
1458
|
+
throw new Error('Existing android project application label is missing.');
|
|
1459
|
+
}
|
|
1460
|
+
const label = main.getAttribute('android:label');
|
|
1461
|
+
if (label === null) {
|
|
1462
|
+
throw new Error('Existing android project application label is missing.');
|
|
1463
|
+
}
|
|
1464
|
+
const effective = resolveAndroidLabel(nativeDirectory, label);
|
|
1465
|
+
if (effective !== expectedDisplayName) {
|
|
1466
|
+
throw new Error('Existing android project application label differs from the requested name.');
|
|
1467
|
+
}
|
|
1468
|
+
const launchers = readAndroidLaunchers(main);
|
|
1469
|
+
for (const launcher of launchers) {
|
|
1470
|
+
const launcherLabel = launcher.getAttribute('android:label');
|
|
1471
|
+
if (launcherLabel !== null
|
|
1472
|
+
&& resolveAndroidLabel(nativeDirectory, launcherLabel) !== expectedDisplayName) {
|
|
1473
|
+
throw new Error('Existing android project launcher label differs from the requested name.');
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
const overlayFile = path.join(nativeDirectory, 'app/src/release/AndroidManifest.xml');
|
|
1477
|
+
if (existsSync(overlayFile)) {
|
|
1478
|
+
const release = readAndroidApplication(overlayFile, false);
|
|
1479
|
+
if (release !== undefined) {
|
|
1480
|
+
if (hasAndroidMergerDirective(release)) {
|
|
1481
|
+
throw new Error('Existing android Release manifest changes the application node.');
|
|
1482
|
+
}
|
|
1483
|
+
const releaseLabel = release.getAttribute('android:label');
|
|
1484
|
+
if (releaseLabel !== null
|
|
1485
|
+
&& resolveAndroidLabel(nativeDirectory, releaseLabel) !== expectedDisplayName) {
|
|
1486
|
+
throw new Error('Existing android Release application label differs from the requested name.');
|
|
1487
|
+
}
|
|
1488
|
+
const appBuildFile = ['app/build.gradle', 'app/build.gradle.kts']
|
|
1489
|
+
.map((relative) => path.join(nativeDirectory, relative))
|
|
1490
|
+
.find((file) => existsSync(file));
|
|
1491
|
+
const appGradle = appBuildFile === undefined ? '' : readFileSync(appBuildFile, 'utf8');
|
|
1492
|
+
const manifestPackage = main.parentNode?.getAttribute('package');
|
|
1493
|
+
const namespace = /\bnamespace\s*(?:=\s*)?["']([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+)["']/u
|
|
1494
|
+
.exec(stripGradleComments(appGradle))?.[1]
|
|
1495
|
+
?? (manifestPackage === null || manifestPackage === '' ? undefined : manifestPackage);
|
|
1496
|
+
const launcherNames = new Set();
|
|
1497
|
+
for (const launcher of launchers) {
|
|
1498
|
+
const qualified = qualifyAndroidActivityName(launcher.getAttribute('android:name'), namespace);
|
|
1499
|
+
if (qualified !== undefined) {
|
|
1500
|
+
launcherNames.add(qualified);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
const releaseLaunchers = readAndroidLaunchers(release);
|
|
1504
|
+
for (const activity of childElements(release)) {
|
|
1505
|
+
if (activity.tagName !== 'activity' && activity.tagName !== 'activity-alias') {
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
const releaseName = qualifyAndroidActivityName(activity.getAttribute('android:name'), namespace);
|
|
1509
|
+
const sameLauncher = releaseName !== undefined && launcherNames.has(releaseName);
|
|
1510
|
+
const activityLabel = activity.getAttribute('android:label');
|
|
1511
|
+
if (sameLauncher && activity.tagName === 'activity-alias'
|
|
1512
|
+
&& activity.hasAttribute('android:targetActivity')) {
|
|
1513
|
+
throw new Error('Existing android Release launcher alias target override is unsupported.');
|
|
1514
|
+
}
|
|
1515
|
+
if ((sameLauncher || releaseLaunchers.includes(activity))
|
|
1516
|
+
&& hasAndroidMergerDirectiveDeep(activity)) {
|
|
1517
|
+
throw new Error('Existing android Release manifest changes a launcher node.');
|
|
1518
|
+
}
|
|
1519
|
+
if (sameLauncher || releaseLaunchers.includes(activity)) {
|
|
1520
|
+
assertAndroidLauncherEnabled(activity, 'Release launcher');
|
|
1521
|
+
}
|
|
1522
|
+
if ((sameLauncher || releaseLaunchers.includes(activity)) && activityLabel !== null
|
|
1523
|
+
&& resolveAndroidLabel(nativeDirectory, activityLabel) !== expectedDisplayName) {
|
|
1524
|
+
throw new Error('Existing android Release launcher label differs from the requested name.');
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
export function assertAndroidReleaseDisplayName(shellApp, expectedDisplayName) {
|
|
1532
|
+
const nativeDirectory = path.join(shellApp, 'android');
|
|
1533
|
+
assertAndroidManifestResources(nativeDirectory);
|
|
1534
|
+
assertNativeDisplayName(nativeDirectory, 'android', expectedDisplayName);
|
|
1535
|
+
}
|
|
1536
|
+
export function assertAndroidReleaseLauncherIntegrity(shellApp) {
|
|
1537
|
+
const nativeDirectory = path.join(shellApp, 'android');
|
|
1538
|
+
const gradleRelative = requireOneNativeFile(nativeDirectory, ['app/build.gradle', 'app/build.gradle.kts'], 'android');
|
|
1539
|
+
const gradle = readFileSync(path.join(nativeDirectory, gradleRelative), 'utf8');
|
|
1540
|
+
assertAndroidLauncherClasses(nativeDirectory, gradle);
|
|
1541
|
+
}
|
|
1542
|
+
export function assertIosReleaseDisplayName(shellApp, expectedDisplayName) {
|
|
1543
|
+
const nativeDirectory = path.join(shellApp, 'ios');
|
|
1544
|
+
const projectFile = path.join(nativeDirectory, 'App/App.xcodeproj/project.pbxproj');
|
|
1545
|
+
const project = readFileSync(projectFile, 'utf8');
|
|
1546
|
+
const infoRelative = path.join('App', readIosReleaseInfoPlist(project));
|
|
1547
|
+
if (!isNativeFile(nativeDirectory, infoRelative)) {
|
|
1548
|
+
throw new Error('Existing ios project Release Info.plist is missing or unsafe.');
|
|
1549
|
+
}
|
|
1550
|
+
assertNativeDisplayName(nativeDirectory, 'ios', expectedDisplayName, infoRelative);
|
|
1551
|
+
assertNoIosLocalizedDisplayNameOverride(nativeDirectory, project);
|
|
1552
|
+
assertReferencedIosFiles(nativeDirectory, project, infoRelative);
|
|
1553
|
+
}
|
|
1554
|
+
function assertNoIosLocalizedDisplayNameOverride(nativeDirectory, project) {
|
|
1555
|
+
const files = listNativeFiles(nativeDirectory, 'App/App', '.strings');
|
|
1556
|
+
if (/\bInfoPlist\.strings\b/u.test(project)
|
|
1557
|
+
|| isNativeFile(nativeDirectory, 'App/InfoPlist.strings')
|
|
1558
|
+
|| files.some((relative) => path.basename(relative) === 'InfoPlist.strings')) {
|
|
1559
|
+
throw new Error('Existing ios project localized InfoPlist.strings overrides are unsupported.');
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
export function assertIosArchiveScheme(shellApp) {
|
|
1563
|
+
const nativeDirectory = path.join(shellApp, 'ios');
|
|
1564
|
+
const projectRelative = 'App/App.xcodeproj/project.pbxproj';
|
|
1565
|
+
const schemeRelative = 'App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme';
|
|
1566
|
+
if (!isNativeFile(nativeDirectory, projectRelative)
|
|
1567
|
+
|| !isNativeFile(nativeDirectory, schemeRelative)) {
|
|
1568
|
+
throw new Error('Existing ios project shared App archive scheme is missing or unsafe.');
|
|
1569
|
+
}
|
|
1570
|
+
const project = readFileSync(path.join(nativeDirectory, projectRelative), 'utf8');
|
|
1571
|
+
const appTargetId = readIosAppTargetId(project);
|
|
1572
|
+
const schemeSource = readFileSync(path.join(nativeDirectory, schemeRelative), 'utf8');
|
|
1573
|
+
const scheme = parseXml(schemeSource, 'ios project App archive scheme').documentElement;
|
|
1574
|
+
if (scheme?.tagName !== 'Scheme') {
|
|
1575
|
+
throw new Error('Existing ios project App archive scheme is malformed.');
|
|
1576
|
+
}
|
|
1577
|
+
const actions = childElements(scheme);
|
|
1578
|
+
const build = actions.filter((item) => item.tagName === 'BuildAction');
|
|
1579
|
+
const archive = actions.filter((item) => item.tagName === 'ArchiveAction');
|
|
1580
|
+
const entryGroups = build.length === 1
|
|
1581
|
+
? childElements(build[0])
|
|
1582
|
+
.filter((item) => item.tagName === 'BuildActionEntries')
|
|
1583
|
+
: [];
|
|
1584
|
+
const entries = entryGroups.length === 1
|
|
1585
|
+
? childElements(entryGroups[0])
|
|
1586
|
+
.filter((item) => item.tagName === 'BuildActionEntry'
|
|
1587
|
+
&& item.getAttribute('buildForArchiving') === 'YES')
|
|
1588
|
+
: [];
|
|
1589
|
+
const references = entries.length === 1 ? childElements(entries[0]) : [];
|
|
1590
|
+
const reference = references.length === 1 ? references[0] : undefined;
|
|
1591
|
+
const hasSchemeActions = (element) => childElements(element).some((item) => item.tagName === 'PreActions' || item.tagName === 'PostActions' || hasSchemeActions(item));
|
|
1592
|
+
if ((build[0] !== undefined && hasSchemeActions(build[0]))
|
|
1593
|
+
|| (archive[0] !== undefined && hasSchemeActions(archive[0]))) {
|
|
1594
|
+
throw new Error('Existing ios project App archive scheme actions are unsupported.');
|
|
1595
|
+
}
|
|
1596
|
+
if (archive.length !== 1 || archive[0]?.getAttribute('buildConfiguration') !== 'Release'
|
|
1597
|
+
|| reference?.tagName !== 'BuildableReference'
|
|
1598
|
+
|| reference.getAttribute('BlueprintIdentifier') !== appTargetId
|
|
1599
|
+
|| reference.getAttribute('BlueprintName') !== 'App'
|
|
1600
|
+
|| reference.getAttribute('BuildableName') !== 'App.app'
|
|
1601
|
+
|| reference.getAttribute('ReferencedContainer') !== 'container:App.xcodeproj') {
|
|
1602
|
+
throw new Error('Existing ios App archive scheme does not select the App target.');
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
function hasAndroidMergerDirective(element) {
|
|
1606
|
+
return ['tools:node', 'tools:remove', 'tools:replace']
|
|
1607
|
+
.some((attribute) => element.hasAttribute(attribute));
|
|
1608
|
+
}
|
|
1609
|
+
function hasAndroidMergerDirectiveDeep(element) {
|
|
1610
|
+
return hasAndroidMergerDirective(element)
|
|
1611
|
+
|| childElements(element).some(hasAndroidMergerDirectiveDeep);
|
|
1612
|
+
}
|
|
1613
|
+
function readAndroidApplication(file, required = true) {
|
|
1614
|
+
const root = parseXml(readFileSync(file, 'utf8'), 'android manifest').documentElement;
|
|
1615
|
+
const application = root === null
|
|
1616
|
+
? undefined
|
|
1617
|
+
: childElements(root).find((element) => element.tagName === 'application');
|
|
1618
|
+
if (root?.tagName !== 'manifest' || (required && application === undefined)) {
|
|
1619
|
+
throw new Error('Existing android project manifest application is missing.');
|
|
1620
|
+
}
|
|
1621
|
+
return application;
|
|
1622
|
+
}
|
|
1623
|
+
function readAndroidLaunchers(application) {
|
|
1624
|
+
return childElements(application).filter((activity) => {
|
|
1625
|
+
if (activity.tagName !== 'activity' && activity.tagName !== 'activity-alias') {
|
|
1626
|
+
return false;
|
|
1627
|
+
}
|
|
1628
|
+
return childElements(activity).some((filter) => filter.tagName === 'intent-filter'
|
|
1629
|
+
&& childElements(filter).some((item) => item.tagName === 'action'
|
|
1630
|
+
&& item.getAttribute('android:name') === 'android.intent.action.MAIN')
|
|
1631
|
+
&& childElements(filter).some((item) => item.tagName === 'category'
|
|
1632
|
+
&& item.getAttribute('android:name') === 'android.intent.category.LAUNCHER'));
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
function resolveAndroidLabel(nativeDirectory, label) {
|
|
1636
|
+
if (label.startsWith('@string/')) {
|
|
1637
|
+
return readAndroidString(nativeDirectory, label.slice('@string/'.length));
|
|
1638
|
+
}
|
|
1639
|
+
if (label.startsWith('@')) {
|
|
1640
|
+
throw new Error('Existing android project application label resource is unsupported.');
|
|
1641
|
+
}
|
|
1642
|
+
return label;
|
|
1643
|
+
}
|
|
1644
|
+
function readAndroidString(nativeDirectory, key) {
|
|
1645
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
|
|
1646
|
+
throw new Error('Existing android project application label is unsupported.');
|
|
1647
|
+
}
|
|
1648
|
+
const overlay = path.join(nativeDirectory, 'app/src/release/res/values');
|
|
1649
|
+
const overlayValues = existsSync(overlay) && lstatSync(overlay).isDirectory()
|
|
1650
|
+
? readdirSync(overlay).filter((file) => file.endsWith('.xml'))
|
|
1651
|
+
.flatMap((file) => readAndroidValueResource(path.join(overlay, file), 'string', key))
|
|
1652
|
+
: [];
|
|
1653
|
+
const main = path.join(nativeDirectory, 'app/src/main/res/values');
|
|
1654
|
+
const mainValues = existsSync(main) && lstatSync(main).isDirectory()
|
|
1655
|
+
? readdirSync(main).filter((file) => file.endsWith('.xml'))
|
|
1656
|
+
.flatMap((file) => readAndroidValueResource(path.join(main, file), 'string', key))
|
|
1657
|
+
: [];
|
|
1658
|
+
const values = overlayValues.length > 0 ? overlayValues : mainValues;
|
|
1659
|
+
if (values.length !== 1) {
|
|
1660
|
+
throw new Error('Existing android project application label resource is missing or ambiguous.');
|
|
1661
|
+
}
|
|
1662
|
+
const qualified = ['main', 'release'].flatMap((sourceSet) => listNativeFiles(nativeDirectory, `app/src/${sourceSet}/res`, '.xml')
|
|
1663
|
+
.filter((relative) => /^values-[^/]+$/u.test(path.basename(path.dirname(relative))))
|
|
1664
|
+
.flatMap((relative) => readAndroidValueResource(path.join(nativeDirectory, relative), 'string', key)));
|
|
1665
|
+
if (qualified.some((value) => value !== values[0])) {
|
|
1666
|
+
throw new Error('Existing android project configuration-qualified application label differs.');
|
|
1667
|
+
}
|
|
1668
|
+
return values[0] ?? '';
|
|
1669
|
+
}
|
|
1670
|
+
function readAndroidValueResource(file, type, name) {
|
|
1671
|
+
const root = parseXml(readFileSync(file, 'utf8'), 'android values resource').documentElement;
|
|
1672
|
+
if (root?.tagName !== 'resources') {
|
|
1673
|
+
throw new Error('Existing android values resource is malformed.');
|
|
1674
|
+
}
|
|
1675
|
+
return childElements(root)
|
|
1676
|
+
.filter((element) => (element.tagName === type
|
|
1677
|
+
|| element.tagName === 'item' && element.getAttribute('type') === type)
|
|
1678
|
+
&& element.getAttribute('name') === name)
|
|
1679
|
+
.map((element) => type === 'string'
|
|
1680
|
+
? decodeAndroidStringResource(element.textContent ?? '')
|
|
1681
|
+
: (element.textContent ?? ''));
|
|
1682
|
+
}
|
|
1683
|
+
export function decodeAndroidStringResource(value) {
|
|
1684
|
+
const trimmed = value.trim();
|
|
1685
|
+
const quoted = trimmed.startsWith('"') && trimmed.endsWith('"');
|
|
1686
|
+
const source = quoted ? trimmed.slice(1, -1) : trimmed;
|
|
1687
|
+
let decoded = '';
|
|
1688
|
+
let unquotedWhitespace = false;
|
|
1689
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1690
|
+
const character = source[index] ?? '';
|
|
1691
|
+
if (character !== '\\') {
|
|
1692
|
+
if (!quoted && /\s/u.test(character)) {
|
|
1693
|
+
if (!unquotedWhitespace) {
|
|
1694
|
+
decoded += ' ';
|
|
1695
|
+
}
|
|
1696
|
+
unquotedWhitespace = true;
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
decoded += character;
|
|
1700
|
+
unquotedWhitespace = false;
|
|
1701
|
+
}
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
unquotedWhitespace = false;
|
|
1705
|
+
const next = source[index + 1];
|
|
1706
|
+
if (next === 'u' && /^[0-9a-fA-F]{4}$/u.test(source.slice(index + 2, index + 6))) {
|
|
1707
|
+
decoded += String.fromCharCode(Number.parseInt(source.slice(index + 2, index + 6), 16));
|
|
1708
|
+
index += 5;
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
const escapes = {
|
|
1712
|
+
n: '\n',
|
|
1713
|
+
r: '\r',
|
|
1714
|
+
t: '\t',
|
|
1715
|
+
'\\': '\\',
|
|
1716
|
+
"'": "'",
|
|
1717
|
+
'"': '"',
|
|
1718
|
+
'@': '@',
|
|
1719
|
+
'?': '?',
|
|
1720
|
+
' ': ' ',
|
|
1721
|
+
};
|
|
1722
|
+
if (next === undefined || !Object.hasOwn(escapes, next)) {
|
|
1723
|
+
throw new Error('Existing android string resource has an unsupported escape.');
|
|
1724
|
+
}
|
|
1725
|
+
decoded += escapes[next] ?? '';
|
|
1726
|
+
index += 1;
|
|
1727
|
+
}
|
|
1728
|
+
return decoded;
|
|
1729
|
+
}
|
|
1730
|
+
function decodeXmlLabel(value) {
|
|
1731
|
+
const trimmed = value.trim();
|
|
1732
|
+
let unsupported = false;
|
|
1733
|
+
const decoded = trimmed.replace(/&([^;]+);/gu, (_match, entity) => {
|
|
1734
|
+
const named = {
|
|
1735
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
|
|
1736
|
+
};
|
|
1737
|
+
if (entity.startsWith('#')) {
|
|
1738
|
+
const point = entity[1]?.toLowerCase() === 'x'
|
|
1739
|
+
? Number.parseInt(entity.slice(2), 16)
|
|
1740
|
+
: Number.parseInt(entity.slice(1), 10);
|
|
1741
|
+
if (Number.isInteger(point) && point > 0 && point <= 0x10ffff
|
|
1742
|
+
&& !(point >= 0xd800 && point <= 0xdfff)) {
|
|
1743
|
+
return String.fromCodePoint(point);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
if (Object.hasOwn(named, entity)) {
|
|
1747
|
+
return named[entity] ?? '';
|
|
1748
|
+
}
|
|
1749
|
+
unsupported = true;
|
|
1750
|
+
return '';
|
|
1751
|
+
});
|
|
1752
|
+
if (unsupported || trimmed.replace(/&[^;]+;/gu, '').includes('&')) {
|
|
1753
|
+
throw new Error('Existing native display name contains an unsupported XML entity.');
|
|
1754
|
+
}
|
|
1755
|
+
return decoded;
|
|
1756
|
+
}
|
|
1757
|
+
function requireOneNativeFile(directory, candidates, platform) {
|
|
1758
|
+
const present = candidates.filter((relative) => isNativeFile(directory, relative));
|
|
1759
|
+
if (present.length !== 1) {
|
|
1760
|
+
throw new Error(`Existing ${platform} project is incomplete or ambiguous: ${candidates.join(', ')}.`);
|
|
1761
|
+
}
|
|
1762
|
+
return present[0] ?? '';
|
|
1763
|
+
}
|
|
1764
|
+
function isNativeFile(directory, relative) {
|
|
1765
|
+
const file = path.resolve(directory, relative);
|
|
1766
|
+
const within = path.relative(directory, file);
|
|
1767
|
+
if (within === '' || within.startsWith('..') || path.isAbsolute(within)) {
|
|
1768
|
+
return false;
|
|
1769
|
+
}
|
|
1770
|
+
let current = directory;
|
|
1771
|
+
for (const segment of within.split(path.sep)) {
|
|
1772
|
+
current = path.join(current, segment);
|
|
1773
|
+
if (!existsSync(current) || lstatSync(current).isSymbolicLink()) {
|
|
1774
|
+
return false;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return lstatSync(file).isFile();
|
|
1778
|
+
}
|
|
1779
|
+
function runCommand(command, args, cwd) {
|
|
1780
|
+
const resolvedCommand = process.platform === 'win32' && command === 'pnpm' ? 'pnpm.cmd' : command;
|
|
1781
|
+
const result = spawnSync(resolvedCommand, [...args], {
|
|
1782
|
+
cwd,
|
|
1783
|
+
stdio: 'inherit',
|
|
1784
|
+
env: process.env,
|
|
1785
|
+
shell: process.platform === 'win32',
|
|
1786
|
+
timeout: 600_000,
|
|
1787
|
+
});
|
|
1788
|
+
if (result.error !== undefined) {
|
|
1789
|
+
throw result.error;
|
|
1790
|
+
}
|
|
1791
|
+
if (result.status !== 0) {
|
|
1792
|
+
throw new Error(`${command} ${args.join(' ')} failed. The shell files remain for a safe retry.`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function assertCompatibleExistingPackage(source, capacitorVersion, appVersion, pluginVersion, typescriptVersion) {
|
|
1796
|
+
const value = parseJsonObject(source, 'existing shell package');
|
|
1797
|
+
if (value.private !== true) {
|
|
1798
|
+
throw new Error('Existing Capacitor shell package must stay private.');
|
|
1799
|
+
}
|
|
1800
|
+
const dependencies = requireObject(value.dependencies, 'existing shell dependencies');
|
|
1801
|
+
for (const [name, version] of Object.entries({
|
|
1802
|
+
'@capacitor/android': capacitorVersion,
|
|
1803
|
+
'@capacitor/app': appVersion,
|
|
1804
|
+
'@capacitor/core': capacitorVersion,
|
|
1805
|
+
'@capacitor/ios': capacitorVersion,
|
|
1806
|
+
'@mpgd/capacitor-game-services': pluginVersion,
|
|
1807
|
+
})) {
|
|
1808
|
+
if (dependencies[name] !== version) {
|
|
1809
|
+
throw new Error(`Existing shell ${name} differs; refusing to rewrite dependencies.`);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
const scripts = requireObject(value.scripts, 'existing shell scripts');
|
|
1813
|
+
if (scripts.cap !== 'cap') {
|
|
1814
|
+
throw new Error('Existing shell must expose the standard cap script.');
|
|
1815
|
+
}
|
|
1816
|
+
const devDependencies = requireObject(value.devDependencies, 'existing shell dev dependencies');
|
|
1817
|
+
if (devDependencies['@capacitor/cli'] !== capacitorVersion
|
|
1818
|
+
|| devDependencies.typescript !== typescriptVersion) {
|
|
1819
|
+
throw new Error('Existing shell must include the pinned Capacitor CLI and TypeScript.');
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
function normalizeBackendUrl(value) {
|
|
1823
|
+
if (value.includes('$')) {
|
|
1824
|
+
throw new Error('Backend URL cannot contain dotenv expansion syntax.');
|
|
1825
|
+
}
|
|
1826
|
+
let parsed;
|
|
1827
|
+
try {
|
|
1828
|
+
parsed = new URL(value);
|
|
1829
|
+
}
|
|
1830
|
+
catch {
|
|
1831
|
+
throw new Error('Backend URL must be an absolute HTTPS URL.');
|
|
1832
|
+
}
|
|
1833
|
+
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== ''
|
|
1834
|
+
|| parsed.hash !== '' || parsed.search !== '') {
|
|
1835
|
+
throw new Error('Backend URL must be HTTPS without credentials, search, or fragment.');
|
|
1836
|
+
}
|
|
1837
|
+
if (isNonPublicServiceHostname(parsed.hostname)) {
|
|
1838
|
+
throw new Error('Backend URL must use a public HTTPS hostname for production.');
|
|
1839
|
+
}
|
|
1840
|
+
return parsed.href.replace(/\/$/u, '');
|
|
1841
|
+
}
|
|
1842
|
+
function readDotenvValue(source) {
|
|
1843
|
+
const value = source.trim();
|
|
1844
|
+
if (!value.startsWith('"') && !value.startsWith("'")) {
|
|
1845
|
+
return value.split('#', 1)[0]?.trim() ?? '';
|
|
1846
|
+
}
|
|
1847
|
+
const quote = value[0];
|
|
1848
|
+
let closing = -1;
|
|
1849
|
+
for (let index = 1; index < value.length; index += 1) {
|
|
1850
|
+
if (value[index] === quote && value[index - 1] !== '\\') {
|
|
1851
|
+
closing = index;
|
|
1852
|
+
break;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
if (closing < 0 || (value.slice(closing + 1).trim() !== ''
|
|
1856
|
+
&& !value.slice(closing + 1).trim().startsWith('#'))) {
|
|
1857
|
+
throw new Error('Production Game Services URL has malformed dotenv syntax.');
|
|
1858
|
+
}
|
|
1859
|
+
return value.slice(1, closing);
|
|
1860
|
+
}
|
|
1861
|
+
function readCapacitorConfigLiteral(source, key) {
|
|
1862
|
+
const expression = new RegExp(`^[ \\t]*${key}:[ \\t]*("(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*')[ \\t]*,?[ \\t]*\\r?$`, 'gmu');
|
|
1863
|
+
const matches = [...source.matchAll(expression)];
|
|
1864
|
+
const literal = matches.length === 1 ? matches[0]?.[1] : undefined;
|
|
1865
|
+
if (literal === undefined) {
|
|
1866
|
+
return undefined;
|
|
1867
|
+
}
|
|
1868
|
+
if (literal.startsWith('"')) {
|
|
1869
|
+
try {
|
|
1870
|
+
return JSON.parse(literal);
|
|
1871
|
+
}
|
|
1872
|
+
catch {
|
|
1873
|
+
return undefined;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const body = literal.slice(1, -1);
|
|
1877
|
+
let value = '';
|
|
1878
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
1879
|
+
const character = body[index];
|
|
1880
|
+
if (character !== '\\') {
|
|
1881
|
+
value += character;
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
const escaped = body[++index];
|
|
1885
|
+
const replacements = {
|
|
1886
|
+
"'": "'",
|
|
1887
|
+
'"': '"',
|
|
1888
|
+
'\\': '\\',
|
|
1889
|
+
n: '\n',
|
|
1890
|
+
r: '\r',
|
|
1891
|
+
t: '\t',
|
|
1892
|
+
};
|
|
1893
|
+
if (escaped === undefined || !(escaped in replacements)) {
|
|
1894
|
+
return undefined;
|
|
1895
|
+
}
|
|
1896
|
+
value += replacements[escaped];
|
|
1897
|
+
}
|
|
1898
|
+
return value;
|
|
1899
|
+
}
|
|
1900
|
+
/** Refuse dynamic identity overrides; preserve the authored file without evaluating it. */
|
|
1901
|
+
function requireStaticCapacitorConfig(source) {
|
|
1902
|
+
let withoutComments = '';
|
|
1903
|
+
let codeOnly = '';
|
|
1904
|
+
for (let index = 0; index < source.length;) {
|
|
1905
|
+
const character = source[index];
|
|
1906
|
+
const next = source[index + 1];
|
|
1907
|
+
if (character === '`') {
|
|
1908
|
+
throw new Error('Existing Capacitor config has ambiguous dynamic syntax.');
|
|
1909
|
+
}
|
|
1910
|
+
if (character === '"' || character === "'") {
|
|
1911
|
+
const quote = character;
|
|
1912
|
+
const start = index;
|
|
1913
|
+
let closed = false;
|
|
1914
|
+
while (index < source.length) {
|
|
1915
|
+
const current = source[index] ?? '';
|
|
1916
|
+
withoutComments += current;
|
|
1917
|
+
codeOnly += current === '\n' ? '\n' : ' ';
|
|
1918
|
+
index += 1;
|
|
1919
|
+
if (current === '\\' && index < source.length) {
|
|
1920
|
+
withoutComments += source[index];
|
|
1921
|
+
codeOnly += ' ';
|
|
1922
|
+
index += 1;
|
|
1923
|
+
}
|
|
1924
|
+
else if (current === quote && index - 1 !== start) {
|
|
1925
|
+
closed = true;
|
|
1926
|
+
break;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (!closed) {
|
|
1930
|
+
throw new Error('Existing Capacitor config has an unterminated string.');
|
|
1931
|
+
}
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
if (character === '/' && (next === '/' || next === '*')) {
|
|
1935
|
+
const block = next === '*';
|
|
1936
|
+
withoutComments += ' ';
|
|
1937
|
+
codeOnly += ' ';
|
|
1938
|
+
index += 2;
|
|
1939
|
+
while (index < source.length) {
|
|
1940
|
+
if (block && source[index] === '*' && source[index + 1] === '/') {
|
|
1941
|
+
withoutComments += ' ';
|
|
1942
|
+
codeOnly += ' ';
|
|
1943
|
+
index += 2;
|
|
1944
|
+
break;
|
|
1945
|
+
}
|
|
1946
|
+
if (!block && source[index] === '\n') {
|
|
1947
|
+
break;
|
|
1948
|
+
}
|
|
1949
|
+
const current = source[index] ?? '';
|
|
1950
|
+
const masked = current === '\n' ? '\n' : ' ';
|
|
1951
|
+
withoutComments += masked;
|
|
1952
|
+
codeOnly += masked;
|
|
1953
|
+
index += 1;
|
|
1954
|
+
}
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
withoutComments += character;
|
|
1958
|
+
codeOnly += character;
|
|
1959
|
+
index += 1;
|
|
1960
|
+
}
|
|
1961
|
+
const configUses = [...codeOnly.matchAll(/\bconfig\b(?!\s*:)/gu)].length;
|
|
1962
|
+
const declaration = /\bconst\s+config\s*:\s*CapacitorConfig\s*=\s*\{/u.exec(codeOnly);
|
|
1963
|
+
if (declaration?.index === undefined) {
|
|
1964
|
+
throw new Error('Existing Capacitor config has ambiguous dynamic syntax.');
|
|
1965
|
+
}
|
|
1966
|
+
const opening = declaration.index + declaration[0].length - 1;
|
|
1967
|
+
const topLevelSource = maskNestedConfig(withoutComments, codeOnly, opening);
|
|
1968
|
+
const topLevelCode = maskNestedConfig(codeOnly, codeOnly, opening);
|
|
1969
|
+
const identityKeysAreUnique = ['appId', 'appName', 'webDir'].every((key) => [...topLevelCode.matchAll(new RegExp(`\\b${key}\\b`, 'gu'))].length === 1);
|
|
1970
|
+
const computedProperty = /(?:\{|,)[ \t\n]*(?:(?:get|set)[ \t\n]+)?\[[^\]]*\][ \t\n]*(?::|\()/u;
|
|
1971
|
+
const hasComputedKey = computedProperty.test(topLevelCode);
|
|
1972
|
+
if (configUses !== 1 || topLevelCode.includes('...') || hasComputedKey
|
|
1973
|
+
|| /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]+\})/u.test(topLevelCode)
|
|
1974
|
+
|| !identityKeysAreUnique
|
|
1975
|
+
|| /^[ \t]*["'](?:appId|appName|webDir)["'][ \t]*:/mu.test(topLevelSource)
|
|
1976
|
+
|| !/\bexport\s+default\s+config\s*;/u.test(codeOnly)) {
|
|
1977
|
+
throw new Error('Existing Capacitor config has ambiguous dynamic syntax.');
|
|
1978
|
+
}
|
|
1979
|
+
assertNoCapacitorServerUrl(withoutComments, codeOnly, topLevelCode);
|
|
1980
|
+
return topLevelSource;
|
|
1981
|
+
}
|
|
1982
|
+
function assertNoCapacitorServerUrl(source, code, topLevel) {
|
|
1983
|
+
if (/^[ \t]*["']server["']\s*:/mu.test(source)) {
|
|
1984
|
+
throw new Error('Existing Capacitor config server field is ambiguous.');
|
|
1985
|
+
}
|
|
1986
|
+
const quotedKeys = [...source.matchAll(/(?:\{|,)\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*:/gu)];
|
|
1987
|
+
if (quotedKeys.some((match) => (match[2] ?? '').includes('\\'))) {
|
|
1988
|
+
throw new Error('Existing Capacitor config quoted property key is ambiguous.');
|
|
1989
|
+
}
|
|
1990
|
+
if (/\b(?:get|set)\s+server\s*\(/u.test(topLevel)
|
|
1991
|
+
|| /(?:^|[,\s{])server\s*(?:,|\})/u.test(topLevel)) {
|
|
1992
|
+
throw new Error('Existing Capacitor config server field is dynamic.');
|
|
1993
|
+
}
|
|
1994
|
+
const server = /\bserver\s*:/u.exec(topLevel);
|
|
1995
|
+
if (server?.index === undefined) {
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
const afterColon = server.index + server[0].length;
|
|
1999
|
+
const opening = code.indexOf('{', afterColon);
|
|
2000
|
+
if (opening < 0 || code.slice(afterColon, opening).trim() !== '') {
|
|
2001
|
+
throw new Error('Existing Capacitor config server field is not a static object.');
|
|
2002
|
+
}
|
|
2003
|
+
let depth = 0;
|
|
2004
|
+
for (let index = opening; index < code.length; index += 1) {
|
|
2005
|
+
if (code[index] === '{') {
|
|
2006
|
+
depth += 1;
|
|
2007
|
+
}
|
|
2008
|
+
else if (code[index] === '}') {
|
|
2009
|
+
depth -= 1;
|
|
2010
|
+
if (depth === 0) {
|
|
2011
|
+
const body = source.slice(opening, index + 1);
|
|
2012
|
+
if (body.includes('...')
|
|
2013
|
+
|| /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]+\})/u.test(body)
|
|
2014
|
+
|| /(?:^|[,\s{])(?:get|set)\s+\w+\s*\(/u.test(body)
|
|
2015
|
+
|| /(?:^|[,\s{])(?:(?:get|set)\s+)?\[[^\]]+\]\s*(?::|\()/u.test(body)) {
|
|
2016
|
+
throw new Error('Existing Capacitor config server field has dynamic properties.');
|
|
2017
|
+
}
|
|
2018
|
+
const bodyCode = code.slice(opening, index + 1);
|
|
2019
|
+
if (/(?:^|[,\s{])["']?url["']?\s*:/u.test(body)
|
|
2020
|
+
|| /(?:^|[,\s{])url\s*(?:,|\})/u.test(bodyCode)) {
|
|
2021
|
+
throw new Error('Existing Capacitor config server.url is unsupported.');
|
|
2022
|
+
}
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
throw new Error('Existing Capacitor config server field is incomplete.');
|
|
2028
|
+
}
|
|
2029
|
+
function maskNestedConfig(source, code, opening) {
|
|
2030
|
+
const output = source.split('').map((character) => character === '\n' ? '\n' : ' ');
|
|
2031
|
+
let depth = 0;
|
|
2032
|
+
for (let index = opening; index < code.length; index += 1) {
|
|
2033
|
+
const character = code[index];
|
|
2034
|
+
if (character === '{') {
|
|
2035
|
+
depth += 1;
|
|
2036
|
+
if (depth === 1) {
|
|
2037
|
+
output[index] = source[index] ?? ' ';
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
else if (character === '}') {
|
|
2041
|
+
if (depth === 1) {
|
|
2042
|
+
output[index] = source[index] ?? ' ';
|
|
2043
|
+
}
|
|
2044
|
+
depth -= 1;
|
|
2045
|
+
if (depth === 0) {
|
|
2046
|
+
return output.join('');
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
else if (depth === 1) {
|
|
2050
|
+
output[index] = source[index] ?? ' ';
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
throw new Error('Existing Capacitor config object is incomplete.');
|
|
2054
|
+
}
|
|
2055
|
+
function appendEnvLine(current, line) {
|
|
2056
|
+
const separator = current !== '' && !current.endsWith('\n') ? '\n' : '';
|
|
2057
|
+
return `${current}${separator}${line}\n`;
|
|
2058
|
+
}
|
|
2059
|
+
function relativeGameFile(gameRoot, file) {
|
|
2060
|
+
const canonical = realpathSync(path.resolve(gameRoot, file));
|
|
2061
|
+
const relative = path.relative(gameRoot, canonical);
|
|
2062
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
2063
|
+
throw new Error('Icon source must be a game-owned file.');
|
|
2064
|
+
}
|
|
2065
|
+
if (!lstatSync(canonical).isFile()) {
|
|
2066
|
+
throw new Error('Icon source must be a file.');
|
|
2067
|
+
}
|
|
2068
|
+
return relative.split(path.sep).join('/');
|
|
2069
|
+
}
|
|
2070
|
+
function readExisting(file) {
|
|
2071
|
+
if (!existsSync(file)) {
|
|
2072
|
+
return undefined;
|
|
2073
|
+
}
|
|
2074
|
+
assertNotSymlink(file);
|
|
2075
|
+
return readFileSync(file, 'utf8');
|
|
2076
|
+
}
|
|
2077
|
+
function assertNotSymlink(file) {
|
|
2078
|
+
try {
|
|
2079
|
+
if (lstatSync(file).isSymbolicLink()) {
|
|
2080
|
+
throw new Error(`Refusing to write through a symbolic link: ${file}`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
catch (error) {
|
|
2084
|
+
if (error.code !== 'ENOENT') {
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
function resolveInstalledPluginVersion(gameRoot, requirement) {
|
|
2090
|
+
const adapterRoot = path.dirname(realpathSync(path.join(gameRoot, 'node_modules/@mpgd/adapter-capacitor/package.json')));
|
|
2091
|
+
const installedPackage = path.join(adapterRoot, 'node_modules/@mpgd/capacitor-game-services/package.json');
|
|
2092
|
+
const fallbackPackage = path.join(gameRoot, 'node_modules/@mpgd/capacitor-game-services/package.json');
|
|
2093
|
+
const installedFile = existsSync(installedPackage) ? installedPackage : fallbackPackage;
|
|
2094
|
+
const installed = existsSync(installedFile)
|
|
2095
|
+
? requireString(readJsonObject(installedFile).version, 'installed game-services plugin version')
|
|
2096
|
+
: undefined;
|
|
2097
|
+
const version = installed ?? requirement;
|
|
2098
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
|
|
2099
|
+
throw new Error('Install a concrete Capacitor game-services plugin before initializing the shell.');
|
|
2100
|
+
}
|
|
2101
|
+
return version;
|
|
2102
|
+
}
|
|
2103
|
+
function readJsonObject(file) {
|
|
2104
|
+
return parseJsonObject(readFileSync(file, 'utf8'), file);
|
|
2105
|
+
}
|
|
2106
|
+
function parseJsonObject(source, label) {
|
|
2107
|
+
let parsed;
|
|
2108
|
+
try {
|
|
2109
|
+
parsed = JSON.parse(source);
|
|
2110
|
+
}
|
|
2111
|
+
catch {
|
|
2112
|
+
throw new Error(`${label} must contain valid JSON.`);
|
|
2113
|
+
}
|
|
2114
|
+
return requireObject(parsed, label);
|
|
2115
|
+
}
|
|
2116
|
+
function requireObject(value, label) {
|
|
2117
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2118
|
+
throw new Error(`${label} must be an object.`);
|
|
2119
|
+
}
|
|
2120
|
+
return value;
|
|
2121
|
+
}
|
|
2122
|
+
function requireString(value, label) {
|
|
2123
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
2124
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
2125
|
+
}
|
|
2126
|
+
return value;
|
|
2127
|
+
}
|
|
2128
|
+
function json(value) {
|
|
2129
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
2130
|
+
}
|