@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.
@@ -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
+ }