@lakutata/builder 3.1.3 → 3.1.5
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/README.md +11 -1
- package/README.zh-CN.md +5 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/lib/Archive.js +3 -2
- package/dist/esm/lib/Bundle.js +2 -1
- package/dist/esm/lib/Directories.js +18 -0
- package/dist/esm/lib/Executable.js +27 -6
- package/dist/esm/lib/Installer.js +26 -12
- package/dist/esm/lib/NativeRuntime.js +2 -0
- package/dist/esm/lib/linux/Linux.js +57 -8
- package/dist/esm/lib/mac/Mac.js +39 -11
- package/dist/esm/lib/nwjs/Addons.js +307 -0
- package/dist/esm/lib/nwjs/Package.js +149 -0
- package/dist/esm/lib/nwjs/Runtime.js +214 -0
- package/dist/esm/lib/nwjs/Stage.js +92 -0
- package/dist/esm/lib/win/Windows.js +63 -41
- package/dist/types/index.d.ts +4 -0
- package/dist/types/lib/Bundle.d.ts +5 -0
- package/dist/types/lib/Directories.d.ts +8 -0
- package/dist/types/lib/Executable.d.ts +7 -0
- package/dist/types/lib/Installer.d.ts +33 -4
- package/dist/types/lib/mac/Mac.d.ts +1 -1
- package/dist/types/lib/nwjs/Addons.d.ts +51 -0
- package/dist/types/lib/nwjs/Package.d.ts +52 -0
- package/dist/types/lib/nwjs/Runtime.d.ts +116 -0
- package/dist/types/lib/nwjs/Stage.d.ts +104 -0
- package/dist/types/lib/win/Windows.d.ts +28 -0
- package/package.json +2 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { runCommand } from '../Processes.js';
|
|
7
|
+
import { cacheDirectory } from '../Tools.js';
|
|
8
|
+
import { compareVersions } from './Runtime.js';
|
|
9
|
+
/**
|
|
10
|
+
* The name of the Node.js the addons of Windows import their functions from, as node-gyp links them (node.lib), and
|
|
11
|
+
* the library NW.js holds them in
|
|
12
|
+
*/
|
|
13
|
+
const HOST = 'node.exe';
|
|
14
|
+
const NWJS_NODE = 'node.dll';
|
|
15
|
+
/**
|
|
16
|
+
* The offset in the file of an address relative to the image, undefined outside the sections
|
|
17
|
+
* @param sections
|
|
18
|
+
* @param address
|
|
19
|
+
*/
|
|
20
|
+
function offsetOf(sections, address) {
|
|
21
|
+
const section = sections.find((item) => address >= item.address && address < item.address + item.size);
|
|
22
|
+
return section && address - section.address + section.offset;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The string ending with a zero at an offset of a file
|
|
26
|
+
* @param file
|
|
27
|
+
* @param offset
|
|
28
|
+
*/
|
|
29
|
+
function stringAt(file, offset) {
|
|
30
|
+
const end = file.indexOf(0, offset);
|
|
31
|
+
return file.toString('latin1', offset, end < 0 ? file.length : end);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Make an addon of Windows load in NW.js: the libraries it imports (imported or delay-loaded) named node.exe are named
|
|
35
|
+
* node.dll, the library of NW.js holding the functions of Node.js (Node-API). The delay-load hook of node-gyp gives an
|
|
36
|
+
* addon the executable when it loads node.exe, which in NW.js does not export them: the addon fails to load ("A dynamic
|
|
37
|
+
* link library (DLL) initialization routine failed"), or blocks NW.js. Named node.dll, the library is the one NW.js
|
|
38
|
+
* loaded. The addon is changed in place (its other bytes are kept); it then loads in NW.js only, not in Node.js.
|
|
39
|
+
* @param file the content of the addon (a .node file of Windows)
|
|
40
|
+
* @returns the names changed: 0 when it imports no node.exe (an addon built for NW.js, or loading Node.js otherwise)
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function nameNwjsNode(file) {
|
|
44
|
+
if (file.length < 64 || file.readUInt16LE(0) !== 0x5a4d)
|
|
45
|
+
throw new Error('Not a portable executable (no MZ header)');
|
|
46
|
+
const header = file.readUInt32LE(0x3c);
|
|
47
|
+
if (file.readUInt32LE(header) !== 0x4550)
|
|
48
|
+
throw new Error('Not a portable executable (no PE signature)');
|
|
49
|
+
const coff = header + 4;
|
|
50
|
+
const sectionCount = file.readUInt16LE(coff + 2);
|
|
51
|
+
const optionalSize = file.readUInt16LE(coff + 16);
|
|
52
|
+
const optional = coff + 20;
|
|
53
|
+
const magic = file.readUInt16LE(optional);
|
|
54
|
+
if (magic !== 0x10b && magic !== 0x20b)
|
|
55
|
+
throw new Error(`Not a portable executable (optional header ${magic.toString(16)})`);
|
|
56
|
+
//The data directories: after 96 bytes (PE32), 112 bytes (PE32+); the image base, for the old delay-load tables (addresses)
|
|
57
|
+
const directories = optional + (magic === 0x10b ? 96 : 112);
|
|
58
|
+
const imageBase = magic === 0x10b ? file.readUInt32LE(optional + 28) : Number(file.readBigUInt64LE(optional + 24));
|
|
59
|
+
const sections = [];
|
|
60
|
+
for (let index = 0, table = optional + optionalSize; index < sectionCount; index++, table += 40) {
|
|
61
|
+
sections.push({ address: file.readUInt32LE(table + 12), size: Math.max(file.readUInt32LE(table + 8), file.readUInt32LE(table + 16)), offset: file.readUInt32LE(table + 20) });
|
|
62
|
+
}
|
|
63
|
+
let changed = 0;
|
|
64
|
+
const rename = (address) => {
|
|
65
|
+
const offset = offsetOf(sections, address);
|
|
66
|
+
if (offset === undefined || stringAt(file, offset).toLowerCase() !== HOST)
|
|
67
|
+
return;
|
|
68
|
+
file.write(NWJS_NODE, offset, 'latin1');
|
|
69
|
+
changed++;
|
|
70
|
+
};
|
|
71
|
+
//The imports (directory 1): descriptors of 20 bytes, the name at 12, ending with an empty one
|
|
72
|
+
const imports = offsetOf(sections, file.readUInt32LE(directories + 8));
|
|
73
|
+
if (imports !== undefined && file.readUInt32LE(directories + 12)) {
|
|
74
|
+
for (let descriptor = imports; descriptor + 20 <= file.length && file.readUInt32LE(descriptor + 12); descriptor += 20)
|
|
75
|
+
rename(file.readUInt32LE(descriptor + 12));
|
|
76
|
+
}
|
|
77
|
+
//The delay-loaded imports (directory 13): descriptors of 32 bytes, the name at 4 (an address before the RVA attribute)
|
|
78
|
+
const delayed = offsetOf(sections, file.readUInt32LE(directories + 13 * 8));
|
|
79
|
+
if (delayed !== undefined && file.readUInt32LE(directories + 13 * 8 + 4)) {
|
|
80
|
+
for (let descriptor = delayed; descriptor + 32 <= file.length && file.readUInt32LE(descriptor + 4); descriptor += 32) {
|
|
81
|
+
const name = file.readUInt32LE(descriptor + 4);
|
|
82
|
+
rename(file.readUInt32LE(descriptor) & 1 ? name : name - imageBase);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return changed;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The architectures of a native binary (Mach-O, possibly universal; ELF; PE) as node names them: x64, ia32, arm64, arm
|
|
89
|
+
* @param file
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
export function binaryArchitectures(file) {
|
|
93
|
+
const names = { 0x01000007: 'x64', 0x0100000c: 'arm64', 7: 'ia32', 12: 'arm' };
|
|
94
|
+
if (file.length >= 8 && file.readUInt32BE(0) === 0xcafebabe) {
|
|
95
|
+
const count = file.readUInt32BE(4);
|
|
96
|
+
return Array.from({ length: count }, (_, index) => names[file.readUInt32BE(8 + index * 20)] ?? 'unknown');
|
|
97
|
+
}
|
|
98
|
+
if (file.length >= 8 && (file.readUInt32LE(0) === 0xfeedfacf || file.readUInt32LE(0) === 0xfeedface))
|
|
99
|
+
return [names[file.readUInt32LE(4)] ?? 'unknown'];
|
|
100
|
+
if (file.length >= 20 && file.readUInt32BE(0) === 0x7f454c46)
|
|
101
|
+
return [{ 62: 'x64', 183: 'arm64', 3: 'ia32', 40: 'arm' }[file.readUInt16LE(18)] ?? 'unknown'];
|
|
102
|
+
if (file.length >= 64 && file.readUInt16LE(0) === 0x5a4d) {
|
|
103
|
+
const machine = file.readUInt16LE(file.readUInt32LE(0x3c) + 4);
|
|
104
|
+
return [{ 0x8664: 'x64', 0x14c: 'ia32', 0xaa64: 'arm64' }[machine] ?? 'unknown'];
|
|
105
|
+
}
|
|
106
|
+
return ['unknown'];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether an addon uses the C++ API of V8 (NAN, v8.h) rather than Node-API: the names of the functions of V8 it imports
|
|
110
|
+
* (mangled by the Itanium ABI or by MSVC). Built for the V8 of Node.js, it does not run in the one of NW.js (Chromium)
|
|
111
|
+
* @param file
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
export function usesV8Api(file) {
|
|
115
|
+
return file.includes('_ZN2v8') || file.includes('@v8@@');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The first release of NW.js whose headers node-gyp builds the addons with (--target, --dist-url)
|
|
119
|
+
*/
|
|
120
|
+
const NODE_GYP_HEADERS = '0.111.3';
|
|
121
|
+
/**
|
|
122
|
+
* The Node.js whose headers the addons of Node-API of 32-bit Windows are built with when the one of NW.js is later:
|
|
123
|
+
* Node.js 23 and later publish no library of 32-bit Windows (node.lib), and Node-API runs the addons built for an older
|
|
124
|
+
* Node.js
|
|
125
|
+
*/
|
|
126
|
+
const WINDOWS_32_NODE = '22.22.0';
|
|
127
|
+
/**
|
|
128
|
+
* node-gyp: the one of the project, of the builder, else the one of npm
|
|
129
|
+
* @param project
|
|
130
|
+
*/
|
|
131
|
+
function nodeGyp(project) {
|
|
132
|
+
for (const base of [path.join(project, 'package.json'), import.meta.url]) {
|
|
133
|
+
try {
|
|
134
|
+
return createRequire(base).resolve('node-gyp/bin/node-gyp.js');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
//Not installed there
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//The node-gyp of npm, next to the node running the build
|
|
141
|
+
const prefix = process.platform === 'win32' ? path.dirname(process.execPath) : path.join(path.dirname(process.execPath), '..', 'lib');
|
|
142
|
+
const bundled = path.join(prefix, 'node_modules', 'npm', 'node_modules', 'node-gyp', 'bin', 'node-gyp.js');
|
|
143
|
+
if (existsSync(bundled))
|
|
144
|
+
return bundled;
|
|
145
|
+
throw new Error('node-gyp builds the addons for NW.js: install it in the project (npm install --save-dev node-gyp)');
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The headers of a release of NW.js for node-gyp, in a directory of their own (--devdir) of the cache: downloaded once,
|
|
149
|
+
* their common.gypi without the linker of Chromium (lld, which the compilers of macOS and of the Linux systems do not
|
|
150
|
+
* have: the linker of the system links the addons as well)
|
|
151
|
+
* @param project
|
|
152
|
+
* @param release
|
|
153
|
+
* @returns the directory given to node-gyp (--devdir)
|
|
154
|
+
*/
|
|
155
|
+
async function nwjsHeaders(project, release) {
|
|
156
|
+
const devdir = path.join(cacheDirectory(), 'nwjs', 'node-gyp');
|
|
157
|
+
const common = path.join(devdir, release.version, 'common.gypi');
|
|
158
|
+
if (!existsSync(common)) {
|
|
159
|
+
const installed = await runCommand(process.execPath, [nodeGyp(project), 'install', `--target=${release.version}`, `--dist-url=${mirror()}`, `--devdir=${devdir}`], project);
|
|
160
|
+
if (installed.code !== 0 || !existsSync(common))
|
|
161
|
+
throw new Error(`The headers of NW.js ${release.version} cannot be downloaded: ${installed.output.trim().split('\n').slice(-3).join(' ')}`);
|
|
162
|
+
}
|
|
163
|
+
const content = await readFile(common, 'utf8');
|
|
164
|
+
const linked = content.replace(/,\s*'-fuse-ld=lld'/g, '').replace(/-fuse-ld=lld\s*/g, '');
|
|
165
|
+
if (linked !== content)
|
|
166
|
+
await writeFile(common, linked);
|
|
167
|
+
return devdir;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Where the releases of NW.js are downloaded from (LAKUTATA_NWJS_MIRROR, else dl.nwjs.io)
|
|
171
|
+
*/
|
|
172
|
+
function mirror() {
|
|
173
|
+
return (process.env.LAKUTATA_NWJS_MIRROR ?? 'https://dl.nwjs.io').replace(/\/+$/, '');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The directory of the package of a file (the one holding its package.json)
|
|
177
|
+
* @param file
|
|
178
|
+
*/
|
|
179
|
+
function packageOf(file) {
|
|
180
|
+
for (let directory = path.dirname(file); directory !== path.dirname(directory); directory = path.dirname(directory)) {
|
|
181
|
+
if (existsSync(path.join(directory, 'package.json')))
|
|
182
|
+
return directory;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The .node files under a directory
|
|
188
|
+
* @param directory
|
|
189
|
+
*/
|
|
190
|
+
async function nodeFiles(directory) {
|
|
191
|
+
if (!existsSync(directory))
|
|
192
|
+
return [];
|
|
193
|
+
const found = [];
|
|
194
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
195
|
+
const file = path.join(directory, entry.name);
|
|
196
|
+
if (entry.isDirectory())
|
|
197
|
+
found.push(...await nodeFiles(file));
|
|
198
|
+
else if (entry.isFile() && entry.name.endsWith('.node'))
|
|
199
|
+
found.push(file);
|
|
200
|
+
}
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The headers node-gyp builds the addons of a release of NW.js with: the ones of NW.js for the addons using V8, else the
|
|
205
|
+
* ones of its Node.js (the Node-API it has; a Node.js 22 on 32-bit Windows when its own one is later)
|
|
206
|
+
* @param project
|
|
207
|
+
* @param release
|
|
208
|
+
* @param platform
|
|
209
|
+
* @param v8
|
|
210
|
+
*/
|
|
211
|
+
async function headersOf(project, release, platform, v8) {
|
|
212
|
+
if (!v8)
|
|
213
|
+
return [`--target=${platform === 'win-ia32' && compareVersions(release.node, '23.0.0') >= 0 ? WINDOWS_32_NODE : release.node}`];
|
|
214
|
+
if (platform === 'win-ia32') {
|
|
215
|
+
//The library of 32-bit Windows (at the root of the release, as node-gyp looks for it): the releases since 0.111.3 have none
|
|
216
|
+
const library = await fetch(`${mirror()}/v${release.version}/node.lib`, { method: 'HEAD' });
|
|
217
|
+
if (!library.ok)
|
|
218
|
+
throw new Error(`NW.js ${release.version} publishes no library of 32-bit Windows (node.lib) to build the addons using V8 with: they run in its x64 build, or use a package of Node-API`);
|
|
219
|
+
}
|
|
220
|
+
return [`--target=${release.version}`, `--dist-url=${mirror()}`, `--devdir=${await nwjsHeaders(project, release)}`];
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Build a package of addons again from its sources (a copy of it), its addons written into build/Release of its copy
|
|
224
|
+
* in the native tree
|
|
225
|
+
* @param project
|
|
226
|
+
* @param source the directory of the package, in the node_modules of the project
|
|
227
|
+
* @param target the directory of the package in the native tree
|
|
228
|
+
* @param arch
|
|
229
|
+
* @param headers the arguments giving node-gyp its headers (headersOf)
|
|
230
|
+
*/
|
|
231
|
+
async function rebuild(project, source, target, arch, headers) {
|
|
232
|
+
if (!existsSync(path.join(source, 'binding.gyp')))
|
|
233
|
+
throw new Error(`${path.basename(source)} has no sources to build its addons for NW.js (binding.gyp): its addons are not for ${arch}${headers.length > 1 ? ' or use V8' : ''}`);
|
|
234
|
+
const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-nwjs-addon-'));
|
|
235
|
+
try {
|
|
236
|
+
const copy = path.join(work, path.basename(source));
|
|
237
|
+
await cp(source, copy, { recursive: true, filter: (file) => path.relative(source, file) !== 'build' });
|
|
238
|
+
//The modules of its build (node-addon-api) found where the package is
|
|
239
|
+
const modules = (createRequire(path.join(source, 'package.json')).resolve.paths('x') ?? []);
|
|
240
|
+
const args = [nodeGyp(project), 'rebuild', `--arch=${arch}`, ...headers];
|
|
241
|
+
const previous = process.env.NODE_PATH;
|
|
242
|
+
process.env.NODE_PATH = [...modules, previous].filter(Boolean).join(path.delimiter);
|
|
243
|
+
try {
|
|
244
|
+
const built = await runCommand(process.execPath, args, copy);
|
|
245
|
+
if (built.code !== 0)
|
|
246
|
+
throw new Error(`The addons of ${path.basename(source)} cannot be built for NW.js (node-gyp ${args.slice(1).join(' ')}): ${built.output.trim().split('\n').slice(-5).join(' ')}`);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (previous === undefined)
|
|
250
|
+
delete process.env.NODE_PATH;
|
|
251
|
+
else
|
|
252
|
+
process.env.NODE_PATH = previous;
|
|
253
|
+
}
|
|
254
|
+
await mkdir(path.join(target, 'build', 'Release'), { recursive: true });
|
|
255
|
+
for (const file of await nodeFiles(path.join(copy, 'build', 'Release')))
|
|
256
|
+
await cp(file, path.join(target, 'build', 'Release', path.basename(file)));
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await rm(work, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Make the addons of the native tree of an application of NW.js load in its runtime: the packages whose addons are not
|
|
264
|
+
* for the architecture of the runtime (built with the headers of its Node.js), or use V8 (NW.js 0.111.3 and later: the
|
|
265
|
+
* headers of NW.js), built again from their sources; on Windows, the node.exe they import named node.dll
|
|
266
|
+
* @param project
|
|
267
|
+
* @param directory the native tree
|
|
268
|
+
* @param files the files of the tree (their sources)
|
|
269
|
+
* @param release
|
|
270
|
+
* @param platform the build of NW.js (win-x64, osx-arm64…)
|
|
271
|
+
* @internal
|
|
272
|
+
*/
|
|
273
|
+
export async function prepareNwjsAddons(project, directory, files, release, platform) {
|
|
274
|
+
const arch = platform.slice(platform.indexOf('-') + 1);
|
|
275
|
+
const result = { renamed: [], rebuilt: [] };
|
|
276
|
+
const done = new Set();
|
|
277
|
+
for (const file of files.filter((item) => item.path.endsWith('.node'))) {
|
|
278
|
+
const target = path.join(directory, ...file.path.split('/'));
|
|
279
|
+
const content = await readFile(target);
|
|
280
|
+
const v8 = usesV8Api(content);
|
|
281
|
+
if (v8 && compareVersions(release.version, NODE_GYP_HEADERS) < 0)
|
|
282
|
+
throw new Error(`${file.path} uses the API of V8 (not Node-API): it runs in NW.js ${NODE_GYP_HEADERS} or later, built with its headers; NW.js ${release.version} needs a package of Node-API instead`);
|
|
283
|
+
if (!v8 && binaryArchitectures(content).includes(arch))
|
|
284
|
+
continue;
|
|
285
|
+
const source = packageOf(file.source);
|
|
286
|
+
if (!source)
|
|
287
|
+
throw new Error(`${file.path} is not for ${arch} and is not in a package: build it for NW.js ${release.version} (${platform})`);
|
|
288
|
+
const relative = path.relative(source, file.source).split(path.sep).join('/');
|
|
289
|
+
const packageTarget = path.join(directory, ...file.path.slice(0, file.path.length - relative.length).split('/'));
|
|
290
|
+
if (done.has(packageTarget))
|
|
291
|
+
continue;
|
|
292
|
+
done.add(packageTarget);
|
|
293
|
+
await rebuild(project, source, packageTarget, arch, await headersOf(project, release, platform, v8));
|
|
294
|
+
result.rebuilt.push(path.basename(source));
|
|
295
|
+
}
|
|
296
|
+
if (platform.startsWith('win-')) {
|
|
297
|
+
for (const file of await nodeFiles(directory)) {
|
|
298
|
+
const content = await readFile(file);
|
|
299
|
+
//The addons of Windows only (portable executables)
|
|
300
|
+
if (content.length > 64 && content.readUInt16LE(0) === 0x5a4d && nameNwjsNode(content)) {
|
|
301
|
+
await writeFile(file, content);
|
|
302
|
+
result.renamed.push(path.relative(directory, file).split(path.sep).join('/'));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { programName } from '../Bundle.js';
|
|
6
|
+
import { applicationIcon, icnsFile, icoFile, setExecutableIcon } from '../Icons.js';
|
|
7
|
+
import { runCommand } from '../Processes.js';
|
|
8
|
+
import { downloadNwjs } from './Runtime.js';
|
|
9
|
+
import { stageNwjsApp } from './Stage.js';
|
|
10
|
+
/**
|
|
11
|
+
* Run a command which must succeed
|
|
12
|
+
* @param command
|
|
13
|
+
* @param args
|
|
14
|
+
* @param cwd
|
|
15
|
+
* @param what
|
|
16
|
+
*/
|
|
17
|
+
async function must(command, args, cwd, what) {
|
|
18
|
+
const result = await runCommand(command, args, cwd);
|
|
19
|
+
if (result.code !== 0)
|
|
20
|
+
throw new Error(`${what}: ${result.output.trim()}`);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Give the files of an application to every user: readable, its directories and its programs runnable. The archives of
|
|
24
|
+
* the older releases of NW.js (0.72) give their files to their owner only: installed by root (/opt, /Applications), the
|
|
25
|
+
* application would run for root only. The files of Windows have no modes
|
|
26
|
+
* @param directory
|
|
27
|
+
*/
|
|
28
|
+
async function shareFiles(directory) {
|
|
29
|
+
if (process.platform !== 'win32')
|
|
30
|
+
await must('chmod', ['-R', 'a+rX', directory], directory, 'The files of the application cannot be given to every user');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The four numbers of a version of Windows (1.2.3 → 1.2.3.0)
|
|
34
|
+
* @param version
|
|
35
|
+
*/
|
|
36
|
+
function windowsVersion(version) {
|
|
37
|
+
const parts = version.split(/[.+-]/).slice(0, 3).map((part) => Math.min(parseInt(part, 10) || 0, 65535));
|
|
38
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, 0];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Give the executable of NW.js of Windows the identity of the application: its icon, and the names and the version of its
|
|
42
|
+
* version information (the ones the task manager and the properties of the file show)
|
|
43
|
+
* @param file
|
|
44
|
+
* @param identity
|
|
45
|
+
*/
|
|
46
|
+
async function windowsIdentity(file, identity) {
|
|
47
|
+
if (identity.ico)
|
|
48
|
+
await setExecutableIcon(file, identity.ico);
|
|
49
|
+
const { NtExecutable, NtExecutableResource, Resource } = await import('resedit');
|
|
50
|
+
const executable = NtExecutable.from(await readFile(file), { ignoreCert: true });
|
|
51
|
+
const resource = NtExecutableResource.from(executable);
|
|
52
|
+
const information = Resource.VersionInfo.fromEntries(resource.entries)[0] ?? Resource.VersionInfo.createEmpty();
|
|
53
|
+
const numbers = windowsVersion(identity.version);
|
|
54
|
+
information.setFileVersion(...numbers);
|
|
55
|
+
information.setProductVersion(...numbers);
|
|
56
|
+
const languages = information.getAllLanguagesForStringValues();
|
|
57
|
+
for (const language of languages.length ? languages : [{ lang: 1033, codepage: 1200 }]) {
|
|
58
|
+
information.setStringValues(language, {
|
|
59
|
+
FileDescription: identity.productName,
|
|
60
|
+
ProductName: identity.productName,
|
|
61
|
+
CompanyName: identity.company,
|
|
62
|
+
InternalName: identity.program,
|
|
63
|
+
OriginalFilename: `${identity.program}.exe`,
|
|
64
|
+
FileVersion: identity.version,
|
|
65
|
+
ProductVersion: identity.version
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
information.outputToResourceEntries(resource.entries);
|
|
69
|
+
resource.outputResource(executable);
|
|
70
|
+
await writeFile(file, Buffer.from(executable.generate()));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Make the application of NW.js of a project for this system: the runtime of NW.js (normal) with the application
|
|
74
|
+
* (stageNwjsApp: its bundle, its native addons made to load in NW.js, its resources, its manifest), named after it.
|
|
75
|
+
* On Windows and Linux, the files of the application are next to its executable (nw.exe, nw renamed after its program:
|
|
76
|
+
* the name of its installers, lakutata.installer.name, else the program of its package; on Windows, its icon and its
|
|
77
|
+
* version information are the ones of the application); on macOS, they are in the bundle
|
|
78
|
+
* (<product>.app/Contents/Resources/app.nw), whose Info.plist gives its name, its identifier (the appId of the
|
|
79
|
+
* installer), its version and its icon, signed again (ad hoc). The icon is lakutata.icon. Its files are readable by
|
|
80
|
+
* every user, its programs runnable (once installed by root, the application runs for the users)
|
|
81
|
+
* @param options
|
|
82
|
+
* @example
|
|
83
|
+
* import {buildNwjsApp} from '@lakutata/builder'
|
|
84
|
+
*
|
|
85
|
+
* //dist/nwjs/desk-osx-arm64/Desk.app on a Mac with Apple silicon
|
|
86
|
+
* const app = await buildNwjsApp({})
|
|
87
|
+
* console.log(app.directory, app.runtime.version)
|
|
88
|
+
*/
|
|
89
|
+
export async function buildNwjsApp(options) {
|
|
90
|
+
const project = path.resolve(options.project ?? process.cwd());
|
|
91
|
+
const manifest = JSON.parse(await readFile(path.join(project, 'package.json'), 'utf8'));
|
|
92
|
+
//The program is named after the package of its installers, else after the program of the package (the package of an
|
|
93
|
+
//application of a workspace is often named app)
|
|
94
|
+
const program = String(manifest.lakutata?.installer?.name ?? programName(manifest));
|
|
95
|
+
const productName = String(manifest.productName ?? manifest.lakutata?.installer?.productName ?? program);
|
|
96
|
+
const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-nwjs-app-'));
|
|
97
|
+
try {
|
|
98
|
+
const stage = await stageNwjsApp({ ...options, project: project, outdir: path.join(work, 'app'), minify: options.minify ?? true });
|
|
99
|
+
const runtime = await downloadNwjs({ version: stage.release.version, flavor: 'normal', platform: options.platform, arch: options.arch });
|
|
100
|
+
const directory = path.join(path.resolve(project, options.outdir ?? path.join('dist', 'nwjs')), `${program}-${runtime.platform}`);
|
|
101
|
+
await rm(directory, { recursive: true, force: true });
|
|
102
|
+
await mkdir(directory, { recursive: true });
|
|
103
|
+
const icon = applicationIcon(project, manifest);
|
|
104
|
+
const version = String(manifest.version ?? '0.0.0');
|
|
105
|
+
if (runtime.platform.startsWith('osx-')) {
|
|
106
|
+
//ditto keeps the links of the frameworks and the signatures of the helpers
|
|
107
|
+
const bundle = path.join(directory, `${productName}.app`);
|
|
108
|
+
await must('ditto', [path.join(runtime.directory, 'nwjs.app'), bundle], directory, 'The bundle of macOS cannot be copied');
|
|
109
|
+
await must('ditto', [stage.directory, path.join(bundle, 'Contents', 'Resources', 'app.nw')], directory, 'The application cannot be copied into the bundle');
|
|
110
|
+
const plist = path.join(bundle, 'Contents', 'Info.plist');
|
|
111
|
+
const appId = String(manifest.lakutata?.installer?.appId ?? `com.${program}.app`);
|
|
112
|
+
for (const [key, value] of Object.entries({ CFBundleName: productName, CFBundleDisplayName: productName, CFBundleIdentifier: appId, CFBundleShortVersionString: version, CFBundleVersion: version })) {
|
|
113
|
+
await must('plutil', ['-replace', key, '-string', value, plist], directory, `${key} cannot be written into the Info.plist`);
|
|
114
|
+
}
|
|
115
|
+
if (icon)
|
|
116
|
+
await writeFile(path.join(bundle, 'Contents', 'Resources', 'app.icns'), icnsFile(icon.image));
|
|
117
|
+
await shareFiles(bundle);
|
|
118
|
+
//Its files changed, the bundle is signed again (ad hoc: the signature of a developer is given by its installer)
|
|
119
|
+
await must('codesign', ['--force', '--deep', '--sign', '-', bundle], directory, 'The bundle cannot be signed');
|
|
120
|
+
return { directory: bundle, executable: path.join(bundle, 'Contents', 'MacOS', 'nwjs'), program: program, productName: productName, runtime: runtime, stage: stage };
|
|
121
|
+
}
|
|
122
|
+
//The links of the runtime of Linux kept as they are (cp -a); the runtime of Windows has none
|
|
123
|
+
for (const source of [runtime.directory, stage.directory]) {
|
|
124
|
+
if (process.platform === 'win32')
|
|
125
|
+
await cp(source, directory, { recursive: true });
|
|
126
|
+
else
|
|
127
|
+
await must('cp', ['-a', `${source}/.`, directory], directory, 'The application cannot be copied');
|
|
128
|
+
}
|
|
129
|
+
const windows = runtime.platform.startsWith('win-');
|
|
130
|
+
const executable = path.join(directory, windows ? `${program}.exe` : program);
|
|
131
|
+
await rename(path.join(directory, windows ? 'nw.exe' : 'nw'), executable);
|
|
132
|
+
if (windows) {
|
|
133
|
+
await windowsIdentity(executable, {
|
|
134
|
+
productName: productName,
|
|
135
|
+
program: program,
|
|
136
|
+
version: version,
|
|
137
|
+
company: String(manifest.lakutata?.installer?.vendor ?? (typeof manifest.author === 'string' ? manifest.author : manifest.author?.name) ?? productName),
|
|
138
|
+
ico: icon && icoFile(icon.image)
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(executable))
|
|
142
|
+
throw new Error(`The executable of NW.js ${runtime.version} (${runtime.platform}) is missing`);
|
|
143
|
+
await shareFiles(directory);
|
|
144
|
+
return { directory: directory, executable: executable, program: program, productName: productName, runtime: runtime, stage: stage };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await rm(work, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { runCommand } from '../Processes.js';
|
|
6
|
+
import { cacheDirectory } from '../Tools.js';
|
|
7
|
+
/**
|
|
8
|
+
* The oldest Node.js lakutata runs on: the releases of NW.js embedding an older one are refused
|
|
9
|
+
*/
|
|
10
|
+
const OLDEST_NODE = '18.10.0';
|
|
11
|
+
/**
|
|
12
|
+
* The aliases of the versions
|
|
13
|
+
*/
|
|
14
|
+
const ALIASES = { win7: '0.72.0' };
|
|
15
|
+
/**
|
|
16
|
+
* The releases are read again after a day
|
|
17
|
+
*/
|
|
18
|
+
const RELEASES_TTL = 24 * 60 * 60 * 1000;
|
|
19
|
+
/**
|
|
20
|
+
* Where the releases are downloaded from: LAKUTATA_NWJS_MIRROR (a mirror of dl.nwjs.io), else dl.nwjs.io
|
|
21
|
+
*/
|
|
22
|
+
function mirror() {
|
|
23
|
+
return (process.env.LAKUTATA_NWJS_MIRROR ?? 'https://dl.nwjs.io').replace(/\/+$/, '');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compare two versions (1.2.3)
|
|
27
|
+
* @param left
|
|
28
|
+
* @param right
|
|
29
|
+
*/
|
|
30
|
+
export function compareVersions(left, right) {
|
|
31
|
+
const parts = (version) => version.replace(/^v/, '').split('.').map((part) => parseInt(part, 10) || 0);
|
|
32
|
+
const [a, b] = [parts(left), parts(right)];
|
|
33
|
+
for (let index = 0; index < Math.max(a.length, b.length); index++) {
|
|
34
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
35
|
+
if (difference)
|
|
36
|
+
return difference;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The releases of NW.js, newest first: the list of nwjs.io (LAKUTATA_NWJS_VERSIONS), kept a day in the cache; the
|
|
42
|
+
* cached one when it cannot be read
|
|
43
|
+
* @example
|
|
44
|
+
* import {nwjsReleases} from '@lakutata/builder'
|
|
45
|
+
*
|
|
46
|
+
* for (const release of (await nwjsReleases()).slice(0, 3)) console.log(release.version, release.node, release.chromium)
|
|
47
|
+
*/
|
|
48
|
+
export async function nwjsReleases() {
|
|
49
|
+
const cache = path.join(cacheDirectory(), 'nwjs', 'versions.json');
|
|
50
|
+
const fresh = existsSync(cache) && Date.now() - (await stat(cache)).mtimeMs < RELEASES_TTL;
|
|
51
|
+
let list;
|
|
52
|
+
if (!fresh) {
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(process.env.LAKUTATA_NWJS_VERSIONS ?? 'https://nwjs.io/versions.json');
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw new Error(`${response.status}`);
|
|
57
|
+
list = await response.json();
|
|
58
|
+
await mkdir(path.dirname(cache), { recursive: true });
|
|
59
|
+
await writeFile(cache, JSON.stringify(list));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!existsSync(cache))
|
|
63
|
+
throw new Error(`The releases of NW.js cannot be read (${error.message}): LAKUTATA_NWJS_VERSIONS gives another list`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
list ??= JSON.parse(await readFile(cache, 'utf8'));
|
|
67
|
+
const versions = list.versions ?? [];
|
|
68
|
+
return versions.map((entry) => ({
|
|
69
|
+
version: String(entry.version).replace(/^v/, ''),
|
|
70
|
+
date: String(entry.date ?? ''),
|
|
71
|
+
node: String(entry.components?.node ?? ''),
|
|
72
|
+
chromium: String(entry.components?.chromium ?? ''),
|
|
73
|
+
platforms: (entry.files ?? []).map((file) => String(file).replace(/-symbol$/, '')).filter((file) => /^(win|osx|linux)-/.test(file))
|
|
74
|
+
})).sort((a, b) => compareVersions(b.version, a.version));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A release of NW.js: a version, latest (the latest release) or win7 (0.72.0, the last one running on Windows 7, 8
|
|
78
|
+
* and 8.1). Refused when it embeds a Node.js older than the one lakutata runs on (18.10)
|
|
79
|
+
* @param version
|
|
80
|
+
* @example
|
|
81
|
+
* import {nwjsRelease} from '@lakutata/builder'
|
|
82
|
+
*
|
|
83
|
+
* const release = await nwjsRelease('win7')
|
|
84
|
+
* console.log(release.version, release.node, release.chromium) //0.72.0 19.3.0 109.0.5414.87
|
|
85
|
+
*/
|
|
86
|
+
export async function nwjsRelease(version) {
|
|
87
|
+
const releases = await nwjsReleases();
|
|
88
|
+
const wanted = ALIASES[version] ?? version.replace(/^v/, '');
|
|
89
|
+
const release = wanted === 'latest' ? releases[0] : releases.find((item) => item.version === wanted);
|
|
90
|
+
if (!release)
|
|
91
|
+
throw new Error(`NW.js ${version} is not a release of NW.js (the latest: ${releases[0]?.version})`);
|
|
92
|
+
if (compareVersions(release.node, OLDEST_NODE) < 0)
|
|
93
|
+
throw new Error(`NW.js ${release.version} embeds Node.js ${release.node}: lakutata runs on Node.js ${OLDEST_NODE} or later (NW.js 0.70.0 or later)`);
|
|
94
|
+
return release;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The system and the architecture of a release as NW.js names them (osx-arm64), the x64 build on a Mac with Apple
|
|
98
|
+
* silicon when the release has no arm64 one
|
|
99
|
+
* @param release
|
|
100
|
+
* @param platform
|
|
101
|
+
* @param arch
|
|
102
|
+
*/
|
|
103
|
+
export function nwjsPlatform(release, platform = process.platform, arch = process.arch) {
|
|
104
|
+
const system = { win32: 'win', darwin: 'osx', linux: 'linux' }[platform];
|
|
105
|
+
if (!system)
|
|
106
|
+
throw new Error(`NW.js has no build for ${platform}`);
|
|
107
|
+
const name = `${system}-${arch}`;
|
|
108
|
+
if (release.platforms.includes(name))
|
|
109
|
+
return name;
|
|
110
|
+
if (system === 'osx' && arch === 'arm64' && release.platforms.includes('osx-x64'))
|
|
111
|
+
return 'osx-x64';
|
|
112
|
+
throw new Error(`NW.js ${release.version} has no build for ${name} (${release.platforms.join(', ')})`);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The sha256 of the files of a release, from its SHASUMS256.txt
|
|
116
|
+
* @param version
|
|
117
|
+
*/
|
|
118
|
+
async function checksums(version) {
|
|
119
|
+
const url = `${mirror()}/v${version}/SHASUMS256.txt`;
|
|
120
|
+
const response = await fetch(url);
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
throw new Error(`The checksums of NW.js ${version} cannot be read (${url}: ${response.status})`);
|
|
123
|
+
const sums = new Map();
|
|
124
|
+
for (const line of (await response.text()).split('\n')) {
|
|
125
|
+
const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/);
|
|
126
|
+
if (match)
|
|
127
|
+
sums.set(match[2], match[1]);
|
|
128
|
+
}
|
|
129
|
+
return sums;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Download a file, its sha256 computed while it is written
|
|
133
|
+
* @param url
|
|
134
|
+
* @param file
|
|
135
|
+
*/
|
|
136
|
+
async function download(url, file) {
|
|
137
|
+
const response = await fetch(url);
|
|
138
|
+
if (!response.ok || !response.body)
|
|
139
|
+
throw new Error(`${url} cannot be downloaded (${response.status})`);
|
|
140
|
+
const hash = createHash('sha256');
|
|
141
|
+
const handle = await open(file, 'w');
|
|
142
|
+
try {
|
|
143
|
+
for await (const chunk of response.body) {
|
|
144
|
+
hash.update(chunk);
|
|
145
|
+
await handle.write(chunk);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await handle.close();
|
|
150
|
+
}
|
|
151
|
+
return hash.digest('hex');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The executable of a runtime in its directory
|
|
155
|
+
* @param platform
|
|
156
|
+
*/
|
|
157
|
+
function executableOf(platform) {
|
|
158
|
+
if (platform.startsWith('win-'))
|
|
159
|
+
return 'nw.exe';
|
|
160
|
+
if (platform.startsWith('osx-'))
|
|
161
|
+
return path.join('nwjs.app', 'Contents', 'MacOS', 'nwjs');
|
|
162
|
+
return 'nw';
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A runtime of NW.js for a system, downloaded once into the cache (~/.cache/lakutata/nwjs, LAKUTATA_CACHE_DIR) from
|
|
166
|
+
* dl.nwjs.io (LAKUTATA_NWJS_MIRROR: a mirror of it) and checked with the SHASUMS256.txt of its release
|
|
167
|
+
* @param options
|
|
168
|
+
* @example
|
|
169
|
+
* import {downloadNwjs} from '@lakutata/builder'
|
|
170
|
+
*
|
|
171
|
+
* //The NW.js of the development (its developer tools), for the machine
|
|
172
|
+
* const runtime = await downloadNwjs({version: 'latest', flavor: 'sdk'})
|
|
173
|
+
* console.log(runtime.executable, runtime.node)
|
|
174
|
+
*/
|
|
175
|
+
export async function downloadNwjs(options) {
|
|
176
|
+
const release = await nwjsRelease(options.version);
|
|
177
|
+
const flavor = options.flavor ?? 'normal';
|
|
178
|
+
const platform = nwjsPlatform(release, options.platform, options.arch);
|
|
179
|
+
const name = `nwjs${flavor === 'sdk' ? '-sdk' : ''}-v${release.version}-${platform}`;
|
|
180
|
+
const archive = `${name}.${platform.startsWith('linux-') ? 'tar.gz' : 'zip'}`;
|
|
181
|
+
const target = path.join(cacheDirectory(), 'nwjs', name);
|
|
182
|
+
const runtime = { ...release, flavor: flavor, platform: platform, directory: target, executable: path.join(target, executableOf(platform)) };
|
|
183
|
+
if (existsSync(runtime.executable))
|
|
184
|
+
return runtime;
|
|
185
|
+
const sum = (await checksums(release.version)).get(archive);
|
|
186
|
+
if (!sum)
|
|
187
|
+
throw new Error(`NW.js ${release.version} has no ${archive} in its checksums`);
|
|
188
|
+
//Extracted aside, then renamed: the builds running together do not see it partly written
|
|
189
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
190
|
+
const temporary = await mkdtemp(`${target}.`);
|
|
191
|
+
try {
|
|
192
|
+
const file = path.join(temporary, archive);
|
|
193
|
+
const downloaded = await download(`${mirror()}/v${release.version}/${archive}`, file);
|
|
194
|
+
if (downloaded !== sum)
|
|
195
|
+
throw new Error(`The ${archive} downloaded is not the one of NW.js ${release.version} (sha256 ${downloaded}, ${sum} expected)`);
|
|
196
|
+
//ditto keeps the links of the frameworks of macOS; the tar of Windows reads the zip archives
|
|
197
|
+
const extracted = process.platform === 'darwin' && archive.endsWith('.zip')
|
|
198
|
+
? await runCommand('ditto', ['-x', '-k', archive, '.'], temporary)
|
|
199
|
+
: await runCommand('tar', ['-xf', archive], temporary);
|
|
200
|
+
if (extracted.code !== 0)
|
|
201
|
+
throw new Error(`${archive} cannot be extracted: ${extracted.output.trim()}`);
|
|
202
|
+
if (!existsSync(path.join(temporary, name, executableOf(platform))))
|
|
203
|
+
throw new Error(`${archive} has no ${name}/${executableOf(platform).split(path.sep).join('/')}`);
|
|
204
|
+
await rename(path.join(temporary, name), target).catch((error) => {
|
|
205
|
+
//Extracted by another build
|
|
206
|
+
if (!existsSync(runtime.executable))
|
|
207
|
+
throw error;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await rm(temporary, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
return runtime;
|
|
214
|
+
}
|