vydanne 0.6.0 → 0.7.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/GETTING_STARTED.md +69 -40
- package/README.md +91 -16
- package/SKILL.md +129 -24
- package/bin/vydanne.mjs +24 -1
- package/package.json +3 -2
- package/src/client.mjs +42 -6
- package/src/commands/accessibility.mjs +13 -0
- package/src/commands/ageRating.mjs +94 -21
- package/src/commands/bridge.mjs +328 -0
- package/src/commands/compliance.mjs +78 -9
- package/src/commands/diff.mjs +63 -19
- package/src/commands/fill.mjs +74 -13
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +59 -6
- package/src/commands/prepare.mjs +220 -0
- package/src/commands/prerelease.mjs +17 -5
- package/src/commands/previews.mjs +33 -2
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +63 -13
- package/src/config.mjs +53 -4
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +20 -19
- package/src/play/commands/prerelease.mjs +94 -20
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +11 -0
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +3 -1
- package/types/index.d.ts +225 -10
- package/vydanne.config.example.mjs +54 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { green, yellow, red } from "../util.mjs";
|
|
5
|
+
import { toAsc } from "../locales.mjs";
|
|
6
|
+
import { IOS_DEVICE, MAC_DEVICE, screenshotBase, IMAGE_FILE } from "../screenshots.mjs";
|
|
7
|
+
import { playImages } from "../play/images.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map zdymak's build output onto the folders `fill` uploads from.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS EXISTS. zdymak and vydanne were documented as lining up with "no glue", and they used to:
|
|
13
|
+
* zdymak's default output paths were exactly vydanne's read paths. As of zdymak 0.15.0 they are not.
|
|
14
|
+
* zdymak writes everything under ONE root (`out:` in zdymak.config.mjs, default `./store-assets`)
|
|
15
|
+
* shaped `<locale>/<dir>/NN-name.png`, while vydanne reads several roots in a different shape. Three
|
|
16
|
+
* things differ, so no amount of retuning `out` can fix it:
|
|
17
|
+
*
|
|
18
|
+
* 1. SEVERAL roots (the screenshot bases, the Play image sources) versus one `out`.
|
|
19
|
+
* 2. Locale CODES — zdymak writes `de`, `zh`, `ur`; Apple wants `de-DE`, `zh-Hans`, `ur-PK`.
|
|
20
|
+
* 3. The slot PREFIX — `fill` picks the device slot from the token before the first underscore, so a
|
|
21
|
+
* file must be named `iphone69_…`; zdymak names it `01-fresh.png`.
|
|
22
|
+
*
|
|
23
|
+
* The failure mode is the dangerous one: `zdymak screenshots` reports success for every locale and
|
|
24
|
+
* writes nothing vydanne can see, so `fill` re-uploads whatever was in the screenshots folder from the
|
|
25
|
+
* last time the two tools agreed — silently shipping stale store art. One app shipped exactly that, its
|
|
26
|
+
* screenshots four days older than its own store-assets. So the mapping lives HERE, in the uploader,
|
|
27
|
+
* where the locale table (`toAsc`), the slot tokens (IOS_DEVICE) and the Play read paths (playImages)
|
|
28
|
+
* are the very values `fill` uploads by — imported, not copied, so the bridge cannot drift from the
|
|
29
|
+
* thing it feeds.
|
|
30
|
+
*
|
|
31
|
+
* SOURCE DIRECTORIES ARE NOT TARGET NAMES. zdymak writes each shot to `<dir || target>`, and `dir:` is
|
|
32
|
+
* how one target serves two purposes — a styled `play-phone/` for the website and a plain
|
|
33
|
+
* `play-phone-plain/` for the Play upload, which Google requires to be the bare interface. It is also
|
|
34
|
+
* the only way to produce Play's 7" slot at all: there is no `play-tablet7` target, so that set can
|
|
35
|
+
* only exist as `{ target: 'play-tablet', dir: 'play-tablet7-plain' }`. Mapping by TARGET therefore got
|
|
36
|
+
* two things wrong at once on any config using `dir:` — it bridged the styled variant into the plain
|
|
37
|
+
* slot, and it treated the real 7" directory as a locale while wiping the destination it belonged in.
|
|
38
|
+
* So the tables below are keyed by DIRECTORY, with the `-plain` convention preferred and the bare
|
|
39
|
+
* target name as the fallback, and `bridge.apple` / `bridge.play` in vydanne.config.mjs override both.
|
|
40
|
+
*
|
|
41
|
+
* OWNERSHIP, NOT REBUILD-IF-PRESENT. The destinations are rebuilt from empty so a screenshot removed
|
|
42
|
+
* upstream disappears here too instead of lingering and being uploaded forever — but only for a store
|
|
43
|
+
* the bridge actually produced files for this run. Resetting a root the bridge did not fill would
|
|
44
|
+
* delete the screenshots of someone who uses zdymak for Play and manages Apple's by hand; resetting it
|
|
45
|
+
* only when something happened to land in it is how dropped macOS art survived a form factor being
|
|
46
|
+
* removed from zdymak.config.mjs. Per STORE is the line that satisfies both: any Apple output at all
|
|
47
|
+
* means the bridge owns both Apple roots, including the macOS one it wrote nothing into today.
|
|
48
|
+
*
|
|
49
|
+
* Local files only — nothing touches a store, which is why this command takes no `--apply`; pass
|
|
50
|
+
* `--dry-run` to see exactly what a real run would write and remove.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
// Filename token that selects an App Store display type -> the zdymak output directory it comes from.
|
|
54
|
+
// The tokens are asserted against IOS_DEVICE/MAC_DEVICE at load: a token `fill` does not understand
|
|
55
|
+
// would make every bridged file silently ignorable, which is the exact class of failure this command
|
|
56
|
+
// exists to close.
|
|
57
|
+
const APPLE_SLOTS = {
|
|
58
|
+
iphone69: "appstore-iphone-6.9",
|
|
59
|
+
iphone65: "appstore-iphone-6.5",
|
|
60
|
+
ipad13: "appstore-ipad-13",
|
|
61
|
+
watch: "appstore-watch",
|
|
62
|
+
};
|
|
63
|
+
/** macOS screenshots live under a different root entirely, so they are carried separately. */
|
|
64
|
+
const MAC_SLOT = { macos: "appstore-mac" };
|
|
65
|
+
|
|
66
|
+
for (const t of Object.keys(APPLE_SLOTS)) {
|
|
67
|
+
if (!(t in IOS_DEVICE)) throw new Error(`bridge: slot token '${t}' unknown to fill — update APPLE_SLOTS`);
|
|
68
|
+
}
|
|
69
|
+
for (const t of Object.keys(MAC_SLOT)) {
|
|
70
|
+
if (!(t in MAC_DEVICE)) throw new Error(`bridge: slot token '${t}' unknown to fill — update MAC_SLOT`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Play image type -> candidate source directories, in preference order. The `-plain` set is what Google
|
|
74
|
+
// asks for on a store listing ("no additional text, graphics, or backgrounds that are not part of the
|
|
75
|
+
// interface"), so it wins when both exist; the bare target name is what a config with no `dir:` override
|
|
76
|
+
// produces. The Play icon is deliberately absent: it comes from znachok, not zdymak, and is already at
|
|
77
|
+
// its read path.
|
|
78
|
+
const PLAY_SOURCES = {
|
|
79
|
+
phoneScreenshots: ["play-phone-plain", "play-phone"],
|
|
80
|
+
sevenInchScreenshots: ["play-tablet7-plain", "play-tablet7"],
|
|
81
|
+
tenInchScreenshots: ["play-tablet-plain", "play-tablet"],
|
|
82
|
+
wearScreenshots: ["play-wear-plain", "play-wear"],
|
|
83
|
+
tvScreenshots: ["play-tv-plain", "play-tv"],
|
|
84
|
+
featureGraphic: ["play-feature-graphic.png"],
|
|
85
|
+
tvBanner: ["play-tv-banner.png"],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// A directory under `out` that is shaped like a locale code. Everything zdymak writes at that level is
|
|
89
|
+
// either an output directory or a locale, and the old rule — "not a known target, therefore a locale" —
|
|
90
|
+
// turned every `dir:` override into a phantom locale (`play-phone-plain`, `appstore-iphone-6.9-dark`)
|
|
91
|
+
// that then failed to resolve and was reported as falling back to the primary listing.
|
|
92
|
+
const LOCALE_SHAPED = /^[a-z]{2,3}(-[A-Za-z]{2,4}|-[0-9]{3})?$/;
|
|
93
|
+
|
|
94
|
+
const dirsIn = (p) => (fs.existsSync(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : []);
|
|
95
|
+
const imagesIn = (p) => (fs.existsSync(p) ? fs.readdirSync(p).filter((f) => IMAGE_FILE.test(f)).sort() : []);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What zdymak is configured to write: its `out`, and every directory name it produces.
|
|
99
|
+
*
|
|
100
|
+
* Read from the app's own zdymak.config.mjs so the two tools cannot disagree about the layout. Absent
|
|
101
|
+
* or unreadable is fine — the directory names are then discovered from disk instead, which is enough
|
|
102
|
+
* for the common case and only loses the ability to tell a `dir:` override apart from a locale by
|
|
103
|
+
* anything other than its shape.
|
|
104
|
+
*/
|
|
105
|
+
async function readZdymak(config) {
|
|
106
|
+
const out = { root: config.bridge?.out || "./store-assets", knownDirs: new Set(), fromConfig: false };
|
|
107
|
+
const file = path.resolve("zdymak.config.mjs");
|
|
108
|
+
if (!fs.existsSync(file)) return out;
|
|
109
|
+
let cfg;
|
|
110
|
+
try {
|
|
111
|
+
cfg = (await import(pathToFileURL(file).href)).default;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
// A config that imports `zdymak` in a project where zdymak is not installed is the likeliest cause,
|
|
114
|
+
// and "Cannot find package 'zdymak'" on its own does not suggest a fix.
|
|
115
|
+
console.log(yellow(`bridge: could not read zdymak.config.mjs (${e.message.split("\n")[0]})`));
|
|
116
|
+
console.log(" Falling back to directory names on disk. Install zdymak, or set `bridge.out` in vydanne.config.mjs.");
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
if (!cfg) return out;
|
|
120
|
+
out.fromConfig = true;
|
|
121
|
+
if (!config.bridge?.out && cfg.out) out.root = cfg.out;
|
|
122
|
+
const devices = Array.isArray(cfg.devices) ? cfg.devices : Object.values(cfg.devices || {});
|
|
123
|
+
for (const d of devices) {
|
|
124
|
+
for (const s of d?.screenshots || []) {
|
|
125
|
+
if (!s?.target) continue;
|
|
126
|
+
// Icons and feature graphics are written as `<target>.png` at the root, ignoring `dir` — see
|
|
127
|
+
// zdymak/src/screenshots.mjs. Both spellings are recorded so neither can be mistaken for a locale.
|
|
128
|
+
out.knownDirs.add(s.dir || s.target);
|
|
129
|
+
out.knownDirs.add(s.target);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** First candidate directory that exists under `from`, or null. */
|
|
136
|
+
const pick = (from, candidates) => candidates.find((c) => fs.existsSync(path.join(from, c))) ?? null;
|
|
137
|
+
|
|
138
|
+
/** Empty a directory, keeping the directory itself. */
|
|
139
|
+
function reset(p) {
|
|
140
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
141
|
+
fs.mkdirSync(p, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function run(config) {
|
|
145
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
146
|
+
const zd = await readZdymak(config);
|
|
147
|
+
const SRC = path.resolve(zd.root);
|
|
148
|
+
if (!fs.existsSync(SRC)) {
|
|
149
|
+
console.error(red(`bridge: no ${path.relative(process.cwd(), SRC)} — run \`zdymak screenshots\` (or \`zdymak build\`) first.`));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const appleSlots = { ...APPLE_SLOTS, ...(config.bridge?.apple || {}) };
|
|
154
|
+
const macSlots = Object.fromEntries(
|
|
155
|
+
Object.entries({ ...MAC_SLOT, ...(config.bridge?.apple || {}) }).filter(([t]) => t in MAC_DEVICE),
|
|
156
|
+
);
|
|
157
|
+
const iosSlots = Object.fromEntries(Object.entries(appleSlots).filter(([t]) => t in IOS_DEVICE));
|
|
158
|
+
const playSources = { ...PLAY_SOURCES, ...(config.bridge?.play || {}) };
|
|
159
|
+
|
|
160
|
+
const APPLE = path.resolve(screenshotBase("IOS", config));
|
|
161
|
+
const APPLE_MAC = path.resolve(screenshotBase("MAC_OS", config));
|
|
162
|
+
|
|
163
|
+
// ── PLAN FIRST, WRITE SECOND ────────────────────────────────────────────────────────────────────
|
|
164
|
+
// Nothing below touches the disk. The old shape reset the destination at the top and discovered it
|
|
165
|
+
// had nothing to put there at the bottom — so an empty `store-assets` emptied the screenshots folder
|
|
166
|
+
// and *then* reported failure, and a `--dry-run` could not check the files a real run would refuse
|
|
167
|
+
// because they had not been copied yet. Planning first makes the dry run exact and the failure paths
|
|
168
|
+
// non-destructive, at the cost of one array.
|
|
169
|
+
const plan = []; // { from, to, store: 'apple' | 'play' }
|
|
170
|
+
const noListing = [];
|
|
171
|
+
const noLanguage = [];
|
|
172
|
+
const ignored = [];
|
|
173
|
+
const carried = new Set();
|
|
174
|
+
const written = new Set();
|
|
175
|
+
|
|
176
|
+
// ── Apple ───────────────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// The root of store-assets holds the untranslated (primary) set; a locale-shaped directory beside it
|
|
178
|
+
// is a translated one.
|
|
179
|
+
// Everything the bridge itself knows how to read counts as a known output, whether or not
|
|
180
|
+
// zdymak.config.mjs was readable — otherwise a project with no zdymak config has each of its own
|
|
181
|
+
// source directories reported as "neither an output nor a locale" while being bridged correctly.
|
|
182
|
+
const knownDirs = new Set([
|
|
183
|
+
...zd.knownDirs,
|
|
184
|
+
...Object.values(appleSlots).flat(),
|
|
185
|
+
...Object.values(macSlots).flat(),
|
|
186
|
+
...Object.values(playSources).flat(),
|
|
187
|
+
]);
|
|
188
|
+
const localeDirs = dirsIn(SRC).filter((d) => {
|
|
189
|
+
if (knownDirs.has(d)) return false;
|
|
190
|
+
if (LOCALE_SHAPED.test(d)) return true;
|
|
191
|
+
ignored.push(d);
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
for (const source of [null, ...localeDirs]) {
|
|
196
|
+
const from = source ? path.join(SRC, source) : SRC;
|
|
197
|
+
const asc = source ? toAsc(source, config.localeMap) : config.primaryLocale;
|
|
198
|
+
// A code with no App Store language (Belarusian) is skipped — a locale with no folder falls back
|
|
199
|
+
// to the primary listing, which is the intent.
|
|
200
|
+
if (!asc) { noLanguage.push(source); continue; }
|
|
201
|
+
|
|
202
|
+
// Only ship screenshots for a locale that also has LISTING TEXT. Uploading them alone makes fill
|
|
203
|
+
// create the App Store localization to attach them to, and a created localization stops falling
|
|
204
|
+
// back to the primary one — the store would show a page with pictures and an empty description
|
|
205
|
+
// instead of the primary text it shows today. Silent, and worse than not translating at all. When
|
|
206
|
+
// the copy for a locale lands, this picks its screenshots up with no edit here.
|
|
207
|
+
if (!fs.existsSync(path.join(config.metadataDir, asc))) { noListing.push(asc); continue; }
|
|
208
|
+
|
|
209
|
+
for (const [root, slots] of [[APPLE, iosSlots], [APPLE_MAC, macSlots]]) {
|
|
210
|
+
for (const [slot, candidates] of Object.entries(slots)) {
|
|
211
|
+
const dir = pick(from, [candidates].flat());
|
|
212
|
+
if (!dir) continue;
|
|
213
|
+
for (const img of imagesIn(path.join(from, dir))) {
|
|
214
|
+
// fill selects the display type off the token before the first underscore, so an unprefixed
|
|
215
|
+
// copy would be a file it silently ignores, in the folder where it looks.
|
|
216
|
+
plan.push({ from: path.join(from, dir, img), to: path.join(root, asc, `${slot}_${img}`), store: "apple" });
|
|
217
|
+
carried.add(dir);
|
|
218
|
+
written.add(asc);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Play ────────────────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// Only the untranslated root is carried: Play graphics default to one set at `defaultLocale`, and an
|
|
226
|
+
// app that opts into per-language art (`google.imageLocales`) points `fill` at the subdirectories it
|
|
227
|
+
// maintains itself.
|
|
228
|
+
const playDest = Object.fromEntries(playImages(config).map(([type, src, kind]) => [type, { src, kind }]));
|
|
229
|
+
const playDirs = new Set();
|
|
230
|
+
const playCarried = new Set();
|
|
231
|
+
for (const [type, candidates] of Object.entries(playSources)) {
|
|
232
|
+
const dest = playDest[type];
|
|
233
|
+
if (!dest) continue; // a type this app's images table doesn't have — nothing reads it, so skip it
|
|
234
|
+
if (dest.kind === "file") {
|
|
235
|
+
const file = [candidates].flat().map((c) => path.join(SRC, c)).find((p) => fs.existsSync(p));
|
|
236
|
+
if (!file) continue;
|
|
237
|
+
plan.push({ from: file, to: path.resolve(dest.src), store: "play" });
|
|
238
|
+
playCarried.add(path.basename(file));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const dir = pick(SRC, [candidates].flat());
|
|
242
|
+
// Owned when zdymak produces it, and ALSO when it merely still exists from a previous run — that
|
|
243
|
+
// second half is the point: a form factor dropped from zdymak.config.mjs must stop being uploaded,
|
|
244
|
+
// and "no new files" is exactly when the old ones would otherwise survive forever. A slot with
|
|
245
|
+
// neither is a slot this app does not use, and creating an empty directory for it would only
|
|
246
|
+
// invent a set for `fill` to report on.
|
|
247
|
+
if (dir || fs.existsSync(dest.src)) playDirs.add(path.resolve(dest.src));
|
|
248
|
+
if (!dir) continue;
|
|
249
|
+
for (const img of imagesIn(path.join(SRC, dir))) {
|
|
250
|
+
plan.push({ from: path.join(SRC, dir, img), to: path.resolve(dest.src, img), store: "play" });
|
|
251
|
+
playCarried.add(dir);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const appleCount = plan.filter((p) => p.store === "apple").length;
|
|
256
|
+
const playCount = plan.filter((p) => p.store === "play").length;
|
|
257
|
+
|
|
258
|
+
if (!appleCount && !playCount) {
|
|
259
|
+
console.error(red("bridge: nothing was bridged — check that zdymak actually wrote store-assets."));
|
|
260
|
+
if (ignored.length) console.error(` neither a zdymak output nor a locale: ${ignored.join(", ")}`);
|
|
261
|
+
console.error(" Nothing was removed; every destination is untouched.");
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Apple rejects alpha, so catch it on the SOURCES, before anything is copied ──────────────────
|
|
266
|
+
// Checked here rather than after the copy for two reasons: a dry run can now report the refusal a
|
|
267
|
+
// real run would hit, and a real run no longer leaves the rejected file sitting in the folder `fill`
|
|
268
|
+
// uploads from — where the next run would send it to Apple and collect the rejection anyway.
|
|
269
|
+
const appleFiles = plan.filter((p) => p.store === "apple");
|
|
270
|
+
if (appleFiles.length) {
|
|
271
|
+
const { default: sharp } = await import("sharp");
|
|
272
|
+
const withAlpha = [];
|
|
273
|
+
for (const p of appleFiles) {
|
|
274
|
+
if ((await sharp(p.from).metadata()).hasAlpha) withAlpha.push(path.relative(process.cwd(), p.from));
|
|
275
|
+
}
|
|
276
|
+
if (withAlpha.length) {
|
|
277
|
+
console.error(red("bridge: these carry an alpha channel and Apple will reject them:"));
|
|
278
|
+
for (const p of withAlpha) console.error(` ${p}`);
|
|
279
|
+
console.error(" Flatten at the source (zdymak), or per file: VYDANNE_FLATTEN=<png> vydanne iap");
|
|
280
|
+
console.error(" Nothing was copied; every destination is untouched.");
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Write ───────────────────────────────────────────────────────────────────────────────────────
|
|
286
|
+
// Ownership is per STORE: producing any Apple output means the bridge owns both Apple roots, so the
|
|
287
|
+
// macOS one is emptied even on a run that wrote nothing into it. An app that bridges only Play never
|
|
288
|
+
// has its hand-managed Apple screenshots touched, and vice versa.
|
|
289
|
+
const countFiles = (root) => {
|
|
290
|
+
if (!fs.existsSync(root)) return 0;
|
|
291
|
+
let n = imagesIn(root).length;
|
|
292
|
+
for (const d of dirsIn(root)) n += imagesIn(path.join(root, d)).length;
|
|
293
|
+
return n;
|
|
294
|
+
};
|
|
295
|
+
const owned = [
|
|
296
|
+
...(appleCount ? [APPLE, APPLE_MAC] : []),
|
|
297
|
+
...(playCount ? [...playDirs] : []),
|
|
298
|
+
];
|
|
299
|
+
const removals = owned.map((root) => [root, countFiles(root)]).filter(([, n]) => n);
|
|
300
|
+
|
|
301
|
+
if (!dryRun) {
|
|
302
|
+
for (const root of owned) reset(root);
|
|
303
|
+
for (const p of plan) {
|
|
304
|
+
fs.mkdirSync(path.dirname(p.to), { recursive: true });
|
|
305
|
+
fs.copyFileSync(p.from, p.to);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Report ──────────────────────────────────────────────────────────────────────────────────────
|
|
310
|
+
console.log(`▸ Apple → ${path.relative(process.cwd(), APPLE)}`);
|
|
311
|
+
console.log(` ${appleCount} files · ${written.size} locale(s) · ${[...carried].join(", ") || "nothing"}`);
|
|
312
|
+
if (noLanguage.length) console.log(` no App Store language, falls back to ${config.primaryLocale}: ${noLanguage.join(" ")}`);
|
|
313
|
+
if (noListing.length) console.log(` held back (screenshots ready, listing text missing): ${noListing.join(" ")}`);
|
|
314
|
+
|
|
315
|
+
console.log(`▸ Play → ${[...playDirs].map((d) => path.relative(process.cwd(), d)).join(", ") || "nothing"}`);
|
|
316
|
+
console.log(` ${playCount} files · ${[...playCarried].join(", ") || "nothing"}`);
|
|
317
|
+
|
|
318
|
+
// Said every run, because "what the bridge is about to delete" is the one thing a dry run exists to
|
|
319
|
+
// show — and because a destination emptied on purpose (its form factor dropped upstream) looks
|
|
320
|
+
// exactly like one that was never filled.
|
|
321
|
+
for (const [root, n] of removals) {
|
|
322
|
+
console.log(yellow(` ${dryRun ? "would replace" : "replaced"} ${n} existing file(s) in ${path.relative(process.cwd(), root)}`));
|
|
323
|
+
}
|
|
324
|
+
if (ignored.length) console.log(yellow(` ignored (neither a zdymak output nor a locale): ${ignored.join(", ")}`));
|
|
325
|
+
|
|
326
|
+
console.log(green(dryRun ? "✓ dry run — nothing written" : "✓ bridged — `vydanne fill` can now find every set"));
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
@@ -1,13 +1,70 @@
|
|
|
1
1
|
import PDFDocument from "pdfkit";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import { green, yellow } from "../util.mjs";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The US encryption self-classification report PDF (ECCN 5D002, License Exception ENC 740.17(b)(1)) —
|
|
7
|
+
* the document for the ASC "App Encryption Documentation" US slot. France is a separate ANSSI territory
|
|
8
|
+
* upload; the US needs no CCATS. Native pdfkit, no Python.
|
|
9
|
+
*
|
|
10
|
+
* THIS COMMAND MAKES LEGAL ASSERTIONS, SO IT DOES NOT GUESS. It used to hold a hardcoded cryptography
|
|
11
|
+
* inventory — AES-GCM, HMAC-SHA256, Ed25519, "resolves to Apple CryptoKit" — and a statement claiming
|
|
12
|
+
* the product did end-to-end encryption for cross-device sync and that "a self-classification report
|
|
13
|
+
* has been submitted to BIS and the NSA". Every app the tool was pointed at got the same page. That is
|
|
14
|
+
* wrong twice over: it describes cryptography an app may not contain, and it asserts a filing with two
|
|
15
|
+
* US government agencies that whoever ran the command may never have made. A wrong screenshot costs a
|
|
16
|
+
* review cycle; this is a document someone signs their company's name under.
|
|
17
|
+
*
|
|
18
|
+
* So the app declares its own inventory and its own statement, exactly the way `accessibility` declares
|
|
19
|
+
* its own claims, and for the same reason: silence is not consent. `export.filed` is separate and
|
|
20
|
+
* defaults to false, because "we intend to file" and "we have filed" are different sentences and only
|
|
21
|
+
* the filer knows which is true.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const EXAMPLE = ` export: {
|
|
25
|
+
encryption: "standard", // "standard" | "none" | "exempt"
|
|
26
|
+
appName: "Your App",
|
|
27
|
+
version: "1.2",
|
|
28
|
+
teamId: "ABCDE12345",
|
|
29
|
+
// What the app actually contains. Every row is [purpose, algorithm, key size].
|
|
30
|
+
algorithms: [
|
|
31
|
+
["Transport", "TLS 1.2 / 1.3", "standard"],
|
|
32
|
+
],
|
|
33
|
+
// One paragraph in your own words, describing what the app does with cryptography.
|
|
34
|
+
statement: "The product uses TLS for network transport only. It is a mass-market consumer " +
|
|
35
|
+
"application distributed through public app stores, uses only standard published algorithms, " +
|
|
36
|
+
"and qualifies for export under License Exception ENC, EAR 740.17(b)(1), ECCN 5D002.",
|
|
37
|
+
// Only true once the report has ACTUALLY been emailed to BIS and the NSA.
|
|
38
|
+
filed: false,
|
|
39
|
+
},`;
|
|
40
|
+
|
|
41
|
+
/** Returns a human-readable problem, or null when the declaration is usable. */
|
|
42
|
+
export function validate(config) {
|
|
43
|
+
const e = config.export || {};
|
|
44
|
+
if ((e.encryption || "standard") !== "standard") return null; // nothing to self-classify
|
|
45
|
+
const missing = [];
|
|
46
|
+
if (!Array.isArray(e.algorithms) || !e.algorithms.length) missing.push("algorithms");
|
|
47
|
+
if (typeof e.statement !== "string" || !e.statement.trim()) missing.push("statement");
|
|
48
|
+
if (!missing.length) return null;
|
|
49
|
+
return [
|
|
50
|
+
`compliance: export.${missing.join(" and export.")} missing.`,
|
|
51
|
+
"This command generates a US export-compliance document that makes factual claims about your",
|
|
52
|
+
"app's cryptography. It will not supply them for you — the previous default described a specific",
|
|
53
|
+
"app's crypto and asserted a BIS/NSA filing, for every app it was run against.",
|
|
54
|
+
"",
|
|
55
|
+
"Declare what your app actually contains:",
|
|
56
|
+
"",
|
|
57
|
+
EXAMPLE,
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
4
60
|
|
|
5
|
-
// Generate the US encryption self-classification report PDF (ECCN 5D002, License Exception ENC 740.17(b)(1))
|
|
6
|
-
// for standard-crypto apps — the document for the ASC "App Encryption Documentation" US slot. France = a
|
|
7
|
-
// separate ANSSI territory upload; US needs no CCATS. Native pdfkit (no python).
|
|
8
61
|
export async function run(config) {
|
|
9
62
|
const e = config.export || {};
|
|
10
|
-
if ((e.encryption || "standard") !== "standard") { console.log(yellow(
|
|
63
|
+
if ((e.encryption || "standard") !== "standard") { console.log(yellow(`export.encryption = ${e.encryption} — nothing to self-classify`)); return true; }
|
|
64
|
+
|
|
65
|
+
const problem = validate(config);
|
|
66
|
+
if (problem) { console.error(red(problem)); return false; }
|
|
67
|
+
|
|
11
68
|
const appName = e.appName || config.bundleId.split(".").pop();
|
|
12
69
|
const out = `export-compliance/${config.bundleId}-US-encryption-self-classification.pdf`;
|
|
13
70
|
fs.mkdirSync("export-compliance", { recursive: true });
|
|
@@ -27,19 +84,31 @@ export async function run(config) {
|
|
|
27
84
|
h("Classification");
|
|
28
85
|
kv("ECCN:", "5D002 (encryption 'software')"); kv("Authorization:", "License Exception ENC, EAR 740.17(b)(1)"); kv("Basis:", "Mass-market, self-classified (Note 3 to Cat. 5 Part 2)");
|
|
29
86
|
h("Cryptography inventory");
|
|
30
|
-
p("The application uses only standard, published cryptographic algorithms (NIST / IETF). No proprietary or non-standard cryptography is implemented.
|
|
87
|
+
p("The application uses only standard, published cryptographic algorithms (NIST / IETF). No proprietary or non-standard cryptography is implemented.");
|
|
31
88
|
doc.moveDown(0.4);
|
|
32
|
-
for (const
|
|
33
|
-
|
|
89
|
+
for (const row of e.algorithms) {
|
|
90
|
+
const [purpose, alg, ks] = row;
|
|
91
|
+
doc.font("Helvetica").fontSize(10.5).fillColor("#1e1e1e").text(`• ${purpose} — ${alg}${ks ? ` (${ks})` : ""}`);
|
|
34
92
|
}
|
|
35
93
|
h("Statement");
|
|
36
|
-
|
|
94
|
+
// The declared statement, and NOTHING appended to it. The filing sentence is added only when the
|
|
95
|
+
// app says the filing happened, because a PDF that claims it has been submitted is the one thing
|
|
96
|
+
// here that cannot be walked back.
|
|
97
|
+
p(e.statement.trim());
|
|
98
|
+
if (e.filed) {
|
|
99
|
+
doc.moveDown(0.4);
|
|
100
|
+
p("A self-classification report has been submitted to BIS (crypt-supp8@bis.doc.gov) and the NSA (enc@nsa.gov).");
|
|
101
|
+
}
|
|
37
102
|
doc.end();
|
|
38
103
|
stream.on("finish", resolve);
|
|
39
104
|
stream.on("error", reject);
|
|
40
105
|
});
|
|
41
106
|
|
|
42
107
|
console.log(green(`wrote ${out}`));
|
|
108
|
+
if (!e.filed) {
|
|
109
|
+
console.log(yellow(" export.filed is false — the PDF does NOT claim the report was submitted."));
|
|
110
|
+
console.log(" Email it to crypt-supp8@bis.doc.gov and enc@nsa.gov, then set export.filed: true.");
|
|
111
|
+
}
|
|
43
112
|
if (e.france) console.log(yellow("France: file + upload the ANSSI declaration too (territory rule). US: email BIS+NSA, no CCATS."));
|
|
44
113
|
return true;
|
|
45
114
|
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { green, yellow, red } from "../util.mjs";
|
|
4
4
|
import { VALID } from "../locales.mjs";
|
|
5
|
+
import { md5 } from "../upload.mjs";
|
|
6
|
+
import { localScreenshots, remoteScreenshots, compareShots, localScreenshotLocales } from "../screenshots.mjs";
|
|
5
7
|
|
|
6
8
|
// [asc attribute, local metadata filename, isLongText]
|
|
7
9
|
const VERSION_FIELDS = [
|
|
@@ -9,8 +11,6 @@ const VERSION_FIELDS = [
|
|
|
9
11
|
["whatsNew", "release_notes", true], ["marketingUrl", "marketing_url", false], ["supportUrl", "support_url", false],
|
|
10
12
|
];
|
|
11
13
|
const INFO_FIELDS = [["name", "name", false], ["subtitle", "subtitle", false]];
|
|
12
|
-
const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
|
|
13
|
-
const MAC_DEVICE = { macos: "APP_DESKTOP" };
|
|
14
14
|
|
|
15
15
|
const norm = (s) => (s == null ? null : String(s).replace(/\r/g, "").replace(/\n+$/, "").trim());
|
|
16
16
|
const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.length > n ? s.slice(0, n) + "…" : s; };
|
|
@@ -19,13 +19,19 @@ const short = (s, n = 24) => { s = String(s).replace(/\n/g, " "); return s.lengt
|
|
|
19
19
|
// Connect — i.e. what `fill` / `previews` would change. Reads each localization by id (list is sparse).
|
|
20
20
|
export async function run(config, client) {
|
|
21
21
|
await client.findApp(config.bundleId);
|
|
22
|
-
|
|
22
|
+
// allowLive for the same reason as editVersion below: comparing local name/subtitle against what is
|
|
23
|
+
// on sale is a legitimate read, and on an app with nothing in preparation the live record is the
|
|
24
|
+
// only one to compare against.
|
|
25
|
+
const info = await client.appInfo({ allowLive: true });
|
|
23
26
|
const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
|
|
24
27
|
let actionable = 0; // differences `fill`/`previews` would actually change (release_notes on a 1.0 is benign)
|
|
25
28
|
|
|
26
29
|
for (const platform of config.platforms) {
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// allowLive: comparing local against the version ON SALE is a legitimate question and often the
|
|
31
|
+
// reason to run this before preparing a release. The state is printed below, so it is never unclear
|
|
32
|
+
// WHICH version the comparison is against — and `fill` no longer targets a live one either way.
|
|
33
|
+
const v = await client.editVersion(platform, { allowLive: true });
|
|
34
|
+
if (!v) { console.log(red(`${platform}: no version at all`)); continue; }
|
|
29
35
|
console.log(`${platform} v${v.attributes.versionString} ${v.attributes.appStoreState}`);
|
|
30
36
|
const verLocs = await client.versionLocalizations(v.id);
|
|
31
37
|
const localDirs = fs.existsSync(config.metadataDir)
|
|
@@ -84,24 +90,62 @@ async function mediaDiff(config, client, platform, verLocs) {
|
|
|
84
90
|
const primary = verLocs.find((l) => l.attributes.locale === config.primaryLocale);
|
|
85
91
|
if (!primary) return 0;
|
|
86
92
|
let diffs = 0;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
93
|
+
// Compared by CONTENT, not by count — the machinery lives in src/screenshots.mjs and is shared with
|
|
94
|
+
// `preflight`, so the two commands cannot drift apart in what "in sync" means. The history is there.
|
|
95
|
+
// Every locale with a local set, for the reason preflight now does the same: `bridge` writes one
|
|
96
|
+
// folder per translated locale, and checking only the primary reported "in sync" about a listing
|
|
97
|
+
// whose other nineteen locales were showing last month's art.
|
|
98
|
+
for (const code of [...new Set([config.primaryLocale, ...localScreenshotLocales(platform, config)])]) {
|
|
99
|
+
const loc = verLocs.find((l) => l.attributes.locale === code);
|
|
100
|
+
if (!loc) continue;
|
|
101
|
+
const local = localScreenshots(platform, code, config);
|
|
102
|
+
const remote = await remoteScreenshots(client, loc.id);
|
|
103
|
+
for (const dt of new Set([...Object.keys(local), ...Object.keys(remote)])) {
|
|
104
|
+
const c = compareShots(local[dt] || new Map(), remote[dt] || new Map());
|
|
105
|
+
if (!c) continue;
|
|
106
|
+
const label = ` ${yellow("screenshots")} ${dt.replace("APP_", "")}`;
|
|
107
|
+
if (c.kind === "count") {
|
|
108
|
+
diffs++;
|
|
109
|
+
console.log(`${label}: local ${c.local} / remote ${c.remote} (@${code})`);
|
|
110
|
+
} else if (c.kind === "renamed") {
|
|
111
|
+
diffs++;
|
|
112
|
+
console.log(`${label}: ${c.names.length} not on the store by name (${c.names.slice(0, 3).join(", ")}) (@${code})`);
|
|
113
|
+
} else if (c.kind === "stale") {
|
|
114
|
+
diffs++;
|
|
115
|
+
console.log(`${label}: ${c.names.length} of ${c.of} differ in content (${c.names.slice(0, 3).join(", ")}) (@${code})`);
|
|
116
|
+
} else {
|
|
117
|
+
// Apple did not give a checksum back. Say so rather than reporting a match we did not establish.
|
|
118
|
+
console.log(`${label}: ${c.names.length} present, checksum not reported by Apple — content unverified (@${code})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
98
121
|
}
|
|
122
|
+
// Previews by content too — the count comparison this replaces had the screenshot bug in miniature:
|
|
123
|
+
// one-local-vs-one-remote reads "in sync" however different the videos are, so a re-rendered App
|
|
124
|
+
// Preview reported nothing to do. `uploadAsset` commits md5(bytes) as sourceFileChecksum for
|
|
125
|
+
// previews exactly as it does for screenshots, so the same comparison is available for free.
|
|
99
126
|
const { json: psets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appPreviewSets?include=appPreviews&limit=50`);
|
|
127
|
+
const prevById = new Map((psets.included || []).filter((r) => r.type === "appPreviews").map((r) => [r.id, r.attributes || {}]));
|
|
100
128
|
const remotePrev = {};
|
|
101
|
-
for (const s of psets.data || [])
|
|
129
|
+
for (const s of psets.data || []) {
|
|
130
|
+
remotePrev[s.attributes.previewType] = (s.relationships?.appPreviews?.data || [])
|
|
131
|
+
.map((ref) => prevById.get(ref.id)?.sourceFileChecksum ?? null);
|
|
132
|
+
}
|
|
102
133
|
for (const spec of (config.previews || []).filter((s) => s.platform === platform && (s.locales || []).includes(config.primaryLocale))) {
|
|
103
|
-
const
|
|
104
|
-
|
|
134
|
+
const file = path.resolve(spec.file);
|
|
135
|
+
const L = fs.existsSync(file) ? 1 : 0;
|
|
136
|
+
const R = remotePrev[spec.type] || [];
|
|
137
|
+
const label = ` ${yellow("preview")} ${spec.type}`;
|
|
138
|
+
if (L !== R.length) {
|
|
139
|
+
diffs++;
|
|
140
|
+
console.log(`${label}: local ${L} / remote ${R.length} (@${config.primaryLocale})`);
|
|
141
|
+
} else if (L === 1) {
|
|
142
|
+
if (R.every((sum) => sum === null)) {
|
|
143
|
+
console.log(`${label}: present, checksum not reported by Apple — content unverified`);
|
|
144
|
+
} else if (!R.includes(md5(fs.readFileSync(file)))) {
|
|
145
|
+
diffs++;
|
|
146
|
+
console.log(`${label}: differs in content (${path.basename(spec.file)}) (@${config.primaryLocale})`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
105
149
|
}
|
|
106
150
|
return diffs;
|
|
107
151
|
}
|