@mpgd/cli 0.63.0 → 0.65.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/app-store-iap-asc.d.ts +5 -14
- package/dist/app-store-iap-asc.js +9 -116
- package/dist/app-store-presence-asc.d.ts +23 -0
- package/dist/app-store-presence-asc.js +162 -0
- package/dist/app-store-presence-port.d.ts +101 -0
- package/dist/app-store-presence-port.js +13 -0
- package/dist/asc-session.d.ts +43 -0
- package/dist/asc-session.js +118 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +75 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +492 -413
- package/dist/play-listing-port.d.ts +88 -0
- package/dist/play-listing-port.js +12 -0
- package/dist/play-sdk-adapter.js +103 -17
- package/dist/store-presence-app-store.d.ts +139 -0
- package/dist/store-presence-app-store.js +537 -0
- package/dist/store-presence-command.d.ts +50 -0
- package/dist/store-presence-command.js +145 -0
- package/dist/store-presence-play.d.ts +116 -0
- package/dist/store-presence-play.js +495 -0
- package/dist/store-presence.d.ts +106 -0
- package/dist/store-presence.js +173 -0
- package/dist/store-products-command.d.ts +2 -1
- package/dist/store-products-command.js +15 -42
- package/dist/store-products.d.ts +3 -11
- package/dist/store-products.js +7 -75
- package/dist/store-sync.d.ts +50 -0
- package/dist/store-sync.js +137 -0
- package/package.json +19 -19
- package/templates/lite-overlay/README.md +4 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/README.md +32 -3
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/apps/target-cloudflare-pages/package.json +2 -2
- package/templates/phaser-game/apps/target-devvit/README.md +10 -0
- package/templates/phaser-game/apps/target-devvit/package.json +2 -2
- package/templates/phaser-game/package.json +2 -2
- package/templates/phaser-game/src/i18n/messages.ts +3 -0
- package/templates/phaser-game/src/main.ts +42 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +5 -0
- package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +5 -0
- package/templates/phaser-game/src/platform/commerceAccountBinding.ts +81 -1
- package/templates/phaser-game/src/platform/gameServices.ts +88 -4
- package/templates/phaser-game/src/platform/guestIdentity.ts +4 -3
- package/templates/phaser-game/src/platform/sessionGatedServices.ts +149 -21
- package/templates/phaser-game/src/styles.css +12 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { appStorePresenceDisplays, appStorePresenceDisplayTypes, appStorePresenceLimits, resolveStorePresenceImageSet, } from '@mpgd/catalog';
|
|
6
|
+
import { describeTextChange, formatTextChange, normalizeLineBreaks, planStoreImageSet, readLocalStoreImage, storePresencePlanCommand, } from './store-presence.js';
|
|
7
|
+
import { ReleaseProcessError } from './deploy-process.js';
|
|
8
|
+
import { canonicalLanguageTag, mapWithConcurrency, stableSha256, StorePlanStaleError, } from './store-sync.js';
|
|
9
|
+
// App information states whose localizations App Store Connect accepts edits
|
|
10
|
+
// for, and the iOS version states the sync writes to (App Store Connect API
|
|
11
|
+
// `AppInfoState` and `AppVersionState`).
|
|
12
|
+
const editableAppInfoStates = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED']);
|
|
13
|
+
const editableVersionStates = new Set([
|
|
14
|
+
'PREPARE_FOR_SUBMISSION',
|
|
15
|
+
'DEVELOPER_REJECTED',
|
|
16
|
+
'REJECTED',
|
|
17
|
+
'METADATA_REJECTED',
|
|
18
|
+
]);
|
|
19
|
+
/** Version states after a release (both spellings): what's new is accepted from then on. */
|
|
20
|
+
const releasedVersionStates = new Set([
|
|
21
|
+
'READY_FOR_DISTRIBUTION',
|
|
22
|
+
'PROCESSING_FOR_DISTRIBUTION',
|
|
23
|
+
'REPLACED_WITH_NEW_VERSION',
|
|
24
|
+
'READY_FOR_SALE',
|
|
25
|
+
'DEVELOPER_REMOVED_FROM_SALE',
|
|
26
|
+
'REMOVED_FROM_SALE',
|
|
27
|
+
]);
|
|
28
|
+
/** App information states of the live record (asc `isNonLiveAppInfoState`). */
|
|
29
|
+
const liveAppInfoStates = new Set(['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE']);
|
|
30
|
+
const readConcurrency = 4;
|
|
31
|
+
/** Read App Store Connect and plan; never writes. */
|
|
32
|
+
export async function planAppStorePresence(input, port) {
|
|
33
|
+
const appStore = requireAppStore(input.presence);
|
|
34
|
+
const local = readLocalScreenshots(input.directory, appStore);
|
|
35
|
+
return planWithLocal(input, appStore, local, port);
|
|
36
|
+
}
|
|
37
|
+
function requireAppStore(presence) {
|
|
38
|
+
if (presence.appStore === undefined) {
|
|
39
|
+
throw new Error('The store presence file has no appStore section.');
|
|
40
|
+
}
|
|
41
|
+
return presence.appStore;
|
|
42
|
+
}
|
|
43
|
+
function readLocalScreenshots(directory, appStore) {
|
|
44
|
+
const result = new Map();
|
|
45
|
+
for (const [locale, entry] of Object.entries(appStore.localizations)) {
|
|
46
|
+
const sets = new Map();
|
|
47
|
+
for (const display of appStorePresenceDisplays) {
|
|
48
|
+
const value = entry.screenshots?.[display];
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { files, complete } = resolveStorePresenceImageSet(value);
|
|
53
|
+
const label = `appStore.localizations.${locale}.screenshots.${display}`;
|
|
54
|
+
const images = files.map((file) => readLocalStoreImage(directory, file, { store: 'app-store', slot: display }, label));
|
|
55
|
+
if (new Set(images.map((image) => image.md5)).size !== images.length) {
|
|
56
|
+
throw new Error(`${label} lists the same image twice under different names.`);
|
|
57
|
+
}
|
|
58
|
+
sets.set(display, { images, complete });
|
|
59
|
+
}
|
|
60
|
+
result.set(locale, sets);
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
async function planWithLocal(input, appStore, local, port) {
|
|
65
|
+
const bundleId = await port.readAppBundleId(input.appId);
|
|
66
|
+
if (bundleId !== input.bundleId) {
|
|
67
|
+
throw new Error(`App Store Connect app ${input.appId} has bundle ID ${bundleId}, not ${input.bundleId} from the deployment profile.`);
|
|
68
|
+
}
|
|
69
|
+
const notes = [];
|
|
70
|
+
const manual = [];
|
|
71
|
+
// App information: the editable record, else the current one (read only).
|
|
72
|
+
const appInfos = await port.listAppInfos(input.appId);
|
|
73
|
+
const editableInfos = appInfos.filter((info) => editableAppInfoStates.has(String(info.state)));
|
|
74
|
+
if (editableInfos.length > 1) {
|
|
75
|
+
throw new Error(`App Store Connect app ${input.appId} has ${editableInfos.length} editable app information records; `
|
|
76
|
+
+ 'finish or remove all but one.');
|
|
77
|
+
}
|
|
78
|
+
const appInfo = editableInfos[0];
|
|
79
|
+
// Without an editable record, read the live one, not a replaced older one.
|
|
80
|
+
const readInfo = appInfo
|
|
81
|
+
?? appInfos.find((info) => liveAppInfoStates.has(String(info.state)))
|
|
82
|
+
?? appInfos.find((info) => info.state !== 'REPLACED_WITH_NEW_INFO')
|
|
83
|
+
?? appInfos[0];
|
|
84
|
+
const appInfoLocalizations = readInfo === undefined
|
|
85
|
+
? []
|
|
86
|
+
: await port.listAppInfoLocalizations(input.appId, readInfo.id);
|
|
87
|
+
if (appInfo === undefined) {
|
|
88
|
+
manual.push('App name, subtitle and privacy policy URL are editable only while an app information record is being '
|
|
89
|
+
+ 'prepared (a new version in PREPARE_FOR_SUBMISSION or rejected); the plan shows what would change.');
|
|
90
|
+
}
|
|
91
|
+
// The editable iOS version; the sync never creates one.
|
|
92
|
+
const versions = await port.listVersions(input.appId);
|
|
93
|
+
const editableVersions = versions.filter((version) => editableVersionStates.has(String(version.state)));
|
|
94
|
+
if (editableVersions.length > 1) {
|
|
95
|
+
throw new Error(`App Store Connect app ${input.appId} has ${editableVersions.length} editable iOS versions; finish or remove all but one.`);
|
|
96
|
+
}
|
|
97
|
+
const version = editableVersions[0];
|
|
98
|
+
// App Store Connect takes what's new only once a version has been released.
|
|
99
|
+
const first = version !== undefined && !versions.some((candidate) => releasedVersionStates.has(String(candidate.state)) || releasedVersionStates.has(String(candidate.appStoreState)));
|
|
100
|
+
if (version === undefined) {
|
|
101
|
+
manual.push('No iOS version is editable (PREPARE_FOR_SUBMISSION or rejected): create the next version in App Store '
|
|
102
|
+
+ 'Connect, then plan again. The sync never creates or submits a version.');
|
|
103
|
+
}
|
|
104
|
+
const versionLocalizations = version === undefined
|
|
105
|
+
? []
|
|
106
|
+
: await port.listVersionLocalizations(version.id);
|
|
107
|
+
const infoByLocale = indexByLocale(appInfoLocalizations);
|
|
108
|
+
const versionByLocale = indexByLocale(versionLocalizations);
|
|
109
|
+
const locales = Object.keys(appStore.localizations);
|
|
110
|
+
const setReads = version === undefined
|
|
111
|
+
? []
|
|
112
|
+
: locales.flatMap((locale) => {
|
|
113
|
+
const remote = versionByLocale.get(locale.toLowerCase());
|
|
114
|
+
return remote !== undefined && (local.get(locale)?.size ?? 0) > 0
|
|
115
|
+
? [{ locale, id: remote.id }]
|
|
116
|
+
: [];
|
|
117
|
+
});
|
|
118
|
+
const setLists = await mapWithConcurrency(setReads, readConcurrency, (read) => port.listScreenshotSets(read.id));
|
|
119
|
+
const remoteSets = new Map();
|
|
120
|
+
setReads.forEach((read, index) => remoteSets.set(read.locale, setLists[index] ?? []));
|
|
121
|
+
const planned = locales.map((locale) => planLocale({
|
|
122
|
+
locale,
|
|
123
|
+
entry: appStore.localizations[locale],
|
|
124
|
+
privacyPolicyUrl: input.presence.privacyPolicyUrl,
|
|
125
|
+
appInfoEditable: appInfo !== undefined,
|
|
126
|
+
remoteInfo: infoByLocale.get(locale.toLowerCase()),
|
|
127
|
+
version,
|
|
128
|
+
first,
|
|
129
|
+
remoteVersion: versionByLocale.get(locale.toLowerCase()),
|
|
130
|
+
local: local.get(locale) ?? new Map(),
|
|
131
|
+
remoteSets: remoteSets.get(locale) ?? [],
|
|
132
|
+
notes,
|
|
133
|
+
}));
|
|
134
|
+
if (first && locales.some((locale) => appStore.localizations[locale]?.whatsNew !== undefined)) {
|
|
135
|
+
notes.push("No version of the app has been released yet: App Store Connect takes no what's new text, so the sync "
|
|
136
|
+
+ 'leaves it out.');
|
|
137
|
+
}
|
|
138
|
+
const named = new Set(locales.map((locale) => locale.toLowerCase()));
|
|
139
|
+
const remoteOnlyLocales = [...new Set([...appInfoLocalizations, ...versionLocalizations].map((entry) => entry.locale))]
|
|
140
|
+
.filter((locale) => !named.has(locale.toLowerCase()))
|
|
141
|
+
.sort();
|
|
142
|
+
manual.push('App Privacy answers, the age rating, App Review notes and demo account, pricing, availability, release '
|
|
143
|
+
+ 'settings and submitting for review stay in App Store Connect.');
|
|
144
|
+
const imageRemovals = planned.reduce((sum, locale) => sum + locale.screenshots.reduce((count, set) => count + (set.action === 'replace' ? set.deleteIds.length : 0), 0), 0);
|
|
145
|
+
const blocked = planned.reduce((sum, locale) => sum + [locale.appInfo.action, locale.version.action].filter((action) => action === 'blocked').length
|
|
146
|
+
+ locale.screenshots.filter((set) => set.blocked === true).length, 0);
|
|
147
|
+
const writes = (action) => action === 'create' || action === 'update';
|
|
148
|
+
const changed = planned.some((locale) => writes(locale.appInfo.action) || writes(locale.version.action)
|
|
149
|
+
|| locale.screenshots.some((set) => set.action !== 'unchanged'));
|
|
150
|
+
const remoteSha256 = stableSha256({
|
|
151
|
+
appInfo: readInfo === undefined
|
|
152
|
+
? null
|
|
153
|
+
: { id: readInfo.id, state: readInfo.state, editable: appInfo !== undefined },
|
|
154
|
+
appInfoLocalizations: sortByLocale(appInfoLocalizations),
|
|
155
|
+
version: version ?? null,
|
|
156
|
+
first,
|
|
157
|
+
versionLocalizations: sortByLocale(versionLocalizations),
|
|
158
|
+
screenshotSets: Object.fromEntries([...remoteSets.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
schemaVersion: 1,
|
|
162
|
+
store: 'app-store',
|
|
163
|
+
appId: input.appId,
|
|
164
|
+
bundleId: input.bundleId,
|
|
165
|
+
...(appInfo === undefined ? {} : { appInfo: { id: appInfo.id, state: String(appInfo.state) } }),
|
|
166
|
+
...(version === undefined ? {} : {
|
|
167
|
+
version: {
|
|
168
|
+
id: version.id,
|
|
169
|
+
...(version.versionString === undefined ? {} : { versionString: version.versionString }),
|
|
170
|
+
state: String(version.state),
|
|
171
|
+
first,
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
locales: planned,
|
|
175
|
+
remoteOnlyLocales,
|
|
176
|
+
imageRemovals,
|
|
177
|
+
changed,
|
|
178
|
+
blocked,
|
|
179
|
+
notes,
|
|
180
|
+
manual,
|
|
181
|
+
remoteSha256,
|
|
182
|
+
desiredSha256: stableSha256({ locales: planned }),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function planLocale(input) {
|
|
186
|
+
const { entry, remoteInfo, remoteVersion } = input;
|
|
187
|
+
const locale = remoteVersion?.locale ?? remoteInfo?.locale ?? canonicalLanguageTag(input.locale);
|
|
188
|
+
// App information: name, subtitle and privacy policy URL.
|
|
189
|
+
const privacyPolicyUrl = entry.privacyPolicyUrl ?? input.privacyPolicyUrl;
|
|
190
|
+
const infoText = [
|
|
191
|
+
describeTextChange('name', remoteInfo?.name, entry.name),
|
|
192
|
+
entry.subtitle === undefined ? undefined : describeTextChange('subtitle', remoteInfo?.subtitle, entry.subtitle),
|
|
193
|
+
privacyPolicyUrl === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: describeTextChange('privacyPolicyUrl', remoteInfo?.privacyPolicyUrl, privacyPolicyUrl),
|
|
196
|
+
].filter((change) => change !== undefined);
|
|
197
|
+
const infoChanges = remoteInfo === undefined || infoText.length > 0;
|
|
198
|
+
const infoAction = !infoChanges
|
|
199
|
+
? 'unchanged'
|
|
200
|
+
: !input.appInfoEditable
|
|
201
|
+
? 'blocked'
|
|
202
|
+
: remoteInfo === undefined
|
|
203
|
+
? 'create'
|
|
204
|
+
: 'update';
|
|
205
|
+
// Version localization: only the fields the file gives; what's new not on a first version.
|
|
206
|
+
const desired = {
|
|
207
|
+
description: normalizeLineBreaks(entry.description),
|
|
208
|
+
keywords: entry.keywords,
|
|
209
|
+
supportUrl: entry.supportUrl,
|
|
210
|
+
...(entry.promotionalText === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { promotionalText: normalizeLineBreaks(entry.promotionalText) }),
|
|
213
|
+
...(entry.whatsNew === undefined || input.first ? {} : { whatsNew: normalizeLineBreaks(entry.whatsNew) }),
|
|
214
|
+
...(entry.marketingUrl === undefined ? {} : { marketingUrl: entry.marketingUrl }),
|
|
215
|
+
};
|
|
216
|
+
const versionText = [];
|
|
217
|
+
const changedFields = {};
|
|
218
|
+
for (const [field, value] of Object.entries(desired)) {
|
|
219
|
+
const change = describeTextChange(field, remoteVersion?.[field], value);
|
|
220
|
+
if (change !== undefined) {
|
|
221
|
+
versionText.push(change);
|
|
222
|
+
changedFields[field] = value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// A new locale needs its app information (the name) first: without an
|
|
226
|
+
// editable record it would be half added, so its version part waits too.
|
|
227
|
+
const waitsForAppInfo = remoteInfo === undefined && !input.appInfoEditable && remoteVersion === undefined;
|
|
228
|
+
if (waitsForAppInfo && input.version !== undefined) {
|
|
229
|
+
input.notes.push(`${locale}: not on App Store Connect yet and no app information record is editable, so its version `
|
|
230
|
+
+ 'localization and screenshots wait too.');
|
|
231
|
+
}
|
|
232
|
+
const versionAction = input.version === undefined || waitsForAppInfo
|
|
233
|
+
? 'blocked'
|
|
234
|
+
: remoteVersion === undefined
|
|
235
|
+
? 'create'
|
|
236
|
+
: versionText.length > 0
|
|
237
|
+
? 'update'
|
|
238
|
+
: 'unchanged';
|
|
239
|
+
// Screenshot sets of the version localization.
|
|
240
|
+
const screenshots = [];
|
|
241
|
+
if (input.version !== undefined && !waitsForAppInfo) {
|
|
242
|
+
for (const display of appStorePresenceDisplays) {
|
|
243
|
+
const set = input.local.get(display);
|
|
244
|
+
if (set === undefined) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const displayType = appStorePresenceDisplayTypes[display];
|
|
248
|
+
const remoteSet = input.remoteSets.find((candidate) => candidate.displayType === displayType);
|
|
249
|
+
const remote = remoteSet?.screenshots ?? [];
|
|
250
|
+
const processing = remote.find((screenshot) => (screenshot.deliveryState === 'COMPLETE' && (screenshot.sourceFileChecksum ?? '') === '')
|
|
251
|
+
|| screenshot.deliveryState === 'AWAITING_UPLOAD' || screenshot.deliveryState === 'UPLOAD_COMPLETE');
|
|
252
|
+
if (processing !== undefined) {
|
|
253
|
+
// Only this set waits; the text and the other sets still plan.
|
|
254
|
+
screenshots.push({
|
|
255
|
+
display,
|
|
256
|
+
displayType,
|
|
257
|
+
action: 'unchanged',
|
|
258
|
+
blocked: true,
|
|
259
|
+
complete: set.complete,
|
|
260
|
+
upload: [],
|
|
261
|
+
keep: [],
|
|
262
|
+
remove: [],
|
|
263
|
+
deleteIds: [],
|
|
264
|
+
notes: [
|
|
265
|
+
`App Store Connect is still processing screenshot ${processing.id} (${String(processing.deliveryState)}); `
|
|
266
|
+
+ 'plan again when it is delivered.',
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const plan = planStoreImageSet({
|
|
272
|
+
local: set.images.map((image) => ({ path: image.path, digest: image.md5 })),
|
|
273
|
+
// A failed delivery counts as no image: a complete set replaces it.
|
|
274
|
+
remote: remote.map((screenshot) => ({
|
|
275
|
+
id: screenshot.id,
|
|
276
|
+
digest: screenshot.deliveryState === undefined || screenshot.deliveryState === 'COMPLETE'
|
|
277
|
+
? String(screenshot.sourceFileChecksum ?? '').toLowerCase()
|
|
278
|
+
: '',
|
|
279
|
+
})),
|
|
280
|
+
complete: set.complete,
|
|
281
|
+
single: false,
|
|
282
|
+
maxImages: appStorePresenceLimits.screenshots,
|
|
283
|
+
label: `App Store ${locale} ${display}`,
|
|
284
|
+
storeName: 'App Store Connect',
|
|
285
|
+
});
|
|
286
|
+
const pending = remote.filter((screenshot) => screenshot.deliveryState !== undefined
|
|
287
|
+
&& screenshot.deliveryState !== 'COMPLETE');
|
|
288
|
+
screenshots.push({
|
|
289
|
+
display,
|
|
290
|
+
displayType,
|
|
291
|
+
action: plan.action,
|
|
292
|
+
complete: set.complete,
|
|
293
|
+
upload: plan.upload.map((image) => ({ path: image.path, md5: image.digest })),
|
|
294
|
+
keep: plan.keep,
|
|
295
|
+
remove: plan.remove.map((image) => ({ id: image.id, md5: image.digest })),
|
|
296
|
+
deleteIds: plan.action === 'replace' ? remote.map((screenshot) => screenshot.id) : [],
|
|
297
|
+
notes: [
|
|
298
|
+
...plan.notes,
|
|
299
|
+
...(plan.action === 'replace' && remote.length > 0
|
|
300
|
+
? [`The whole set (${remote.length}) is deleted, then the file's ${plan.upload.length} uploaded in order.`]
|
|
301
|
+
: []),
|
|
302
|
+
...pending.map((screenshot) => `Screenshot ${screenshot.id} is ${String(screenshot.deliveryState)}; mark the set complete to replace it.`),
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (input.local.size > 0 && input.version === undefined) {
|
|
308
|
+
input.notes.push(`${locale}: screenshots wait for an editable version.`);
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
locale,
|
|
312
|
+
appInfo: {
|
|
313
|
+
action: infoAction,
|
|
314
|
+
text: infoText,
|
|
315
|
+
...(infoAction === 'create' || infoAction === 'update'
|
|
316
|
+
? {
|
|
317
|
+
write: {
|
|
318
|
+
name: entry.name,
|
|
319
|
+
...(entry.subtitle === undefined ? {} : { subtitle: entry.subtitle }),
|
|
320
|
+
...(privacyPolicyUrl === undefined ? {} : { privacyPolicyUrl }),
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
},
|
|
325
|
+
version: {
|
|
326
|
+
action: versionAction,
|
|
327
|
+
...(remoteVersion === undefined ? {} : { localizationId: remoteVersion.id }),
|
|
328
|
+
text: versionText,
|
|
329
|
+
...(versionAction === 'create' ? { write: desired } : versionAction === 'update' ? { write: changedFields } : {}),
|
|
330
|
+
},
|
|
331
|
+
screenshots,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function indexByLocale(entries) {
|
|
335
|
+
return new Map(entries.map((entry) => [entry.locale.toLowerCase(), entry]));
|
|
336
|
+
}
|
|
337
|
+
function sortByLocale(entries) {
|
|
338
|
+
return [...entries].sort((left, right) => left.locale.localeCompare(right.locale));
|
|
339
|
+
}
|
|
340
|
+
export class AppStorePresenceApplyError extends Error {
|
|
341
|
+
stage;
|
|
342
|
+
written;
|
|
343
|
+
definitive;
|
|
344
|
+
constructor(stage,
|
|
345
|
+
/** The writes that landed before the failure. */
|
|
346
|
+
written, cause, detail,
|
|
347
|
+
/** App Store Connect refused the step (asc exited with a 4xx-class code): nothing from it landed. */
|
|
348
|
+
definitive = false) {
|
|
349
|
+
super(`App Store Connect ${stage} ${definitive ? 'failed; nothing from this step landed' : 'failed or its result is unknown'} (${cause instanceof Error ? cause.message : String(cause)}). Written before it: ${written.join('; ') || 'nothing'}. ${detail === undefined ? '' : `${detail} `}Run "${storePresencePlanCommand}" again: it reads App Store Connect and lists what is left.`, { cause });
|
|
350
|
+
this.stage = stage;
|
|
351
|
+
this.written = written;
|
|
352
|
+
this.definitive = definitive;
|
|
353
|
+
this.name = 'AppStorePresenceApplyError';
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Apply a reviewed plan: re-plan, refuse when App Store Connect or the files
|
|
358
|
+
* moved past the reviewed plan, then write step by step. A plan whose
|
|
359
|
+
* changes already landed is a no-op, so a re-run is safe.
|
|
360
|
+
*/
|
|
361
|
+
export async function applyAppStorePresence(input) {
|
|
362
|
+
const { saved, port } = input;
|
|
363
|
+
const { appId } = input.plan;
|
|
364
|
+
if (!input.approved) {
|
|
365
|
+
throw new Error('Writing the App Store page requires --approve.');
|
|
366
|
+
}
|
|
367
|
+
if (saved.appId !== appId || saved.bundleId !== input.plan.bundleId) {
|
|
368
|
+
throw new StorePlanStaleError(`The deployment profile now names app ${appId} (${input.plan.bundleId}), not ${saved.appId} (${saved.bundleId}).`, storePresencePlanCommand);
|
|
369
|
+
}
|
|
370
|
+
const appStore = requireAppStore(input.plan.presence);
|
|
371
|
+
const local = readLocalScreenshots(input.plan.directory, appStore);
|
|
372
|
+
const current = await planWithLocal(input.plan, appStore, local, port);
|
|
373
|
+
if (!current.changed) {
|
|
374
|
+
return { appId, status: 'unchanged', steps: [], manual: current.manual };
|
|
375
|
+
}
|
|
376
|
+
if (current.remoteSha256 !== saved.remoteSha256 || current.desiredSha256 !== saved.desiredSha256) {
|
|
377
|
+
throw new StorePlanStaleError('The App Store page or the store presence file changed since the plan.', storePresencePlanCommand);
|
|
378
|
+
}
|
|
379
|
+
if (current.imageRemovals > 0 && input.confirmImageRemoval !== true) {
|
|
380
|
+
throw new Error(`The plan removes ${current.imageRemovals} screenshot(s) from App Store Connect; confirm with --confirm-image-removal.`);
|
|
381
|
+
}
|
|
382
|
+
const steps = [];
|
|
383
|
+
let staging;
|
|
384
|
+
let stage = 'start';
|
|
385
|
+
let deletedSet;
|
|
386
|
+
const step = async (name, action) => {
|
|
387
|
+
stage = name;
|
|
388
|
+
const result = await action();
|
|
389
|
+
steps.push(name);
|
|
390
|
+
return result;
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
for (const locale of current.locales) {
|
|
394
|
+
const infoWrite = locale.appInfo.write;
|
|
395
|
+
if (infoWrite !== undefined && current.appInfo !== undefined) {
|
|
396
|
+
const appInfoId = current.appInfo.id;
|
|
397
|
+
await step(`${locale.locale} app information`, () => port.upsertAppInfoLocalization({
|
|
398
|
+
appId,
|
|
399
|
+
appInfoId,
|
|
400
|
+
locale: locale.locale,
|
|
401
|
+
...infoWrite,
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
if (current.version === undefined) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const versionId = current.version.id;
|
|
408
|
+
let localizationId = locale.version.localizationId;
|
|
409
|
+
const versionWrite = locale.version.write;
|
|
410
|
+
if (locale.version.action === 'create' && versionWrite !== undefined) {
|
|
411
|
+
localizationId = await step(`${locale.locale} version localization create`, () => port.createVersionLocalization({ versionId, locale: locale.locale, fields: versionWrite }));
|
|
412
|
+
}
|
|
413
|
+
else if (locale.version.action === 'update' && versionWrite !== undefined && localizationId !== undefined) {
|
|
414
|
+
const id = localizationId;
|
|
415
|
+
await step(`${locale.locale} version localization update`, () => port.updateVersionLocalization({ localizationId: id, fields: versionWrite }));
|
|
416
|
+
}
|
|
417
|
+
for (const set of locale.screenshots) {
|
|
418
|
+
if (set.action === 'unchanged') {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (localizationId === undefined) {
|
|
422
|
+
throw new Error(`${locale.locale} has no version localization for its screenshots`);
|
|
423
|
+
}
|
|
424
|
+
const versionLocalizationId = localizationId;
|
|
425
|
+
deletedSet = undefined;
|
|
426
|
+
// Every file of the set is read once, checked and copied to a private
|
|
427
|
+
// directory before the set is cleared; asc uploads the copies, so
|
|
428
|
+
// the bytes cannot change during the (minutes-long) uploads, and asc
|
|
429
|
+
// never sees a symbolic link (it refuses a source reached through one).
|
|
430
|
+
const files = set.upload.map((image, index) => {
|
|
431
|
+
const bytes = readFileSync(path.resolve(input.plan.directory, image.path));
|
|
432
|
+
if (createHash('md5').update(bytes).digest('hex') !== image.md5) {
|
|
433
|
+
throw new StorePlanStaleError(`${image.path} changed since the plan.`, storePresencePlanCommand);
|
|
434
|
+
}
|
|
435
|
+
staging ??= realpathSync(mkdtempSync(path.join(tmpdir(), 'mpgd-asc-screenshots-')));
|
|
436
|
+
const directory = path.join(staging, `${locale.locale}-${set.display}-${index}`);
|
|
437
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
438
|
+
const file = path.join(directory, path.basename(image.path));
|
|
439
|
+
writeFileSync(file, bytes, { mode: 0o600 });
|
|
440
|
+
return { image, file };
|
|
441
|
+
});
|
|
442
|
+
for (const id of set.deleteIds) {
|
|
443
|
+
await step(`${locale.locale} ${set.display} delete ${id}`, () => port.deleteScreenshot(id));
|
|
444
|
+
deletedSet = `${locale.locale} ${set.display}`;
|
|
445
|
+
}
|
|
446
|
+
for (const { image, file } of files) {
|
|
447
|
+
stage = `${locale.locale} ${set.display} upload of ${image.path}`;
|
|
448
|
+
await step(stage, () => port.uploadScreenshot({
|
|
449
|
+
versionLocalizationId,
|
|
450
|
+
displayType: set.displayType,
|
|
451
|
+
file,
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
deletedSet = undefined;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (error instanceof StorePlanStaleError && steps.length === 0) {
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
throw new AppStorePresenceApplyError(stage, steps, error, deletedSet === undefined
|
|
463
|
+
? undefined
|
|
464
|
+
: `The ${deletedSet} screenshots were deleted before the upload; apply the new plan to upload them again.`,
|
|
465
|
+
// An upload is several App Store Connect calls (reserve, send, commit):
|
|
466
|
+
// even a refused one may have left a reservation behind.
|
|
467
|
+
!stage.includes(' upload of ') && isDefinitiveAscFailure(error));
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
if (staging !== undefined) {
|
|
471
|
+
rmSync(staging, { recursive: true, force: true });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { appId, status: 'updated', steps, manual: current.manual };
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* asc 5.5.0 exit codes (`cmd/exit_codes.go`): 2 usage, 3 auth, 4 not found,
|
|
478
|
+
* 5 conflict, and 10 + (status - 400) for other 4xx answers. Those are App
|
|
479
|
+
* Store Connect (or asc) refusing the request; 1 (generic, including network
|
|
480
|
+
* failures), 5xx codes, a timeout or an abort leave the result unknown.
|
|
481
|
+
*/
|
|
482
|
+
function isDefinitiveAscFailure(error) {
|
|
483
|
+
if (!(error instanceof ReleaseProcessError) || error.reason !== 'exit' || error.exitCode === undefined) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
const code = error.exitCode;
|
|
487
|
+
const timeoutOrRateLimit = code === 10 + (408 - 400) || code === 10 + (429 - 400);
|
|
488
|
+
return (code >= 2 && code <= 5) || (code >= 10 && code < 60 && !timeoutOrRateLimit);
|
|
489
|
+
}
|
|
490
|
+
/** The plan for the terminal: per locale, the text and screenshot changes. */
|
|
491
|
+
export function formatAppStorePresencePlan(plan) {
|
|
492
|
+
const lines = [
|
|
493
|
+
`App Store Connect app ${plan.appId} (${plan.bundleId})${plan.version === undefined ? '' : `, version ${plan.version.versionString ?? plan.version.id} ${plan.version.state}`}${plan.changed ? '' : plan.blocked > 0 ? ': nothing writable now' : ': no changes'}${plan.blocked > 0 ? ` (${plan.blocked} blocked)` : ''}`,
|
|
494
|
+
];
|
|
495
|
+
for (const locale of plan.locales) {
|
|
496
|
+
lines.push(` ${locale.locale}`);
|
|
497
|
+
const part = (label, action, text) => {
|
|
498
|
+
if (action === 'unchanged') {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
lines.push(` ${action.padEnd(9)} ${label}`);
|
|
502
|
+
for (const change of text) {
|
|
503
|
+
lines.push(` ${formatTextChange(change)}`);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
part('app information', locale.appInfo.action, locale.appInfo.text);
|
|
507
|
+
part('version localization', locale.version.action, locale.version.text);
|
|
508
|
+
for (const set of locale.screenshots) {
|
|
509
|
+
if (set.action === 'unchanged' && set.notes.length === 0) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
lines.push(` ${set.action.padEnd(9)} ${set.display} (${set.displayType})${set.complete ? ' complete set' : ''}`);
|
|
513
|
+
for (const image of set.upload) {
|
|
514
|
+
lines.push(` + ${image.path} ${image.md5.slice(0, 12)}`);
|
|
515
|
+
}
|
|
516
|
+
for (const image of set.remove) {
|
|
517
|
+
lines.push(` - ${image.id} ${image.md5.slice(0, 12) || '(no checksum)'}`);
|
|
518
|
+
}
|
|
519
|
+
for (const note of set.notes) {
|
|
520
|
+
lines.push(` note: ${note}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (const locale of plan.remoteOnlyLocales) {
|
|
525
|
+
lines.push(` remote ${locale}: only on App Store Connect; not in the file (never deleted)`);
|
|
526
|
+
}
|
|
527
|
+
if (plan.imageRemovals > 0) {
|
|
528
|
+
lines.push(` IMAGE REMOVAL: ${plan.imageRemovals} screenshot(s); apply needs --confirm-image-removal`);
|
|
529
|
+
}
|
|
530
|
+
for (const note of plan.notes) {
|
|
531
|
+
lines.push(` note ${note}`);
|
|
532
|
+
}
|
|
533
|
+
for (const step of plan.manual) {
|
|
534
|
+
lines.push(` manual ${step}`);
|
|
535
|
+
}
|
|
536
|
+
return lines.join('\n');
|
|
537
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AscStorePresencePort } from './app-store-presence-port.js';
|
|
2
|
+
import type { AscCredential } from './asc-session.js';
|
|
3
|
+
import type { PlayStoreListingPort } from './play-listing-port.js';
|
|
4
|
+
import { type AppStorePresenceApplyResult, type AppStorePresencePlan } from './store-presence-app-store.js';
|
|
5
|
+
import { type PlayPresenceApplyResult, type PlayPresencePlan } from './store-presence-play.js';
|
|
6
|
+
import { type StoreSyncStore } from './store-sync.js';
|
|
7
|
+
/**
|
|
8
|
+
* `mpgd store presence plan|apply`: the deployment profile names the app and
|
|
9
|
+
* the store credential (the same ones `deploy submit` uses); the game's
|
|
10
|
+
* `mpgd.store-presence.json` names the store page text and graphics.
|
|
11
|
+
*/
|
|
12
|
+
export type StorePresenceStore = StoreSyncStore;
|
|
13
|
+
export interface StorePresencePlanOptions {
|
|
14
|
+
readonly game: string;
|
|
15
|
+
readonly profile: string;
|
|
16
|
+
readonly store: StorePresenceStore;
|
|
17
|
+
readonly presenceFile?: string;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
/** The saved plan: the inputs apply re-reads, and the reviewed plan. */
|
|
21
|
+
export interface StorePresencePlanFile {
|
|
22
|
+
readonly schemaVersion: 1;
|
|
23
|
+
readonly kind: 'mpgd-store-presence-plan';
|
|
24
|
+
readonly gameRoot: string;
|
|
25
|
+
readonly profile: string;
|
|
26
|
+
readonly presenceFile: string;
|
|
27
|
+
readonly plan: PlayPresencePlan | AppStorePresencePlan;
|
|
28
|
+
}
|
|
29
|
+
/** Store clients; the tests replace them with fakes. */
|
|
30
|
+
export interface StorePresencePorts {
|
|
31
|
+
readonly googlePlay: (serviceAccountFile: string) => Promise<PlayStoreListingPort>;
|
|
32
|
+
readonly appStore: (credential: AscCredential, signal?: AbortSignal) => Promise<{
|
|
33
|
+
readonly port: AscStorePresencePort;
|
|
34
|
+
close(): void;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export declare function planStorePresence(options: StorePresencePlanOptions, environment?: NodeJS.ProcessEnv, ports?: Partial<StorePresencePorts>): Promise<StorePresencePlanFile>;
|
|
38
|
+
export declare function saveStorePresencePlan(file: string, plan: StorePresencePlanFile): string;
|
|
39
|
+
export interface StorePresenceApplyOptions {
|
|
40
|
+
readonly planFile: string;
|
|
41
|
+
/** Current game directory, when the game moved since the plan. */
|
|
42
|
+
readonly game?: string;
|
|
43
|
+
readonly approved: boolean;
|
|
44
|
+
/** Allow a complete image set to remove images the store holds. */
|
|
45
|
+
readonly confirmImageRemoval?: boolean;
|
|
46
|
+
/** Google Play: commit with `changesNotSentForReview` (send for review in Play Console). */
|
|
47
|
+
readonly changesNotSentForReview?: boolean;
|
|
48
|
+
readonly signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
export declare function applyStorePresence(options: StorePresenceApplyOptions, environment?: NodeJS.ProcessEnv, ports?: Partial<StorePresencePorts>): Promise<PlayPresenceApplyResult | AppStorePresenceApplyResult>;
|