@lakutata/builder 3.1.3 → 3.1.6

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.
Files changed (39) hide show
  1. package/README.md +24 -1
  2. package/README.zh-CN.md +7 -1
  3. package/dist/esm/index.js +5 -0
  4. package/dist/esm/lib/Archive.js +3 -2
  5. package/dist/esm/lib/Bundle.js +2 -1
  6. package/dist/esm/lib/Directories.js +18 -0
  7. package/dist/esm/lib/Executable.js +27 -6
  8. package/dist/esm/lib/Installer.js +28 -14
  9. package/dist/esm/lib/NativeRuntime.js +27 -3
  10. package/dist/esm/lib/Processes.js +3 -2
  11. package/dist/esm/lib/Tools.js +98 -19
  12. package/dist/esm/lib/linux/Linux.js +60 -10
  13. package/dist/esm/lib/mac/Mac.js +39 -11
  14. package/dist/esm/lib/mobile/Addons.js +675 -0
  15. package/dist/esm/lib/mobile/Node.js +190 -0
  16. package/dist/esm/lib/mobile/Stage.js +180 -0
  17. package/dist/esm/lib/nwjs/Addons.js +310 -0
  18. package/dist/esm/lib/nwjs/Package.js +149 -0
  19. package/dist/esm/lib/nwjs/Runtime.js +188 -0
  20. package/dist/esm/lib/nwjs/Stage.js +92 -0
  21. package/dist/esm/lib/win/Windows.js +68 -45
  22. package/dist/types/index.d.ts +7 -0
  23. package/dist/types/lib/Bundle.d.ts +5 -0
  24. package/dist/types/lib/Directories.d.ts +8 -0
  25. package/dist/types/lib/Executable.d.ts +7 -0
  26. package/dist/types/lib/Installer.d.ts +37 -4
  27. package/dist/types/lib/Processes.d.ts +2 -1
  28. package/dist/types/lib/Tools.d.ts +47 -1
  29. package/dist/types/lib/linux/Linux.d.ts +2 -1
  30. package/dist/types/lib/mac/Mac.d.ts +1 -1
  31. package/dist/types/lib/mobile/Addons.d.ts +154 -0
  32. package/dist/types/lib/mobile/Node.d.ts +89 -0
  33. package/dist/types/lib/mobile/Stage.d.ts +111 -0
  34. package/dist/types/lib/nwjs/Addons.d.ts +69 -0
  35. package/dist/types/lib/nwjs/Package.d.ts +56 -0
  36. package/dist/types/lib/nwjs/Runtime.d.ts +120 -0
  37. package/dist/types/lib/nwjs/Stage.d.ts +104 -0
  38. package/dist/types/lib/win/Windows.d.ts +30 -1
  39. package/package.json +2 -2
@@ -0,0 +1,310 @@
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
+ * @internal
131
+ */
132
+ export function nodeGyp(project) {
133
+ for (const base of [path.join(project, 'package.json'), import.meta.url]) {
134
+ try {
135
+ return createRequire(base).resolve('node-gyp/bin/node-gyp.js');
136
+ }
137
+ catch {
138
+ //Not installed there
139
+ }
140
+ }
141
+ //The node-gyp of npm, next to the node running the build
142
+ const prefix = process.platform === 'win32' ? path.dirname(process.execPath) : path.join(path.dirname(process.execPath), '..', 'lib');
143
+ const bundled = path.join(prefix, 'node_modules', 'npm', 'node_modules', 'node-gyp', 'bin', 'node-gyp.js');
144
+ if (existsSync(bundled))
145
+ return bundled;
146
+ throw new Error('node-gyp builds the native addons again: install it in the project (npm install --save-dev node-gyp)');
147
+ }
148
+ /**
149
+ * The headers of a release of NW.js for node-gyp, in a directory of their own (--devdir) of the cache: downloaded once,
150
+ * their common.gypi without the linker of Chromium (lld, which the compilers of macOS and of the Linux systems do not
151
+ * have: the linker of the system links the addons as well)
152
+ * @param project
153
+ * @param release
154
+ * @returns the directory given to node-gyp (--devdir)
155
+ */
156
+ async function nwjsHeaders(project, release) {
157
+ const devdir = path.join(cacheDirectory(), 'nwjs', 'node-gyp');
158
+ const common = path.join(devdir, release.version, 'common.gypi');
159
+ if (!existsSync(common)) {
160
+ const installed = await runCommand(process.execPath, [nodeGyp(project), 'install', `--target=${release.version}`, `--dist-url=${mirror()}`, `--devdir=${devdir}`], project);
161
+ if (installed.code !== 0 || !existsSync(common))
162
+ throw new Error(`The headers of NW.js ${release.version} cannot be downloaded: ${installed.output.trim().split('\n').slice(-3).join(' ')}`);
163
+ }
164
+ const content = await readFile(common, 'utf8');
165
+ const linked = content.replace(/,\s*'-fuse-ld=lld'/g, '').replace(/-fuse-ld=lld\s*/g, '');
166
+ if (linked !== content)
167
+ await writeFile(common, linked);
168
+ return devdir;
169
+ }
170
+ /**
171
+ * Where the releases of NW.js are downloaded from (LAKUTATA_NWJS_MIRROR, else dl.nwjs.io)
172
+ */
173
+ function mirror() {
174
+ return (process.env.LAKUTATA_NWJS_MIRROR ?? 'https://dl.nwjs.io').replace(/\/+$/, '');
175
+ }
176
+ /**
177
+ * The directory of the package of a file (the one holding its package.json)
178
+ * @param file
179
+ * @internal
180
+ */
181
+ export function packageOf(file) {
182
+ for (let directory = path.dirname(file); directory !== path.dirname(directory); directory = path.dirname(directory)) {
183
+ if (existsSync(path.join(directory, 'package.json')))
184
+ return directory;
185
+ }
186
+ return undefined;
187
+ }
188
+ /**
189
+ * The .node files under a directory
190
+ * @param directory
191
+ * @internal
192
+ */
193
+ export async function nodeFiles(directory) {
194
+ if (!existsSync(directory))
195
+ return [];
196
+ const found = [];
197
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
198
+ const file = path.join(directory, entry.name);
199
+ if (entry.isDirectory())
200
+ found.push(...await nodeFiles(file));
201
+ else if (entry.isFile() && entry.name.endsWith('.node'))
202
+ found.push(file);
203
+ }
204
+ return found;
205
+ }
206
+ /**
207
+ * 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
208
+ * ones of its Node.js (the Node-API it has; a Node.js 22 on 32-bit Windows when its own one is later)
209
+ * @param project
210
+ * @param release
211
+ * @param platform
212
+ * @param v8
213
+ */
214
+ async function headersOf(project, release, platform, v8) {
215
+ if (!v8)
216
+ return [`--target=${platform === 'win-ia32' && compareVersions(release.node, '23.0.0') >= 0 ? WINDOWS_32_NODE : release.node}`];
217
+ if (platform === 'win-ia32') {
218
+ //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
219
+ const library = await fetch(`${mirror()}/v${release.version}/node.lib`, { method: 'HEAD' });
220
+ if (!library.ok)
221
+ 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`);
222
+ }
223
+ return [`--target=${release.version}`, `--dist-url=${mirror()}`, `--devdir=${await nwjsHeaders(project, release)}`];
224
+ }
225
+ /**
226
+ * Build a package of addons again from its sources (a copy of it), its addons written into build/Release of its copy
227
+ * in the native tree
228
+ * @param project
229
+ * @param source the directory of the package, in the node_modules of the project
230
+ * @param target the directory of the package in the native tree
231
+ * @param arch
232
+ * @param headers the arguments giving node-gyp its headers (headersOf)
233
+ */
234
+ async function rebuild(project, source, target, arch, headers) {
235
+ if (!existsSync(path.join(source, 'binding.gyp')))
236
+ 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' : ''}`);
237
+ const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-nwjs-addon-'));
238
+ try {
239
+ const copy = path.join(work, path.basename(source));
240
+ await cp(source, copy, { recursive: true, filter: (file) => path.relative(source, file) !== 'build' });
241
+ //The modules of its build (node-addon-api) found where the package is
242
+ const modules = (createRequire(path.join(source, 'package.json')).resolve.paths('x') ?? []);
243
+ const args = [nodeGyp(project), 'rebuild', `--arch=${arch}`, ...headers];
244
+ const previous = process.env.NODE_PATH;
245
+ process.env.NODE_PATH = [...modules, previous].filter(Boolean).join(path.delimiter);
246
+ try {
247
+ const built = await runCommand(process.execPath, args, copy);
248
+ if (built.code !== 0)
249
+ 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(' ')}`);
250
+ }
251
+ finally {
252
+ if (previous === undefined)
253
+ delete process.env.NODE_PATH;
254
+ else
255
+ process.env.NODE_PATH = previous;
256
+ }
257
+ await mkdir(path.join(target, 'build', 'Release'), { recursive: true });
258
+ for (const file of await nodeFiles(path.join(copy, 'build', 'Release')))
259
+ await cp(file, path.join(target, 'build', 'Release', path.basename(file)));
260
+ }
261
+ finally {
262
+ await rm(work, { recursive: true, force: true });
263
+ }
264
+ }
265
+ /**
266
+ * Make the addons of the native tree of an application of NW.js load in its runtime: the packages whose addons are not
267
+ * 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
268
+ * headers of NW.js), built again from their sources; on Windows, the node.exe they import named node.dll
269
+ * @param project
270
+ * @param directory the native tree
271
+ * @param files the files of the tree (their sources)
272
+ * @param release
273
+ * @param platform the build of NW.js (win-x64, osx-arm64…)
274
+ * @internal
275
+ */
276
+ export async function prepareNwjsAddons(project, directory, files, release, platform) {
277
+ const arch = platform.slice(platform.indexOf('-') + 1);
278
+ const result = { renamed: [], rebuilt: [] };
279
+ const done = new Set();
280
+ for (const file of files.filter((item) => item.path.endsWith('.node'))) {
281
+ const target = path.join(directory, ...file.path.split('/'));
282
+ const content = await readFile(target);
283
+ const v8 = usesV8Api(content);
284
+ if (v8 && compareVersions(release.version, NODE_GYP_HEADERS) < 0)
285
+ 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`);
286
+ if (!v8 && binaryArchitectures(content).includes(arch))
287
+ continue;
288
+ const source = packageOf(file.source);
289
+ if (!source)
290
+ throw new Error(`${file.path} is not for ${arch} and is not in a package: build it for NW.js ${release.version} (${platform})`);
291
+ const relative = path.relative(source, file.source).split(path.sep).join('/');
292
+ const packageTarget = path.join(directory, ...file.path.slice(0, file.path.length - relative.length).split('/'));
293
+ if (done.has(packageTarget))
294
+ continue;
295
+ done.add(packageTarget);
296
+ await rebuild(project, source, packageTarget, arch, await headersOf(project, release, platform, v8));
297
+ result.rebuilt.push(path.basename(source));
298
+ }
299
+ if (platform.startsWith('win-')) {
300
+ for (const file of await nodeFiles(directory)) {
301
+ const content = await readFile(file);
302
+ //The addons of Windows only (portable executables)
303
+ if (content.length > 64 && content.readUInt16LE(0) === 0x5a4d && nameNwjsNode(content)) {
304
+ await writeFile(file, content);
305
+ result.renamed.push(path.relative(directory, file).split(path.sep).join('/'));
306
+ }
307
+ }
308
+ }
309
+ return result;
310
+ }
@@ -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, progress: options.progress });
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,188 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { runCommand } from '../Processes.js';
5
+ import { cacheDirectory, downloadFile, moveIntoCache } from '../Tools.js';
6
+ /**
7
+ * The oldest Node.js lakutata runs on: the releases of NW.js embedding an older one are refused
8
+ */
9
+ const OLDEST_NODE = '18.10.0';
10
+ /**
11
+ * The aliases of the versions
12
+ */
13
+ const ALIASES = { win7: '0.72.0' };
14
+ /**
15
+ * The releases are read again after a day
16
+ */
17
+ const RELEASES_TTL = 24 * 60 * 60 * 1000;
18
+ /**
19
+ * Where the releases are downloaded from: LAKUTATA_NWJS_MIRROR (a mirror of dl.nwjs.io), else dl.nwjs.io
20
+ */
21
+ function mirror() {
22
+ return (process.env.LAKUTATA_NWJS_MIRROR ?? 'https://dl.nwjs.io').replace(/\/+$/, '');
23
+ }
24
+ /**
25
+ * Compare two versions (1.2.3)
26
+ * @param left
27
+ * @param right
28
+ */
29
+ export function compareVersions(left, right) {
30
+ const parts = (version) => version.replace(/^v/, '').split('.').map((part) => parseInt(part, 10) || 0);
31
+ const [a, b] = [parts(left), parts(right)];
32
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
33
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
34
+ if (difference)
35
+ return difference;
36
+ }
37
+ return 0;
38
+ }
39
+ /**
40
+ * The releases of NW.js, newest first: the list of nwjs.io (LAKUTATA_NWJS_VERSIONS), kept a day in the cache; the
41
+ * cached one when it cannot be read
42
+ * @example
43
+ * import {nwjsReleases} from '@lakutata/builder'
44
+ *
45
+ * for (const release of (await nwjsReleases()).slice(0, 3)) console.log(release.version, release.node, release.chromium)
46
+ */
47
+ export async function nwjsReleases() {
48
+ const cache = path.join(cacheDirectory(), 'nwjs', 'versions.json');
49
+ const fresh = existsSync(cache) && Date.now() - (await stat(cache)).mtimeMs < RELEASES_TTL;
50
+ let list;
51
+ if (!fresh) {
52
+ try {
53
+ const response = await fetch(process.env.LAKUTATA_NWJS_VERSIONS ?? 'https://nwjs.io/versions.json');
54
+ if (!response.ok)
55
+ throw new Error(`${response.status}`);
56
+ list = await response.json();
57
+ await mkdir(path.dirname(cache), { recursive: true });
58
+ await writeFile(cache, JSON.stringify(list));
59
+ }
60
+ catch (error) {
61
+ if (!existsSync(cache))
62
+ throw new Error(`The releases of NW.js cannot be read (${error.message}): LAKUTATA_NWJS_VERSIONS gives another list`, { cause: error });
63
+ }
64
+ }
65
+ list ??= JSON.parse(await readFile(cache, 'utf8'));
66
+ const versions = list.versions ?? [];
67
+ return versions.map((entry) => ({
68
+ version: String(entry.version).replace(/^v/, ''),
69
+ date: String(entry.date ?? ''),
70
+ node: String(entry.components?.node ?? ''),
71
+ chromium: String(entry.components?.chromium ?? ''),
72
+ platforms: (entry.files ?? []).map((file) => String(file).replace(/-symbol$/, '')).filter((file) => /^(win|osx|linux)-/.test(file))
73
+ })).sort((a, b) => compareVersions(b.version, a.version));
74
+ }
75
+ /**
76
+ * A release of NW.js: a version, latest (the latest release) or win7 (0.72.0, the last one running on Windows 7, 8
77
+ * and 8.1). Refused when it embeds a Node.js older than the one lakutata runs on (18.10)
78
+ * @param version
79
+ * @example
80
+ * import {nwjsRelease} from '@lakutata/builder'
81
+ *
82
+ * const release = await nwjsRelease('win7')
83
+ * console.log(release.version, release.node, release.chromium) //0.72.0 19.3.0 109.0.5414.87
84
+ */
85
+ export async function nwjsRelease(version) {
86
+ const releases = await nwjsReleases();
87
+ const wanted = ALIASES[version] ?? version.replace(/^v/, '');
88
+ const release = wanted === 'latest' ? releases[0] : releases.find((item) => item.version === wanted);
89
+ if (!release)
90
+ throw new Error(`NW.js ${version} is not a release of NW.js (the latest: ${releases[0]?.version})`);
91
+ if (compareVersions(release.node, OLDEST_NODE) < 0)
92
+ 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)`);
93
+ return release;
94
+ }
95
+ /**
96
+ * The system and the architecture of a release as NW.js names them (osx-arm64), the x64 build on a Mac with Apple
97
+ * silicon when the release has no arm64 one
98
+ * @param release
99
+ * @param platform
100
+ * @param arch
101
+ */
102
+ export function nwjsPlatform(release, platform = process.platform, arch = process.arch) {
103
+ const system = { win32: 'win', darwin: 'osx', linux: 'linux' }[platform];
104
+ if (!system)
105
+ throw new Error(`NW.js has no build for ${platform}`);
106
+ const name = `${system}-${arch}`;
107
+ if (release.platforms.includes(name))
108
+ return name;
109
+ if (system === 'osx' && arch === 'arm64' && release.platforms.includes('osx-x64'))
110
+ return 'osx-x64';
111
+ throw new Error(`NW.js ${release.version} has no build for ${name} (${release.platforms.join(', ')})`);
112
+ }
113
+ /**
114
+ * The sha256 of the files of a release, from its SHASUMS256.txt
115
+ * @param version
116
+ */
117
+ async function checksums(version) {
118
+ const url = `${mirror()}/v${version}/SHASUMS256.txt`;
119
+ const response = await fetch(url);
120
+ if (!response.ok)
121
+ throw new Error(`The checksums of NW.js ${version} cannot be read (${url}: ${response.status})`);
122
+ const sums = new Map();
123
+ for (const line of (await response.text()).split('\n')) {
124
+ const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/);
125
+ if (match)
126
+ sums.set(match[2], match[1]);
127
+ }
128
+ return sums;
129
+ }
130
+ /**
131
+ * The executable of a runtime in its directory
132
+ * @param platform
133
+ */
134
+ function executableOf(platform) {
135
+ if (platform.startsWith('win-'))
136
+ return 'nw.exe';
137
+ if (platform.startsWith('osx-'))
138
+ return path.join('nwjs.app', 'Contents', 'MacOS', 'nwjs');
139
+ return 'nw';
140
+ }
141
+ /**
142
+ * A runtime of NW.js for a system, downloaded once into the cache (~/.cache/lakutata/nwjs, LAKUTATA_CACHE_DIR) from
143
+ * dl.nwjs.io (LAKUTATA_NWJS_MIRROR: a mirror of it) and checked with the SHASUMS256.txt of its release
144
+ * @param options
145
+ * @example
146
+ * import {downloadNwjs} from '@lakutata/builder'
147
+ *
148
+ * //The NW.js of the development (its developer tools), for the machine
149
+ * const runtime = await downloadNwjs({version: 'latest', flavor: 'sdk'})
150
+ * console.log(runtime.executable, runtime.node)
151
+ */
152
+ export async function downloadNwjs(options) {
153
+ const release = await nwjsRelease(options.version);
154
+ const flavor = options.flavor ?? 'normal';
155
+ const platform = nwjsPlatform(release, options.platform, options.arch);
156
+ const name = `nwjs${flavor === 'sdk' ? '-sdk' : ''}-v${release.version}-${platform}`;
157
+ const archive = `${name}.${platform.startsWith('linux-') ? 'tar.gz' : 'zip'}`;
158
+ const target = path.join(cacheDirectory(), 'nwjs', name);
159
+ const runtime = { ...release, flavor: flavor, platform: platform, directory: target, executable: path.join(target, executableOf(platform)) };
160
+ if (existsSync(runtime.executable))
161
+ return runtime;
162
+ const sum = (await checksums(release.version)).get(archive);
163
+ if (!sum)
164
+ throw new Error(`NW.js ${release.version} has no ${archive} in its checksums`);
165
+ options.progress?.(`${archive} (NW.js ${release.version})`);
166
+ //Extracted aside, then renamed: the builds running together do not see it partly written
167
+ await mkdir(path.dirname(target), { recursive: true });
168
+ const temporary = await mkdtemp(`${target}.`);
169
+ try {
170
+ const file = path.join(temporary, archive);
171
+ const downloaded = await downloadFile(`${mirror()}/v${release.version}/${archive}`, file, { progress: options.progress });
172
+ if (downloaded !== sum)
173
+ throw new Error(`The ${archive} downloaded is not the one of NW.js ${release.version} (sha256 ${downloaded}, ${sum} expected)`);
174
+ //ditto keeps the links of the frameworks of macOS; the tar of Windows reads the zip archives
175
+ const extracted = process.platform === 'darwin' && archive.endsWith('.zip')
176
+ ? await runCommand('ditto', ['-x', '-k', archive, '.'], temporary)
177
+ : await runCommand('tar', ['-xf', archive], temporary);
178
+ if (extracted.code !== 0)
179
+ throw new Error(`${archive} cannot be extracted: ${extracted.output.trim()}`);
180
+ if (!existsSync(path.join(temporary, name, executableOf(platform))))
181
+ throw new Error(`${archive} has no ${name}/${executableOf(platform).split(path.sep).join('/')}`);
182
+ await moveIntoCache(path.join(temporary, name), target, () => existsSync(runtime.executable));
183
+ }
184
+ finally {
185
+ await rm(temporary, { recursive: true, force: true });
186
+ }
187
+ return runtime;
188
+ }