@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,495 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { googlePlayPresenceImageSlots, googlePlayPresenceLimits, resolveStorePresenceImageSet, } from '@mpgd/catalog';
|
|
3
|
+
import { isDefinitiveHttpError } from './play-internal-submission.js';
|
|
4
|
+
import { assertLocalStoreImageUnchanged, describeTextChange, formatTextChange, imageMimeType, normalizeLineBreaks, planStoreImageSet, readLocalStoreImage, sameUrlForm, storePresencePlanCommand, } from './store-presence.js';
|
|
5
|
+
import { canonicalLanguageTag, mapWithConcurrency, stableSha256, StorePlanStaleError, } from './store-sync.js';
|
|
6
|
+
const readConcurrency = 4;
|
|
7
|
+
const uploadTimeoutMs = 5 * 60_000;
|
|
8
|
+
/** Read Play through a throwaway edit and plan; never writes. */
|
|
9
|
+
export async function planPlayPresence(input, port) {
|
|
10
|
+
const play = requirePlay(input.presence);
|
|
11
|
+
// Graphics are read and checked before Play is asked anything.
|
|
12
|
+
const local = readLocalGraphics(input.directory, play);
|
|
13
|
+
const editId = await port.insertEdit(input.packageName, input.signal);
|
|
14
|
+
try {
|
|
15
|
+
return await planInEdit(input, play, local, port, editId);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await discardEdit(port, input.packageName, editId);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function requirePlay(presence) {
|
|
22
|
+
if (presence.googlePlay === undefined) {
|
|
23
|
+
throw new Error('The store presence file has no googlePlay section.');
|
|
24
|
+
}
|
|
25
|
+
return presence.googlePlay;
|
|
26
|
+
}
|
|
27
|
+
function readLocalGraphics(directory, play) {
|
|
28
|
+
const result = new Map();
|
|
29
|
+
for (const [language, listing] of Object.entries(play.listings)) {
|
|
30
|
+
const slots = new Map();
|
|
31
|
+
for (const slot of googlePlayPresenceImageSlots) {
|
|
32
|
+
const value = listing.graphics?.[slot];
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const { files, complete } = typeof value === 'string'
|
|
37
|
+
? { files: [value], complete: true }
|
|
38
|
+
: resolveStorePresenceImageSet(value);
|
|
39
|
+
const label = `googlePlay.listings.${language}.graphics.${slot}`;
|
|
40
|
+
const images = files.map((file) => readLocalStoreImage(directory, file, { store: 'google-play', slot }, label));
|
|
41
|
+
const digests = new Set(images.map((image) => image.sha256));
|
|
42
|
+
if (digests.size !== images.length) {
|
|
43
|
+
throw new Error(`${label} lists the same image twice under different names.`);
|
|
44
|
+
}
|
|
45
|
+
slots.set(slot, { images, complete });
|
|
46
|
+
}
|
|
47
|
+
result.set(language, slots);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
async function planInEdit(input, play, local, port, editId) {
|
|
52
|
+
const { packageName, signal } = input;
|
|
53
|
+
const [details, remoteListings] = await Promise.all([
|
|
54
|
+
port.getDetails(packageName, editId, signal),
|
|
55
|
+
port.listListings(packageName, editId, signal),
|
|
56
|
+
]);
|
|
57
|
+
const remoteByLanguage = new Map();
|
|
58
|
+
for (const listing of remoteListings) {
|
|
59
|
+
if (typeof listing.language === 'string') {
|
|
60
|
+
remoteByLanguage.set(listing.language.toLowerCase(), listing);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const languages = Object.keys(play.listings);
|
|
64
|
+
// Play's images of every slot the file manages, for languages Play lists.
|
|
65
|
+
const reads = languages.flatMap((language) => {
|
|
66
|
+
const remote = remoteByLanguage.get(language.toLowerCase());
|
|
67
|
+
if (remote === undefined) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return [...(local.get(language)?.keys() ?? [])].map((imageType) => ({
|
|
71
|
+
language,
|
|
72
|
+
remoteLanguage: String(remote.language),
|
|
73
|
+
imageType,
|
|
74
|
+
}));
|
|
75
|
+
});
|
|
76
|
+
const imageLists = await mapWithConcurrency(reads, readConcurrency, (read) => port.listImages({
|
|
77
|
+
packageName,
|
|
78
|
+
editId,
|
|
79
|
+
language: read.remoteLanguage,
|
|
80
|
+
imageType: read.imageType,
|
|
81
|
+
...(signal === undefined ? {} : { signal }),
|
|
82
|
+
}));
|
|
83
|
+
const remoteImages = new Map();
|
|
84
|
+
reads.forEach((read, index) => {
|
|
85
|
+
remoteImages.set(`${read.language}\n${read.imageType}`, imageLists[index] ?? []);
|
|
86
|
+
});
|
|
87
|
+
const notes = [];
|
|
88
|
+
const planned = languages.map((language) => planLanguage({
|
|
89
|
+
notes,
|
|
90
|
+
language,
|
|
91
|
+
entry: play.listings[language],
|
|
92
|
+
remote: remoteByLanguage.get(language.toLowerCase()),
|
|
93
|
+
local: local.get(language) ?? new Map(),
|
|
94
|
+
remoteImages: (imageType) => remoteImages.get(`${language}\n${imageType}`) ?? [],
|
|
95
|
+
}));
|
|
96
|
+
const named = new Set(languages.map((language) => language.toLowerCase()));
|
|
97
|
+
const remoteOnlyLanguages = remoteListings
|
|
98
|
+
.map((listing) => String(listing.language))
|
|
99
|
+
.filter((language) => !named.has(language.toLowerCase()))
|
|
100
|
+
.sort();
|
|
101
|
+
const defaultLanguage = typeof details.defaultLanguage === 'string'
|
|
102
|
+
? details.defaultLanguage
|
|
103
|
+
: undefined;
|
|
104
|
+
if (defaultLanguage !== undefined && !named.has(defaultLanguage.toLowerCase())) {
|
|
105
|
+
notes.push(`Play's default language ${defaultLanguage} is not in the file; its listing stays as Play has it.`);
|
|
106
|
+
}
|
|
107
|
+
const plannedDetails = planDetails(details, play, notes);
|
|
108
|
+
const manual = manualSteps(input.presence);
|
|
109
|
+
const imageRemovals = planned.reduce((sum, language) => sum + language.images.reduce((count, slot) => count + (slot.complete && isMultiImage(slot.imageType) ? slot.remove.length : 0), 0), 0);
|
|
110
|
+
const changed = plannedDetails !== undefined || planned.some((language) => language.action !== 'unchanged' || language.images.some((slot) => slot.action !== 'unchanged'));
|
|
111
|
+
// Images by ID and digest only: their preview URLs may differ per request.
|
|
112
|
+
const remoteSha256 = stableSha256({
|
|
113
|
+
details,
|
|
114
|
+
listings: [...remoteListings].sort((left, right) => String(left.language).localeCompare(String(right.language))),
|
|
115
|
+
images: Object.fromEntries([...remoteImages.entries()]
|
|
116
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
117
|
+
.map(([key, images]) => [
|
|
118
|
+
key,
|
|
119
|
+
images.map((image) => ({ id: image.id, sha256: image.sha256, sha1: image.sha1 })),
|
|
120
|
+
])),
|
|
121
|
+
});
|
|
122
|
+
const desiredSha256 = stableSha256({ details: plannedDetails?.write, languages: planned });
|
|
123
|
+
return {
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
store: 'google-play',
|
|
126
|
+
packageName,
|
|
127
|
+
...(plannedDetails === undefined ? {} : { details: plannedDetails }),
|
|
128
|
+
languages: planned,
|
|
129
|
+
remoteOnlyLanguages,
|
|
130
|
+
imageRemovals,
|
|
131
|
+
changed,
|
|
132
|
+
notes,
|
|
133
|
+
manual,
|
|
134
|
+
remoteSha256,
|
|
135
|
+
desiredSha256,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function planLanguage(input) {
|
|
139
|
+
const { entry, remote } = input;
|
|
140
|
+
const language = typeof remote?.language === 'string'
|
|
141
|
+
? remote.language
|
|
142
|
+
: canonicalLanguageTag(input.language);
|
|
143
|
+
const video = entry.video ?? (typeof remote?.video === 'string' && remote.video !== '' ? remote.video : undefined);
|
|
144
|
+
const fullDescription = normalizeLineBreaks(entry.fullDescription);
|
|
145
|
+
const text = [
|
|
146
|
+
describeTextChange('title', remote?.title, entry.title),
|
|
147
|
+
describeTextChange('shortDescription', remote?.shortDescription, entry.shortDescription),
|
|
148
|
+
describeTextChange('fullDescription', remote?.fullDescription, fullDescription),
|
|
149
|
+
entry.video === undefined ? undefined : describeTextChange('video', remote?.video, entry.video),
|
|
150
|
+
].filter((change) => change !== undefined);
|
|
151
|
+
if (entry.video !== undefined && typeof remote?.video === 'string' && remote.video !== entry.video
|
|
152
|
+
&& sameUrlForm(remote.video, entry.video)) {
|
|
153
|
+
input.notes.push(`${language} video differs from Play's only in URL form (${remote.video}); Play may normalize it.`);
|
|
154
|
+
}
|
|
155
|
+
const images = googlePlayPresenceImageSlots.flatMap((imageType) => {
|
|
156
|
+
const slot = input.local.get(imageType);
|
|
157
|
+
return slot === undefined
|
|
158
|
+
? []
|
|
159
|
+
: [planImageSlot(imageType, slot.images, slot.complete, input.remoteImages(imageType))];
|
|
160
|
+
});
|
|
161
|
+
const action = remote === undefined
|
|
162
|
+
? 'create'
|
|
163
|
+
: text.length > 0
|
|
164
|
+
? 'update'
|
|
165
|
+
: 'unchanged';
|
|
166
|
+
return {
|
|
167
|
+
language,
|
|
168
|
+
action,
|
|
169
|
+
text,
|
|
170
|
+
...(action === 'unchanged' ? {} : {
|
|
171
|
+
listing: {
|
|
172
|
+
language,
|
|
173
|
+
title: entry.title,
|
|
174
|
+
shortDescription: entry.shortDescription,
|
|
175
|
+
fullDescription,
|
|
176
|
+
...(video === undefined ? {} : { video }),
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
images,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function isMultiImage(imageType) {
|
|
183
|
+
return imageType !== 'icon' && imageType !== 'featureGraphic';
|
|
184
|
+
}
|
|
185
|
+
function planImageSlot(imageType, local, complete, remote) {
|
|
186
|
+
const plan = planStoreImageSet({
|
|
187
|
+
local: local.map((image) => ({ path: image.path, digest: image.sha256 })),
|
|
188
|
+
remote: remote.map((image) => ({
|
|
189
|
+
id: String(image.id),
|
|
190
|
+
digest: remoteImageDigest(image, local, imageType),
|
|
191
|
+
})),
|
|
192
|
+
complete,
|
|
193
|
+
single: !isMultiImage(imageType),
|
|
194
|
+
maxImages: googlePlayPresenceLimits.screenshots,
|
|
195
|
+
label: `Google Play ${imageType}`,
|
|
196
|
+
storeName: 'Play',
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
imageType,
|
|
200
|
+
action: plan.action,
|
|
201
|
+
complete,
|
|
202
|
+
upload: plan.upload.map((image) => ({ path: image.path, sha256: image.digest })),
|
|
203
|
+
keep: plan.keep,
|
|
204
|
+
remove: plan.remove.map((image) => ({ id: image.id, sha256: image.digest })),
|
|
205
|
+
notes: plan.notes,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The digest a Play image is compared by: its SHA-256, else (Play reported
|
|
210
|
+
* only SHA-1) the SHA-256 of the file image with that SHA-1. An image with
|
|
211
|
+
* neither cannot be compared, so the plan stops instead of re-uploading it
|
|
212
|
+
* on every run.
|
|
213
|
+
*/
|
|
214
|
+
function remoteImageDigest(image, local, imageType) {
|
|
215
|
+
if (typeof image.sha256 === 'string' && image.sha256 !== '') {
|
|
216
|
+
return image.sha256.toLowerCase();
|
|
217
|
+
}
|
|
218
|
+
if (typeof image.sha1 === 'string' && image.sha1 !== '') {
|
|
219
|
+
const sha1 = image.sha1.toLowerCase();
|
|
220
|
+
return local.find((candidate) => candidate.sha1 === sha1)?.sha256 ?? `sha1:${sha1}`;
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`Google Play ${imageType}: Play reports image ${String(image.id)} without a SHA-256 or SHA-1, so the sync `
|
|
223
|
+
+ 'cannot compare it; replace the images of this slot in Play Console.');
|
|
224
|
+
}
|
|
225
|
+
function planDetails(details, play, notes) {
|
|
226
|
+
const contact = play.contact;
|
|
227
|
+
if (contact === undefined) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const changes = [];
|
|
231
|
+
const write = {};
|
|
232
|
+
for (const [field, value] of Object.entries(details)) {
|
|
233
|
+
if (typeof value === 'string' && value !== '') {
|
|
234
|
+
write[field] = value;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const fields = [
|
|
238
|
+
['contactEmail', contact.email],
|
|
239
|
+
['contactWebsite', contact.website],
|
|
240
|
+
['contactPhone', contact.phone],
|
|
241
|
+
];
|
|
242
|
+
for (const [field, value] of fields) {
|
|
243
|
+
if (value === undefined) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const from = details[field];
|
|
247
|
+
if (from !== value) {
|
|
248
|
+
if (field === 'contactWebsite' && typeof from === 'string' && sameUrlForm(from, value)) {
|
|
249
|
+
notes.push(`The contact website differs from Play's only in URL form (${from}); Play may normalize it.`);
|
|
250
|
+
}
|
|
251
|
+
changes.push({
|
|
252
|
+
field,
|
|
253
|
+
...(typeof from === 'string' && from !== '' ? { from } : {}),
|
|
254
|
+
to: value,
|
|
255
|
+
});
|
|
256
|
+
write[field] = value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (changes.length === 0) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
// edits.details.update replaces the whole object, and Play requires a contact email.
|
|
263
|
+
if (write.contactEmail === undefined) {
|
|
264
|
+
throw new Error('Google Play has no contact email for the app and the file gives none; Play needs one to update the '
|
|
265
|
+
+ 'contact details. Add googlePlay.contact.email.');
|
|
266
|
+
}
|
|
267
|
+
return { changes, write };
|
|
268
|
+
}
|
|
269
|
+
function manualSteps(presence) {
|
|
270
|
+
const steps = [];
|
|
271
|
+
if (presence.privacyPolicyUrl !== undefined) {
|
|
272
|
+
steps.push(`Privacy policy (Play Console > Policy and programs > App content): ${presence.privacyPolicyUrl}; `
|
|
273
|
+
+ 'the Android Publisher API has no field for it.');
|
|
274
|
+
}
|
|
275
|
+
steps.push('Data safety, content rating, target audience, ads and the app category stay in Play Console '
|
|
276
|
+
+ '(App content and Store settings).');
|
|
277
|
+
return steps;
|
|
278
|
+
}
|
|
279
|
+
export class PlayPresenceApplyError extends Error {
|
|
280
|
+
stage;
|
|
281
|
+
outcome;
|
|
282
|
+
constructor(stage,
|
|
283
|
+
/** `discarded`: Play discarded the edit; `unknown`: the commit may or may not have landed. */
|
|
284
|
+
outcome, cause) {
|
|
285
|
+
super(`Google Play ${stage} failed (${cause instanceof Error ? cause.message : String(cause)}). ${outcome === 'discarded'
|
|
286
|
+
? 'The edit was discarded; nothing was published.'
|
|
287
|
+
: 'The commit result is unknown.'} Run "${storePresencePlanCommand}" again: it reads Play and lists `
|
|
288
|
+
+ 'what is left.', { cause });
|
|
289
|
+
this.stage = stage;
|
|
290
|
+
this.outcome = outcome;
|
|
291
|
+
this.name = 'PlayPresenceApplyError';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Apply a reviewed plan in one edit: re-plan inside the edit, refuse when
|
|
296
|
+
* Play or the files moved past the reviewed plan, write, then commit once.
|
|
297
|
+
* A plan whose changes already landed is a no-op, so a re-run is safe.
|
|
298
|
+
*/
|
|
299
|
+
export async function applyPlayPresence(input) {
|
|
300
|
+
const { saved, port } = input;
|
|
301
|
+
const { packageName, signal } = input.plan;
|
|
302
|
+
if (!input.approved) {
|
|
303
|
+
throw new Error('Writing the Google Play store listing requires --approve.');
|
|
304
|
+
}
|
|
305
|
+
if (saved.packageName !== packageName) {
|
|
306
|
+
throw new StorePlanStaleError(`The deployment profile now names ${packageName}, not ${saved.packageName}.`, storePresencePlanCommand);
|
|
307
|
+
}
|
|
308
|
+
const play = requirePlay(input.plan.presence);
|
|
309
|
+
const local = readLocalGraphics(input.plan.directory, play);
|
|
310
|
+
const editId = await port.insertEdit(packageName, signal);
|
|
311
|
+
let stage = 'read';
|
|
312
|
+
// A commit that failed without a definitive answer may have landed.
|
|
313
|
+
let commitUncertain = false;
|
|
314
|
+
try {
|
|
315
|
+
const current = await planInEdit(input.plan, play, local, port, editId);
|
|
316
|
+
if (!current.changed) {
|
|
317
|
+
await discardEdit(port, packageName, editId);
|
|
318
|
+
return { packageName, status: 'unchanged', languages: [] };
|
|
319
|
+
}
|
|
320
|
+
if (current.remoteSha256 !== saved.remoteSha256 || current.desiredSha256 !== saved.desiredSha256) {
|
|
321
|
+
throw new StorePlanStaleError('The Google Play store listing or the store presence file changed since the plan.', storePresencePlanCommand);
|
|
322
|
+
}
|
|
323
|
+
if (current.imageRemovals > 0 && input.confirmImageRemoval !== true) {
|
|
324
|
+
throw new Error(`The plan removes ${current.imageRemovals} image(s) from Google Play; confirm with --confirm-image-removal.`);
|
|
325
|
+
}
|
|
326
|
+
const optionalSignal = signal === undefined ? {} : { signal };
|
|
327
|
+
if (current.details !== undefined) {
|
|
328
|
+
stage = 'details update';
|
|
329
|
+
await port.updateDetails({
|
|
330
|
+
packageName,
|
|
331
|
+
editId,
|
|
332
|
+
details: current.details.write,
|
|
333
|
+
...optionalSignal,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const languages = [];
|
|
337
|
+
for (const language of current.languages) {
|
|
338
|
+
if (language.listing !== undefined) {
|
|
339
|
+
stage = `listing ${language.language}`;
|
|
340
|
+
await port.updateListing({
|
|
341
|
+
packageName,
|
|
342
|
+
editId,
|
|
343
|
+
listing: language.listing,
|
|
344
|
+
...optionalSignal,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
let uploaded = 0;
|
|
348
|
+
for (const slot of language.images) {
|
|
349
|
+
if (slot.action === 'unchanged') {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const target = {
|
|
353
|
+
packageName,
|
|
354
|
+
editId,
|
|
355
|
+
language: language.language,
|
|
356
|
+
imageType: slot.imageType,
|
|
357
|
+
...optionalSignal,
|
|
358
|
+
};
|
|
359
|
+
if (slot.action === 'replace') {
|
|
360
|
+
stage = `${language.language} ${slot.imageType} delete`;
|
|
361
|
+
await port.deleteAllImages(target);
|
|
362
|
+
}
|
|
363
|
+
for (const image of slot.upload) {
|
|
364
|
+
stage = `${language.language} ${slot.imageType} upload of ${image.path}`;
|
|
365
|
+
const file = path.resolve(input.plan.directory, image.path);
|
|
366
|
+
assertLocalStoreImageUnchanged(file, image.sha256);
|
|
367
|
+
const localFile = localImage(local, language.language, slot.imageType, image.sha256);
|
|
368
|
+
const stored = await port.uploadImage({
|
|
369
|
+
...target,
|
|
370
|
+
file,
|
|
371
|
+
mimeType: imageMimeType(localFile.info),
|
|
372
|
+
timeoutMs: uploadTimeoutMs,
|
|
373
|
+
});
|
|
374
|
+
uploaded += 1;
|
|
375
|
+
assertStoredDigest(stored, localFile);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
languages.push({ language: language.language, listing: language.action, images: uploaded });
|
|
379
|
+
}
|
|
380
|
+
stage = 'commit';
|
|
381
|
+
try {
|
|
382
|
+
await port.commitEdit({
|
|
383
|
+
packageName,
|
|
384
|
+
editId,
|
|
385
|
+
changesNotSentForReview: input.changesNotSentForReview === true,
|
|
386
|
+
...optionalSignal,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
// A definitive 4xx (for example ERROR_IF_IN_REVIEW while a release is
|
|
391
|
+
// in review) did not commit; anything else may have.
|
|
392
|
+
commitUncertain = !isDefinitiveHttpError(error);
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
return { packageName, status: 'committed', languages };
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
// Never delete an edit that may have been committed (as deploy promote).
|
|
399
|
+
if (!commitUncertain) {
|
|
400
|
+
await discardEdit(port, packageName, editId);
|
|
401
|
+
}
|
|
402
|
+
// Nothing was written before the first write stage: the error stands as it is.
|
|
403
|
+
if (error instanceof StorePlanStaleError || stage === 'read') {
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
throw new PlayPresenceApplyError(stage, commitUncertain ? 'unknown' : 'discarded', error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Images are matched by digest: one Play stores under another digest (for
|
|
411
|
+
* example re-encoded) would be uploaded again by every later apply, and one
|
|
412
|
+
* without a digest could never be matched.
|
|
413
|
+
*/
|
|
414
|
+
function assertStoredDigest(stored, image) {
|
|
415
|
+
if (typeof stored.sha256 === 'string' && stored.sha256 !== '') {
|
|
416
|
+
if (stored.sha256.toLowerCase() !== image.sha256) {
|
|
417
|
+
throw new Error(`Play stored ${image.path} with SHA-256 ${stored.sha256}, not the file's ${image.sha256}; `
|
|
418
|
+
+ 'the sync matches images by digest and cannot keep this slot in step');
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (typeof stored.sha1 === 'string' && stored.sha1 !== '') {
|
|
423
|
+
if (stored.sha1.toLowerCase() !== image.sha1) {
|
|
424
|
+
throw new Error(`Play stored ${image.path} with SHA-1 ${stored.sha1}, not the file's ${image.sha1}; `
|
|
425
|
+
+ 'the sync matches images by digest and cannot keep this slot in step');
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
throw new Error(`Play answered the upload of ${image.path} without a SHA-256 or SHA-1; the sync cannot confirm it`);
|
|
430
|
+
}
|
|
431
|
+
function localImage(local, language, imageType, sha256) {
|
|
432
|
+
for (const [fileLanguage, slots] of local) {
|
|
433
|
+
if (fileLanguage.toLowerCase() !== language.toLowerCase()) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const image = slots.get(imageType)?.images.find((candidate) => candidate.sha256 === sha256);
|
|
437
|
+
if (image !== undefined) {
|
|
438
|
+
return image;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
throw new StorePlanStaleError(`${language} ${imageType} image ${sha256} is no longer in the file.`, storePresencePlanCommand);
|
|
442
|
+
}
|
|
443
|
+
/** Discard an edit; a failure here must not hide the error that led to it. */
|
|
444
|
+
async function discardEdit(port, packageName, editId) {
|
|
445
|
+
try {
|
|
446
|
+
await port.deleteEdit(packageName, editId);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// An invalidated or committed edit cannot be deleted; Play drops it anyway.
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** The plan for the terminal: per language, the text and image changes. */
|
|
453
|
+
export function formatPlayPresencePlan(plan) {
|
|
454
|
+
const lines = [
|
|
455
|
+
`Google Play ${plan.packageName} store listing${plan.changed ? '' : ': no changes'}`,
|
|
456
|
+
];
|
|
457
|
+
for (const change of plan.details?.changes ?? []) {
|
|
458
|
+
lines.push(` details ${change.field}: ${JSON.stringify(change.from ?? null)} -> ${JSON.stringify(change.to)}`);
|
|
459
|
+
}
|
|
460
|
+
for (const language of plan.languages) {
|
|
461
|
+
const changedSlots = language.images.filter((slot) => slot.action !== 'unchanged');
|
|
462
|
+
lines.push(` ${language.action.padEnd(9)} ${language.language}${changedSlots.length === 0 ? '' : ` (images: ${changedSlots.map((slot) => slot.imageType).join(', ')})`}`);
|
|
463
|
+
for (const change of language.text) {
|
|
464
|
+
lines.push(` ${formatTextChange(change)}`);
|
|
465
|
+
}
|
|
466
|
+
for (const slot of language.images) {
|
|
467
|
+
if (slot.action === 'unchanged' && slot.notes.length === 0) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
lines.push(` ${slot.imageType}: ${slot.action}${slot.complete && isMultiImage(slot.imageType) ? ' (complete set)' : ''}`);
|
|
471
|
+
for (const image of slot.upload) {
|
|
472
|
+
lines.push(` + ${image.path} ${image.sha256.slice(0, 12)}`);
|
|
473
|
+
}
|
|
474
|
+
for (const image of slot.remove) {
|
|
475
|
+
lines.push(` - ${image.id} ${image.sha256.slice(0, 12) || '(no digest)'}`);
|
|
476
|
+
}
|
|
477
|
+
for (const note of slot.notes) {
|
|
478
|
+
lines.push(` note: ${note}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const language of plan.remoteOnlyLanguages) {
|
|
483
|
+
lines.push(` remote ${language}: only on Play; not in the file (never deleted)`);
|
|
484
|
+
}
|
|
485
|
+
if (plan.imageRemovals > 0) {
|
|
486
|
+
lines.push(` IMAGE REMOVAL: ${plan.imageRemovals} image(s); apply needs --confirm-image-removal`);
|
|
487
|
+
}
|
|
488
|
+
for (const note of plan.notes) {
|
|
489
|
+
lines.push(` note ${note}`);
|
|
490
|
+
}
|
|
491
|
+
for (const step of plan.manual) {
|
|
492
|
+
lines.push(` manual ${step}`);
|
|
493
|
+
}
|
|
494
|
+
return lines.join('\n');
|
|
495
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type StoreImageInfo, type StorePresence, type StorePresenceImageSlot } from '@mpgd/catalog';
|
|
2
|
+
/**
|
|
3
|
+
* The game side of `mpgd store presence plan|apply`: the store presence
|
|
4
|
+
* file and the graphics it names, read and checked against the store rules
|
|
5
|
+
* before anything talks to a store.
|
|
6
|
+
*/
|
|
7
|
+
export declare const defaultStorePresenceFileName = "mpgd.store-presence.json";
|
|
8
|
+
export declare const storePresencePlanCommand = "mpgd store presence plan";
|
|
9
|
+
export interface StorePresenceSource {
|
|
10
|
+
readonly file: string;
|
|
11
|
+
/** Graphics paths resolve against this directory. */
|
|
12
|
+
readonly directory: string;
|
|
13
|
+
readonly presence: StorePresence;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read and validate the store presence file. A given path resolves against
|
|
17
|
+
* the current directory, like the other mpgd file flags; the default is the
|
|
18
|
+
* game's own file.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readStorePresenceSource(input: {
|
|
21
|
+
readonly gameRoot: string;
|
|
22
|
+
readonly presenceFile?: string;
|
|
23
|
+
}): StorePresenceSource;
|
|
24
|
+
/** One graphic the file names, read from this checkout. */
|
|
25
|
+
export interface LocalStoreImage {
|
|
26
|
+
/** Path relative to the store presence file, as the file names it. */
|
|
27
|
+
readonly path: string;
|
|
28
|
+
/** Absolute path on this checkout. */
|
|
29
|
+
readonly file: string;
|
|
30
|
+
readonly sha256: string;
|
|
31
|
+
/** For a store that reports only SHA-1. */
|
|
32
|
+
readonly sha1: string;
|
|
33
|
+
readonly md5: string;
|
|
34
|
+
readonly byteLength: number;
|
|
35
|
+
readonly info: StoreImageInfo;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read one graphic: it must stay inside the file's directory (symbolic links
|
|
39
|
+
* included), be the PNG or JPEG its extension says, and meet the store's
|
|
40
|
+
* rules for its slot.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readLocalStoreImage(directory: string, relative: string, target: StorePresenceImageSlot, label: string): LocalStoreImage;
|
|
43
|
+
/** Refuse to upload a graphic whose bytes changed after the plan was checked. */
|
|
44
|
+
export declare function assertLocalStoreImageUnchanged(file: string, sha256: string): void;
|
|
45
|
+
export declare function imageMimeType(info: StoreImageInfo): 'image/png' | 'image/jpeg';
|
|
46
|
+
/**
|
|
47
|
+
* A text field change for the plan output: lengths always, the texts
|
|
48
|
+
* themselves only while both are short.
|
|
49
|
+
*/
|
|
50
|
+
export interface StorePresenceTextChange {
|
|
51
|
+
readonly field: string;
|
|
52
|
+
readonly fromLength: number | null;
|
|
53
|
+
readonly toLength: number;
|
|
54
|
+
readonly from?: string;
|
|
55
|
+
readonly to?: string;
|
|
56
|
+
}
|
|
57
|
+
/** CRLF and lone CR as LF, so a file saved on Windows and the store's text compare equal. */
|
|
58
|
+
export declare function normalizeLineBreaks(value: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Two URLs that differ only in form (host case, a trailing slash, default
|
|
61
|
+
* port): a store may normalize the URL it was given.
|
|
62
|
+
*/
|
|
63
|
+
export declare function sameUrlForm(left: string, right: string): boolean;
|
|
64
|
+
/** Compares after normalizing line breaks; `to` is expected normalized already. */
|
|
65
|
+
export declare function describeTextChange(field: string, from: string | null | undefined, to: string): StorePresenceTextChange | undefined;
|
|
66
|
+
export declare function formatTextChange(change: StorePresenceTextChange): string;
|
|
67
|
+
/** One image the store holds, by its ID and the digest the store reports for it. */
|
|
68
|
+
export interface RemoteStoreImage {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
/** Lowercase hex; empty when the store reports none. */
|
|
71
|
+
readonly digest: string;
|
|
72
|
+
}
|
|
73
|
+
export interface StoreImageSetPlan {
|
|
74
|
+
/** `add` uploads after the store's images; `replace` deletes the store's first. */
|
|
75
|
+
readonly action: 'unchanged' | 'add' | 'replace';
|
|
76
|
+
/** Uploads, in order. */
|
|
77
|
+
readonly upload: readonly {
|
|
78
|
+
readonly path: string;
|
|
79
|
+
readonly digest: string;
|
|
80
|
+
}[];
|
|
81
|
+
/** Digests of the store's images that stay. */
|
|
82
|
+
readonly keep: readonly string[];
|
|
83
|
+
/** The store's images the write removes and does not upload again. */
|
|
84
|
+
readonly remove: readonly RemoteStoreImage[];
|
|
85
|
+
readonly notes: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Plan one image slot. A single-image slot (an icon) is replaced when it
|
|
89
|
+
* differs. A list adds the images the store lacks after the ones it holds;
|
|
90
|
+
* a complete set appends when the store holds a prefix of it, else replaces
|
|
91
|
+
* the slot so the store holds exactly the set in order.
|
|
92
|
+
*/
|
|
93
|
+
export declare function planStoreImageSet(input: {
|
|
94
|
+
readonly local: readonly {
|
|
95
|
+
readonly path: string;
|
|
96
|
+
readonly digest: string;
|
|
97
|
+
}[];
|
|
98
|
+
readonly remote: readonly RemoteStoreImage[];
|
|
99
|
+
readonly complete: boolean;
|
|
100
|
+
readonly single: boolean;
|
|
101
|
+
readonly maxImages: number;
|
|
102
|
+
/** For messages: the slot, for example "Google Play phoneScreenshots". */
|
|
103
|
+
readonly label: string;
|
|
104
|
+
/** For messages: "Play" or "App Store Connect". */
|
|
105
|
+
readonly storeName: string;
|
|
106
|
+
}): StoreImageSetPlan;
|