@lakutata/builder 3.0.0-beta.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/LICENSE +23 -0
- package/README.md +27 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/lib/Archive.js +21 -0
- package/dist/esm/lib/Bundle.js +162 -0
- package/dist/esm/lib/Executable.js +221 -0
- package/dist/esm/lib/Icons.js +372 -0
- package/dist/esm/lib/Installer.js +158 -0
- package/dist/esm/lib/Native.js +533 -0
- package/dist/esm/lib/NativeRuntime.js +177 -0
- package/dist/esm/lib/Platform.js +9 -0
- package/dist/esm/lib/Processes.js +42 -0
- package/dist/esm/lib/Scripts.js +161 -0
- package/dist/esm/lib/Tools.js +109 -0
- package/dist/esm/lib/electron/Electron.js +366 -0
- package/dist/esm/lib/linux/Linux.js +361 -0
- package/dist/esm/lib/mac/Mac.js +270 -0
- package/dist/esm/lib/win/Windows.js +525 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/lib/Archive.d.ts +14 -0
- package/dist/types/lib/Bundle.d.ts +96 -0
- package/dist/types/lib/Executable.d.ts +50 -0
- package/dist/types/lib/Icons.d.ts +70 -0
- package/dist/types/lib/Installer.d.ts +242 -0
- package/dist/types/lib/Native.d.ts +196 -0
- package/dist/types/lib/NativeRuntime.d.ts +75 -0
- package/dist/types/lib/Platform.d.ts +4 -0
- package/dist/types/lib/Processes.d.ts +21 -0
- package/dist/types/lib/Scripts.d.ts +77 -0
- package/dist/types/lib/Tools.d.ts +32 -0
- package/dist/types/lib/electron/Electron.d.ts +13 -0
- package/dist/types/lib/linux/Linux.d.ts +29 -0
- package/dist/types/lib/mac/Mac.d.ts +19 -0
- package/dist/types/lib/win/Windows.d.ts +20 -0
- package/package.json +51 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { deflateSync, inflateSync } from 'node:zlib';
|
|
5
|
+
/**
|
|
6
|
+
* The signature of the PNG files
|
|
7
|
+
*/
|
|
8
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
9
|
+
/**
|
|
10
|
+
* The table of the CRC-32 of the chunks of PNG
|
|
11
|
+
*/
|
|
12
|
+
const CRC_TABLE = (() => {
|
|
13
|
+
const table = new Uint32Array(256);
|
|
14
|
+
for (let n = 0; n < 256; n++) {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++)
|
|
17
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
18
|
+
table[n] = c >>> 0;
|
|
19
|
+
}
|
|
20
|
+
return table;
|
|
21
|
+
})();
|
|
22
|
+
/**
|
|
23
|
+
* The CRC-32 of a chunk of PNG (its type and its data)
|
|
24
|
+
* @param data
|
|
25
|
+
*/
|
|
26
|
+
function crc32(data) {
|
|
27
|
+
let crc = 0xffffffff;
|
|
28
|
+
for (const byte of data)
|
|
29
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
30
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The predictor of Paeth (the filter 4 of PNG)
|
|
34
|
+
* @param a the byte on the left
|
|
35
|
+
* @param b the byte above
|
|
36
|
+
* @param c the byte above on the left
|
|
37
|
+
*/
|
|
38
|
+
function paeth(a, b, c) {
|
|
39
|
+
const p = a + b - c;
|
|
40
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
41
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Decode a PNG file (not interlaced): its pixels in RGBA, whatever its colors (gray, RGB, palette, with alpha) and its
|
|
45
|
+
* depth (1 to 16 bits)
|
|
46
|
+
* @param data
|
|
47
|
+
* @param file its name, in the errors
|
|
48
|
+
*/
|
|
49
|
+
export function decodePng(data, file = 'the image') {
|
|
50
|
+
if (data.length < 8 || !data.subarray(0, 8).equals(PNG_SIGNATURE))
|
|
51
|
+
throw new Error(`${file} is not a PNG image`);
|
|
52
|
+
let offset = 8;
|
|
53
|
+
let width = 0, height = 0, depth = 0, type = 0, interlace = 0;
|
|
54
|
+
let palette;
|
|
55
|
+
let transparency;
|
|
56
|
+
const compressed = [];
|
|
57
|
+
while (offset + 8 <= data.length) {
|
|
58
|
+
const length = data.readUInt32BE(offset);
|
|
59
|
+
const name = data.toString('latin1', offset + 4, offset + 8);
|
|
60
|
+
const chunk = data.subarray(offset + 8, offset + 8 + length);
|
|
61
|
+
offset += 12 + length;
|
|
62
|
+
if (name === 'IHDR') {
|
|
63
|
+
width = chunk.readUInt32BE(0);
|
|
64
|
+
height = chunk.readUInt32BE(4);
|
|
65
|
+
depth = chunk[8];
|
|
66
|
+
type = chunk[9];
|
|
67
|
+
interlace = chunk[12];
|
|
68
|
+
}
|
|
69
|
+
else if (name === 'PLTE')
|
|
70
|
+
palette = chunk;
|
|
71
|
+
else if (name === 'tRNS')
|
|
72
|
+
transparency = chunk;
|
|
73
|
+
else if (name === 'IDAT')
|
|
74
|
+
compressed.push(chunk);
|
|
75
|
+
else if (name === 'IEND')
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (!width || !height)
|
|
79
|
+
throw new Error(`${file} has no header (IHDR)`);
|
|
80
|
+
if (interlace)
|
|
81
|
+
throw new Error(`${file} is interlaced: save it without interlacing`);
|
|
82
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type];
|
|
83
|
+
if (!channels || ![1, 2, 4, 8, 16].includes(depth))
|
|
84
|
+
throw new Error(`${file} has colors this tool does not read (type ${type}, ${depth} bits)`);
|
|
85
|
+
if (type === 3 && !palette)
|
|
86
|
+
throw new Error(`${file} has no palette (PLTE)`);
|
|
87
|
+
const raw = inflateSync(Buffer.concat(compressed));
|
|
88
|
+
const bitsPerPixel = channels * depth;
|
|
89
|
+
const stride = Math.ceil(width * bitsPerPixel / 8);
|
|
90
|
+
//The distance of the byte on the left: one pixel, at least one byte
|
|
91
|
+
const left = Math.max(1, bitsPerPixel >> 3);
|
|
92
|
+
const rows = Buffer.alloc(stride * height);
|
|
93
|
+
for (let y = 0; y < height; y++) {
|
|
94
|
+
const filter = raw[y * (stride + 1)];
|
|
95
|
+
const source = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
96
|
+
const row = rows.subarray(y * stride, (y + 1) * stride);
|
|
97
|
+
const above = y ? rows.subarray((y - 1) * stride, y * stride) : undefined;
|
|
98
|
+
for (let x = 0; x < stride; x++) {
|
|
99
|
+
const a = x >= left ? row[x - left] : 0;
|
|
100
|
+
const b = above ? above[x] : 0;
|
|
101
|
+
const c = above && x >= left ? above[x - left] : 0;
|
|
102
|
+
const predicted = filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : filter === 4 ? paeth(a, b, c) : -1;
|
|
103
|
+
if (predicted < 0)
|
|
104
|
+
throw new Error(`${file} has an unknown filter (${filter})`);
|
|
105
|
+
row[x] = (source[x] + predicted) & 0xff;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//A sample of a pixel, as it is written, and scaled to 8 bits
|
|
109
|
+
const value = (row, index) => {
|
|
110
|
+
if (depth === 8)
|
|
111
|
+
return row[index];
|
|
112
|
+
if (depth === 16)
|
|
113
|
+
return row.readUInt16BE(index * 2);
|
|
114
|
+
return row[(index * depth) >> 3] >> (8 - depth - ((index * depth) & 7)) & ((1 << depth) - 1);
|
|
115
|
+
};
|
|
116
|
+
const sample = (row, index) => {
|
|
117
|
+
if (depth === 8 || type === 3)
|
|
118
|
+
return value(row, index);
|
|
119
|
+
if (depth === 16)
|
|
120
|
+
return row[index * 2];
|
|
121
|
+
return Math.round(value(row, index) * 255 / ((1 << depth) - 1));
|
|
122
|
+
};
|
|
123
|
+
//The color made transparent by tRNS, of the gray and RGB images
|
|
124
|
+
const key = (index) => transparency && transparency.length >= (index + 1) * 2 ? transparency.readUInt16BE(index * 2) : -1;
|
|
125
|
+
const pixels = Buffer.alloc(width * height * 4);
|
|
126
|
+
for (let y = 0; y < height; y++) {
|
|
127
|
+
const row = rows.subarray(y * stride, (y + 1) * stride);
|
|
128
|
+
for (let x = 0; x < width; x++) {
|
|
129
|
+
const target = (y * width + x) * 4;
|
|
130
|
+
const raw16 = (channel) => value(row, x * channels + channel);
|
|
131
|
+
if (type === 3) {
|
|
132
|
+
const index = sample(row, x);
|
|
133
|
+
palette.copy(pixels, target, index * 3, index * 3 + 3);
|
|
134
|
+
pixels[target + 3] = transparency && index < transparency.length ? transparency[index] : 255;
|
|
135
|
+
}
|
|
136
|
+
else if (type === 0 || type === 4) {
|
|
137
|
+
const gray = sample(row, x * channels);
|
|
138
|
+
pixels[target] = pixels[target + 1] = pixels[target + 2] = gray;
|
|
139
|
+
pixels[target + 3] = type === 4 ? sample(row, x * channels + 1) : raw16(0) === key(0) ? 0 : 255;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
for (let channel = 0; channel < 3; channel++)
|
|
143
|
+
pixels[target + channel] = sample(row, x * channels + channel);
|
|
144
|
+
pixels[target + 3] = type === 6 ? sample(row, x * channels + 3) : raw16(0) === key(0) && raw16(1) === key(1) && raw16(2) === key(2) ? 0 : 255;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { width: width, height: height, pixels: pixels };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Encode an image into a PNG file (RGBA, 8 bits)
|
|
152
|
+
* @param image
|
|
153
|
+
*/
|
|
154
|
+
export function encodePng(image) {
|
|
155
|
+
const chunk = (name, data) => {
|
|
156
|
+
const typed = Buffer.concat([Buffer.from(name, 'latin1'), data]);
|
|
157
|
+
const length = Buffer.alloc(4);
|
|
158
|
+
length.writeUInt32BE(data.length);
|
|
159
|
+
const crc = Buffer.alloc(4);
|
|
160
|
+
crc.writeUInt32BE(crc32(typed));
|
|
161
|
+
return Buffer.concat([length, typed, crc]);
|
|
162
|
+
};
|
|
163
|
+
const header = Buffer.alloc(13);
|
|
164
|
+
header.writeUInt32BE(image.width, 0);
|
|
165
|
+
header.writeUInt32BE(image.height, 4);
|
|
166
|
+
header[8] = 8;
|
|
167
|
+
header[9] = 6;
|
|
168
|
+
const stride = image.width * 4;
|
|
169
|
+
const raw = Buffer.alloc((stride + 1) * image.height);
|
|
170
|
+
for (let y = 0; y < image.height; y++) {
|
|
171
|
+
//The filter Sub (1): the icons compress better
|
|
172
|
+
raw[y * (stride + 1)] = 1;
|
|
173
|
+
for (let x = 0; x < stride; x++) {
|
|
174
|
+
const value = image.pixels[y * stride + x];
|
|
175
|
+
raw[y * (stride + 1) + 1 + x] = (value - (x >= 4 ? image.pixels[y * stride + x - 4] : 0)) & 0xff;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Buffer.concat([PNG_SIGNATURE, chunk('IHDR', header), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resize an image (a square into a square): each pixel the average of the ones it covers, weighted by their alpha
|
|
182
|
+
* (premultiplied, the transparent edges keep their colors)
|
|
183
|
+
* @param image
|
|
184
|
+
* @param size
|
|
185
|
+
*/
|
|
186
|
+
export function resize(image, size) {
|
|
187
|
+
if (image.width === size && image.height === size)
|
|
188
|
+
return image;
|
|
189
|
+
//Separable: the columns, then the rows
|
|
190
|
+
const pass = (source, width, height, target, horizontal) => {
|
|
191
|
+
const length = horizontal ? width : height;
|
|
192
|
+
const scale = length / target;
|
|
193
|
+
const output = new Float64Array((horizontal ? target * height : width * target) * 4);
|
|
194
|
+
for (let i = 0; i < target; i++) {
|
|
195
|
+
const start = i * scale, end = (i + 1) * scale;
|
|
196
|
+
//The weights of the source pixels the target pixel covers
|
|
197
|
+
const weights = [];
|
|
198
|
+
for (let j = Math.floor(start); j < Math.min(length, Math.ceil(end)); j++) {
|
|
199
|
+
const weight = Math.min(end, j + 1) - Math.max(start, j);
|
|
200
|
+
if (weight > 0)
|
|
201
|
+
weights.push([j, weight]);
|
|
202
|
+
}
|
|
203
|
+
const total = weights.reduce((sum, [, weight]) => sum + weight, 0);
|
|
204
|
+
for (let k = 0; k < (horizontal ? height : width); k++) {
|
|
205
|
+
for (let channel = 0; channel < 4; channel++) {
|
|
206
|
+
let sum = 0;
|
|
207
|
+
for (const [j, weight] of weights)
|
|
208
|
+
sum += source[((horizontal ? k * width + j : j * width + k) * 4) + channel] * weight;
|
|
209
|
+
output[((horizontal ? k * target + i : i * width + k) * 4) + channel] = sum / total;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return output;
|
|
214
|
+
};
|
|
215
|
+
const premultiplied = new Float64Array(image.width * image.height * 4);
|
|
216
|
+
for (let i = 0; i < image.width * image.height; i++) {
|
|
217
|
+
const alpha = image.pixels[i * 4 + 3] / 255;
|
|
218
|
+
for (let channel = 0; channel < 3; channel++)
|
|
219
|
+
premultiplied[i * 4 + channel] = image.pixels[i * 4 + channel] * alpha;
|
|
220
|
+
premultiplied[i * 4 + 3] = image.pixels[i * 4 + 3];
|
|
221
|
+
}
|
|
222
|
+
const resized = pass(pass(premultiplied, image.width, image.height, size, true), size, image.height, size, false);
|
|
223
|
+
const pixels = Buffer.alloc(size * size * 4);
|
|
224
|
+
for (let i = 0; i < size * size; i++) {
|
|
225
|
+
const alpha = resized[i * 4 + 3];
|
|
226
|
+
for (let channel = 0; channel < 3; channel++)
|
|
227
|
+
pixels[i * 4 + channel] = alpha > 0 ? Math.min(255, Math.round(resized[i * 4 + channel] * 255 / alpha)) : 0;
|
|
228
|
+
pixels[i * 4 + 3] = Math.min(255, Math.round(alpha));
|
|
229
|
+
}
|
|
230
|
+
return { width: size, height: size, pixels: pixels };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The sizes of the icons of Windows (ico)
|
|
234
|
+
*/
|
|
235
|
+
export const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256];
|
|
236
|
+
/**
|
|
237
|
+
* The icon of Windows (ico) of an image, in its sizes: the bitmaps of the ones smaller than 256 (the format every tool
|
|
238
|
+
* reads: the resources of the executables, NSIS, Windows Installer), the PNG of 256
|
|
239
|
+
* @param image
|
|
240
|
+
*/
|
|
241
|
+
export function icoFile(image) {
|
|
242
|
+
const images = ICO_SIZES.map((size) => {
|
|
243
|
+
const resized = resize(image, size);
|
|
244
|
+
if (size >= 256)
|
|
245
|
+
return encodePng(resized);
|
|
246
|
+
//BITMAPINFOHEADER, its height the one of the colors and of the mask; the rows from the bottom, BGRA, then the mask
|
|
247
|
+
const header = Buffer.alloc(40);
|
|
248
|
+
header.writeUInt32LE(40, 0);
|
|
249
|
+
header.writeInt32LE(size, 4);
|
|
250
|
+
header.writeInt32LE(size * 2, 8);
|
|
251
|
+
header.writeUInt16LE(1, 12);
|
|
252
|
+
header.writeUInt16LE(32, 14);
|
|
253
|
+
const colors = Buffer.alloc(size * size * 4);
|
|
254
|
+
const maskStride = Math.ceil(size / 32) * 4;
|
|
255
|
+
const mask = Buffer.alloc(maskStride * size);
|
|
256
|
+
for (let y = 0; y < size; y++) {
|
|
257
|
+
for (let x = 0; x < size; x++) {
|
|
258
|
+
const from = (y * size + x) * 4;
|
|
259
|
+
const to = ((size - 1 - y) * size + x) * 4;
|
|
260
|
+
colors[to] = resized.pixels[from + 2];
|
|
261
|
+
colors[to + 1] = resized.pixels[from + 1];
|
|
262
|
+
colors[to + 2] = resized.pixels[from];
|
|
263
|
+
colors[to + 3] = resized.pixels[from + 3];
|
|
264
|
+
if (!resized.pixels[from + 3])
|
|
265
|
+
mask[(size - 1 - y) * maskStride + (x >> 3)] |= 0x80 >> (x & 7);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return Buffer.concat([header, colors, mask]);
|
|
269
|
+
});
|
|
270
|
+
const directory = Buffer.alloc(6 + 16 * images.length);
|
|
271
|
+
directory.writeUInt16LE(1, 2);
|
|
272
|
+
directory.writeUInt16LE(images.length, 4);
|
|
273
|
+
let offset = directory.length;
|
|
274
|
+
images.forEach((data, index) => {
|
|
275
|
+
const entry = 6 + index * 16;
|
|
276
|
+
const size = ICO_SIZES[index];
|
|
277
|
+
directory[entry] = size >= 256 ? 0 : size;
|
|
278
|
+
directory[entry + 1] = size >= 256 ? 0 : size;
|
|
279
|
+
directory.writeUInt16LE(1, entry + 4);
|
|
280
|
+
directory.writeUInt16LE(32, entry + 6);
|
|
281
|
+
directory.writeUInt32LE(data.length, entry + 8);
|
|
282
|
+
directory.writeUInt32LE(offset, entry + 12);
|
|
283
|
+
offset += data.length;
|
|
284
|
+
});
|
|
285
|
+
return Buffer.concat([directory, ...images]);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The types of the icons of macOS (icns) of PNG, by their sizes in pixels (the Retina ones are the doubles of the others)
|
|
289
|
+
*/
|
|
290
|
+
const ICNS_TYPES = [['icp4', 16], ['icp5', 32], ['ic11', 32], ['icp6', 64], ['ic12', 64], ['ic07', 128], ['ic08', 256], ['ic13', 256], ['ic09', 512], ['ic14', 512], ['ic10', 1024]];
|
|
291
|
+
/**
|
|
292
|
+
* The icon of macOS (icns) of an image: its PNG in the sizes of macOS, but the ones larger than it
|
|
293
|
+
* @param image
|
|
294
|
+
*/
|
|
295
|
+
export function icnsFile(image) {
|
|
296
|
+
const encoded = new Map();
|
|
297
|
+
const entries = [];
|
|
298
|
+
for (const [type, size] of ICNS_TYPES) {
|
|
299
|
+
if (size > image.width)
|
|
300
|
+
continue;
|
|
301
|
+
if (!encoded.has(size))
|
|
302
|
+
encoded.set(size, encodePng(resize(image, size)));
|
|
303
|
+
const data = encoded.get(size);
|
|
304
|
+
const header = Buffer.alloc(8);
|
|
305
|
+
header.write(type, 0, 'latin1');
|
|
306
|
+
header.writeUInt32BE(data.length + 8, 4);
|
|
307
|
+
entries.push(header, data);
|
|
308
|
+
}
|
|
309
|
+
const header = Buffer.alloc(8);
|
|
310
|
+
header.write('icns', 0, 'latin1');
|
|
311
|
+
header.writeUInt32BE(8 + entries.reduce((sum, entry) => sum + entry.length, 0), 4);
|
|
312
|
+
return Buffer.concat([header, ...entries]);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The sizes of the icons of Linux (the hicolor theme)
|
|
316
|
+
*/
|
|
317
|
+
export const LINUX_SIZES = [16, 24, 32, 48, 64, 128, 256, 512];
|
|
318
|
+
/**
|
|
319
|
+
* Write the icons of Linux of an image into a directory: its PNG in the sizes of the hicolor theme (<size>x<size>.png),
|
|
320
|
+
* but the ones larger than it
|
|
321
|
+
* @param image
|
|
322
|
+
* @param directory
|
|
323
|
+
*/
|
|
324
|
+
export async function writeLinuxIcons(image, directory) {
|
|
325
|
+
await mkdir(directory, { recursive: true });
|
|
326
|
+
const files = [];
|
|
327
|
+
for (const size of LINUX_SIZES.filter((size) => size <= image.width)) {
|
|
328
|
+
const file = path.join(directory, `${size}x${size}.png`);
|
|
329
|
+
await writeFile(file, encodePng(resize(image, size)));
|
|
330
|
+
files.push(file);
|
|
331
|
+
}
|
|
332
|
+
return files;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Give an executable of Windows the icon (ico), as its icon groups (the one of node.exe), its signature removed (it does
|
|
336
|
+
* not match it anymore)
|
|
337
|
+
* @param file
|
|
338
|
+
* @param ico
|
|
339
|
+
*/
|
|
340
|
+
export async function setExecutableIcon(file, ico) {
|
|
341
|
+
const { NtExecutable, NtExecutableResource, Data, Resource } = await import('resedit');
|
|
342
|
+
const executable = NtExecutable.from(await readFile(file), { ignoreCert: true });
|
|
343
|
+
const resource = NtExecutableResource.from(executable);
|
|
344
|
+
const icons = Data.IconFile.from(ico).icons.map((item) => item.data);
|
|
345
|
+
const groups = Resource.IconGroupEntry.fromEntries(resource.entries);
|
|
346
|
+
for (const { id, lang } of groups.length ? groups : [{ id: 1, lang: 1033 }])
|
|
347
|
+
Resource.IconGroupEntry.replaceIconsForResource(resource.entries, id, lang, icons);
|
|
348
|
+
resource.outputResource(executable);
|
|
349
|
+
await writeFile(file, Buffer.from(executable.generate()));
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The icon of the application (lakutata.icon of its package.json): a square PNG image, 256 pixels at least (1024 for
|
|
353
|
+
* the Retina screens of macOS), undefined without icon
|
|
354
|
+
* @param project
|
|
355
|
+
* @param manifest
|
|
356
|
+
*/
|
|
357
|
+
export function applicationIcon(project, manifest) {
|
|
358
|
+
const declared = manifest.lakutata?.icon;
|
|
359
|
+
if (declared === undefined)
|
|
360
|
+
return undefined;
|
|
361
|
+
if (typeof declared !== 'string' || !declared)
|
|
362
|
+
throw new Error('The icon of the application (lakutata.icon of its package.json) must be a PNG image of the project');
|
|
363
|
+
const file = path.resolve(project, declared);
|
|
364
|
+
if (!existsSync(file))
|
|
365
|
+
throw new Error(`The icon of the application ${declared} (lakutata.icon) does not exist`);
|
|
366
|
+
const image = decodePng(readFileSync(file), declared);
|
|
367
|
+
if (image.width !== image.height)
|
|
368
|
+
throw new Error(`The icon of the application ${declared} must be square (it is ${image.width}x${image.height})`);
|
|
369
|
+
if (image.width < 256)
|
|
370
|
+
throw new Error(`The icon of the application ${declared} must be 256 pixels at least (it is ${image.width}): its icons are made from it`);
|
|
371
|
+
return { file: file, image: image };
|
|
372
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFile, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { programName } from './Bundle.js';
|
|
5
|
+
import { buildElectronInstaller } from './electron/Electron.js';
|
|
6
|
+
import { buildExecutable } from './Executable.js';
|
|
7
|
+
import { applicationIcon, icoFile } from './Icons.js';
|
|
8
|
+
import { detectLibc } from './Platform.js';
|
|
9
|
+
import { resolveScripts } from './Scripts.js';
|
|
10
|
+
import { buildLinuxPackages, LINUX_TARGETS } from './linux/Linux.js';
|
|
11
|
+
import { buildMacPackages, MAC_TARGETS } from './mac/Mac.js';
|
|
12
|
+
import { buildWindowsPackages, WINDOWS_TARGETS } from './win/Windows.js';
|
|
13
|
+
/**
|
|
14
|
+
* The maintainer of the package.json: its author
|
|
15
|
+
* @param author
|
|
16
|
+
*/
|
|
17
|
+
function packageAuthor(author) {
|
|
18
|
+
if (typeof author === 'string' && author.trim())
|
|
19
|
+
return author.trim();
|
|
20
|
+
if (author && typeof author === 'object' && typeof author.name === 'string') {
|
|
21
|
+
const { name, email } = author;
|
|
22
|
+
return email ? `${name} <${email}>` : name;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The configuration of the installers of a project, checked
|
|
28
|
+
* @param manifest
|
|
29
|
+
*/
|
|
30
|
+
function installerConfig(manifest) {
|
|
31
|
+
const config = manifest.lakutata?.installer ?? {};
|
|
32
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
33
|
+
throw new Error('The installers of the application (lakutata.installer of its package.json) must be an object');
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Make the installers of the application for the platform it is made on (the installers of the other platforms are
|
|
38
|
+
* made on them, as its executables): its executable in a directory, with its native addons and its resources, installed
|
|
39
|
+
* in /opt/<name> (Linux) or /usr/local/lib/<name> (macOS), its program in the PATH, its service registered (systemd,
|
|
40
|
+
* OpenRC, launchd)
|
|
41
|
+
* @param options
|
|
42
|
+
*/
|
|
43
|
+
export async function buildInstaller(options) {
|
|
44
|
+
const project = path.resolve(options.project);
|
|
45
|
+
const manifest = JSON.parse(await readFile(path.join(project, 'package.json'), 'utf8'));
|
|
46
|
+
const config = installerConfig(manifest);
|
|
47
|
+
if (config.runtime === 'electron')
|
|
48
|
+
return buildElectronInstaller(project, manifest, config, options);
|
|
49
|
+
const mac = process.platform === 'darwin';
|
|
50
|
+
const windows = process.platform === 'win32';
|
|
51
|
+
if (process.platform !== 'linux' && !mac && !windows)
|
|
52
|
+
throw new Error(`The installers of ${process.platform} are not supported: the ones of Linux are made on Linux, the ones of macOS on macOS, the ones of Windows on Windows`);
|
|
53
|
+
const libc = detectLibc();
|
|
54
|
+
const targets = options.targets?.length ? options.targets
|
|
55
|
+
: mac ? config.mac?.targets ?? ['pkg', 'tar.gz']
|
|
56
|
+
: windows ? config.win?.targets ?? ['nsis', 'msi', 'zip']
|
|
57
|
+
: config.linux?.targets ?? (libc === 'glibc' ? ['deb', 'rpm', 'tar.gz'] : ['apk', 'tar.gz']);
|
|
58
|
+
for (const target of targets) {
|
|
59
|
+
if (mac) {
|
|
60
|
+
if (!MAC_TARGETS.includes(target))
|
|
61
|
+
throw new Error(`The installer ${target} is not one of macOS (${MAC_TARGETS.join(', ')})`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (windows) {
|
|
65
|
+
if (!WINDOWS_TARGETS.includes(target))
|
|
66
|
+
throw new Error(`The installer ${target} is not one of Windows (${WINDOWS_TARGETS.join(', ')})`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!LINUX_TARGETS.includes(target))
|
|
70
|
+
throw new Error(`The installer ${target} is not one of Linux (${LINUX_TARGETS.join(', ')})`);
|
|
71
|
+
//The executable links the C library of the system it is made on
|
|
72
|
+
if (target === 'apk' && libc !== 'musl')
|
|
73
|
+
throw new Error('The apk packages are made on Alpine (musl): the executables made on the systems of the glibc do not run there');
|
|
74
|
+
if (['deb', 'rpm', 'archlinux'].includes(target) && libc !== 'glibc')
|
|
75
|
+
throw new Error(`The ${target} packages are made on a system of the glibc: the executables made on Alpine (musl) do not run there`);
|
|
76
|
+
}
|
|
77
|
+
const program = programName(manifest);
|
|
78
|
+
const name = config.name ?? program.toLowerCase();
|
|
79
|
+
if (!/^[a-z0-9][a-z0-9+.-]*$/.test(name))
|
|
80
|
+
throw new Error(`The name of the package "${name}" must be lower case letters, digits, "+", "-" and "." (lakutata.installer.name)`);
|
|
81
|
+
const version = String(manifest.version ?? '');
|
|
82
|
+
if (!/^\d+\.\d+\.\d+/.test(version))
|
|
83
|
+
throw new Error(`The version of the package "${version}" must be a version (1.2.3) of the package.json`);
|
|
84
|
+
const warnings = [];
|
|
85
|
+
let maintainer = config.maintainer ?? packageAuthor(manifest.author);
|
|
86
|
+
if (!maintainer) {
|
|
87
|
+
maintainer = name;
|
|
88
|
+
warnings.push(`The packages have no maintainer: give it (the author of the package.json, or lakutata.installer.maintainer)`);
|
|
89
|
+
}
|
|
90
|
+
const appId = config.appId ?? name;
|
|
91
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(appId))
|
|
92
|
+
throw new Error(`The identifier of the application "${appId}" must be letters, digits, "." and "-" (lakutata.installer.appId)`);
|
|
93
|
+
const service = config.service === true ? {} : config.service || undefined;
|
|
94
|
+
if (service?.name !== undefined && !/^[A-Za-z0-9_.@-]+$/.test(service.name))
|
|
95
|
+
throw new Error(`The name of the service "${service.name}" must be letters, digits, "_", ".", "@" and "-"`);
|
|
96
|
+
const platform = mac ? 'mac' : windows ? 'win' : 'linux';
|
|
97
|
+
const scripts = resolveScripts(project, platform, config[platform], targets, warnings);
|
|
98
|
+
const application = applicationIcon(project, manifest);
|
|
99
|
+
const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-installer-'));
|
|
100
|
+
try {
|
|
101
|
+
//The icon of the installers of Windows (the executable has it too)
|
|
102
|
+
let icon;
|
|
103
|
+
if (windows && application) {
|
|
104
|
+
icon = path.join(work, 'icon.ico');
|
|
105
|
+
await writeFile(icon, icoFile(application.image));
|
|
106
|
+
}
|
|
107
|
+
const executable = await buildExecutable({
|
|
108
|
+
project: project,
|
|
109
|
+
entry: options.entry,
|
|
110
|
+
minify: options.minify,
|
|
111
|
+
external: options.external,
|
|
112
|
+
runtime: config.runtime,
|
|
113
|
+
layout: 'directory',
|
|
114
|
+
outdir: path.join(work, 'stage')
|
|
115
|
+
});
|
|
116
|
+
warnings.push(...executable.warnings);
|
|
117
|
+
const plan = {
|
|
118
|
+
name: name,
|
|
119
|
+
appId: appId,
|
|
120
|
+
productName: config.productName ?? (typeof manifest.productName === 'string' ? manifest.productName : name),
|
|
121
|
+
program: program,
|
|
122
|
+
version: version,
|
|
123
|
+
description: config.description ?? manifest.description ?? name,
|
|
124
|
+
maintainer: maintainer,
|
|
125
|
+
homepage: config.homepage ?? (typeof manifest.homepage === 'string' ? manifest.homepage : undefined),
|
|
126
|
+
license: config.license ?? (typeof manifest.license === 'string' ? manifest.license : undefined),
|
|
127
|
+
vendor: config.vendor,
|
|
128
|
+
addToPath: config.addToPath ?? true,
|
|
129
|
+
depends: config.linux?.depends ?? [],
|
|
130
|
+
recommends: config.linux?.recommends ?? [],
|
|
131
|
+
section: config.linux?.section,
|
|
132
|
+
priority: config.linux?.priority,
|
|
133
|
+
service: service && {
|
|
134
|
+
name: service.name ?? name,
|
|
135
|
+
description: service.description ?? config.description ?? manifest.description ?? name,
|
|
136
|
+
args: service.args ?? [],
|
|
137
|
+
env: { MODE: 'production', ...service.env },
|
|
138
|
+
//The users of the services of macOS are hidden by their names (_www), the ones of Windows are virtual
|
|
139
|
+
user: service.user ?? (mac ? `_${name}` : windows ? `NT SERVICE\\${service.name ?? name}` : name),
|
|
140
|
+
restart: service.restart ?? 'on-failure',
|
|
141
|
+
autoStart: service.autoStart ?? true
|
|
142
|
+
},
|
|
143
|
+
directory: executable.directory,
|
|
144
|
+
prefix: mac ? `/usr/local/lib/${name}` : windows ? `C:\\Program Files\\${name}` : `/opt/${name}`,
|
|
145
|
+
scripts: scripts,
|
|
146
|
+
icon: icon
|
|
147
|
+
};
|
|
148
|
+
const outdir = path.resolve(project, options.outdir ?? path.join('dist', 'installer'));
|
|
149
|
+
await mkdir(outdir, { recursive: true });
|
|
150
|
+
const files = mac ? await buildMacPackages(plan, targets, path.join(work, 'mac'), outdir)
|
|
151
|
+
: windows ? await buildWindowsPackages(plan, targets, path.join(work, 'win'), outdir)
|
|
152
|
+
: await buildLinuxPackages(plan, targets, path.join(work, 'linux'), outdir);
|
|
153
|
+
return { name: name, version: version, files: files, executable: executable, warnings: warnings };
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await rm(work, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|