@mpgd/cli 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/android-bundle-signer.d.ts +2 -0
  2. package/dist/android-bundle-signer.js +38 -0
  3. package/dist/android-signing-session.d.ts +22 -0
  4. package/dist/android-signing-session.js +198 -0
  5. package/dist/artifact-inspection-environment.d.ts +2 -0
  6. package/dist/artifact-inspection-environment.js +31 -0
  7. package/dist/asc-pin.json +26 -0
  8. package/dist/capacitor-shell-starter.d.ts +22 -0
  9. package/dist/capacitor-shell-starter.js +130 -7
  10. package/dist/deploy-planning.d.ts +64 -0
  11. package/dist/deploy-planning.js +471 -0
  12. package/dist/deploy-process.d.ts +28 -0
  13. package/dist/deploy-process.js +197 -0
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.js +409 -10
  16. package/dist/ios-ipa-inspection.js +151 -0
  17. package/dist/ios-keychain-import.swift +92 -0
  18. package/dist/ios-plist.d.ts +2 -0
  19. package/dist/ios-plist.js +75 -0
  20. package/dist/ios-signing-profile.d.ts +12 -0
  21. package/dist/ios-signing-profile.js +60 -0
  22. package/dist/ios-signing-session.d.ts +22 -0
  23. package/dist/ios-signing-session.js +256 -0
  24. package/dist/kit-upgrade-target-readiness.d.ts +8 -0
  25. package/dist/kit-upgrade-target-readiness.js +119 -0
  26. package/dist/kit-upgrade.d.ts +2 -0
  27. package/dist/kit-upgrade.js +3 -0
  28. package/dist/native-build-info.json +5 -0
  29. package/dist/native-build-target.js +12121 -0
  30. package/dist/native-deploy-run.d.ts +35 -0
  31. package/dist/native-deploy-run.js +462 -0
  32. package/dist/native-deploy-submission.d.ts +40 -0
  33. package/dist/native-deploy-submission.js +244 -0
  34. package/dist/native-deploy-workflow.d.ts +11 -0
  35. package/dist/native-deploy-workflow.js +328 -0
  36. package/dist/play-internal-submission.d.ts +32 -0
  37. package/dist/play-internal-submission.js +247 -0
  38. package/dist/play-publisher-port.d.ts +30 -0
  39. package/dist/play-publisher-port.js +1 -0
  40. package/dist/play-sdk-adapter.js +78 -0
  41. package/dist/release-state.d.ts +140 -0
  42. package/dist/release-state.js +1046 -0
  43. package/dist/release-workspace.d.ts +61 -0
  44. package/dist/release-workspace.js +504 -0
  45. package/dist/testflight-submission.d.ts +49 -0
  46. package/dist/testflight-submission.js +462 -0
  47. package/package.json +18 -13
  48. package/templates/phaser-game/README.md +30 -8
  49. package/templates/phaser-game/apps/target-devvit/README.md +12 -0
  50. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +513 -0
  51. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +8 -291
  52. package/templates/phaser-game/src/main.ts +34 -33
  53. package/templates/phaser-game/vite.shared.ts +5 -1
@@ -0,0 +1,2 @@
1
+ /** Recheck the signer of these AAB bytes before persisting or submitting them. */
2
+ export declare function inspectAndroidBundleSigner(aabFile: string): Promise<string>;
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { runReleaseProcess } from './deploy-process.js';
3
+ import { artifactInspectionEnvironment } from './artifact-inspection-environment.js';
4
+ const signerPattern = /\bSHA256:\s*((?:[A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2})/u;
5
+ const timeoutMs = 60_000;
6
+ const maxOutputBytes = 1024 * 1024;
7
+ /** Recheck the signer of these AAB bytes before persisting or submitting them. */
8
+ export async function inspectAndroidBundleSigner(aabFile) {
9
+ const bundle = path.resolve(aabFile);
10
+ const options = {
11
+ cwd: path.dirname(bundle),
12
+ timeoutMs,
13
+ maxOutputBytes,
14
+ environment: artifactInspectionEnvironment(),
15
+ };
16
+ const verified = await runReleaseProcess({
17
+ ...options,
18
+ command: 'jarsigner',
19
+ args: ['-verify', '-verbose', bundle],
20
+ });
21
+ if (verified.truncated || !/\bjar verified\./u.test(verified.output)
22
+ || /jar is unsigned|contains unsigned entries|has expired/iu.test(verified.output)) {
23
+ throw new Error('Android App Bundle JAR signature verification failed.');
24
+ }
25
+ const certificate = await runReleaseProcess({
26
+ ...options,
27
+ command: 'keytool',
28
+ args: ['-J-Duser.language=en', '-printcert', '-jarfile', bundle],
29
+ });
30
+ const signer = /\bSigner #1:/u.test(certificate.output)
31
+ ? signerPattern.exec(certificate.output)?.[1]
32
+ : undefined;
33
+ if (certificate.truncated || /\bSigner #2:/u.test(certificate.output)
34
+ || signer === undefined) {
35
+ throw new Error('Android App Bundle must have exactly one inspectable signer.');
36
+ }
37
+ return signer.replaceAll(':', '').toLowerCase();
38
+ }
@@ -0,0 +1,22 @@
1
+ export interface AndroidUploadSigningInput {
2
+ readonly keystoreFile: string;
3
+ readonly storePassword: string;
4
+ readonly keyAlias: string;
5
+ readonly keyPassword: string;
6
+ readonly expectedCertSha256: string;
7
+ readonly environment?: NodeJS.ProcessEnv;
8
+ readonly signal?: AbortSignal;
9
+ readonly temporaryParent?: string;
10
+ }
11
+ export interface AndroidUploadSigningSession {
12
+ readonly environment: NodeJS.ProcessEnv;
13
+ readonly expectedCertSha256: string;
14
+ readonly temporaryKeystore: string;
15
+ readonly gradleInitScript: string;
16
+ readonly secretValues: readonly string[];
17
+ dispose(): void;
18
+ }
19
+ /** Restore one upload key to an owned temporary path and preflight its identity. */
20
+ export declare function prepareAndroidUploadSigningSession(input: AndroidUploadSigningInput): Promise<AndroidUploadSigningSession>;
21
+ /** Always remove the restored key after a build, failure, or cancellation. */
22
+ export declare function withAndroidUploadSigningSession<T>(input: AndroidUploadSigningInput, action: (session: AndroidUploadSigningSession) => Promise<T>): Promise<T>;
@@ -0,0 +1,198 @@
1
+ import { chmodSync, copyFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { runReleaseProcess } from './deploy-process.js';
5
+ const fingerprintPattern = /^[0-9A-F]{64}$/u;
6
+ const maximumKeystoreBytes = 64 * 1024 * 1024;
7
+ const initScript = `gradle.beforeProject { project ->
8
+ if (project.path != ':app') return
9
+ project.pluginManager.withPlugin('com.android.application') {
10
+ def android = project.extensions.getByName('android')
11
+ def signing = android.signingConfigs.findByName('mpgdUpload')
12
+ ?: android.signingConfigs.create('mpgdUpload')
13
+ signing.storeFile = project.file(System.getenv('MPGD_ANDROID_SIGNING_KEYSTORE'))
14
+ signing.storePassword = System.getenv('MPGD_ANDROID_SIGNING_STORE_PASSWORD')
15
+ signing.keyAlias = System.getenv('MPGD_ANDROID_SIGNING_KEY_ALIAS')
16
+ signing.keyPassword = System.getenv('MPGD_ANDROID_SIGNING_KEY_PASSWORD')
17
+ android.buildTypes.getByName('release').signingConfig = signing
18
+ }
19
+ }
20
+ gradle.projectsEvaluated {
21
+ def app = gradle.rootProject.findProject(':app')
22
+ if (app == null || app.extensions.findByName('android') == null
23
+ || app.extensions.getByName('android').buildTypes.getByName('release').signingConfig?.name
24
+ != 'mpgdUpload') {
25
+ throw new GradleException('The game shell did not accept mpgd upload signing.')
26
+ }
27
+ }
28
+ `;
29
+ /** Restore one upload key to an owned temporary path and preflight its identity. */
30
+ export async function prepareAndroidUploadSigningSession(input) {
31
+ const expectedCertSha256 = input.expectedCertSha256.replace(/:/gu, '').toUpperCase();
32
+ const keyAlias = input.keyAlias.trim();
33
+ if (!fingerprintPattern.test(expectedCertSha256) || keyAlias === ''
34
+ || input.storePassword === '' || input.keyPassword === '') {
35
+ throw new Error('Android upload signing requires an alias, passwords and SHA-256 certificate.');
36
+ }
37
+ const source = path.resolve(input.keystoreFile);
38
+ let sourceStat;
39
+ try {
40
+ sourceStat = statSync(source);
41
+ }
42
+ catch {
43
+ throw new Error(`Android upload keystore is missing or unreadable: ${source}`);
44
+ }
45
+ if (!sourceStat.isFile() || sourceStat.size < 1 || sourceStat.size > maximumKeystoreBytes) {
46
+ throw new Error('Android upload keystore must be a nonempty regular file under 64 MiB.');
47
+ }
48
+ const parent = input.temporaryParent ?? tmpdir();
49
+ const ownedRoot = mkdtempSync(path.join(parent, 'mpgd-android-upload-'));
50
+ const temporaryKeystore = path.join(ownedRoot, 'upload-keystore');
51
+ const gradleInitScript = path.join(ownedRoot, 'upload-signing.init.gradle');
52
+ const environment = {
53
+ ...(input.environment ?? process.env),
54
+ MPGD_ANDROID_SIGNING_KEYSTORE: temporaryKeystore,
55
+ MPGD_ANDROID_SIGNING_STORE_PASSWORD: input.storePassword,
56
+ MPGD_ANDROID_SIGNING_KEY_ALIAS: keyAlias,
57
+ MPGD_ANDROID_SIGNING_KEY_PASSWORD: input.keyPassword,
58
+ MPGD_ANDROID_SIGNING_INIT_SCRIPT: gradleInitScript,
59
+ MPGD_ANDROID_UPLOAD_CERT_SHA256: expectedCertSha256,
60
+ };
61
+ delete environment.MPGD_ANDROID_UPLOAD_KEYSTORE;
62
+ const secretValues = [input.storePassword, input.keyPassword];
63
+ try {
64
+ if (process.platform !== 'win32') {
65
+ chmodSync(ownedRoot, 0o700);
66
+ }
67
+ copyFileSync(source, temporaryKeystore);
68
+ chmodSync(temporaryKeystore, 0o600);
69
+ const javaHome = environment.JAVA_HOME?.trim();
70
+ const keytoolExecutable = process.platform === 'win32' ? 'keytool.exe' : 'keytool';
71
+ const keytool = javaHome === undefined || javaHome === ''
72
+ ? keytoolExecutable
73
+ : path.join(javaHome, 'bin', keytoolExecutable);
74
+ const listed = await runReleaseProcess({
75
+ command: keytool,
76
+ args: [
77
+ '-J-Duser.language=en',
78
+ '-list',
79
+ '-v',
80
+ '-keystore',
81
+ temporaryKeystore,
82
+ '-alias',
83
+ keyAlias,
84
+ '-storepass:env',
85
+ 'MPGD_ANDROID_SIGNING_STORE_PASSWORD',
86
+ ],
87
+ cwd: ownedRoot,
88
+ environment,
89
+ timeoutMs: 30_000,
90
+ signal: input.signal,
91
+ secretValues,
92
+ captureMachineStdout: true,
93
+ });
94
+ if (listed.truncated || listed.machineStdout === undefined) {
95
+ throw new Error('Android upload certificate inspection output was truncated.');
96
+ }
97
+ const observed = /\bSHA256:\s*((?:[0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2})/u
98
+ .exec(listed.machineStdout)?.[1]?.replace(/:/gu, '').toUpperCase();
99
+ if (observed !== expectedCertSha256) {
100
+ throw new Error('Android upload keystore certificate does not match the expected SHA-256.');
101
+ }
102
+ const typeListing = await runReleaseProcess({
103
+ command: keytool,
104
+ args: [
105
+ '-J-Duser.language=en',
106
+ '-list',
107
+ '-keystore',
108
+ temporaryKeystore,
109
+ '-storepass:env',
110
+ 'MPGD_ANDROID_SIGNING_STORE_PASSWORD',
111
+ ],
112
+ cwd: ownedRoot,
113
+ environment,
114
+ timeoutMs: 30_000,
115
+ signal: input.signal,
116
+ secretValues,
117
+ captureMachineStdout: true,
118
+ });
119
+ if (typeListing.truncated || typeListing.machineStdout === undefined) {
120
+ throw new Error('Android upload keystore type inspection output was truncated.');
121
+ }
122
+ // -list -v with -alias omits the keystore type. PKCS12 ignores a distinct
123
+ // key password during -certreq, so Gradle must receive the store password.
124
+ if (/^Keystore type:\s*PKCS12\s*$/imu.test(typeListing.machineStdout)
125
+ && input.keyPassword !== input.storePassword) {
126
+ throw new Error('PKCS12 upload signing requires the key password to equal the store password.');
127
+ }
128
+ await runReleaseProcess({
129
+ command: keytool,
130
+ args: [
131
+ '-J-Duser.language=en',
132
+ '-certreq',
133
+ '-keystore',
134
+ temporaryKeystore,
135
+ '-alias',
136
+ keyAlias,
137
+ '-file',
138
+ path.join(ownedRoot, 'upload.csr'),
139
+ '-storepass:env',
140
+ 'MPGD_ANDROID_SIGNING_STORE_PASSWORD',
141
+ '-keypass:env',
142
+ 'MPGD_ANDROID_SIGNING_KEY_PASSWORD',
143
+ ],
144
+ cwd: ownedRoot,
145
+ environment,
146
+ timeoutMs: 30_000,
147
+ signal: input.signal,
148
+ secretValues,
149
+ });
150
+ writeFileSync(gradleInitScript, initScript, { flag: 'wx', mode: 0o600 });
151
+ return {
152
+ environment,
153
+ expectedCertSha256,
154
+ temporaryKeystore,
155
+ gradleInitScript,
156
+ secretValues,
157
+ dispose() {
158
+ removeOwnedSigningRoot(ownedRoot);
159
+ },
160
+ };
161
+ }
162
+ catch (error) {
163
+ try {
164
+ removeOwnedSigningRoot(ownedRoot);
165
+ }
166
+ catch (cleanupError) {
167
+ throw new AggregateError([error, cleanupError], 'Android upload signing preparation and cleanup both failed.');
168
+ }
169
+ throw error;
170
+ }
171
+ }
172
+ /** Always remove the restored key after a build, failure, or cancellation. */
173
+ export async function withAndroidUploadSigningSession(input, action) {
174
+ const session = await prepareAndroidUploadSigningSession(input);
175
+ let outcome;
176
+ try {
177
+ outcome = { ok: true, value: await action(session) };
178
+ }
179
+ catch (error) {
180
+ outcome = { ok: false, error };
181
+ }
182
+ try {
183
+ session.dispose();
184
+ }
185
+ catch (cleanupError) {
186
+ if (!outcome.ok) {
187
+ throw new AggregateError([outcome.error, cleanupError], 'Android upload signing action and cleanup both failed.');
188
+ }
189
+ throw cleanupError;
190
+ }
191
+ if (!outcome.ok) {
192
+ throw outcome.error;
193
+ }
194
+ return outcome.value;
195
+ }
196
+ function removeOwnedSigningRoot(root) {
197
+ rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
198
+ }
@@ -0,0 +1,2 @@
1
+ /** Artifact inspectors need tool locations, never release-state or signing credentials. */
2
+ export declare function artifactInspectionEnvironment(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,31 @@
1
+ const allowedInspectionEnvironmentNames = [
2
+ 'PATH',
3
+ 'Path',
4
+ 'HOME',
5
+ 'TMPDIR',
6
+ 'TMP',
7
+ 'TEMP',
8
+ 'LANG',
9
+ 'LC_ALL',
10
+ 'LC_CTYPE',
11
+ 'JAVA_HOME',
12
+ 'ANDROID_HOME',
13
+ 'ANDROID_SDK_ROOT',
14
+ 'DEVELOPER_DIR',
15
+ 'SDKROOT',
16
+ 'SystemRoot',
17
+ 'windir',
18
+ 'PATHEXT',
19
+ 'USERPROFILE',
20
+ ];
21
+ /** Artifact inspectors need tool locations, never release-state or signing credentials. */
22
+ export function artifactInspectionEnvironment(source = process.env) {
23
+ const result = {};
24
+ for (const name of allowedInspectionEnvironmentNames) {
25
+ const value = source[name];
26
+ if (value !== undefined) {
27
+ result[name] = value;
28
+ }
29
+ }
30
+ return result;
31
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "repository": "rorkai/App-Store-Connect-CLI",
3
+ "version": "5.5.0",
4
+ "assets": {
5
+ "darwin-arm64": {
6
+ "name": "asc_5.5.0_macOS_arm64",
7
+ "sha256": "8d0c38ad9c7e3094d2fc9b7b5a86d7c9a08fd2b36a3df081aefbf60503035b29"
8
+ },
9
+ "darwin-x64": {
10
+ "name": "asc_5.5.0_macOS_amd64",
11
+ "sha256": "296553af9cd63f9f93782f22dc12077c3d6678ee57cf52e05aee7e850fe13f63"
12
+ },
13
+ "linux-arm64": {
14
+ "name": "asc_5.5.0_linux_arm64",
15
+ "sha256": "c8f6d0adf7ca3ad41391adda3c83ff2023b921f4f9b077c6498c6d18b39aaebd"
16
+ },
17
+ "linux-x64": {
18
+ "name": "asc_5.5.0_linux_amd64",
19
+ "sha256": "712b29762c7ad8c2624bccc9752a30ff3c1d4c5b4cb689a00680f45081939239"
20
+ },
21
+ "win32-x64": {
22
+ "name": "asc_5.5.0_windows_amd64.exe",
23
+ "sha256": "52d1eb290139570b377b8fca439c84be7275934209dd342bb2a8b44f14e00c89"
24
+ }
25
+ }
26
+ }
@@ -24,12 +24,34 @@ export interface CapacitorShellStarterPlan {
24
24
  * never rewritten by this initializer.
25
25
  */
26
26
  export declare function planCapacitorShellStarter(input: CapacitorShellStarterInput): CapacitorShellStarterPlan;
27
+ export declare function assertCapacitorAppId(appId: string): void;
27
28
  export declare function applyCapacitorShellStarter(plan: CapacitorShellStarterPlan, installStagedFile?: (temporary: string, destination: string) => void): void;
28
29
  export interface CapacitorShellCommandRunner {
29
30
  run(command: string, args: readonly string[], cwd: string): void;
30
31
  }
31
32
  /** Materialize only missing native projects after the non-destructive plan is applied. */
32
33
  export declare function materializeCapacitorShellStarter(plan: CapacitorShellStarterPlan, runner?: CapacitorShellCommandRunner): void;
34
+ /**
35
+ * Backup rules for API < 31 (android:fullBackupContent). Every data domain is
36
+ * excluded so nothing from the app's private storage reaches a backup even
37
+ * if a game later turns android:allowBackup back on.
38
+ */
39
+ export declare const androidBackupRulesXml: string;
40
+ /**
41
+ * Data extraction rules for API 31+ (android:dataExtractionRules). Android 12
42
+ * ignores android:allowBackup="false" for device-to-device transfer on some
43
+ * devices, so cloud backup and device transfer each exclude every domain.
44
+ */
45
+ export declare const androidDataExtractionRulesXml: string;
46
+ /** Manifest application attributes that opt the shell out of backups. */
47
+ export declare const androidManifestBackupAttributes: readonly (readonly [string, string])[];
48
+ /**
49
+ * Set the backup opt-out attributes on the manifest application node and bind
50
+ * the tools namespace on the manifest root. Existing values are replaced in
51
+ * place; the attribute group keeps the indentation of the first attribute so
52
+ * the result is stable across repeated runs.
53
+ */
54
+ export declare function applyAndroidManifestBackupPolicy(manifest: string): string;
33
55
  /** Fail closed when a Release plist hardcodes values independently of Xcode build settings. */
34
56
  export declare function assertIosReleasePlistIdentity(source: string): void;
35
57
  export declare function assertAndroidReleaseDisplayName(shellApp: string, expectedDisplayName: string): void;
@@ -31,12 +31,7 @@ export function planCapacitorShellStarter(input) {
31
31
  if (!lstatSync(gameRoot).isDirectory()) {
32
32
  throw new Error('Capacitor shell game root must be a directory.');
33
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
- }
34
+ assertCapacitorAppId(input.appId);
40
35
  if (input.displayName.trim() !== input.displayName
41
36
  || input.displayName.length === 0 || input.displayName.length > 80) {
42
37
  throw new Error('Capacitor display name must be 1-80 characters without outer whitespace.');
@@ -349,6 +344,14 @@ export function planCapacitorShellStarter(input) {
349
344
  nativePlatformsToAdd,
350
345
  };
351
346
  }
347
+ export function assertCapacitorAppId(appId) {
348
+ if (!appIdPattern.test(appId)) {
349
+ throw new Error('Capacitor app ID must be a lowercase reverse-domain identifier.');
350
+ }
351
+ if (appId.split('.').some((segment) => javaKeywords.has(segment))) {
352
+ throw new Error('Capacitor app ID cannot contain Java keyword segments.');
353
+ }
354
+ }
352
355
  export function applyCapacitorShellStarter(plan, installStagedFile = renameSync) {
353
356
  const staged = plan.files.filter((file) => !plan.nativePlatformsToAdd.some((platform) => file.path.startsWith(`${shellPath}/${platform}/`))).map((file) => {
354
357
  const destination = safeDestination(plan.gameRoot, file.path);
@@ -447,10 +450,14 @@ export function materializeCapacitorShellStarter(plan, runner = { run: runComman
447
450
  runner.run('pnpm', ['install', '--no-frozen-lockfile'], plan.gameRoot);
448
451
  runner.run('pnpm', ['--dir', shellPath, 'install', '--ignore-workspace', '--no-frozen-lockfile'], plan.gameRoot);
449
452
  for (const platform of plan.nativePlatformsToAdd) {
450
- if (!existsSync(path.join(plan.gameRoot, shellPath, platform))) {
453
+ const generatedNow = !existsSync(path.join(plan.gameRoot, shellPath, platform));
454
+ if (generatedNow) {
451
455
  runner.run('pnpm', ['--dir', shellPath, 'cap', 'add', platform], plan.gameRoot);
452
456
  }
453
457
  const manifest = readJsonObject(path.join(plan.gameRoot, shellPath, 'mpgd.native-shell.json'));
458
+ if (platform === 'android' && generatedNow) {
459
+ materializeAndroidBackupPolicy(plan.gameRoot);
460
+ }
454
461
  if (platform === 'ios') {
455
462
  materializeIosArchiveScheme(plan.gameRoot);
456
463
  const relative = `${shellPath}/ios/App/App/Info-Smoke.plist`;
@@ -470,6 +477,122 @@ export function materializeCapacitorShellStarter(plan, runner = { run: runComman
470
477
  throw new Error('Capacitor shell changed during installation; rerun the initializer.');
471
478
  }
472
479
  }
480
+ const androidToolsNamespace = 'http://schemas.android.com/tools';
481
+ const androidBackupDomains = ['root', 'file', 'database', 'sharedpref', 'external'];
482
+ const androidBackupExcludes = androidBackupDomains
483
+ .map((domain) => `<exclude domain="${domain}" path="." />`);
484
+ /**
485
+ * Backup rules for API < 31 (android:fullBackupContent). Every data domain is
486
+ * excluded so nothing from the app's private storage reaches a backup even
487
+ * if a game later turns android:allowBackup back on.
488
+ */
489
+ export const androidBackupRulesXml = [
490
+ '<?xml version="1.0" encoding="utf-8"?>',
491
+ '<!--',
492
+ ' Generated by mpgd target init capacitor. WebView storage and the',
493
+ ' game-services SharedPreferences save store hold player data, so the whole',
494
+ ' private data tree stays out of Android backups.',
495
+ '-->',
496
+ '<full-backup-content>',
497
+ ...androidBackupExcludes.map((line) => ` ${line}`),
498
+ '</full-backup-content>',
499
+ '',
500
+ ].join('\n');
501
+ /**
502
+ * Data extraction rules for API 31+ (android:dataExtractionRules). Android 12
503
+ * ignores android:allowBackup="false" for device-to-device transfer on some
504
+ * devices, so cloud backup and device transfer each exclude every domain.
505
+ */
506
+ export const androidDataExtractionRulesXml = [
507
+ '<?xml version="1.0" encoding="utf-8"?>',
508
+ '<!--',
509
+ ' Generated by mpgd target init capacitor. WebView storage and the',
510
+ ' game-services SharedPreferences save store hold player data, so neither',
511
+ ' cloud backup nor Android 12+ device-to-device transfer may copy them.',
512
+ '-->',
513
+ '<data-extraction-rules>',
514
+ ' <cloud-backup>',
515
+ ...androidBackupExcludes.map((line) => ` ${line}`),
516
+ ' </cloud-backup>',
517
+ ' <device-transfer>',
518
+ ...androidBackupExcludes.map((line) => ` ${line}`),
519
+ ' </device-transfer>',
520
+ '</data-extraction-rules>',
521
+ '',
522
+ ].join('\n');
523
+ /** Manifest application attributes that opt the shell out of backups. */
524
+ export const androidManifestBackupAttributes = [
525
+ ['android:allowBackup', 'false'],
526
+ ['android:fullBackupContent', '@xml/backup_rules'],
527
+ ['android:dataExtractionRules', '@xml/data_extraction_rules'],
528
+ ['tools:targetApi', 's'],
529
+ ];
530
+ /**
531
+ * Opt a freshly generated Android project out of cloud backups and Android
532
+ * 12+ device-to-device transfers. The Capacitor template enables
533
+ * android:allowBackup, which would copy WebView storage and the game-services
534
+ * SharedPreferences save store into Google backups; only a project created by
535
+ * this run is touched, never an existing game-owned shell.
536
+ */
537
+ function materializeAndroidBackupPolicy(gameRoot) {
538
+ const manifestFile = safeDestination(gameRoot, `${shellPath}/android/app/src/main/AndroidManifest.xml`);
539
+ if (!existsSync(manifestFile) || !lstatSync(manifestFile).isFile()) {
540
+ return;
541
+ }
542
+ const resources = [
543
+ ['backup_rules.xml', androidBackupRulesXml],
544
+ ['data_extraction_rules.xml', androidDataExtractionRulesXml],
545
+ ];
546
+ for (const [name, content] of resources) {
547
+ const resource = safeDestination(gameRoot, `${shellPath}/android/app/src/main/res/xml/${name}`);
548
+ if (!existsSync(resource)) {
549
+ mkdirSync(path.dirname(resource), { recursive: true });
550
+ writeFileSync(resource, content);
551
+ }
552
+ }
553
+ const source = readFileSync(manifestFile, 'utf8');
554
+ const hardened = applyAndroidManifestBackupPolicy(source);
555
+ if (hardened !== source) {
556
+ writeFileSync(manifestFile, hardened);
557
+ }
558
+ }
559
+ function xmlAttributePattern(name) {
560
+ return new RegExp(`(\\s*)${name.replaceAll('.', '\\.')}\\s*=\\s*(?:"[^"]*"|'[^']*')`, 'u');
561
+ }
562
+ /**
563
+ * Set the backup opt-out attributes on the manifest application node and bind
564
+ * the tools namespace on the manifest root. Existing values are replaced in
565
+ * place; the attribute group keeps the indentation of the first attribute so
566
+ * the result is stable across repeated runs.
567
+ */
568
+ export function applyAndroidManifestBackupPolicy(manifest) {
569
+ const root = /<manifest\b[^>]*?>/u.exec(manifest);
570
+ const application = /<application\b[^>]*?\/?>/u.exec(manifest);
571
+ if (root === null || application === null || application.index < root.index) {
572
+ return manifest;
573
+ }
574
+ let rootTag = root[0];
575
+ if (!/\sxmlns:tools\s*=/u.test(rootTag)) {
576
+ const androidNamespace = /\sxmlns:android\s*=\s*(?:"[^"]*"|'[^']*')/u.exec(rootTag);
577
+ const toolsNamespace = ` xmlns:tools="${androidToolsNamespace}"`;
578
+ rootTag = androidNamespace === null
579
+ ? rootTag.replace(/^<manifest\b/u, `<manifest${toolsNamespace}`)
580
+ : `${rootTag.slice(0, androidNamespace.index + androidNamespace[0].length)}${toolsNamespace}${rootTag.slice(androidNamespace.index + androidNamespace[0].length)}`;
581
+ }
582
+ let applicationTag = application[0];
583
+ const existing = xmlAttributePattern('android:allowBackup').exec(applicationTag);
584
+ const separator = existing?.[1] !== undefined && existing[1] !== ''
585
+ ? existing[1]
586
+ : (/^<application(\s+)\S/u.exec(applicationTag)?.[1] ?? ' ');
587
+ for (const [name] of androidManifestBackupAttributes) {
588
+ applicationTag = applicationTag.replace(xmlAttributePattern(name), '');
589
+ }
590
+ const attributes = androidManifestBackupAttributes
591
+ .map(([name, value]) => `${separator}${name}="${value}"`)
592
+ .join('');
593
+ applicationTag = applicationTag.replace(/^<application\b/u, `<application${attributes}`);
594
+ return `${manifest.slice(0, root.index)}${rootTag}${manifest.slice(root.index + root[0].length, application.index)}${applicationTag}${manifest.slice(application.index + application[0].length)}`;
595
+ }
473
596
  function materializeIosArchiveScheme(gameRoot) {
474
597
  const schemeRelative = `${shellPath}/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme`;
475
598
  const schemeFile = safeDestination(gameRoot, schemeRelative);
@@ -0,0 +1,64 @@
1
+ export type NativeDeployTarget = 'android' | 'ios';
2
+ export interface CredentialReference {
3
+ readonly env: string;
4
+ }
5
+ export interface DeployTargetProfile {
6
+ readonly destination: 'play-internal' | 'testflight';
7
+ readonly signingCredential: CredentialReference;
8
+ readonly submissionCredential: CredentialReference;
9
+ readonly testGroup?: string;
10
+ }
11
+ export interface DeployProfile {
12
+ readonly buildProfile: 'production';
13
+ readonly approval: 'manual' | 'preapproved-internal-test';
14
+ readonly targets: Partial<Record<NativeDeployTarget, DeployTargetProfile>>;
15
+ }
16
+ export interface DeployConfig {
17
+ readonly schemaVersion: 1;
18
+ readonly profiles: Record<string, DeployProfile>;
19
+ }
20
+ export interface PlannedDeployTarget {
21
+ readonly target: NativeDeployTarget;
22
+ readonly destination: 'play-internal' | 'testflight';
23
+ readonly appId: string;
24
+ readonly testGroup?: string;
25
+ }
26
+ export interface NativeDeploymentPlan {
27
+ readonly schemaVersion: 1;
28
+ readonly gameRoot: string;
29
+ readonly profile: string;
30
+ readonly buildProfile: 'production';
31
+ readonly approval: DeployProfile['approval'];
32
+ readonly targetConfigSha256: string;
33
+ readonly deployConfigSha256: string;
34
+ readonly targets: readonly PlannedDeployTarget[];
35
+ }
36
+ export interface DeployDoctorCheck {
37
+ readonly name: string;
38
+ readonly status: 'ok' | 'missing' | 'warning';
39
+ readonly detail: string;
40
+ }
41
+ export interface DeployDoctorResult {
42
+ readonly checks: readonly DeployDoctorCheck[];
43
+ readonly healthy: boolean;
44
+ }
45
+ export declare function initializeDeployConfig(game: string): string;
46
+ export declare function planNativeDeployment(input: {
47
+ readonly game: string;
48
+ readonly profile: string;
49
+ readonly targets?: readonly NativeDeployTarget[];
50
+ }): NativeDeploymentPlan;
51
+ export declare function writeNativeDeploymentPlan(file: string, plan: NativeDeploymentPlan): void;
52
+ /** Load a saved plan only if it still equals the game-owned current configuration. */
53
+ export declare function readNativeDeploymentPlan(file: string, gameRootOverride?: string): NativeDeploymentPlan;
54
+ /** Resolve credential references from the one authoritative game-owned profile. */
55
+ export declare function readNativeDeployTargetProfile(plan: NativeDeploymentPlan, target: NativeDeployTarget): DeployTargetProfile;
56
+ /** Credential names across every configured profile, including unselected targets. */
57
+ export declare function readNativeDeployCredentialNames(plan: NativeDeploymentPlan): readonly string[];
58
+ export declare function doctorNativeDeployment(input: {
59
+ readonly game: string;
60
+ readonly profile: string;
61
+ readonly targets?: readonly NativeDeployTarget[];
62
+ readonly environment?: NodeJS.ProcessEnv;
63
+ }): DeployDoctorResult;
64
+ export declare function parseDeployTargets(value: string | undefined): readonly NativeDeployTarget[] | undefined;