@tantawowa/hosanna-tools 3.30.0 → 3.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artwork/app-artwork.d.ts +122 -0
- package/dist/artwork/app-artwork.js +525 -0
- package/dist/artwork/app-artwork.js.map +1 -0
- package/dist/artwork/native-artwork.d.ts +33 -0
- package/dist/artwork/native-artwork.js +297 -0
- package/dist/artwork/native-artwork.js.map +1 -0
- package/dist/artwork/packaged-artwork.d.ts +10 -0
- package/dist/artwork/packaged-artwork.js +465 -0
- package/dist/artwork/packaged-artwork.js.map +1 -0
- package/dist/build-info.json +3 -3
- package/dist/index.d.ts +11 -0
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/native/artifacts.d.ts +5 -0
- package/dist/native/artifacts.js +55 -15
- package/dist/native/artifacts.js.map +1 -1
- package/dist/run/android-emulator.js +11 -7
- package/dist/run/android-emulator.js.map +1 -1
- package/dist/run/apple-tv.js +8 -3
- package/dist/run/apple-tv.js.map +1 -1
- package/dist/run/executor.d.ts +6 -0
- package/dist/run/executor.js +119 -56
- package/dist/run/executor.js.map +1 -1
- package/dist/run/ios-device.js +3 -1
- package/dist/run/ios-device.js.map +1 -1
- package/dist/run/ios-simulator.js +6 -2
- package/dist/run/ios-simulator.js.map +1 -1
- package/dist/run/native-build.js +10 -6
- package/dist/run/native-build.js.map +1 -1
- package/dist/run/planner.d.ts +2 -0
- package/dist/run/planner.js +17 -5
- package/dist/run/planner.js.map +1 -1
- package/dist/run/run-config.d.ts +7 -0
- package/dist/run/run-config.js +7 -0
- package/dist/run/run-config.js.map +1 -1
- package/dist/run/run-controller.js +13 -1
- package/dist/run/run-controller.js.map +1 -1
- package/dist/vite-app-artwork.d.ts +47 -0
- package/dist/vite-app-artwork.js +180 -0
- package/dist/vite-app-artwork.js.map +1 -0
- package/dist/vite.d.ts +2 -0
- package/dist/vite.js +4 -1
- package/dist/vite.js.map +1 -1
- package/package.json +7 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { HosannaPwaIcon } from '../vite-pwa.js';
|
|
2
|
+
export type AppArtworkPlatform = 'browser' | 'roku' | 'ios' | 'apple-tv' | 'android' | 'android-tv';
|
|
3
|
+
/** Build-time sources. Paths are relative to this manifest, never runtime URLs. */
|
|
4
|
+
export interface AppArtworkManifest {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
icon?: string;
|
|
7
|
+
ios?: {
|
|
8
|
+
icon?: string;
|
|
9
|
+
launchScreen?: {
|
|
10
|
+
image: string;
|
|
11
|
+
backgroundColor: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
android?: {
|
|
15
|
+
icon?: string;
|
|
16
|
+
adaptive?: {
|
|
17
|
+
foreground: string;
|
|
18
|
+
background: string;
|
|
19
|
+
monochrome?: string;
|
|
20
|
+
};
|
|
21
|
+
launchScreen?: {
|
|
22
|
+
backgroundColor: string;
|
|
23
|
+
icon?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
androidTv?: {
|
|
27
|
+
icon?: string;
|
|
28
|
+
banner: string;
|
|
29
|
+
launchScreen?: {
|
|
30
|
+
backgroundColor: string;
|
|
31
|
+
icon?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
appleTv?: {
|
|
35
|
+
assetCatalog: string;
|
|
36
|
+
appIconName?: string;
|
|
37
|
+
launchScreen?: {
|
|
38
|
+
image: string;
|
|
39
|
+
backgroundColor: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
roku?: {
|
|
43
|
+
channel: {
|
|
44
|
+
hd: string;
|
|
45
|
+
fhd: string;
|
|
46
|
+
};
|
|
47
|
+
splash?: {
|
|
48
|
+
sd: string;
|
|
49
|
+
hd: string;
|
|
50
|
+
fhd?: string;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
web?: {
|
|
54
|
+
icon?: string;
|
|
55
|
+
maskableIcon?: string;
|
|
56
|
+
appleTouchIcon?: string;
|
|
57
|
+
favicon?: string;
|
|
58
|
+
loadingScreen?: {
|
|
59
|
+
image: string;
|
|
60
|
+
backgroundColor: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export interface PrepareAppArtworkOptions {
|
|
65
|
+
cwd: string;
|
|
66
|
+
app: string;
|
|
67
|
+
platform: AppArtworkPlatform;
|
|
68
|
+
manifest: string;
|
|
69
|
+
}
|
|
70
|
+
export interface PreparedAppArtwork {
|
|
71
|
+
schemaVersion: 1;
|
|
72
|
+
app: string;
|
|
73
|
+
platform: AppArtworkPlatform;
|
|
74
|
+
manifestPath: string;
|
|
75
|
+
inputHash: string;
|
|
76
|
+
outputDir: string;
|
|
77
|
+
receiptPath: string;
|
|
78
|
+
apple?: {
|
|
79
|
+
assetCatalogPath: string;
|
|
80
|
+
appIconName: string;
|
|
81
|
+
watchAppIconName?: string;
|
|
82
|
+
launchScreen?: {
|
|
83
|
+
storyboardPath: string;
|
|
84
|
+
storyboardName: string;
|
|
85
|
+
imageName: string;
|
|
86
|
+
backgroundColor: string;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
android?: {
|
|
90
|
+
resourceDirectory: string;
|
|
91
|
+
iconResource: string;
|
|
92
|
+
bannerResource?: string;
|
|
93
|
+
launchScreen?: {
|
|
94
|
+
iconResource: string;
|
|
95
|
+
backgroundColor: string;
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
roku?: {
|
|
99
|
+
files: Array<{
|
|
100
|
+
source: string;
|
|
101
|
+
path: string;
|
|
102
|
+
}>;
|
|
103
|
+
manifestValues: Record<string, string>;
|
|
104
|
+
};
|
|
105
|
+
web?: {
|
|
106
|
+
icons: HosannaPwaIcon[];
|
|
107
|
+
appleTouchIcon?: string;
|
|
108
|
+
favicon?: {
|
|
109
|
+
source: string;
|
|
110
|
+
src: string;
|
|
111
|
+
};
|
|
112
|
+
loadingScreen?: {
|
|
113
|
+
source: string;
|
|
114
|
+
src: string;
|
|
115
|
+
backgroundColor: string;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Prepare one selected target without modifying any supplied source or native project. */
|
|
120
|
+
export declare function prepareAppArtwork(options: PrepareAppArtworkOptions): Promise<PreparedAppArtwork>;
|
|
121
|
+
/** Validate the generic local manifest shape before an app-owned downloader activates it. */
|
|
122
|
+
export declare function validateAppArtworkManifest(value: unknown): AppArtworkManifest;
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.prepareAppArtwork = prepareAppArtwork;
|
|
7
|
+
exports.validateAppArtworkManifest = validateAppArtworkManifest;
|
|
8
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
12
|
+
const RECIPE_VERSION = 4;
|
|
13
|
+
const PLATFORMS = ['browser', 'roku', 'ios', 'apple-tv', 'android', 'android-tv'];
|
|
14
|
+
const SAFE_APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
/** Prepare one selected target without modifying any supplied source or native project. */
|
|
16
|
+
async function prepareAppArtwork(options) {
|
|
17
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2;
|
|
18
|
+
const cwd = fs_1.default.realpathSync(path_1.default.resolve(options.cwd));
|
|
19
|
+
if (!SAFE_APP.test(options.app) || options.app === '.' || options.app === '..') {
|
|
20
|
+
throw new Error(`Invalid artwork app key: ${JSON.stringify(options.app)}`);
|
|
21
|
+
}
|
|
22
|
+
if (!PLATFORMS.includes(options.platform))
|
|
23
|
+
throw new Error(`Unsupported artwork platform: ${options.platform}`);
|
|
24
|
+
const manifestPath = insideFile(cwd, path_1.default.resolve(cwd, options.manifest), 'artwork manifest');
|
|
25
|
+
const context = `Artwork for ${options.app} (${options.platform}), manifest ${manifestPath}`;
|
|
26
|
+
const sources = new Map();
|
|
27
|
+
const read = (absolute) => {
|
|
28
|
+
const resolved = insideFile(cwd, absolute, context);
|
|
29
|
+
const cached = sources.get(resolved);
|
|
30
|
+
if (cached)
|
|
31
|
+
return cached;
|
|
32
|
+
const contents = fs_1.default.readFileSync(resolved);
|
|
33
|
+
const source = { path: resolved, contents, sha256: digest(contents) };
|
|
34
|
+
sources.set(resolved, source);
|
|
35
|
+
return source;
|
|
36
|
+
};
|
|
37
|
+
let manifest;
|
|
38
|
+
try {
|
|
39
|
+
manifest = validateAppArtworkManifest(JSON.parse(read(manifestPath).contents.toString('utf8')));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`${context}: ${errorMessage(error)}`);
|
|
43
|
+
}
|
|
44
|
+
const image = async (role, source, square = false, opaque = false, minimum = 1) => {
|
|
45
|
+
if (!source)
|
|
46
|
+
throw new Error(`${context}: missing required ${role} source. Add ${role} to the artwork manifest.`);
|
|
47
|
+
let decoded;
|
|
48
|
+
try {
|
|
49
|
+
const file = read(path_1.default.resolve(path_1.default.dirname(manifestPath), source));
|
|
50
|
+
// A full decode catches truncated files that can still supply valid metadata.
|
|
51
|
+
const { info, data } = await (0, sharp_1.default)(file.contents, { failOn: 'warning' }).toColourspace('srgb').ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
52
|
+
let isOpaque = true;
|
|
53
|
+
for (let offset = 3; offset < data.length; offset += 4) {
|
|
54
|
+
if (data[offset] !== 255) {
|
|
55
|
+
isOpaque = false;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
decoded = Object.assign(Object.assign({}, file), { width: info.width, height: info.height, opaque: isOpaque });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new Error(`${context}: invalid ${role} source ${source}: ${errorMessage(error)}`);
|
|
63
|
+
}
|
|
64
|
+
if (square && decoded.width !== decoded.height)
|
|
65
|
+
throw new Error(`${context}: ${role} must be square; got ${decoded.width}x${decoded.height}. Supply the approved composition; artwork preparation does not crop.`);
|
|
66
|
+
if (Math.min(decoded.width, decoded.height) < minimum)
|
|
67
|
+
throw new Error(`${context}: ${role} must be at least ${minimum}px in each dimension; got ${decoded.width}x${decoded.height}.`);
|
|
68
|
+
if (opaque && !decoded.opaque)
|
|
69
|
+
throw new Error(`${context}: ${role} must be opaque.`);
|
|
70
|
+
return decoded;
|
|
71
|
+
};
|
|
72
|
+
const images = new Map();
|
|
73
|
+
let tvCatalog;
|
|
74
|
+
if (options.platform === 'ios') {
|
|
75
|
+
images.set('icon', await image('ios.icon (or icon)', (_b = (_a = manifest.ios) === null || _a === void 0 ? void 0 : _a.icon) !== null && _b !== void 0 ? _b : manifest.icon, true, true, 1024));
|
|
76
|
+
}
|
|
77
|
+
else if (options.platform === 'android' || options.platform === 'android-tv') {
|
|
78
|
+
images.set('icon', await image(`${options.platform === 'android-tv' ? 'androidTv' : 'android'}.icon (or icon)`, options.platform === 'android-tv' ? (_d = (_c = manifest.androidTv) === null || _c === void 0 ? void 0 : _c.icon) !== null && _d !== void 0 ? _d : manifest.icon : (_f = (_e = manifest.android) === null || _e === void 0 ? void 0 : _e.icon) !== null && _f !== void 0 ? _f : manifest.icon, true, false, 192));
|
|
79
|
+
if (options.platform === 'android-tv') {
|
|
80
|
+
const banner = await image('androidTv.banner', (_g = manifest.androidTv) === null || _g === void 0 ? void 0 : _g.banner, false, true);
|
|
81
|
+
if (banner.width * 9 !== banner.height * 16 || banner.width < 320)
|
|
82
|
+
throw new Error(`${context}: androidTv.banner must be an opaque 16:9 image of at least 320x180; got ${banner.width}x${banner.height}. Supply a TV banner composition.`);
|
|
83
|
+
images.set('banner', banner);
|
|
84
|
+
}
|
|
85
|
+
else if ((_h = manifest.android) === null || _h === void 0 ? void 0 : _h.adaptive) {
|
|
86
|
+
for (const key of ['foreground', 'background', 'monochrome']) {
|
|
87
|
+
const source = manifest.android.adaptive[key];
|
|
88
|
+
if (key === 'monochrome' && !source)
|
|
89
|
+
continue;
|
|
90
|
+
images.set(key, await image(`android.adaptive.${key}`, source, true, key === 'background', 108));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (options.platform === 'browser') {
|
|
95
|
+
const icon = (_k = (_j = manifest.web) === null || _j === void 0 ? void 0 : _j.icon) !== null && _k !== void 0 ? _k : manifest.icon;
|
|
96
|
+
images.set('icon', await image('web.icon (or icon)', icon, true, false, 512));
|
|
97
|
+
images.set('apple-touch-icon', await image('web.appleTouchIcon (or web.icon/icon)', (_m = (_l = manifest.web) === null || _l === void 0 ? void 0 : _l.appleTouchIcon) !== null && _m !== void 0 ? _m : icon, true, true, 180));
|
|
98
|
+
images.set('favicon', await image('web.favicon (or web.icon/icon)', (_p = (_o = manifest.web) === null || _o === void 0 ? void 0 : _o.favicon) !== null && _p !== void 0 ? _p : icon, true, false, 32));
|
|
99
|
+
if ((_q = manifest.web) === null || _q === void 0 ? void 0 : _q.maskableIcon)
|
|
100
|
+
images.set('maskable', await image('web.maskableIcon', manifest.web.maskableIcon, true, true, 512));
|
|
101
|
+
}
|
|
102
|
+
else if (options.platform === 'roku') {
|
|
103
|
+
if (!((_s = (_r = manifest.roku) === null || _r === void 0 ? void 0 : _r.channel) === null || _s === void 0 ? void 0 : _s.hd))
|
|
104
|
+
throw new Error(`${context}: missing required roku.channel.hd source. Supply channel artwork explicitly.`);
|
|
105
|
+
if (!manifest.roku.channel.fhd)
|
|
106
|
+
throw new Error(`${context}: missing required roku.channel.fhd source. Supply the approved 540x405 channel artwork explicitly.`);
|
|
107
|
+
if (manifest.roku.splash && !manifest.roku.splash.sd)
|
|
108
|
+
throw new Error(`${context}: missing required roku.splash.sd source. Supply the approved 720x480 splash composition.`);
|
|
109
|
+
for (const role of ['channel', 'splash']) {
|
|
110
|
+
const values = (_t = manifest.roku) === null || _t === void 0 ? void 0 : _t[role];
|
|
111
|
+
if (!values)
|
|
112
|
+
continue;
|
|
113
|
+
const dimensions = role === 'channel'
|
|
114
|
+
? { hd: [290, 218], fhd: [540, 405] }
|
|
115
|
+
: { sd: [720, 480], hd: [1280, 720], fhd: [1920, 1080] };
|
|
116
|
+
for (const [resolution, [width, height]] of Object.entries(dimensions)) {
|
|
117
|
+
const source = values[resolution];
|
|
118
|
+
if (!source)
|
|
119
|
+
continue;
|
|
120
|
+
const rolePath = `roku.${role}.${resolution}`;
|
|
121
|
+
const decoded = await image(rolePath, source, false, true);
|
|
122
|
+
if (decoded.width !== width || decoded.height !== height)
|
|
123
|
+
throw new Error(`${context}: ${rolePath} must be exactly ${width}x${height}; got ${decoded.width}x${decoded.height} from ${source}. Supply the approved composition; artwork preparation does not crop.`);
|
|
124
|
+
images.set(`${role}-${resolution}`, decoded);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
if (!((_u = manifest.appleTv) === null || _u === void 0 ? void 0 : _u.assetCatalog))
|
|
130
|
+
throw new Error(`${context}: missing required appleTv.assetCatalog. Supply an approved layered icon and top-shelf catalog; a splash screenshot is not an icon.`);
|
|
131
|
+
try {
|
|
132
|
+
tvCatalog = await readTvCatalog(cwd, path_1.default.resolve(path_1.default.dirname(manifestPath), manifest.appleTv.assetCatalog), manifest.appleTv.appIconName, read);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new Error(`${context}: invalid appleTv.assetCatalog: ${errorMessage(error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const screen = options.platform === 'ios' ? (_v = manifest.ios) === null || _v === void 0 ? void 0 : _v.launchScreen
|
|
139
|
+
: options.platform === 'apple-tv' ? (_w = manifest.appleTv) === null || _w === void 0 ? void 0 : _w.launchScreen
|
|
140
|
+
: options.platform === 'android' ? (_x = manifest.android) === null || _x === void 0 ? void 0 : _x.launchScreen
|
|
141
|
+
: options.platform === 'android-tv' ? (_y = manifest.androidTv) === null || _y === void 0 ? void 0 : _y.launchScreen
|
|
142
|
+
: options.platform === 'browser' ? (_z = manifest.web) === null || _z === void 0 ? void 0 : _z.loadingScreen : undefined;
|
|
143
|
+
if (screen) {
|
|
144
|
+
if ('image' in screen)
|
|
145
|
+
images.set('launch', await image(`${options.platform}.launch/loadingScreen.image`, screen.image));
|
|
146
|
+
else if (screen.icon)
|
|
147
|
+
images.set('launch', await image(`${options.platform}.launchScreen.icon`, screen.icon, true, false, 192));
|
|
148
|
+
else
|
|
149
|
+
images.set('launch', images.get('icon'));
|
|
150
|
+
}
|
|
151
|
+
const sourceList = [...sources.values()].sort((a, b) => projectRelative(cwd, a.path).localeCompare(projectRelative(cwd, b.path)));
|
|
152
|
+
const inputHash = digest(JSON.stringify({
|
|
153
|
+
recipeVersion: RECIPE_VERSION, app: options.app, platform: options.platform,
|
|
154
|
+
sources: sourceList.map(source => ({ path: projectRelative(cwd, source.path), sha256: source.sha256 })),
|
|
155
|
+
}));
|
|
156
|
+
const outputDir = path_1.default.join(cwd, '.hosanna-tools/artwork', options.app, options.platform, inputHash);
|
|
157
|
+
ensureOutputParents(cwd, outputDir);
|
|
158
|
+
const result = resultFor(cwd, options, manifestPath, inputHash, outputDir, images, screen === null || screen === void 0 ? void 0 : screen.backgroundColor);
|
|
159
|
+
if (validReceipt(outputDir, inputHash))
|
|
160
|
+
return result;
|
|
161
|
+
const temporary = fs_1.default.mkdtempSync(path_1.default.join(path_1.default.dirname(outputDir), '.prepare-'));
|
|
162
|
+
try {
|
|
163
|
+
const write = (relative, contents) => {
|
|
164
|
+
const destination = path_1.default.join(temporary, relative);
|
|
165
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
166
|
+
fs_1.default.writeFileSync(destination, contents);
|
|
167
|
+
};
|
|
168
|
+
const png = async (role, relative, width, height = width, opaque = false) => {
|
|
169
|
+
const source = images.get(role);
|
|
170
|
+
let raster = (0, sharp_1.default)(source.contents, { failOn: 'warning' });
|
|
171
|
+
if (width !== undefined)
|
|
172
|
+
raster = raster.resize(width, height, { fit: 'fill' }); // callers validate matching aspect first
|
|
173
|
+
if (opaque)
|
|
174
|
+
raster = raster.removeAlpha();
|
|
175
|
+
write(relative, await raster.png().toBuffer());
|
|
176
|
+
};
|
|
177
|
+
if (result.apple && options.platform === 'ios') {
|
|
178
|
+
for (const [name, platform] of [[result.apple.appIconName, 'ios'], [result.apple.watchAppIconName, 'watchos']]) {
|
|
179
|
+
const directory = `Assets.xcassets/${name}.appiconset`;
|
|
180
|
+
await png('icon', `${directory}/icon-1024.png`, 1024, 1024, true);
|
|
181
|
+
write(`${directory}/Contents.json`, json({ images: [{ filename: 'icon-1024.png', idiom: 'universal', platform, size: '1024x1024' }], info: { author: 'xcode', version: 1 } }));
|
|
182
|
+
}
|
|
183
|
+
write('Assets.xcassets/Contents.json', json({ info: { author: 'xcode', version: 1 } }));
|
|
184
|
+
}
|
|
185
|
+
else if (result.apple && tvCatalog) {
|
|
186
|
+
write('Assets.xcassets/Contents.json', json({ info: { author: 'xcode', version: 1 } }));
|
|
187
|
+
for (const source of tvCatalog.files)
|
|
188
|
+
write(`Assets.xcassets/${result.apple.appIconName}.brandassets/${path_1.default.relative(tvCatalog.root, source.path)}`, source.contents);
|
|
189
|
+
}
|
|
190
|
+
else if (result.android) {
|
|
191
|
+
const name = result.android.iconResource.slice('@mipmap/'.length);
|
|
192
|
+
for (const [density, size] of [['mdpi', 48], ['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]]) {
|
|
193
|
+
await png('icon', `res/mipmap-${density}/${name}.png`, size);
|
|
194
|
+
}
|
|
195
|
+
if (images.has('banner'))
|
|
196
|
+
await png('banner', `res/drawable-xhdpi/${result.android.bannerResource.slice('@drawable/'.length)}.png`, 320, 180, true);
|
|
197
|
+
if (images.has('foreground')) {
|
|
198
|
+
for (const role of ['foreground', 'background', 'monochrome']) {
|
|
199
|
+
if (images.has(role))
|
|
200
|
+
await png(role, `res/drawable-nodpi/${name}_${role}.png`, 432, 432, role === 'background');
|
|
201
|
+
}
|
|
202
|
+
const adaptive = (monochrome) => `<?xml version="1.0" encoding="utf-8"?>\n<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">\n <background android:drawable="@drawable/${name}_background" />\n <foreground android:drawable="@drawable/${name}_foreground" />\n${monochrome ? ` <monochrome android:drawable="@drawable/${name}_monochrome" />\n` : ''}</adaptive-icon>\n`;
|
|
203
|
+
write(`res/mipmap-anydpi-v26/${name}.xml`, adaptive(false));
|
|
204
|
+
if (images.has('monochrome'))
|
|
205
|
+
write(`res/mipmap-anydpi-v33/${name}.xml`, adaptive(true));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (result.web) {
|
|
209
|
+
await png('icon', 'web/icon-192.png', 192);
|
|
210
|
+
await png('icon', 'web/icon-512.png', 512);
|
|
211
|
+
await png('apple-touch-icon', 'web/apple-touch-icon-180.png', 180, 180, true);
|
|
212
|
+
await png('favicon', 'web/favicon-32.png', 32);
|
|
213
|
+
if (images.has('maskable'))
|
|
214
|
+
await png('maskable', 'web/icon-maskable-512.png', 512, 512, true);
|
|
215
|
+
}
|
|
216
|
+
else if (result.roku) {
|
|
217
|
+
for (const key of images.keys())
|
|
218
|
+
await png(key, `roku/images/${key}.png`, undefined, undefined, true);
|
|
219
|
+
}
|
|
220
|
+
if ((_0 = result.apple) === null || _0 === void 0 ? void 0 : _0.launchScreen) {
|
|
221
|
+
const launch = result.apple.launchScreen;
|
|
222
|
+
const imageSet = `Assets.xcassets/${launch.imageName}.imageset`;
|
|
223
|
+
await png('launch', `${imageSet}/launch.png`);
|
|
224
|
+
write(`${imageSet}/Contents.json`, json({ images: [{ filename: 'launch.png', idiom: 'universal' }], info: { author: 'xcode', version: 1 } }));
|
|
225
|
+
write(`apple/${launch.storyboardName}.storyboard`, launchStoryboard(launch, images.get('launch'), options.platform === 'apple-tv'));
|
|
226
|
+
}
|
|
227
|
+
if ((_1 = result.android) === null || _1 === void 0 ? void 0 : _1.launchScreen) {
|
|
228
|
+
// Android masks its OS splash icon. Contain the complete supplied image in
|
|
229
|
+
// the documented 192dp circular safe area; the square's diagonal fits
|
|
230
|
+
// inside that circle, so even fully opaque source corners remain visible.
|
|
231
|
+
const launch = images.get('launch');
|
|
232
|
+
const bytes = await (0, sharp_1.default)(launch.contents).resize(135, 135).extend({ top: 76, bottom: 77, left: 76, right: 77, background: '#00000000' }).png().toBuffer();
|
|
233
|
+
write(`res/drawable-mdpi/${result.android.launchScreen.iconResource.slice('@drawable/'.length)}.png`, bytes);
|
|
234
|
+
}
|
|
235
|
+
if ((_2 = result.web) === null || _2 === void 0 ? void 0 : _2.loadingScreen)
|
|
236
|
+
await png('launch', 'web/loading-screen.png');
|
|
237
|
+
const receipt = {
|
|
238
|
+
schemaVersion: 1, recipeVersion: RECIPE_VERSION, app: options.app, platform: options.platform, inputHash,
|
|
239
|
+
sources: sourceList.map(source => ({ path: projectRelative(cwd, source.path), sha256: source.sha256 })),
|
|
240
|
+
outputs: filesIn(temporary).map(file => {
|
|
241
|
+
const contents = fs_1.default.readFileSync(file);
|
|
242
|
+
return { path: projectRelative(temporary, file), sha256: digest(contents), bytes: contents.length };
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
write('receipt.json', json(receipt));
|
|
246
|
+
// Never expose an incomplete directory. A concurrent identical preparation can win.
|
|
247
|
+
if (fs_1.default.existsSync(outputDir)) {
|
|
248
|
+
if (validReceipt(outputDir, inputHash))
|
|
249
|
+
return result;
|
|
250
|
+
fs_1.default.rmSync(outputDir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
fs_1.default.renameSync(temporary, outputDir);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (!validReceipt(outputDir, inputHash))
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
fs_1.default.rmSync(temporary, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function resultFor(cwd, options, manifestPath, inputHash, outputDir, images, backgroundColor) {
|
|
266
|
+
const result = { schemaVersion: 1, app: options.app, platform: options.platform, manifestPath, inputHash, outputDir, receiptPath: path_1.default.join(outputDir, 'receipt.json') };
|
|
267
|
+
const suffix = inputHash.slice(0, 16);
|
|
268
|
+
if (options.platform === 'ios' || options.platform === 'apple-tv') {
|
|
269
|
+
result.apple = Object.assign({ assetCatalogPath: path_1.default.join(outputDir, 'Assets.xcassets'), appIconName: `HstAppIcon_${suffix}` }, (options.platform === 'ios' ? { watchAppIconName: `HstWatchAppIcon_${suffix}` } : {}));
|
|
270
|
+
if (backgroundColor)
|
|
271
|
+
result.apple.launchScreen = { storyboardPath: path_1.default.join(outputDir, 'apple', `HstLaunch_${suffix}.storyboard`),
|
|
272
|
+
storyboardName: `HstLaunch_${suffix}`, imageName: `HstLaunchImage_${suffix}`, backgroundColor };
|
|
273
|
+
}
|
|
274
|
+
else if (options.platform === 'android' || options.platform === 'android-tv') {
|
|
275
|
+
result.android = Object.assign({ resourceDirectory: path_1.default.join(outputDir, 'res'), iconResource: `@mipmap/hst_app_icon_${suffix}` }, (options.platform === 'android-tv' ? { bannerResource: `@drawable/hst_app_banner_${suffix}` } : {}));
|
|
276
|
+
if (backgroundColor)
|
|
277
|
+
result.android.launchScreen = { iconResource: `@drawable/hst_launch_icon_${suffix}`, backgroundColor };
|
|
278
|
+
}
|
|
279
|
+
else if (options.platform === 'browser') {
|
|
280
|
+
const publicRoot = `/_hosanna/artwork/${options.app}/${inputHash}`;
|
|
281
|
+
const icon = (filename, size, purpose) => ({ source: projectRelative(cwd, path_1.default.join(outputDir, 'web', filename)), src: `${publicRoot}/${filename}`, sizes: `${size}x${size}`, type: 'image/png', purpose });
|
|
282
|
+
result.web = Object.assign({ icons: [icon('icon-192.png', 192, 'any'), icon('icon-512.png', 512, 'any'), icon('apple-touch-icon-180.png', 180, 'any'),
|
|
283
|
+
...(images.has('maskable') ? [icon('icon-maskable-512.png', 512, 'maskable')] : [])], appleTouchIcon: `${publicRoot}/apple-touch-icon-180.png`, favicon: { source: projectRelative(cwd, path_1.default.join(outputDir, 'web/favicon-32.png')), src: `${publicRoot}/favicon-32.png` } }, (backgroundColor ? { loadingScreen: { source: projectRelative(cwd, path_1.default.join(outputDir, 'web/loading-screen.png')), src: `${publicRoot}/loading-screen.png`, backgroundColor } } : {}));
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const files = [];
|
|
287
|
+
const manifestValues = {};
|
|
288
|
+
for (const key of images.keys()) {
|
|
289
|
+
const [role, resolution] = key.split('-');
|
|
290
|
+
const packagePath = `images/hst-artwork/${options.app}/${inputHash}/${key}.png`;
|
|
291
|
+
files.push({ source: path_1.default.join(outputDir, 'roku/images', `${key}.png`), path: packagePath });
|
|
292
|
+
manifestValues[`${role === 'channel' ? 'mm_icon_focus' : 'splash_screen'}_${resolution}`] = `pkg:/${packagePath}`;
|
|
293
|
+
}
|
|
294
|
+
result.roku = { files, manifestValues };
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
function launchStoryboard(launch, image, tv) {
|
|
299
|
+
const [red, green, blue] = [1, 3, 5].map(index => parseInt(launch.backgroundColor.slice(index, index + 2), 16) / 255);
|
|
300
|
+
const width = tv ? 1920 : 375;
|
|
301
|
+
const height = tv ? 1080 : 667;
|
|
302
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
303
|
+
<document type="${tv ? 'com.apple.InterfaceBuilder.AppleTV.Storyboard' : 'com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB'}" version="3.0" toolsVersion="13122.16" targetRuntime="${tv ? 'AppleTV' : 'iOS.CocoaTouch'}" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="hst-controller">
|
|
304
|
+
<dependencies><plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/><capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/></dependencies>
|
|
305
|
+
<scenes><scene sceneID="hst-scene"><objects>
|
|
306
|
+
<viewController id="hst-controller" sceneMemberID="viewController"><view key="view" contentMode="scaleToFill" id="hst-root">
|
|
307
|
+
<rect key="frame" x="0" y="0" width="${width}" height="${height}"/><autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
308
|
+
<subviews><imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" image="${launch.imageName}" translatesAutoresizingMaskIntoConstraints="NO" id="hst-image"><rect key="frame" x="0" y="0" width="${width}" height="${height}"/></imageView></subviews>
|
|
309
|
+
<color key="backgroundColor" red="${red}" green="${green}" blue="${blue}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
|
310
|
+
<constraints><constraint firstItem="hst-image" firstAttribute="leading" secondItem="hst-root" secondAttribute="leading" id="hst-leading"/><constraint firstItem="hst-image" firstAttribute="trailing" secondItem="hst-root" secondAttribute="trailing" id="hst-trailing"/><constraint firstItem="hst-image" firstAttribute="top" secondItem="hst-root" secondAttribute="top" id="hst-top"/><constraint firstItem="hst-image" firstAttribute="bottom" secondItem="hst-root" secondAttribute="bottom" id="hst-bottom"/></constraints>
|
|
311
|
+
</view></viewController><placeholder placeholderIdentifier="IBFirstResponder" id="hst-responder" sceneMemberID="firstResponder"/>
|
|
312
|
+
</objects></scene></scenes><resources><image name="${launch.imageName}" width="${image.width}" height="${image.height}"/></resources>
|
|
313
|
+
</document>\n`;
|
|
314
|
+
}
|
|
315
|
+
/** Validate the generic local manifest shape before an app-owned downloader activates it. */
|
|
316
|
+
function validateAppArtworkManifest(value) {
|
|
317
|
+
const object = checkedObject(value, 'manifest', ['schemaVersion', 'icon', 'ios', 'android', 'androidTv', 'appleTv', 'roku', 'web']);
|
|
318
|
+
if (object.schemaVersion !== 1)
|
|
319
|
+
throw new Error('schemaVersion must be 1.');
|
|
320
|
+
checkSource(object, 'icon', 'icon');
|
|
321
|
+
for (const [section, fields] of Object.entries({ ios: ['icon', 'launchScreen'], android: ['icon', 'adaptive', 'launchScreen'], androidTv: ['icon', 'banner', 'launchScreen'], appleTv: ['assetCatalog', 'appIconName', 'launchScreen'], roku: ['channel', 'splash'], web: ['icon', 'maskableIcon', 'appleTouchIcon', 'favicon', 'loadingScreen'] })) {
|
|
322
|
+
if (object[section] === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
const values = checkedObject(object[section], section, fields);
|
|
325
|
+
for (const field of fields) {
|
|
326
|
+
if (field === 'launchScreen' || field === 'loadingScreen') {
|
|
327
|
+
if (values[field] === undefined)
|
|
328
|
+
continue;
|
|
329
|
+
const android = section === 'android' || section === 'androidTv';
|
|
330
|
+
const screen = checkedObject(values[field], `${section}.${field}`, [android ? 'icon' : 'image', 'backgroundColor']);
|
|
331
|
+
checkSource(screen, android ? 'icon' : 'image', `${section}.${field}.${android ? 'icon' : 'image'}`);
|
|
332
|
+
if (!android && !screen.image)
|
|
333
|
+
throw new Error(`${section}.${field}.image is required.`);
|
|
334
|
+
if (typeof screen.backgroundColor !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(screen.backgroundColor))
|
|
335
|
+
throw new Error(`${section}.${field}.backgroundColor must be an opaque #RRGGBB color.`);
|
|
336
|
+
}
|
|
337
|
+
else if (field === 'adaptive') {
|
|
338
|
+
if (values.adaptive === undefined)
|
|
339
|
+
continue;
|
|
340
|
+
const adaptive = checkedObject(values.adaptive, 'android.adaptive', ['foreground', 'background', 'monochrome']);
|
|
341
|
+
for (const key of ['foreground', 'background', 'monochrome'])
|
|
342
|
+
checkSource(adaptive, key, `android.adaptive.${key}`);
|
|
343
|
+
if (!adaptive.foreground || !adaptive.background)
|
|
344
|
+
throw new Error('android.adaptive requires foreground and background sources.');
|
|
345
|
+
}
|
|
346
|
+
else if (section === 'roku') {
|
|
347
|
+
if (values[field] === undefined)
|
|
348
|
+
continue;
|
|
349
|
+
const resolutions = checkedObject(values[field], `roku.${field}`, field === 'channel' ? ['hd', 'fhd'] : ['sd', 'hd', 'fhd']);
|
|
350
|
+
for (const resolution of ['sd', 'hd', 'fhd'])
|
|
351
|
+
checkSource(resolutions, resolution, `roku.${field}.${resolution}`);
|
|
352
|
+
if (!resolutions.hd)
|
|
353
|
+
throw new Error(`roku.${field}.hd is required when ${field} is configured.`);
|
|
354
|
+
}
|
|
355
|
+
else
|
|
356
|
+
checkSource(values, field, `${section}.${field}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return object;
|
|
360
|
+
}
|
|
361
|
+
function checkedObject(value, label, keys) {
|
|
362
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
363
|
+
throw new Error(`${label} must be an object.`);
|
|
364
|
+
for (const key of Object.keys(value))
|
|
365
|
+
if (!keys.includes(key))
|
|
366
|
+
throw new Error(`Unknown artwork field: ${label}.${key}`);
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
function checkSource(object, key, label) {
|
|
370
|
+
const value = object[key];
|
|
371
|
+
if (value === undefined)
|
|
372
|
+
return;
|
|
373
|
+
if (typeof value !== 'string' || !value.trim() || value !== value.trim() || value.includes('\0') || /^(?:[a-z]+:|\/|\\)/i.test(value))
|
|
374
|
+
throw new Error(`${label} must be a non-empty relative local source path.`);
|
|
375
|
+
}
|
|
376
|
+
async function readTvCatalog(cwd, input, name, read) {
|
|
377
|
+
let root = insideDirectory(cwd, input, 'Apple TV asset catalog');
|
|
378
|
+
if (root.endsWith('.xcassets')) {
|
|
379
|
+
const candidates = fs_1.default.readdirSync(root).filter(entry => entry.endsWith('.brandassets'));
|
|
380
|
+
const selected = name ? `${name.replace(/\.brandassets$/, '')}.brandassets` : candidates.length === 1 ? candidates[0] : undefined;
|
|
381
|
+
if (!selected || !candidates.includes(selected))
|
|
382
|
+
throw new Error('Specify appIconName matching one .brandassets set in the catalog.');
|
|
383
|
+
root = insideDirectory(cwd, path_1.default.join(root, selected), 'Apple TV brand assets');
|
|
384
|
+
}
|
|
385
|
+
if (!root.endsWith('.brandassets'))
|
|
386
|
+
throw new Error('assetCatalog must be a .xcassets catalog or a .brandassets directory.');
|
|
387
|
+
const files = filesIn(root).map(file => read(file));
|
|
388
|
+
const byPath = new Map(files.map(file => [file.path, file]));
|
|
389
|
+
const contents = (directory) => {
|
|
390
|
+
const file = byPath.get(path_1.default.join(directory, 'Contents.json'));
|
|
391
|
+
if (!file)
|
|
392
|
+
throw new Error(`Missing Contents.json: ${directory}`);
|
|
393
|
+
const value = JSON.parse(file.contents.toString('utf8'));
|
|
394
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
395
|
+
throw new Error(`Invalid Contents.json: ${directory}`);
|
|
396
|
+
return value;
|
|
397
|
+
};
|
|
398
|
+
const child = (directory, value) => {
|
|
399
|
+
if (typeof value !== 'string' || !value || path_1.default.basename(value) !== value || value === '.' || value === '..')
|
|
400
|
+
throw new Error(`Catalog filename must name a child of ${directory}`);
|
|
401
|
+
return path_1.default.join(directory, value);
|
|
402
|
+
};
|
|
403
|
+
const validateImages = async (directory, width, height, opaque) => {
|
|
404
|
+
const entries = contents(directory).images;
|
|
405
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
406
|
+
throw new Error(`Missing image entries: ${directory}`);
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
const filePath = child(directory, entry.filename);
|
|
409
|
+
const file = byPath.get(filePath);
|
|
410
|
+
if (!file)
|
|
411
|
+
throw new Error(`Referenced artwork is missing: ${filePath}`);
|
|
412
|
+
const scale = entry.scale === undefined ? 1 : entry.scale === '1x' ? 1 : entry.scale === '2x' ? 2 : undefined;
|
|
413
|
+
if (!scale)
|
|
414
|
+
throw new Error(`Unsupported TV image scale: ${String(entry.scale)}`);
|
|
415
|
+
const metadata = await (0, sharp_1.default)(file.contents, { failOn: 'warning' }).metadata();
|
|
416
|
+
const stats = await (0, sharp_1.default)(file.contents, { failOn: 'warning' }).stats();
|
|
417
|
+
if (metadata.format !== 'png' || metadata.width !== width * scale || metadata.height !== height * scale)
|
|
418
|
+
throw new Error(`Expected ${width * scale}x${height * scale} PNG at ${filePath}; got ${metadata.width}x${metadata.height}.`);
|
|
419
|
+
if (opaque && !stats.isOpaque)
|
|
420
|
+
throw new Error(`TV background/top-shelf artwork must be opaque: ${filePath}`);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const assets = contents(root).assets;
|
|
424
|
+
if (!Array.isArray(assets))
|
|
425
|
+
throw new Error('TV brand assets require assets entries.');
|
|
426
|
+
const icons = assets.filter(asset => asset.role === 'primary-app-icon');
|
|
427
|
+
if (!icons.some(asset => asset.size === '400x240') || !icons.some(asset => asset.size === '1280x768'))
|
|
428
|
+
throw new Error('TV brand assets require 400x240 launcher and 1280x768 App Store image stacks.');
|
|
429
|
+
if (!assets.some(asset => asset.role === 'top-shelf-image' || asset.role === 'top-shelf-image-wide'))
|
|
430
|
+
throw new Error('TV brand assets require explicit top-shelf artwork.');
|
|
431
|
+
for (const asset of assets) {
|
|
432
|
+
const size = typeof asset.size === 'string' ? /^(\d+)x(\d+)$/.exec(asset.size) : null;
|
|
433
|
+
if (!size)
|
|
434
|
+
throw new Error(`Missing catalog asset size for ${String(asset.filename)}`);
|
|
435
|
+
const directory = child(root, asset.filename);
|
|
436
|
+
if (asset.role === 'primary-app-icon') {
|
|
437
|
+
const layers = contents(directory).layers;
|
|
438
|
+
if (!Array.isArray(layers) || layers.length < 2 || layers.length > 5)
|
|
439
|
+
throw new Error(`TV image stack requires two to five explicit layers: ${directory}`);
|
|
440
|
+
for (const [index, layer] of layers.entries()) {
|
|
441
|
+
const layerDirectory = child(directory, layer.filename);
|
|
442
|
+
contents(layerDirectory);
|
|
443
|
+
const imageSets = fs_1.default.readdirSync(layerDirectory).filter(entry => entry.endsWith('.imageset'));
|
|
444
|
+
if (imageSets.length !== 1)
|
|
445
|
+
throw new Error(`TV layer must contain one image set: ${layerDirectory}`);
|
|
446
|
+
await validateImages(path_1.default.join(layerDirectory, imageSets[0]), Number(size[1]), Number(size[2]), index === layers.length - 1);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else if (asset.role === 'top-shelf-image' || asset.role === 'top-shelf-image-wide') {
|
|
450
|
+
await validateImages(directory, Number(size[1]), Number(size[2]), true);
|
|
451
|
+
}
|
|
452
|
+
else
|
|
453
|
+
throw new Error(`Unsupported TV brand asset role: ${String(asset.role)}`);
|
|
454
|
+
}
|
|
455
|
+
return { root, files };
|
|
456
|
+
}
|
|
457
|
+
function validReceipt(outputDir, inputHash) {
|
|
458
|
+
try {
|
|
459
|
+
if (fs_1.default.lstatSync(outputDir).isSymbolicLink())
|
|
460
|
+
return false;
|
|
461
|
+
const receipt = JSON.parse(fs_1.default.readFileSync(path_1.default.join(outputDir, 'receipt.json'), 'utf8'));
|
|
462
|
+
if (receipt.recipeVersion !== RECIPE_VERSION || receipt.inputHash !== inputHash || !Array.isArray(receipt.outputs) || !receipt.outputs.length)
|
|
463
|
+
return false;
|
|
464
|
+
const actual = filesIn(outputDir).filter(file => path_1.default.basename(file) !== 'receipt.json');
|
|
465
|
+
if (actual.length !== receipt.outputs.length)
|
|
466
|
+
return false;
|
|
467
|
+
return receipt.outputs.every(output => {
|
|
468
|
+
const file = insideFile(outputDir, path_1.default.resolve(outputDir, output.path), 'generated artwork');
|
|
469
|
+
const contents = fs_1.default.readFileSync(file);
|
|
470
|
+
return contents.length === output.bytes && digest(contents) === output.sha256;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch (_a) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function ensureOutputParents(cwd, outputDir) {
|
|
478
|
+
let current = cwd;
|
|
479
|
+
for (const segment of path_1.default.relative(cwd, outputDir).split(path_1.default.sep)) {
|
|
480
|
+
current = path_1.default.join(current, segment);
|
|
481
|
+
if (fs_1.default.existsSync(current) && fs_1.default.lstatSync(current).isSymbolicLink())
|
|
482
|
+
throw new Error(`Refusing artwork output through a symbolic link: ${current}`);
|
|
483
|
+
}
|
|
484
|
+
fs_1.default.mkdirSync(path_1.default.dirname(outputDir), { recursive: true });
|
|
485
|
+
}
|
|
486
|
+
function insideFile(root, file, label) {
|
|
487
|
+
assertInside(root, file, label);
|
|
488
|
+
if (!fs_1.default.existsSync(file) || !fs_1.default.statSync(file).isFile())
|
|
489
|
+
throw new Error(`${label}: file not found: ${file}`);
|
|
490
|
+
const real = fs_1.default.realpathSync(file);
|
|
491
|
+
assertInside(root, real, label);
|
|
492
|
+
return real;
|
|
493
|
+
}
|
|
494
|
+
function insideDirectory(root, directory, label) {
|
|
495
|
+
assertInside(root, directory, label);
|
|
496
|
+
if (!fs_1.default.existsSync(directory) || !fs_1.default.statSync(directory).isDirectory())
|
|
497
|
+
throw new Error(`${label}: directory not found: ${directory}`);
|
|
498
|
+
const real = fs_1.default.realpathSync(directory);
|
|
499
|
+
assertInside(root, real, label);
|
|
500
|
+
return real;
|
|
501
|
+
}
|
|
502
|
+
function assertInside(root, candidate, label) {
|
|
503
|
+
const relative = path_1.default.relative(root, candidate);
|
|
504
|
+
if (relative === '' || (!relative.startsWith(`..${path_1.default.sep}`) && relative !== '..' && !path_1.default.isAbsolute(relative)))
|
|
505
|
+
return;
|
|
506
|
+
throw new Error(`${label}: path is outside project root: ${candidate}`);
|
|
507
|
+
}
|
|
508
|
+
function filesIn(root) {
|
|
509
|
+
const files = [];
|
|
510
|
+
for (const entry of fs_1.default.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
511
|
+
const file = path_1.default.join(root, entry.name);
|
|
512
|
+
if (entry.isSymbolicLink())
|
|
513
|
+
throw new Error(`Artwork directories must not contain symbolic links: ${file}`);
|
|
514
|
+
if (entry.isDirectory())
|
|
515
|
+
files.push(...filesIn(file));
|
|
516
|
+
else if (entry.isFile())
|
|
517
|
+
files.push(file);
|
|
518
|
+
}
|
|
519
|
+
return files;
|
|
520
|
+
}
|
|
521
|
+
function digest(contents) { return crypto_1.default.createHash('sha256').update(contents).digest('hex'); }
|
|
522
|
+
function json(value) { return `${JSON.stringify(value, undefined, 2)}\n`; }
|
|
523
|
+
function projectRelative(root, file) { return path_1.default.relative(root, file).split(path_1.default.sep).join('/'); }
|
|
524
|
+
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
525
|
+
//# sourceMappingURL=app-artwork.js.map
|