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.
@@ -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("export.encryption != standard — nothing to self-classify")); return true; }
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. On Apple platforms the primitives resolve to Apple CryptoKit.");
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 [purpose, alg, ks] of [["Content & media confidentiality", "AES-GCM (AEAD)", "256-bit"], ["Key derivation / addressing", "HMAC-SHA256", "256-bit"], ["Token signing", "Ed25519", "255-bit curve"], ["Transport", "TLS 1.2 / 1.3", "standard"]]) {
33
- doc.font("Helvetica").fontSize(10.5).fillColor("#1e1e1e").text(`• ${purpose} — ${alg} (${ks})`);
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
- p("The product provides general data-confidentiality end-to-end encryption of the user's own data for optional cross-device sync. It is a mass-market consumer application distributed through public app stores, uses only standard published algorithms, and qualifies for export under License Exception ENC, EAR 740.17(b)(1), ECCN 5D002. A self-classification report has been submitted to BIS (crypt-supp8@bis.doc.gov) and the NSA (enc@nsa.gov).");
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
  }
@@ -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
- const info = await client.appInfo();
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
- const v = await client.editVersion(platform);
28
- if (!v) { console.log(red(`${platform}: no editable version`)); continue; }
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
- const dev = platform === "MAC_OS" ? MAC_DEVICE : IOS_DEVICE;
88
- const base = platform === "MAC_OS" ? "fastlane/screenshots-macos" : "fastlane/screenshots";
89
- const localDir = path.join(base, config.primaryLocale);
90
- const local = {};
91
- if (fs.existsSync(localDir)) for (const f of fs.readdirSync(localDir).filter((f) => f.endsWith(".png"))) { const dt = dev[f.split("_")[0]]; if (dt) local[dt] = (local[dt] || 0) + 1; }
92
- const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${primary.id}/appScreenshotSets?include=appScreenshots&limit=50`);
93
- const remote = {};
94
- for (const s of sets.data || []) remote[s.attributes.screenshotDisplayType] = (s.relationships?.appScreenshots?.data || []).length;
95
- for (const dt of new Set([...Object.keys(local), ...Object.keys(remote)])) {
96
- const L = local[dt] || 0, R = remote[dt] || 0;
97
- if (L !== R) { diffs++; console.log(` ${yellow("screenshots")} ${dt.replace("APP_", "")}: local ${L} / remote ${R} (@${config.primaryLocale})`); }
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 || []) remotePrev[s.attributes.previewType] = (s.relationships?.appPreviews?.data || []).length;
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 L = fs.existsSync(path.resolve(spec.file)) ? 1 : 0, R = remotePrev[spec.type] || 0;
104
- if (L !== R) { diffs++; console.log(` ${yellow("preview")} ${spec.type}: local ${L} / remote ${R} (@${config.primaryLocale})`); }
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
  }