@mpgd/cli 0.56.0 → 0.58.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 +30 -0
- package/dist/capacitor-shell-starter.js +243 -11
- package/dist/index.js +170 -11
- package/dist/legal-site-output.d.ts +18 -0
- package/dist/legal-site-output.js +46 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +3935 -1308
- package/dist/native-deploy-run.js +4 -0
- package/dist/native-shell-store-policy.d.ts +123 -0
- package/dist/native-shell-store-policy.js +816 -0
- package/dist/notifications-starter.js +26 -3
- package/dist/release-state.d.ts +2 -0
- package/dist/release-state.js +4 -2
- package/dist/testflight-submission.d.ts +9 -0
- package/dist/testflight-submission.js +69 -0
- package/package.json +16 -16
- package/templates/lite-overlay/agent/acceptance.md +1 -1
- package/templates/lite-overlay/agent/brief.md +1 -1
- package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
- package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
- package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +28 -6
- package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
- package/templates/notifications-overlay/common/notifications/topics.json +3 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +111 -2
- package/templates/notifications-overlay/common/src/platform/notifications.ts +36 -4
- package/templates/phaser-game/README.md +72 -0
- package/templates/phaser-game/agent/acceptance.md +4 -1
- package/templates/phaser-game/agent/brief.md +14 -4
- package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +37 -0
- package/templates/phaser-game/gitignore +11 -0
- package/templates/phaser-game/legal/delete-account.html +133 -0
- package/templates/phaser-game/legal/privacy.html +111 -12
- package/templates/phaser-game/legal/support.html +1 -0
- package/templates/phaser-game/legal/terms.html +1 -0
- package/templates/phaser-game/mpgd.game.json +3 -0
- package/templates/phaser-game/src/env.d.ts +6 -0
- package/templates/phaser-game/src/i18n/messages.ts +86 -1
- package/templates/phaser-game/src/main.ts +69 -8
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
- package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
- package/templates/phaser-game/src/platform/gameServices.ts +55 -10
- package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
- package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
- package/templates/phaser-game/src/platform/serverSession.ts +8 -0
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
- package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
- package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store-facing settings of the game-owned Capacitor shell: the iOS
|
|
3
|
+
* Info.plist keys App Store Connect reads (export compliance, device
|
|
4
|
+
* capabilities). The starter writes them into a newly generated project;
|
|
5
|
+
* a production build only reports what an existing shell still lacks, so
|
|
6
|
+
* game-owned native files are never rewritten behind the game's back.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Adds the iOS keys every kit shell should ship with, keeping any value the
|
|
10
|
+
* game already set:
|
|
11
|
+
* - `ITSAppUsesNonExemptEncryption` = false. The kit itself only uses HTTPS
|
|
12
|
+
* through the OS and OS crypto, which Apple's export rules exempt, so
|
|
13
|
+
* App Store Connect does not ask the export-compliance question per build.
|
|
14
|
+
* A game that adds its own encryption sets the key to true and answers it.
|
|
15
|
+
* - `UIRequiredDeviceCapabilities` lists `arm64` instead of the 32-bit
|
|
16
|
+
* `armv7` left over from the Capacitor template.
|
|
17
|
+
*/
|
|
18
|
+
export function applyIosInfoPlistStorePolicy(plist) {
|
|
19
|
+
let next = plist;
|
|
20
|
+
if (lacksExportComplianceKey(next)) {
|
|
21
|
+
next = insertPlistTopLevelEntry(next, 'ITSAppUsesNonExemptEncryption', '<false/>');
|
|
22
|
+
}
|
|
23
|
+
const capabilities = readPlistTopLevelValue(next, 'UIRequiredDeviceCapabilities');
|
|
24
|
+
if (capabilities !== undefined && requires32BitArm(capabilities.xml)) {
|
|
25
|
+
const replaced = /<string>\s*arm64\s*<\/string>/u.test(capabilities.xml)
|
|
26
|
+
? capabilities.xml.replace(/\s*<string>\s*armv7\s*<\/string>/gu, '')
|
|
27
|
+
: capabilities.xml
|
|
28
|
+
.replace(/<string>\s*armv7\s*<\/string>/u, '<string>arm64</string>')
|
|
29
|
+
.replace(/\s*<string>\s*armv7\s*<\/string>/gu, '');
|
|
30
|
+
next = `${next.slice(0, capabilities.start)}${replaced}${next.slice(capabilities.end)}`;
|
|
31
|
+
}
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
34
|
+
/** What a shell's release Info.plist still lacks compared with `applyIosInfoPlistStorePolicy`. */
|
|
35
|
+
export function inspectIosInfoPlistStorePolicy(plist) {
|
|
36
|
+
const issues = [];
|
|
37
|
+
if (lacksExportComplianceKey(plist)) {
|
|
38
|
+
issues.push({
|
|
39
|
+
level: 'warning',
|
|
40
|
+
message: 'iOS Info.plist has no ITSAppUsesNonExemptEncryption: App Store Connect will ask '
|
|
41
|
+
+ 'the export-compliance question for every build and TestFlight holds the build until '
|
|
42
|
+
+ 'it is answered. Add <key>ITSAppUsesNonExemptEncryption</key><false/> when the game '
|
|
43
|
+
+ 'uses only HTTPS/OS encryption (see docs/TESTFLIGHT_INTERNAL_SUBMISSION.md).',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const capabilities = readPlistTopLevelValue(plist, 'UIRequiredDeviceCapabilities');
|
|
47
|
+
if (capabilities !== undefined && requires32BitArm(capabilities.xml)) {
|
|
48
|
+
issues.push({
|
|
49
|
+
level: 'warning',
|
|
50
|
+
message: 'iOS Info.plist UIRequiredDeviceCapabilities lists the 32-bit armv7; use arm64.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
function lacksExportComplianceKey(plist) {
|
|
56
|
+
return readPlistTopLevelValue(plist, 'ITSAppUsesNonExemptEncryption') === undefined;
|
|
57
|
+
}
|
|
58
|
+
function requires32BitArm(capabilitiesXml) {
|
|
59
|
+
return /<string>\s*armv7\s*<\/string>/u.test(capabilitiesXml);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The value element following `<key>name</key>` (first occurrence), or
|
|
63
|
+
* undefined. Only for keys that appear once in the document, such as the
|
|
64
|
+
* top-level Info.plist keys this module manages.
|
|
65
|
+
*/
|
|
66
|
+
export function readPlistTopLevelValue(plist, key) {
|
|
67
|
+
const masked = plist.replace(/<!--[\s\S]*?-->/gu, (comment) => comment.replace(/[^\n]/gu, ' '));
|
|
68
|
+
const marker = new RegExp(`<key>\\s*${escapeRegExp(key)}\\s*</key>\\s*`, 'u').exec(masked);
|
|
69
|
+
if (marker === null) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const start = marker.index + marker[0].length;
|
|
73
|
+
const open = /^<([A-Za-z]+)\s*(\/?)>/u.exec(masked.slice(start));
|
|
74
|
+
if (open === null) {
|
|
75
|
+
throw new Error(`Info.plist key ${key} has no readable value.`);
|
|
76
|
+
}
|
|
77
|
+
const tag = open[1] ?? '';
|
|
78
|
+
if (open[2] === '/') {
|
|
79
|
+
const end = start + open[0].length;
|
|
80
|
+
return { start, end, xml: plist.slice(start, end) };
|
|
81
|
+
}
|
|
82
|
+
let depth = 0;
|
|
83
|
+
const tags = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'gu');
|
|
84
|
+
tags.lastIndex = start;
|
|
85
|
+
for (let match = tags.exec(masked); match !== null; match = tags.exec(masked)) {
|
|
86
|
+
if (match[2] === '/') {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
depth += match[1] === '/' ? -1 : 1;
|
|
90
|
+
if (depth === 0) {
|
|
91
|
+
const end = match.index + match[0].length;
|
|
92
|
+
return { start, end, xml: plist.slice(start, end) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Info.plist key ${key} has an unclosed value.`);
|
|
96
|
+
}
|
|
97
|
+
/** Replaces the value of `key`, or appends the entry to the root dictionary. */
|
|
98
|
+
export function setPlistTopLevelValue(plist, key, valueXml) {
|
|
99
|
+
const existing = readPlistTopLevelValue(plist, key);
|
|
100
|
+
if (existing !== undefined) {
|
|
101
|
+
return `${plist.slice(0, existing.start)}${valueXml}${plist.slice(existing.end)}`;
|
|
102
|
+
}
|
|
103
|
+
return insertPlistTopLevelEntry(plist, key, valueXml);
|
|
104
|
+
}
|
|
105
|
+
function insertPlistTopLevelEntry(plist, key, valueXml) {
|
|
106
|
+
const close = /<\/dict>\s*<\/plist>\s*$/u.exec(plist);
|
|
107
|
+
if (close === null) {
|
|
108
|
+
throw new Error('Info.plist must end with its root </dict></plist>.');
|
|
109
|
+
}
|
|
110
|
+
const newline = plist.includes('\r\n') ? '\r\n' : '\n';
|
|
111
|
+
const head = plist.slice(0, close.index).replace(/[ \t]*$/u, '');
|
|
112
|
+
const separator = head.endsWith('\n') ? '' : newline;
|
|
113
|
+
return `${head}${separator}\t<key>${key}</key>${newline}\t${valueXml}${newline}${plist.slice(close.index)}`;
|
|
114
|
+
}
|
|
115
|
+
function escapeRegExp(value) {
|
|
116
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
117
|
+
}
|
|
118
|
+
const nativeShellOrientations = ['portrait', 'landscape', 'any'];
|
|
119
|
+
/**
|
|
120
|
+
* Reads `native.orientation` from a parsed `mpgd.game.json`; undefined when
|
|
121
|
+
* the game has not chosen one, in which case nothing is enforced.
|
|
122
|
+
*/
|
|
123
|
+
export function readGameNativeOrientation(gameConfig) {
|
|
124
|
+
if (!isRecord(gameConfig) || gameConfig.native === undefined) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (!isRecord(gameConfig.native)) {
|
|
128
|
+
throw new Error('mpgd.game.json native must be an object when present.');
|
|
129
|
+
}
|
|
130
|
+
for (const key of Object.keys(gameConfig.native)) {
|
|
131
|
+
if (key !== 'orientation') {
|
|
132
|
+
throw new Error(`mpgd.game.json native.${key} is not supported.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const orientation = gameConfig.native.orientation;
|
|
136
|
+
if (orientation === undefined) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
if (!nativeShellOrientations.includes(orientation)) {
|
|
140
|
+
throw new Error("mpgd.game.json native.orientation must be 'portrait', 'landscape' or 'any'.");
|
|
141
|
+
}
|
|
142
|
+
return orientation;
|
|
143
|
+
}
|
|
144
|
+
/** Reads `metadata.iosDeviceFamily` of the ios target; undefined when not chosen. */
|
|
145
|
+
export function readIosDeviceFamily(metadata) {
|
|
146
|
+
if (!isRecord(metadata) || metadata.iosDeviceFamily === undefined) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (metadata.iosDeviceFamily !== 'universal' && metadata.iosDeviceFamily !== 'iphone') {
|
|
150
|
+
throw new Error("ios metadata.iosDeviceFamily must be 'universal' or 'iphone'.");
|
|
151
|
+
}
|
|
152
|
+
return metadata.iosDeviceFamily;
|
|
153
|
+
}
|
|
154
|
+
const iosPortrait = 'UIInterfaceOrientationPortrait';
|
|
155
|
+
const iosPortraitUpsideDown = 'UIInterfaceOrientationPortraitUpsideDown';
|
|
156
|
+
const iosLandscape = [
|
|
157
|
+
'UIInterfaceOrientationLandscapeLeft',
|
|
158
|
+
'UIInterfaceOrientationLandscapeRight',
|
|
159
|
+
];
|
|
160
|
+
/** iPhone orientations for a design orientation (upside-down portrait stays off, as in Xcode's template). */
|
|
161
|
+
export function iosIphoneOrientations(orientation) {
|
|
162
|
+
if (orientation === 'portrait') {
|
|
163
|
+
return [iosPortrait];
|
|
164
|
+
}
|
|
165
|
+
if (orientation === 'landscape') {
|
|
166
|
+
return iosLandscape;
|
|
167
|
+
}
|
|
168
|
+
return [iosPortrait, ...iosLandscape];
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* iPad always lists all four orientations. Apple deprecated
|
|
172
|
+
* `UIRequiresFullScreen` in iPadOS 26 and asks apps to support every
|
|
173
|
+
* orientation (TN3192); iPadOS keeps a game that prefers one orientation at a
|
|
174
|
+
* consistent scene size, and the kit's runtime orientation policy letterboxes
|
|
175
|
+
* or asks the player to rotate.
|
|
176
|
+
*/
|
|
177
|
+
export const iosIpadOrientations = [
|
|
178
|
+
iosPortrait,
|
|
179
|
+
iosPortraitUpsideDown,
|
|
180
|
+
...iosLandscape,
|
|
181
|
+
];
|
|
182
|
+
/** Writes the iPhone and iPad orientation lists for the game's design orientation. */
|
|
183
|
+
export function applyIosInfoPlistOrientation(plist, orientation) {
|
|
184
|
+
const array = (values) => `<array>${values.map((value) => `\n\t\t<string>${value}</string>`).join('')}\n\t</array>`;
|
|
185
|
+
return setPlistTopLevelValue(setPlistTopLevelValue(plist, 'UISupportedInterfaceOrientations', array(iosIphoneOrientations(orientation))), 'UISupportedInterfaceOrientations~ipad', array(iosIpadOrientations));
|
|
186
|
+
}
|
|
187
|
+
function readPlistStrings(plist, key) {
|
|
188
|
+
const value = readPlistTopLevelValue(plist, key);
|
|
189
|
+
if (value === undefined) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
return [...value.xml.matchAll(/<string>\s*([^<]*?)\s*<\/string>/gu)].map((match) => match[1] ?? '');
|
|
193
|
+
}
|
|
194
|
+
export function inspectIosInfoPlistOrientation(plist, orientation, deviceFamily) {
|
|
195
|
+
const issues = [];
|
|
196
|
+
if (orientation !== undefined) {
|
|
197
|
+
// A missing key means every orientation to iOS.
|
|
198
|
+
const listed = readPlistStrings(plist, 'UISupportedInterfaceOrientations');
|
|
199
|
+
const iphone = listed ?? iosIpadOrientations;
|
|
200
|
+
const portrait = iphone.some((value) => value === iosPortrait || value === iosPortraitUpsideDown);
|
|
201
|
+
const landscape = iphone.some((value) => iosLandscape.includes(value));
|
|
202
|
+
const matchesByOrientation = {
|
|
203
|
+
portrait: portrait && !landscape,
|
|
204
|
+
landscape: landscape && !portrait,
|
|
205
|
+
any: portrait && landscape,
|
|
206
|
+
};
|
|
207
|
+
const matches = matchesByOrientation[orientation];
|
|
208
|
+
if (!matches) {
|
|
209
|
+
issues.push({
|
|
210
|
+
level: 'error',
|
|
211
|
+
message: `iOS Info.plist UISupportedInterfaceOrientations (${listed === undefined ? 'key missing, so every orientation' : iphone.join(', ') || 'none'}) `
|
|
212
|
+
+ `does not match mpgd.game.json native.orientation "${orientation}".`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (deviceFamily === 'universal') {
|
|
217
|
+
// Without the ~ipad key iPad uses the base list; without either, every orientation.
|
|
218
|
+
const ipad = readPlistStrings(plist, 'UISupportedInterfaceOrientations~ipad')
|
|
219
|
+
?? readPlistStrings(plist, 'UISupportedInterfaceOrientations')
|
|
220
|
+
?? iosIpadOrientations;
|
|
221
|
+
if (iosIpadOrientations.some((value) => !ipad.includes(value))) {
|
|
222
|
+
issues.push({
|
|
223
|
+
level: 'warning',
|
|
224
|
+
message: 'iOS Info.plist iPad orientations (UISupportedInterfaceOrientations~ipad, or '
|
|
225
|
+
+ 'UISupportedInterfaceOrientations when that key is absent) do not list all four; '
|
|
226
|
+
+ 'Apple asks iPad apps to support every orientation (TN3192) and deprecated '
|
|
227
|
+
+ 'UIRequiresFullScreen in iPadOS 26.',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return issues;
|
|
232
|
+
}
|
|
233
|
+
/** `TARGETED_DEVICE_FAMILY` value: 1 = iPhone, 2 = iPad. */
|
|
234
|
+
export function iosTargetedDeviceFamily(family) {
|
|
235
|
+
return family === 'iphone' ? '1' : '1,2';
|
|
236
|
+
}
|
|
237
|
+
/** Sets `TARGETED_DEVICE_FAMILY` in every build configuration of the App target. */
|
|
238
|
+
export function applyIosProjectDeviceFamily(project, family) {
|
|
239
|
+
const value = `"${iosTargetedDeviceFamily(family)}"`;
|
|
240
|
+
let next = project;
|
|
241
|
+
for (const id of readAppTargetConfigurationIds(project)) {
|
|
242
|
+
const settings = locatePbxBuildSettings(next, id);
|
|
243
|
+
const body = next.slice(settings.start, settings.end);
|
|
244
|
+
const assignment = /(\bTARGETED_DEVICE_FAMILY\s*=\s*)[^;]+;/u;
|
|
245
|
+
const updated = assignment.test(body)
|
|
246
|
+
? body.replace(assignment, `$1${value};`)
|
|
247
|
+
: `\n\t\t\t\tTARGETED_DEVICE_FAMILY = ${value};${body}`;
|
|
248
|
+
next = `${next.slice(0, settings.start)}${updated}${next.slice(settings.end)}`;
|
|
249
|
+
}
|
|
250
|
+
return next;
|
|
251
|
+
}
|
|
252
|
+
/** The App target's Release `TARGETED_DEVICE_FAMILY`, without quotes, or undefined. */
|
|
253
|
+
export function readIosAppReleaseDeviceFamily(project) {
|
|
254
|
+
for (const id of readAppTargetConfigurationIds(project)) {
|
|
255
|
+
const settings = locatePbxBuildSettings(project, id);
|
|
256
|
+
if (settings.name !== 'Release') {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const value = /\bTARGETED_DEVICE_FAMILY\s*=\s*([^;]+);/u.exec(project.slice(settings.start, settings.end))?.[1];
|
|
260
|
+
return value?.trim().replace(/^"(.*)"$/u, '$1').replace(/\s+/gu, '');
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
export function inspectIosProjectDeviceFamily(project, family) {
|
|
265
|
+
if (family === undefined) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const actual = readIosAppReleaseDeviceFamily(project);
|
|
269
|
+
const expected = iosTargetedDeviceFamily(family);
|
|
270
|
+
if (actual === expected) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
return [{
|
|
274
|
+
level: 'error',
|
|
275
|
+
message: `iOS App target Release TARGETED_DEVICE_FAMILY is ${actual ?? 'unset'}, but `
|
|
276
|
+
+ `mpgd.targets.json ios metadata.iosDeviceFamily "${family}" needs "${expected}".`,
|
|
277
|
+
}];
|
|
278
|
+
}
|
|
279
|
+
const androidOrientationValues = {
|
|
280
|
+
portrait: ['portrait', 'sensorPortrait', 'userPortrait', 'reversePortrait'],
|
|
281
|
+
landscape: ['landscape', 'sensorLandscape', 'userLandscape', 'reverseLandscape'],
|
|
282
|
+
any: ['unspecified', 'user', 'sensor', 'fullSensor', 'fullUser'],
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Sets the launcher activity's `android:screenOrientation` (`portrait`,
|
|
286
|
+
* `sensorLandscape`, or none for `any`) and marks the application
|
|
287
|
+
* `android:appCategory="game"`. Android 16 ignores orientation restrictions
|
|
288
|
+
* on large screens for apps targeting API 36 unless the app is a game.
|
|
289
|
+
*/
|
|
290
|
+
export function applyAndroidManifestOrientation(manifest, orientation) {
|
|
291
|
+
const activity = findAndroidLauncherActivity(manifest);
|
|
292
|
+
if (activity === undefined) {
|
|
293
|
+
throw new Error('Android manifest has no launcher activity to orient.');
|
|
294
|
+
}
|
|
295
|
+
let tag = removeXmlAttribute(activity.tag, 'android:screenOrientation');
|
|
296
|
+
if (orientation !== 'any') {
|
|
297
|
+
const value = orientation === 'portrait' ? 'portrait' : 'sensorLandscape';
|
|
298
|
+
tag = addXmlAttribute(tag, 'android:screenOrientation', value);
|
|
299
|
+
}
|
|
300
|
+
let next = `${manifest.slice(0, activity.start)}${tag}${manifest.slice(activity.start + activity.tag.length)}`;
|
|
301
|
+
const application = /<application\b[^>]*?>/u.exec(maskXmlComments(next));
|
|
302
|
+
if (application !== null) {
|
|
303
|
+
const applicationTag = next.slice(application.index, application.index + application[0].length);
|
|
304
|
+
if (!/\sandroid:appCategory\s*=/u.test(applicationTag)) {
|
|
305
|
+
next = `${next.slice(0, application.index)}${addXmlAttribute(applicationTag, 'android:appCategory', 'game')}${next.slice(application.index + applicationTag.length)}`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return next;
|
|
309
|
+
}
|
|
310
|
+
export function inspectAndroidManifestOrientation(manifest, orientation) {
|
|
311
|
+
if (orientation === undefined) {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
const activity = findAndroidLauncherActivity(manifest);
|
|
315
|
+
if (activity === undefined) {
|
|
316
|
+
return [{ level: 'error', message: 'Android manifest has no launcher activity.' }];
|
|
317
|
+
}
|
|
318
|
+
const value = readXmlAttribute(activity.tag, 'android:screenOrientation') ?? 'unspecified';
|
|
319
|
+
const issues = [];
|
|
320
|
+
if (!androidOrientationValues[orientation].includes(value)) {
|
|
321
|
+
issues.push({
|
|
322
|
+
level: 'error',
|
|
323
|
+
message: `Android launcher activity android:screenOrientation="${value}" does not match `
|
|
324
|
+
+ `mpgd.game.json native.orientation "${orientation}".`,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const application = /<application\b[^>]*?>/u.exec(maskXmlComments(manifest))?.[0] ?? '';
|
|
328
|
+
if (orientation !== 'any' && readXmlAttribute(application, 'android:appCategory') !== 'game') {
|
|
329
|
+
issues.push({
|
|
330
|
+
level: 'warning',
|
|
331
|
+
message: 'Android <application> has no android:appCategory="game": Android 16 ignores '
|
|
332
|
+
+ 'the orientation lock on tablets and foldables for apps targeting API 36 unless '
|
|
333
|
+
+ 'they are games.',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return issues;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The activity the launcher opens: one whose own intent filter has the
|
|
340
|
+
* LAUNCHER category, or the `android:targetActivity` of a launcher
|
|
341
|
+
* `<activity-alias>` (an arrangement the shell initializer accepts).
|
|
342
|
+
*/
|
|
343
|
+
function findAndroidLauncherActivity(manifest) {
|
|
344
|
+
const elements = readAndroidActivityElements(manifest, 'activity');
|
|
345
|
+
const direct = elements.find((element) => element.launcher);
|
|
346
|
+
if (direct !== undefined) {
|
|
347
|
+
return { start: direct.start, tag: direct.tag };
|
|
348
|
+
}
|
|
349
|
+
const alias = readAndroidActivityElements(manifest, 'activity-alias')
|
|
350
|
+
.find((element) => element.launcher);
|
|
351
|
+
const target = alias === undefined
|
|
352
|
+
? undefined
|
|
353
|
+
: readXmlAttribute(alias.tag, 'android:targetActivity');
|
|
354
|
+
if (target === undefined) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
// Exact name, or a relative `.Main` against a fully qualified `pkg.Main`.
|
|
358
|
+
const sameClass = (name) => name === target
|
|
359
|
+
|| (target.startsWith('.') && name.endsWith(target))
|
|
360
|
+
|| (name.startsWith('.') && target.endsWith(name))
|
|
361
|
+
// A dotless name is relative to the namespace too (`MainActivity`).
|
|
362
|
+
|| (!name.includes('.') && name !== '' && target.endsWith(`.${name}`))
|
|
363
|
+
|| (!target.includes('.') && name.endsWith(`.${target}`));
|
|
364
|
+
const match = elements.filter((element) => sameClass(readXmlAttribute(element.tag, 'android:name') ?? ''));
|
|
365
|
+
return match.length === 1 && match[0] !== undefined
|
|
366
|
+
? { start: match[0].start, tag: match[0].tag }
|
|
367
|
+
: undefined;
|
|
368
|
+
}
|
|
369
|
+
function readAndroidActivityElements(manifest, element) {
|
|
370
|
+
const masked = maskXmlComments(manifest);
|
|
371
|
+
// The element name followed by whitespace, `/` or `>`, so `activity` never matches `activity-alias`.
|
|
372
|
+
const openings = new RegExp(`<${element}(?=[\\s/>])(?:"[^"]*"|'[^']*'|[^>"'])*?(\\/?)>`, 'gu');
|
|
373
|
+
const found = [];
|
|
374
|
+
for (const opening of masked.matchAll(openings)) {
|
|
375
|
+
const start = opening.index ?? 0;
|
|
376
|
+
const tagEnd = start + opening[0].length;
|
|
377
|
+
const tag = manifest.slice(start, tagEnd);
|
|
378
|
+
if (opening[1] === '/') {
|
|
379
|
+
found.push({ start, tag, launcher: false });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const close = masked.indexOf(`</${element}>`, tagEnd);
|
|
383
|
+
const body = close === -1 ? '' : masked.slice(tagEnd, close);
|
|
384
|
+
found.push({ start, tag, launcher: /android\.intent\.category\.LAUNCHER/u.test(body) });
|
|
385
|
+
}
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
function maskXmlComments(xml) {
|
|
389
|
+
return xml.replace(/<!--[\s\S]*?-->/gu, (comment) => comment.replace(/[^\n]/gu, ' '));
|
|
390
|
+
}
|
|
391
|
+
function xmlAttribute(name) {
|
|
392
|
+
return new RegExp(`(\\s+)${escapeRegExp(name)}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'u');
|
|
393
|
+
}
|
|
394
|
+
function readXmlAttribute(tag, name) {
|
|
395
|
+
const match = xmlAttribute(name).exec(tag);
|
|
396
|
+
return match === null ? undefined : (match[2] ?? match[3]);
|
|
397
|
+
}
|
|
398
|
+
function removeXmlAttribute(tag, name) {
|
|
399
|
+
return tag.replace(xmlAttribute(name), '');
|
|
400
|
+
}
|
|
401
|
+
/** Appends an attribute before the tag closes, reusing the last attribute's separator. */
|
|
402
|
+
function addXmlAttribute(tag, name, value) {
|
|
403
|
+
const close = /\s*\/?>$/u.exec(tag);
|
|
404
|
+
const end = close?.index ?? tag.length;
|
|
405
|
+
const separators = [...tag.slice(0, end).matchAll(/(\s+)[A-Za-z_:][\w:.-]*\s*=/gu)];
|
|
406
|
+
const separator = separators.at(-1)?.[1] ?? ' ';
|
|
407
|
+
return `${tag.slice(0, end)}${separator}${name}="${value}"${tag.slice(end)}`;
|
|
408
|
+
}
|
|
409
|
+
function readAppTargetConfigurationIds(project) {
|
|
410
|
+
const target = findPbxObject(project, (body) => /\bisa\s*=\s*PBXNativeTarget;/u.test(body) && /\bname\s*=\s*"?App"?\s*;/u.test(body));
|
|
411
|
+
const listId = /\bbuildConfigurationList\s*=\s*([A-F0-9]+)\b/u.exec(target?.text ?? '')?.[1];
|
|
412
|
+
const list = listId === undefined ? undefined : findPbxObjectById(project, listId);
|
|
413
|
+
const ids = list === undefined
|
|
414
|
+
? undefined
|
|
415
|
+
: readPbxList(project, list, 'buildConfigurations')?.ids;
|
|
416
|
+
if (ids === undefined) {
|
|
417
|
+
throw new Error('Xcode project has no App target build configuration list.');
|
|
418
|
+
}
|
|
419
|
+
return ids;
|
|
420
|
+
}
|
|
421
|
+
function locatePbxBuildSettings(project, id) {
|
|
422
|
+
const marker = new RegExp(`\\b${id}\\s*(?:\\/\\*[^*]*\\*\\/)?\\s*=\\s*\\{`, 'u').exec(project);
|
|
423
|
+
if (marker === null) {
|
|
424
|
+
throw new Error(`Xcode build configuration ${id} is missing.`);
|
|
425
|
+
}
|
|
426
|
+
const object = readBraced(project, project.indexOf('{', marker.index));
|
|
427
|
+
const name = /\bname\s*=\s*"?([^";]+)"?\s*;/u.exec(object.text.replace(/\bbuildSettings\s*=\s*\{[\s\S]*\}\s*;/u, ''))?.[1] ?? '';
|
|
428
|
+
const settingsMarker = /\bbuildSettings\s*=\s*\{/u.exec(object.text);
|
|
429
|
+
if (settingsMarker === null) {
|
|
430
|
+
throw new Error(`Xcode build configuration ${id} has no build settings.`);
|
|
431
|
+
}
|
|
432
|
+
const settings = readBraced(project, object.start + settingsMarker.index + settingsMarker[0].length - 1);
|
|
433
|
+
return { name: name.trim(), start: settings.start, end: settings.end };
|
|
434
|
+
}
|
|
435
|
+
/** The text between a `{` and its matching `}` (exclusive), skipping quoted strings and comments. */
|
|
436
|
+
function readBraced(source, open) {
|
|
437
|
+
let depth = 0;
|
|
438
|
+
let quote = false;
|
|
439
|
+
for (let index = open; index < source.length; index += 1) {
|
|
440
|
+
const character = source[index];
|
|
441
|
+
if (quote) {
|
|
442
|
+
if (character === '\\') {
|
|
443
|
+
index += 1;
|
|
444
|
+
}
|
|
445
|
+
else if (character === '"') {
|
|
446
|
+
quote = false;
|
|
447
|
+
}
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (character === '/' && source[index + 1] === '*') {
|
|
451
|
+
const close = source.indexOf('*/', index + 2);
|
|
452
|
+
index = close === -1 ? source.length : close + 1;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (character === '"') {
|
|
456
|
+
quote = true;
|
|
457
|
+
}
|
|
458
|
+
else if (character === '{') {
|
|
459
|
+
depth += 1;
|
|
460
|
+
}
|
|
461
|
+
else if (character === '}') {
|
|
462
|
+
depth -= 1;
|
|
463
|
+
if (depth === 0) {
|
|
464
|
+
return { text: source.slice(open + 1, index), start: open + 1, end: index };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
throw new Error('Xcode project has an unclosed object.');
|
|
469
|
+
}
|
|
470
|
+
export function isRecord(value) {
|
|
471
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
472
|
+
}
|
|
473
|
+
// App privacy manifest.
|
|
474
|
+
export const iosAppPrivacyManifestFile = 'PrivacyInfo.xcprivacy';
|
|
475
|
+
/**
|
|
476
|
+
* App-level privacy manifest of a kit shell. The App target's own code
|
|
477
|
+
* (AppDelegate, SceneDelegate) calls no required-reason API and the kit does
|
|
478
|
+
* no tracking; Capacitor core and the kit's game-services plugin ship their
|
|
479
|
+
* own manifests (the plugin declares UserDefaults CA92.1). The collected
|
|
480
|
+
* data array is empty only because the kit-owned shell code collects
|
|
481
|
+
* nothing: a game adds the required-reason APIs its own native code uses
|
|
482
|
+
* and an entry for each data type it collects
|
|
483
|
+
* (docs/STORE_PRIVACY_DISCLOSURES.md).
|
|
484
|
+
*/
|
|
485
|
+
export const iosAppPrivacyManifestXml = [
|
|
486
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
487
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
488
|
+
'<plist version="1.0">',
|
|
489
|
+
'<dict>',
|
|
490
|
+
'\t<key>NSPrivacyTracking</key>',
|
|
491
|
+
'\t<false/>',
|
|
492
|
+
'\t<key>NSPrivacyTrackingDomains</key>',
|
|
493
|
+
'\t<array/>',
|
|
494
|
+
'\t<key>NSPrivacyCollectedDataTypes</key>',
|
|
495
|
+
'\t<array/>',
|
|
496
|
+
'\t<key>NSPrivacyAccessedAPITypes</key>',
|
|
497
|
+
'\t<array/>',
|
|
498
|
+
'</dict>',
|
|
499
|
+
'</plist>',
|
|
500
|
+
'',
|
|
501
|
+
].join('\n');
|
|
502
|
+
/**
|
|
503
|
+
* Adds a file in the App group to the App target's Resources phase of a
|
|
504
|
+
* Capacitor-generated project. Returns undefined when the project does not
|
|
505
|
+
* have that layout (an App group with `path = App`, a Resources phase, the
|
|
506
|
+
* PBXBuildFile/PBXFileReference section markers), so the caller can leave the
|
|
507
|
+
* project alone. A project whose Resources phase already bundles the file is
|
|
508
|
+
* returned unchanged; one whose App group references it without bundling it
|
|
509
|
+
* is left to the game (undefined).
|
|
510
|
+
*/
|
|
511
|
+
export function addIosAppResource(project, fileName) {
|
|
512
|
+
const resourcesPhase = findAppResourcesPhase(project);
|
|
513
|
+
const group = findAppGroup(project);
|
|
514
|
+
if (resourcesPhase === undefined || group === undefined
|
|
515
|
+
|| !project.includes('/* End PBXBuildFile section */')
|
|
516
|
+
|| !project.includes('/* End PBXFileReference section */')) {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
if (resourcesPhase.text.includes(`/* ${fileName} in Resources */`)) {
|
|
520
|
+
return project;
|
|
521
|
+
}
|
|
522
|
+
if (group.text.includes(`/* ${fileName} */`)) {
|
|
523
|
+
// Referenced but not bundled: a game-owned arrangement this helper leaves alone.
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
const fileId = uniquePbxId(project, `${fileName}:file`);
|
|
527
|
+
const buildId = uniquePbxId(project, `${fileName}:build`);
|
|
528
|
+
const edits = [
|
|
529
|
+
{
|
|
530
|
+
at: project.indexOf('/* End PBXBuildFile section */'),
|
|
531
|
+
text: `\t\t${buildId} /* ${fileName} in Resources */ = {isa = PBXBuildFile; fileRef = ${fileId} /* ${fileName} */; };\n`,
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
at: project.indexOf('/* End PBXFileReference section */'),
|
|
535
|
+
text: `\t\t${fileId} /* ${fileName} */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = ${fileName}; sourceTree = "<group>"; };\n`,
|
|
536
|
+
},
|
|
537
|
+
{ at: group.childrenEnd, text: `\t${fileId} /* ${fileName} */,\n\t\t\t` },
|
|
538
|
+
{ at: resourcesPhase.filesEnd, text: `\t${buildId} /* ${fileName} in Resources */,\n\t\t\t` },
|
|
539
|
+
].sort((left, right) => right.at - left.at);
|
|
540
|
+
let next = project;
|
|
541
|
+
for (const edit of edits) {
|
|
542
|
+
next = `${next.slice(0, edit.at)}${edit.text}${next.slice(edit.at)}`;
|
|
543
|
+
}
|
|
544
|
+
return next;
|
|
545
|
+
}
|
|
546
|
+
/** Whether the App target's Resources phase bundles `fileName`. */
|
|
547
|
+
export function iosAppBundlesResource(project, fileName) {
|
|
548
|
+
return findAppResourcesPhase(project)?.text.includes(`/* ${fileName} in Resources */`) === true;
|
|
549
|
+
}
|
|
550
|
+
export function inspectIosAppPrivacyManifest(project) {
|
|
551
|
+
if (iosAppBundlesResource(project, iosAppPrivacyManifestFile)) {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
return [{
|
|
555
|
+
level: 'warning',
|
|
556
|
+
message: `iOS App target does not bundle an app-level ${iosAppPrivacyManifestFile}; add one `
|
|
557
|
+
+ 'to the App group and its Resources phase (the kit reference shell has one) and declare '
|
|
558
|
+
+ "any required-reason API the game's own native code uses.",
|
|
559
|
+
}];
|
|
560
|
+
}
|
|
561
|
+
function findAppResourcesPhase(project) {
|
|
562
|
+
const target = findPbxObject(project, (body) => /\bisa\s*=\s*PBXNativeTarget;/u.test(body) && /\bname\s*=\s*"?App"?\s*;/u.test(body));
|
|
563
|
+
if (target === undefined) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
for (const id of readPbxList(project, target, 'buildPhases')?.ids ?? []) {
|
|
567
|
+
const phase = findPbxObjectById(project, id);
|
|
568
|
+
if (phase === undefined || !/\bisa\s*=\s*PBXResourcesBuildPhase\s*;/u.test(phase.text)) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const files = readPbxList(project, phase, 'files');
|
|
572
|
+
return files === undefined ? undefined : { text: files.text, filesEnd: files.end };
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
/** The `path = App` group listed in the project's main group, with its children list. */
|
|
577
|
+
function findAppGroup(project) {
|
|
578
|
+
const projectObject = findPbxObject(project, (body) => /\bisa\s*=\s*PBXProject\s*;/u.test(body));
|
|
579
|
+
const mainGroupId = /\bmainGroup\s*=\s*([A-F0-9]+)\b/u.exec(projectObject?.text ?? '')?.[1];
|
|
580
|
+
const mainGroup = mainGroupId === undefined ? undefined : findPbxObjectById(project, mainGroupId);
|
|
581
|
+
if (mainGroup === undefined) {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
for (const childId of readPbxList(project, mainGroup, 'children')?.ids ?? []) {
|
|
585
|
+
const child = findPbxObjectById(project, childId);
|
|
586
|
+
if (child === undefined || !/\bisa\s*=\s*PBXGroup\s*;/u.test(child.text)
|
|
587
|
+
|| !/\bpath\s*=\s*"?App"?\s*;/u.test(child.text)) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const children = readPbxList(project, child, 'children');
|
|
591
|
+
return children === undefined ? undefined : { text: children.text, childrenEnd: children.end };
|
|
592
|
+
}
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
function findPbxObject(project, accept) {
|
|
596
|
+
for (const match of project.matchAll(/\b([A-F0-9]+)\s*(?:\/\*[^*]*\*\/)?\s*=\s*\{/gu)) {
|
|
597
|
+
const body = readBraced(project, project.indexOf('{', match.index ?? 0));
|
|
598
|
+
if (accept(body.text)) {
|
|
599
|
+
return { text: body.text, start: body.start };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
function findPbxObjectById(project, id) {
|
|
605
|
+
const marker = new RegExp(`\\b${id}\\s*(?:\\/\\*[^*]*\\*\\/)?\\s*=\\s*\\{`, 'u').exec(project);
|
|
606
|
+
if (marker === null) {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
const body = readBraced(project, project.indexOf('{', marker.index));
|
|
610
|
+
return { text: body.text, start: body.start };
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* A `key = ( ... );` list inside an object: its raw text, the IDs of its
|
|
614
|
+
* entries, and the offset of its closing parenthesis, skipping comments and
|
|
615
|
+
* quoted strings (a `/* Info (1).plist *\/` comment does not end the list).
|
|
616
|
+
*/
|
|
617
|
+
function readPbxList(project, object, key) {
|
|
618
|
+
const marker = new RegExp(`\\b${key}\\s*=\\s*\\(`, 'u').exec(object.text);
|
|
619
|
+
if (marker === null) {
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
const start = object.start + marker.index + marker[0].length;
|
|
623
|
+
let quote = false;
|
|
624
|
+
for (let index = start; index < project.length; index += 1) {
|
|
625
|
+
const character = project[index];
|
|
626
|
+
if (quote) {
|
|
627
|
+
if (character === '\\') {
|
|
628
|
+
index += 1;
|
|
629
|
+
}
|
|
630
|
+
else if (character === '"') {
|
|
631
|
+
quote = false;
|
|
632
|
+
}
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (character === '/' && project[index + 1] === '*') {
|
|
636
|
+
const close = project.indexOf('*/', index + 2);
|
|
637
|
+
index = close === -1 ? project.length : close + 1;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (character === '"') {
|
|
641
|
+
quote = true;
|
|
642
|
+
}
|
|
643
|
+
else if (character === ')') {
|
|
644
|
+
const text = project.slice(start, index);
|
|
645
|
+
const ids = text.replace(/\/\*[^]*?\*\//gu, ' ').split(',')
|
|
646
|
+
.map((entry) => /^\s*([A-F0-9]+)\b/u.exec(entry)?.[1])
|
|
647
|
+
.filter((id) => id !== undefined);
|
|
648
|
+
return { text, ids, end: index };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
/** A deterministic 24-hex object ID that the project does not use yet. */
|
|
654
|
+
function uniquePbxId(project, seed) {
|
|
655
|
+
for (let attempt = 0;; attempt += 1) {
|
|
656
|
+
let hash = 0x811c9dc5;
|
|
657
|
+
const input = `mpgd:${seed}:${attempt}`;
|
|
658
|
+
let hex = '';
|
|
659
|
+
for (let round = 0; hex.length < 24; round += 1) {
|
|
660
|
+
for (const character of `${input}:${round}`) {
|
|
661
|
+
hash ^= character.charCodeAt(0);
|
|
662
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
663
|
+
}
|
|
664
|
+
hex += hash.toString(16).toUpperCase().padStart(8, '0');
|
|
665
|
+
}
|
|
666
|
+
const id = hex.slice(0, 24);
|
|
667
|
+
if (!project.includes(id)) {
|
|
668
|
+
return id;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Advertising declarations.
|
|
673
|
+
export const admobCapacitorPlugin = '@capacitor-community/admob';
|
|
674
|
+
export const androidAdIdPermission = 'com.google.android.gms.permission.AD_ID';
|
|
675
|
+
/** Whether the shell's package.json installs the AdMob Capacitor plugin. */
|
|
676
|
+
export function shellUsesAdMob(shellPackage) {
|
|
677
|
+
if (!isRecord(shellPackage)) {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
return [shellPackage.dependencies, shellPackage.devDependencies]
|
|
681
|
+
.some((dependencies) => isRecord(dependencies) && dependencies[admobCapacitorPlugin] !== undefined);
|
|
682
|
+
}
|
|
683
|
+
function adIdPermissions(manifest) {
|
|
684
|
+
const masked = maskXmlComments(manifest);
|
|
685
|
+
return [...masked.matchAll(/<uses-permission(?=[\s/>])[^>]*?\/?>(?:\s*<\/uses-permission>)?/gu)]
|
|
686
|
+
.filter((match) => readXmlAttribute(match[0], 'android:name') === androidAdIdPermission);
|
|
687
|
+
}
|
|
688
|
+
/** `declared`, `removed` (tools:node="remove"), or `absent` from the app manifest. */
|
|
689
|
+
export function readAndroidAdIdDeclaration(manifest) {
|
|
690
|
+
const permissions = adIdPermissions(manifest);
|
|
691
|
+
if (permissions.length === 0) {
|
|
692
|
+
return 'absent';
|
|
693
|
+
}
|
|
694
|
+
return permissions.some((match) => readXmlAttribute(match[0], 'tools:node') === 'remove')
|
|
695
|
+
? 'removed'
|
|
696
|
+
: 'declared';
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Declares the advertising ID permission when the game shows AdMob ads
|
|
700
|
+
* (Google Mobile Ads also merges it) and removes it from the merged manifest
|
|
701
|
+
* otherwise, so the Play Console advertising ID answer matches the binary.
|
|
702
|
+
*/
|
|
703
|
+
export function applyAndroidManifestAdId(manifest, usesAds) {
|
|
704
|
+
if (adIdPermissions(manifest).length === 1
|
|
705
|
+
&& readAndroidAdIdDeclaration(manifest) === (usesAds ? 'declared' : 'removed')) {
|
|
706
|
+
return manifest;
|
|
707
|
+
}
|
|
708
|
+
let next = manifest;
|
|
709
|
+
for (const permission of [...adIdPermissions(manifest)].reverse()) {
|
|
710
|
+
const start = permission.index ?? 0;
|
|
711
|
+
const lineStart = next.lastIndexOf('\n', start - 1) + 1;
|
|
712
|
+
const end = start + permission[0].length;
|
|
713
|
+
const lineEnd = /^[ \t]*\r?\n/u.exec(next.slice(end))?.[0].length ?? 0;
|
|
714
|
+
const whole = next.slice(lineStart, start).trim() === '' ? lineStart : start;
|
|
715
|
+
next = `${next.slice(0, whole)}${next.slice(end + (whole === lineStart ? lineEnd : 0))}`;
|
|
716
|
+
}
|
|
717
|
+
const masked = maskXmlComments(next);
|
|
718
|
+
const close = masked.lastIndexOf('</manifest>');
|
|
719
|
+
const root = /<manifest\b[^>]*?>/u.exec(masked);
|
|
720
|
+
if (close === -1 || root === null) {
|
|
721
|
+
throw new Error('Android manifest has no <manifest> root.');
|
|
722
|
+
}
|
|
723
|
+
const newline = next.includes('\r\n') ? '\r\n' : '\n';
|
|
724
|
+
const element = usesAds
|
|
725
|
+
? `<uses-permission android:name="${androidAdIdPermission}" />`
|
|
726
|
+
: `<uses-permission android:name="${androidAdIdPermission}" tools:node="remove" />`;
|
|
727
|
+
let rootTag = next.slice(root.index, root.index + root[0].length);
|
|
728
|
+
if (!usesAds && !/\sxmlns:tools\s*=/u.test(rootTag)) {
|
|
729
|
+
rootTag = addXmlAttribute(rootTag, 'xmlns:tools', 'http://schemas.android.com/tools');
|
|
730
|
+
}
|
|
731
|
+
const head = `${next.slice(0, root.index)}${rootTag}${next.slice(root.index + root[0].length, close)}`;
|
|
732
|
+
return `${head.trimEnd()}${newline} ${element}${newline}${next.slice(close)}`;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* `ads.admob.advertisingId` in `mpgd.game.json`: `removed` records a
|
|
736
|
+
* deliberate no-advertising-ID policy (for example a child-directed game)
|
|
737
|
+
* for a shell that installs AdMob; anything else expects the permission.
|
|
738
|
+
*/
|
|
739
|
+
export function readGameAdvertisingIdPolicy(gameConfig) {
|
|
740
|
+
const ads = isRecord(gameConfig) ? gameConfig.ads : undefined;
|
|
741
|
+
const admob = isRecord(ads) ? ads.admob : undefined;
|
|
742
|
+
const value = isRecord(admob) ? admob.advertisingId : undefined;
|
|
743
|
+
if (value === undefined || value === 'declared') {
|
|
744
|
+
return 'declared';
|
|
745
|
+
}
|
|
746
|
+
if (value === 'removed') {
|
|
747
|
+
return 'removed';
|
|
748
|
+
}
|
|
749
|
+
throw new Error("mpgd.game.json ads.admob.advertisingId must be 'declared' or 'removed'.");
|
|
750
|
+
}
|
|
751
|
+
export function inspectAndroidManifestAds(manifest, usesAds, advertisingIdPolicy = 'declared') {
|
|
752
|
+
const issues = [];
|
|
753
|
+
const declaration = readAndroidAdIdDeclaration(manifest);
|
|
754
|
+
if (usesAds) {
|
|
755
|
+
const hasAppId = [...maskXmlComments(manifest).matchAll(/<meta-data\b[^>]*?\/?>/gu)]
|
|
756
|
+
.some((match) => readXmlAttribute(match[0], 'android:name') === 'com.google.android.gms.ads.APPLICATION_ID');
|
|
757
|
+
if (!hasAppId) {
|
|
758
|
+
issues.push({
|
|
759
|
+
level: 'error',
|
|
760
|
+
message: 'The shell installs AdMob but neither android/app/src/main/AndroidManifest.xml '
|
|
761
|
+
+ "nor the release source set's manifest has com.google.android.gms.ads.APPLICATION_ID "
|
|
762
|
+
+ 'meta-data; the app crashes at launch.',
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
if (declaration === 'removed' && advertisingIdPolicy !== 'removed') {
|
|
766
|
+
issues.push({
|
|
767
|
+
level: 'error',
|
|
768
|
+
message: `AndroidManifest.xml removes ${androidAdIdPermission} although the shell installs `
|
|
769
|
+
+ 'AdMob: ads get a zeroed advertising ID on Android 13+ and the Play Console answer no '
|
|
770
|
+
+ 'longer matches. Replace the tools:node="remove" line with '
|
|
771
|
+
+ `<uses-permission android:name="${androidAdIdPermission}" />, or, for a deliberate `
|
|
772
|
+
+ 'no-advertising-ID policy (for example a child-directed game), set '
|
|
773
|
+
+ 'ads.admob.advertisingId to "removed" in mpgd.game.json.',
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (declaration !== 'removed' && advertisingIdPolicy === 'removed') {
|
|
777
|
+
issues.push({
|
|
778
|
+
level: 'error',
|
|
779
|
+
message: 'mpgd.game.json ads.admob.advertisingId is "removed", but AndroidManifest.xml '
|
|
780
|
+
+ `does not remove ${androidAdIdPermission}; add `
|
|
781
|
+
+ `<uses-permission android:name="${androidAdIdPermission}" tools:node="remove" />.`,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
else if (declaration !== 'removed') {
|
|
786
|
+
issues.push({
|
|
787
|
+
level: 'warning',
|
|
788
|
+
message: `The shell shows no AdMob ads, but AndroidManifest.xml does not remove `
|
|
789
|
+
+ `${androidAdIdPermission}; a dependency can still merge it. Add `
|
|
790
|
+
+ `<uses-permission android:name="${androidAdIdPermission}" tools:node="remove" />.`,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return issues;
|
|
794
|
+
}
|
|
795
|
+
export function inspectIosInfoPlistAds(plist, usesAds) {
|
|
796
|
+
if (!usesAds) {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
const issues = [];
|
|
800
|
+
if (readPlistTopLevelValue(plist, 'GADApplicationIdentifier') === undefined) {
|
|
801
|
+
issues.push({
|
|
802
|
+
level: 'error',
|
|
803
|
+
message: 'The shell installs AdMob but Info.plist has no GADApplicationIdentifier; the '
|
|
804
|
+
+ 'Google Mobile Ads SDK stops the app at launch.',
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const networks = readPlistTopLevelValue(plist, 'SKAdNetworkItems');
|
|
808
|
+
if (networks === undefined || !/cstr6suwn9\.skadnetwork/u.test(networks.xml)) {
|
|
809
|
+
issues.push({
|
|
810
|
+
level: 'warning',
|
|
811
|
+
message: "Info.plist SKAdNetworkItems does not list Google's cstr6suwn9.skadnetwork; copy "
|
|
812
|
+
+ 'the current list from https://developers.google.com/admob/ios/3p-skadnetworks.',
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
return issues;
|
|
816
|
+
}
|