vydanne 0.5.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 +161 -23
- package/SKILL.md +158 -26
- package/bin/vydanne.mjs +67 -13
- package/package.json +4 -3
- package/src/client.mjs +78 -7
- 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 +85 -13
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +65 -6
- package/src/commands/prepare.mjs +220 -0
- package/src/commands/prerelease.mjs +25 -5
- package/src/commands/previews.mjs +43 -5
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +65 -14
- package/src/config.mjs +55 -4
- package/src/crossStore.mjs +151 -0
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/client.mjs +6 -3
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +32 -24
- package/src/play/commands/preflight.mjs +5 -0
- package/src/play/commands/prerelease.mjs +96 -22
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +27 -9
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +6 -1
- package/types/index.d.ts +240 -10
- package/vydanne.config.example.mjs +54 -1
|
@@ -1,33 +1,106 @@
|
|
|
1
1
|
import { green, yellow, red } from "../util.mjs";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Set the age rating via the AppInfo age-rating declaration.
|
|
5
|
+
*
|
|
6
|
+
* `rating: "4+"` stays the shorthand it always was: every content descriptor NONE, every capability
|
|
7
|
+
* question false, which is what Apple recomputes 4+ from. Anything else is declared feature by feature
|
|
8
|
+
* in `ageRating`, merged over that all-NONE base — so an app with fantasy violence sets one key rather
|
|
9
|
+
* than restating twenty-seven.
|
|
10
|
+
*
|
|
11
|
+
* This used to refuse outright for any rating but 4+, which made the whole command — and `push`, which
|
|
12
|
+
* runs it as a step — unusable for any app with violence, gambling, chat or user-generated content.
|
|
13
|
+
* The base was always the general thing; only the door was missing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const N = "NONE";
|
|
17
|
+
|
|
18
|
+
// Apple's 2025 age-rating schema. A PATCH must include ALL required fields (a partial set 409s), and
|
|
19
|
+
// `ageRatingOverride` (deprecated) cannot be sent alongside `ageRatingOverrideV2` — so we send only V2.
|
|
20
|
+
// Content descriptors are enums (NONE/INFREQUENT_OR_MILD/FREQUENT_OR_INTENSE); capability questions are
|
|
21
|
+
// booleans. All benign here → 4+.
|
|
22
|
+
const BASE = {
|
|
23
|
+
advertising: false, alcoholTobaccoOrDrugUseOrReferences: N, contests: N, gambling: false,
|
|
24
|
+
gamblingSimulated: N, gunsOrOtherWeapons: N, healthOrWellnessTopics: false, kidsAgeBand: null,
|
|
25
|
+
lootBox: false, medicalOrTreatmentInformation: N, messagingAndChat: false, parentalControls: false,
|
|
26
|
+
profanityOrCrudeHumor: N, ageAssurance: false, sexualContentGraphicAndNudity: N, sexualContentOrNudity: N,
|
|
27
|
+
socialMedia: false, socialMediaAgeRestricted: false, horrorOrFearThemes: N, matureOrSuggestiveThemes: N,
|
|
28
|
+
unrestrictedWebAccess: false, userGeneratedContent: false, violenceCartoonOrFantasy: N,
|
|
29
|
+
violenceRealisticProlongedGraphicOrSadistic: N, violenceRealistic: N, ageRatingOverrideV2: N,
|
|
30
|
+
koreaAgeRatingOverride: N,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Values Apple accepts for a content descriptor. `kidsAgeBand` and the overrides are checked separately. */
|
|
34
|
+
const DESCRIPTOR_VALUES = new Set([N, "INFREQUENT_OR_MILD", "FREQUENT_OR_INTENSE"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The attributes to send, or a human-readable problem.
|
|
38
|
+
*
|
|
39
|
+
* A declaration is validated against the schema BEFORE it reaches Apple, because the failure otherwise
|
|
40
|
+
* is a 409 naming a field the operator did not know existed. An unknown key is a typo — and a typo in
|
|
41
|
+
* this table means a descriptor silently stayed NONE, which is a rating that understates the app.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveAttributes(config) {
|
|
44
|
+
const declared = config.ageRating;
|
|
45
|
+
if (!declared) {
|
|
46
|
+
if (config.rating === "4+") return { attributes: { ...BASE } };
|
|
47
|
+
return {
|
|
48
|
+
problem: [
|
|
49
|
+
`age-rating: rating is '${config.rating}', but nothing describes what makes it that.`,
|
|
50
|
+
"'4+' is the only rating that needs no detail (every descriptor NONE). For anything else,",
|
|
51
|
+
"declare the content Apple asks about — it computes the rating from these, you don't set it:",
|
|
52
|
+
"",
|
|
53
|
+
" ageRating: {",
|
|
54
|
+
" violenceCartoonOrFantasy: 'INFREQUENT_OR_MILD',",
|
|
55
|
+
" userGeneratedContent: false,",
|
|
56
|
+
" },",
|
|
57
|
+
"",
|
|
58
|
+
`Known keys: ${Object.keys(BASE).join(", ")}`,
|
|
59
|
+
].join("\n"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const unknown = Object.keys(declared).filter((k) => !(k in BASE));
|
|
63
|
+
if (unknown.length) {
|
|
64
|
+
return { problem: `age-rating: unknown key(s) ${unknown.join(", ")}. Known: ${Object.keys(BASE).join(", ")}` };
|
|
65
|
+
}
|
|
66
|
+
const bad = [];
|
|
67
|
+
for (const [k, v] of Object.entries(declared)) {
|
|
68
|
+
if (k === "kidsAgeBand") continue; // null | FIVE_AND_UNDER | SIX_TO_EIGHT | NINE_TO_ELEVEN
|
|
69
|
+
if (typeof BASE[k] === "boolean" && typeof v !== "boolean") bad.push(`${k} must be true or false`);
|
|
70
|
+
else if (typeof BASE[k] === "string" && !DESCRIPTOR_VALUES.has(v)) bad.push(`${k} must be one of ${[...DESCRIPTOR_VALUES].join(" / ")}`);
|
|
71
|
+
}
|
|
72
|
+
if (bad.length) return { problem: `age-rating: ${bad.join("; ")}` };
|
|
73
|
+
return { attributes: { ...BASE, ...declared } };
|
|
74
|
+
}
|
|
75
|
+
|
|
6
76
|
export async function run(config, client) {
|
|
7
|
-
|
|
77
|
+
const { attributes, problem } = resolveAttributes(config);
|
|
78
|
+
if (problem) { console.error(red(problem)); return false; }
|
|
8
79
|
await client.findApp(config.bundleId);
|
|
80
|
+
// No allowLive: this PATCHes the age-rating declaration, and the live app-info's declaration is not
|
|
81
|
+
// ours to aim a write at. The refusal below used to be unreachable — appInfo() handed back the live
|
|
82
|
+
// record instead of null, so the write was planned against it and Apple's INVALID_STATE was the
|
|
83
|
+
// first anyone heard of it.
|
|
9
84
|
const info = await client.appInfo();
|
|
10
|
-
if (!info) {
|
|
85
|
+
if (!info) {
|
|
86
|
+
console.error(red("age-rating: no editable app info — refusing to write to the live record."));
|
|
87
|
+
console.error(" `vydanne prepare --apply` starts the next version, which makes app info editable again.");
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
11
90
|
const { json } = await client.get(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
|
|
12
91
|
const id = json.data?.id;
|
|
13
92
|
if (!id) { console.error(red("age-rating: no declaration")); return false; }
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
advertising: false, alcoholTobaccoOrDrugUseOrReferences: N, contests: N, gambling: false,
|
|
21
|
-
gamblingSimulated: N, gunsOrOtherWeapons: N, healthOrWellnessTopics: false, kidsAgeBand: null,
|
|
22
|
-
lootBox: false, medicalOrTreatmentInformation: N, messagingAndChat: false, parentalControls: false,
|
|
23
|
-
profanityOrCrudeHumor: N, ageAssurance: false, sexualContentGraphicAndNudity: N, sexualContentOrNudity: N,
|
|
24
|
-
socialMedia: false, socialMediaAgeRestricted: false, horrorOrFearThemes: N, matureOrSuggestiveThemes: N,
|
|
25
|
-
unrestrictedWebAccess: false, userGeneratedContent: false, violenceCartoonOrFantasy: N,
|
|
26
|
-
violenceRealisticProlongedGraphicOrSadistic: N, violenceRealistic: N, ageRatingOverrideV2: N,
|
|
27
|
-
koreaAgeRatingOverride: N,
|
|
28
|
-
};
|
|
93
|
+
|
|
94
|
+
// What is actually being asserted, printed before it is sent. Apple computes the rating from these,
|
|
95
|
+
// so the declared non-defaults ARE the rating — worth seeing in the log of the run that set them.
|
|
96
|
+
const declared = Object.entries(attributes).filter(([k, v]) => v !== BASE[k] || (v !== false && v !== N && v !== null));
|
|
97
|
+
console.log(` declaring: ${declared.length ? declared.map(([k, v]) => `${k}=${v}`).join(", ") : "everything NONE (4+)"}`);
|
|
98
|
+
|
|
29
99
|
const r = await client.patch(`/v1/ageRatingDeclarations/${id}`, { data: { type: "ageRatingDeclarations", id, attributes } });
|
|
30
100
|
if (r.status >= 300) { console.error(red(`age-rating: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
|
|
31
|
-
|
|
101
|
+
// Apple decides the band from the descriptors; naming config.rating here reports what was ASKED for,
|
|
102
|
+
// which is the only thing this command controls.
|
|
103
|
+
const label = config.ageRating ? `${config.rating} (from the declared descriptors)` : "4+";
|
|
104
|
+
console.log(client.dryRun ? yellow(`age rating WOULD be set -> ${label}`) : green(`age rating set -> ${label}`));
|
|
32
105
|
return true;
|
|
33
106
|
}
|
|
@@ -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
|
}
|