@lakutata/builder 3.1.5 → 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.
@@ -0,0 +1,675 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { runCommand } from '../Processes.js';
8
+ import { binaryArchitectures, nodeFiles, nodeGyp, packageOf } from '../nwjs/Addons.js';
9
+ import { compareVersions } from '../nwjs/Runtime.js';
10
+ import { cacheDirectory, moveIntoCache } from '../Tools.js';
11
+ import { ANDROID_ABIS, mobileNodeAbis, mobileNodeVersion } from './Node.js';
12
+ /**
13
+ * The version of Android the addons are built for: the minSdkVersion of the projects of React Native 0.86
14
+ */
15
+ const ANDROID_API = 24;
16
+ /**
17
+ * The SDKs of iOS the native addons are built for: the architectures built and the slice of the framework of the
18
+ * Node.js of the application (NodeMobile.xcframework) they link
19
+ */
20
+ const IOS_SDKS = {
21
+ iphoneos: { archs: 'arm64', slice: 'ios-arm64', platform: 'iPhoneOS' },
22
+ iphonesimulator: { archs: 'arm64', slice: 'ios-arm64-simulator', platform: 'iPhoneSimulator' }
23
+ };
24
+ /**
25
+ * The version of iOS the addons are built for: the minimum of React Native 0.86, the deployment target of libnode
26
+ */
27
+ const IOS_DEPLOYMENT_TARGET = '15.1';
28
+ /**
29
+ * Where the libraries of the HAP are on HarmonyOS (the libs/arm64-v8a of its module), which the addons are loaded from
30
+ */
31
+ const HARMONY_LIBRARIES = '/data/storage/el1/bundle/libs/arm64';
32
+ /**
33
+ * The file of a Node.js project mapping the placeholders of its addons (their paths in the native tree) to the libraries
34
+ * the system loads instead (the frameworks of the application on iOS, the libraries of the HAP on HarmonyOS):
35
+ * {directory, files}, the directory relative to the project or absolute
36
+ */
37
+ export const MOBILE_ADDONS_FILE = 'addons.json';
38
+ /**
39
+ * The packages of the addons of the native tree: built again from their sources, a package is the unit
40
+ * @param files the files of the tree
41
+ */
42
+ function addonPackages(files) {
43
+ const packages = new Map();
44
+ for (const file of files.filter((item) => item.path.endsWith('.node'))) {
45
+ const source = packageOf(file.source);
46
+ if (!source)
47
+ throw new Error(`${file.path} is in no package: the native addons are built for iOS and Android from the sources of their packages`);
48
+ const relative = path.relative(source, file.source).split(path.sep).join('/');
49
+ let item = packages.get(source);
50
+ if (!item) {
51
+ const manifest = JSON.parse(readFileSync(path.join(source, 'package.json'), 'utf8'));
52
+ item = { name: manifest.name ?? path.basename(source), source: source, target: '', files: [] };
53
+ packages.set(source, item);
54
+ }
55
+ if (file.path.endsWith(`/${relative}`))
56
+ item.target = file.path.slice(0, file.path.length - relative.length - 1);
57
+ item.files.push(file.path);
58
+ }
59
+ return [...packages.values()];
60
+ }
61
+ /**
62
+ * The NDK of Android: ANDROID_NDK_HOME, ANDROID_NDK_ROOT, else the latest one of the SDK (ANDROID_HOME, ANDROID_SDK_ROOT,
63
+ * else the directory Android Studio installs it into)
64
+ * @returns its directory, the directory of its compilers and its revision
65
+ */
66
+ function androidNdk() {
67
+ let ndk = [process.env.ANDROID_NDK_HOME, process.env.ANDROID_NDK_ROOT].find((directory) => !!directory && existsSync(path.join(directory, 'source.properties')));
68
+ if (!ndk) {
69
+ const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ?? path.join(os.homedir(), ...process.platform === 'darwin' ? ['Library', 'Android', 'sdk'] : ['Android', 'Sdk']);
70
+ const root = path.join(sdk, 'ndk');
71
+ const latest = existsSync(root) ? readdirSync(root).filter((version) => existsSync(path.join(root, version, 'source.properties'))).sort(compareVersions).at(-1) : undefined;
72
+ if (latest)
73
+ ndk = path.join(root, latest);
74
+ }
75
+ if (!ndk)
76
+ throw new Error('The NDK of Android builds the native addons for Android: install it (the SDK Manager of Android Studio, or sdkmanager "ndk;27.0.12077973"), or give its directory (ANDROID_NDK_HOME)');
77
+ const bin = path.join(ndk, 'toolchains', 'llvm', 'prebuilt', `${process.platform === 'darwin' ? 'darwin' : 'linux'}-x86_64`, 'bin');
78
+ if (!existsSync(bin))
79
+ throw new Error(`The NDK ${ndk} has no compilers for this system (${bin})`);
80
+ const revision = /Pkg\.Revision\s*=\s*(\S+)/.exec(readFileSync(path.join(ndk, 'source.properties'), 'utf8'))?.[1] ?? 'unknown';
81
+ return { root: ndk, bin: bin, revision: revision };
82
+ }
83
+ /**
84
+ * The format of the builds kept in the cache: changed when what a build makes changes
85
+ */
86
+ const CACHE_FORMAT = 4;
87
+ /**
88
+ * The files of a package, by their paths, their sizes and their times (not their contents: a package of sources has
89
+ * thousands of files), its build, its modules and the files of git left out
90
+ * @param directory
91
+ * @param base
92
+ */
93
+ async function fingerprint(directory, base = directory) {
94
+ const lines = [];
95
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
96
+ const file = path.join(directory, entry.name);
97
+ const relative = path.relative(base, file).split(path.sep).join('/');
98
+ if (['build', 'node_modules', '.git'].includes(relative))
99
+ continue;
100
+ if (entry.isDirectory()) {
101
+ lines.push(...await fingerprint(file, base));
102
+ }
103
+ else if (entry.isFile()) {
104
+ const stats = await stat(file);
105
+ lines.push(`${relative}\0${stats.size}\0${stats.mtimeMs}`);
106
+ }
107
+ }
108
+ return lines;
109
+ }
110
+ /**
111
+ * What a libnode is to the addons built against it: its version and its configuration
112
+ * @param node the export of the libnode
113
+ */
114
+ function nodeIdentity(node) {
115
+ const config = path.join(node, 'include', 'node', 'config.gypi');
116
+ return `${mobileNodeVersion(node)} ${existsSync(config) ? createHash('sha256').update(readFileSync(config)).digest('hex').slice(0, 16) : ''}`;
117
+ }
118
+ /**
119
+ * The addons of a package built for a target, kept in the cache (~/.cache/lakutata/mobile/addons, LAKUTATA_CACHE_DIR)
120
+ * by the files of the package, the target, the toolchain and the libnode: built once, then taken from the cache (the
121
+ * development stages the application at each change)
122
+ * @param item
123
+ * @param target the ABI, the SDK of iOS, or harmony
124
+ * @param toolchain the version of the NDK, of Xcode, or of the native SDK of HarmonyOS
125
+ * @param node the export of the libnode built against
126
+ * @param build
127
+ * @returns the addons
128
+ */
129
+ async function cachedBuild(item, target, toolchain, node, build) {
130
+ const hash = createHash('sha256').update(JSON.stringify({ format: CACHE_FORMAT, node: nodeIdentity(node), name: item.name, target: target, toolchain: toolchain }));
131
+ for (const line of (await fingerprint(item.source)).sort())
132
+ hash.update(`${line}\n`);
133
+ const directory = path.join(cacheDirectory(), 'mobile', 'addons', hash.digest('hex').slice(0, 32));
134
+ const addons = async () => (await readdir(directory)).filter((name) => name.endsWith('.node')).sort().map((name) => path.join(directory, name));
135
+ if (existsSync(path.join(directory, '.complete')))
136
+ return addons();
137
+ const built = await build();
138
+ //Written aside, then renamed: the builds running together do not see it partly written
139
+ await mkdir(path.dirname(directory), { recursive: true });
140
+ const temporary = await mkdtemp(`${directory}.`);
141
+ try {
142
+ for (const addon of built)
143
+ await cp(addon, path.join(temporary, path.basename(addon)));
144
+ await writeFile(path.join(temporary, '.complete'), '');
145
+ await moveIntoCache(temporary, directory, () => existsSync(path.join(directory, '.complete')));
146
+ }
147
+ finally {
148
+ await rm(temporary, { recursive: true, force: true });
149
+ }
150
+ return addons();
151
+ }
152
+ /**
153
+ * The modules the build of a package loads (node-addon-api), found where the package is
154
+ * @param source
155
+ */
156
+ function buildModules(source) {
157
+ return [...createRequire(path.join(source, 'package.json')).resolve.paths('x') ?? [], process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
158
+ }
159
+ /**
160
+ * Build the addons of a package for an architecture of Android with node-gyp (a copy of the package), cross-compiled
161
+ * by the NDK against the headers and the library of libnode
162
+ * @param project
163
+ * @param item
164
+ * @param abi
165
+ * @param node the export of the libnode of Android
166
+ * @param ndk the NDK
167
+ * @param work a directory of the build
168
+ * @returns the addons built (in build/Release of the copy)
169
+ */
170
+ async function buildAndroid(project, item, abi, node, ndk, work) {
171
+ const bin = ndk.bin;
172
+ const copy = path.join(work, path.basename(item.source));
173
+ await cp(item.source, copy, { recursive: true, filter: (file) => path.relative(item.source, file) !== 'build' });
174
+ const compiler = path.join(bin, `${ANDROID_ABIS[abi].target}${ANDROID_API}-clang`);
175
+ const env = {
176
+ ...process.env,
177
+ NODE_PATH: buildModules(item.source),
178
+ GYP_CROSSCOMPILE: '1',
179
+ CC: compiler,
180
+ CXX: `${compiler}++`,
181
+ LINK: `${compiler}++`,
182
+ AR: path.join(bin, 'llvm-ar'),
183
+ CC_target: compiler,
184
+ CXX_target: `${compiler}++`,
185
+ LINK_target: `${compiler}++`,
186
+ AR_target: path.join(bin, 'llvm-ar'),
187
+ CC_host: process.env.CC_host ?? 'cc',
188
+ CXX_host: process.env.CXX_host ?? 'c++',
189
+ LINK_host: process.env.LINK_host ?? 'c++',
190
+ AR_host: process.env.AR_host ?? 'ar',
191
+ //The addons load the Node.js of the application (libnode.so); the pages of 16 KB of Android 15
192
+ LDFLAGS: `-L${path.join(node, 'libs', abi)} -lnode -Wl,-z,max-page-size=16384`
193
+ };
194
+ //The common.gypi of Node.js 24 reads the NDK (its cpufeatures) when OS is android
195
+ const args = [nodeGyp(project), 'rebuild', `--arch=${ANDROID_ABIS[abi].arch}`, `--nodedir=${await addonNodeHeaders(node, work)}`, '--jobs=max', '--', '-f', 'make-android', '-DOS=android', `-Dandroid_ndk_path=${ndk.root}`];
196
+ const built = await runCommand(process.execPath, args, copy, env);
197
+ if (built.code !== 0)
198
+ throw new Error(`The addons of ${item.name} cannot be built for Android (${abi}): ${built.output.trim().split('\n').slice(-8).join('\n')}`);
199
+ const addons = await nodeFiles(path.join(copy, 'build', 'Release'));
200
+ if (!addons.length)
201
+ throw new Error(`The build of ${item.name} for Android (${abi}) made no addon (build/Release)`);
202
+ for (const addon of addons) {
203
+ if (!binaryArchitectures(await readFile(addon)).includes(ANDROID_ABIS[abi].arch))
204
+ throw new Error(`${path.basename(addon)} of ${item.name} is not built for ${abi}`);
205
+ }
206
+ return addons;
207
+ }
208
+ /**
209
+ * Give an addon package of a native tree the addons of an architecture: each addon of the package takes the one built of
210
+ * its name (in build/Release, or where node-pre-gyp puts it), the others (the addons of other systems) are removed
211
+ * @param tree
212
+ * @param item
213
+ * @param built
214
+ * @param kept the addons of the tree kept as they are (the prebuilt ones of the architecture)
215
+ */
216
+ async function giveAddons(tree, item, built, kept) {
217
+ const names = new Map(built.map((file) => [path.basename(file), file]));
218
+ for (const file of item.files) {
219
+ if (kept(file))
220
+ continue;
221
+ const target = path.join(tree, ...file.split('/'));
222
+ const addon = names.get(path.basename(file));
223
+ if (addon)
224
+ await cp(addon, target);
225
+ else
226
+ await rm(target, { force: true });
227
+ }
228
+ //The ones of build/Release, which the loaders look for first
229
+ if (item.target) {
230
+ for (const [name, addon] of names) {
231
+ const target = path.join(tree, ...item.target.split('/'), 'build', 'Release', name);
232
+ await mkdir(path.dirname(target), { recursive: true });
233
+ await cp(addon, target);
234
+ }
235
+ }
236
+ }
237
+ /**
238
+ * Build the native addons of a mobile application of Android: a native tree per architecture (<directory>-<arch>, the
239
+ * one of process.arch loaded by the bundle), each package of addons built for it from its sources (node-gyp, the NDK,
240
+ * the headers and the library of libnode), or keeping its prebuilt addons of the architecture
241
+ * (prebuilds/android-<arch>, node-gyp-build). The tree of the build is removed once they are made
242
+ * @param project
243
+ * @param directory the native tree
244
+ * @param files the files of the tree
245
+ * @param node the export of the libnode of Android
246
+ * @param options
247
+ * @internal
248
+ */
249
+ export async function prepareAndroidAddons(project, directory, files, node, options = {}) {
250
+ if (process.platform === 'win32')
251
+ throw new Error('The native addons are built for Android on macOS and on Linux (node-gyp, make and the NDK)');
252
+ const built = mobileNodeAbis(node);
253
+ const abis = options.abis?.length ? options.abis : built;
254
+ for (const abi of abis)
255
+ if (!built.includes(abi))
256
+ throw new Error(`The libnode of ${node} is not built for the architecture ${abi} of Android (${built.join(', ') || 'none'})`);
257
+ const packages = addonPackages(files);
258
+ const result = { built: [], prebuilt: [], frameworks: [], libraries: [] };
259
+ const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-mobile-addons-'));
260
+ const trees = [];
261
+ try {
262
+ //The NDK, once a package is built
263
+ let ndk;
264
+ for (const abi of abis) {
265
+ const arch = ANDROID_ABIS[abi].arch;
266
+ const tree = `${directory}-${arch}`;
267
+ await rm(tree, { recursive: true, force: true });
268
+ await cp(directory, tree, { recursive: true, verbatimSymlinks: true, mode: constants.COPYFILE_FICLONE });
269
+ trees.push(tree);
270
+ for (const item of packages) {
271
+ //The prebuilt addons of the architecture (node-gyp-build loads them), when the package keeps its addons in its tree
272
+ const prebuilds = `${item.target}/prebuilds/android-${arch}/`;
273
+ if (item.target && existsSync(path.join(item.source, 'prebuilds', `android-${arch}`))) {
274
+ await giveAddons(tree, { ...item, target: '' }, [], (file) => file.startsWith(prebuilds));
275
+ result.prebuilt.push(`${item.name} (${abi})`);
276
+ continue;
277
+ }
278
+ if (!existsSync(path.join(item.source, 'binding.gyp')))
279
+ throw new Error(`${item.name} has no sources to build its addons for Android (binding.gyp), and no prebuilt addons of Android (prebuilds/android-${arch})`);
280
+ ndk ??= androidNdk();
281
+ const toolchain = ndk;
282
+ const addons = await cachedBuild(item, abi, `ndk ${toolchain.revision}`, node, async () => {
283
+ options.progress?.(`The addons of ${item.name} built for Android (${abi})`);
284
+ const build = path.join(work, `${result.built.length}`);
285
+ await mkdir(build, { recursive: true });
286
+ return buildAndroid(project, item, abi, node, toolchain, build);
287
+ });
288
+ await giveAddons(tree, item, addons, () => false);
289
+ result.built.push(`${item.name} (${abi})`);
290
+ }
291
+ }
292
+ await rm(directory, { recursive: true, force: true });
293
+ }
294
+ catch (error) {
295
+ //Nothing half made
296
+ for (const tree of trees)
297
+ await rm(tree, { recursive: true, force: true });
298
+ throw error;
299
+ }
300
+ finally {
301
+ await rm(work, { recursive: true, force: true });
302
+ }
303
+ return result;
304
+ }
305
+ /**
306
+ * The name of the framework of an addon: its package and its file, in the characters of the names and of the
307
+ * identifiers of the bundles (letters, digits and hyphens)
308
+ * @param name the name of its package
309
+ * @param file its file
310
+ * @internal
311
+ */
312
+ export function frameworkName(name, file) {
313
+ return `${name}-${path.basename(file, '.node')}`.replace(/^@/, '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
314
+ }
315
+ /**
316
+ * The Info.plist of the framework of an addon: the keys the frameworks embedded in an application of the App Store
317
+ * have
318
+ * @param name
319
+ * @param version the version of its package
320
+ * @param sdk
321
+ * @internal
322
+ */
323
+ export function frameworkInfo(name, version, sdk) {
324
+ const short = /^\d+(\.\d+){0,2}/.exec(version)?.[0] ?? '1.0.0';
325
+ const entries = [
326
+ ['CFBundleDevelopmentRegion', 'en'],
327
+ ['CFBundleExecutable', name],
328
+ ['CFBundleIdentifier', `dev.lakutata.addon.${name}`],
329
+ ['CFBundleInfoDictionaryVersion', '6.0'],
330
+ ['CFBundleName', name],
331
+ ['CFBundlePackageType', 'FMWK'],
332
+ ['CFBundleShortVersionString', short],
333
+ ['CFBundleVersion', short],
334
+ ['MinimumOSVersion', IOS_DEPLOYMENT_TARGET]
335
+ ];
336
+ return `<?xml version="1.0" encoding="UTF-8"?>
337
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
338
+ <plist version="1.0">
339
+ <dict>
340
+ ${entries.map(([key, value]) => `\t<key>${key}</key>\n\t<string>${value}</string>`).join('\n')}
341
+ \t<key>CFBundleSupportedPlatforms</key>
342
+ \t<array>
343
+ \t\t<string>${IOS_SDKS[sdk].platform}</string>
344
+ \t</array>
345
+ </dict>
346
+ </plist>
347
+ `;
348
+ }
349
+ /**
350
+ * The calls of the cleanup hooks of node::ObjectWrap in its header, in its constructor and its destructor. Node.js 24.19
351
+ * backported them without what lets the destructor remove its hook when no Environment is current, which is how the
352
+ * garbage collector calls it: the destructor of an object it frees aborts the process (Assertion failed: (env) !=
353
+ * nullptr, in RemoveEnvironmentCleanupHook; https://github.com/nodejs/node/issues/65446)
354
+ */
355
+ const OBJECT_WRAP_HOOKS = /^[ \t]*(?:Add|Remove)CleanupHook\(\);[ \t]*\r?\n/gm;
356
+ /**
357
+ * The headers of a libnode as node-gyp builds the addons against them, linked but for these:
358
+ * - node_object_wrap.h without the calls of the cleanup hooks of node::ObjectWrap (OBJECT_WRAP_HOOKS), as in the headers
359
+ * of Node.js 24.18: an addon built on node::ObjectWrap (better-sqlite3, the addons of NAN) would abort the application
360
+ * whenever the garbage collector frees one of its objects. A header without them is copied as it is;
361
+ * - for iOS, config.gypi with OS "mac": the libnode of iOS is configured with OS "ios", for which the common.gypi of
362
+ * Node.js turns the exceptions and the RTTI of C++ off in the projects of Xcode as it does for macOS, while the addons
363
+ * (and node-addon-api) give their settings of Xcode (the exceptions, the RTTI, the standard of C++) to OS "mac" only.
364
+ * Built as for macOS, an addon keeps them; the SDK, the architectures and the deployment target of iOS are given to
365
+ * xcodebuild
366
+ * @param node the export of the libnode
367
+ * @param work a directory of the build
368
+ * @param os "mac" for iOS: the OS given to config.gypi
369
+ * @returns the directory of the headers (the nodedir of node-gyp)
370
+ */
371
+ export async function addonNodeHeaders(node, work, os) {
372
+ const source = path.join(node, 'include', 'node');
373
+ const directory = path.join(work, 'node-headers');
374
+ const include = path.join(directory, 'include', 'node');
375
+ await mkdir(include, { recursive: true });
376
+ for (const entry of await readdir(source)) {
377
+ const target = path.join(include, entry);
378
+ if (existsSync(target))
379
+ continue;
380
+ if (entry === 'node_object_wrap.h') {
381
+ await writeFile(target, (await readFile(path.join(source, entry), 'utf8')).replace(OBJECT_WRAP_HOOKS, ''));
382
+ }
383
+ else if (entry === 'config.gypi' && os) {
384
+ await writeFile(target, (await readFile(path.join(source, entry), 'utf8')).replace(/(["']OS["']\s*:\s*)(["'])ios\2/, `$1$2${os}$2`));
385
+ }
386
+ else {
387
+ await symlink(path.join(source, entry), target);
388
+ }
389
+ }
390
+ return directory;
391
+ }
392
+ /**
393
+ * Build the addons of a package for a SDK of iOS (a copy of the package): node-gyp makes its project of Xcode, built by
394
+ * xcodebuild into dynamic libraries linking the framework of the Node.js of the application (NodeMobile)
395
+ * @param project
396
+ * @param item
397
+ * @param sdk
398
+ * @param node the export of the libnode of iOS
399
+ * @param work a directory of the build
400
+ * @returns the addons built
401
+ */
402
+ async function buildIos(project, item, sdk, node, work) {
403
+ const copy = path.join(work, path.basename(item.source));
404
+ await cp(item.source, copy, { recursive: true, filter: (file) => path.relative(item.source, file) !== 'build' });
405
+ const env = { ...process.env, NODE_PATH: buildModules(item.source) };
406
+ const configured = await runCommand(process.execPath, [nodeGyp(project), 'configure', `--nodedir=${await addonNodeHeaders(node, work, 'mac')}`, '--', '-f', 'xcode'], copy, env);
407
+ if (configured.code !== 0)
408
+ throw new Error(`The addons of ${item.name} cannot be built for iOS (node-gyp configure): ${configured.output.trim().split('\n').slice(-8).join('\n')}`);
409
+ //The addons made dynamic libraries (in frameworks) rather than bundles: their targets only, not the static libraries
410
+ //they link (a setting of xcodebuild applies to every target)
411
+ const pbxproj = path.join(copy, 'build', 'binding.xcodeproj', 'project.pbxproj');
412
+ await writeFile(pbxproj, (await readFile(pbxproj, 'utf8')).replaceAll('MACH_O_TYPE = mh_bundle;', 'MACH_O_TYPE = mh_dylib;'));
413
+ const output = path.join(work, 'products');
414
+ const framework = path.join(node, 'NodeMobile.xcframework', IOS_SDKS[sdk].slice);
415
+ const settings = [
416
+ `ARCHS=${IOS_SDKS[sdk].archs}`, 'ONLY_ACTIVE_ARCH=NO', `SDKROOT=${sdk}`, `SUPPORTED_PLATFORMS=${sdk}`,
417
+ `IPHONEOS_DEPLOYMENT_TARGET=${IOS_DEPLOYMENT_TARGET}`, 'MACOSX_DEPLOYMENT_TARGET=',
418
+ //Linking the Node.js of the application, not left to look it up (-undefined dynamic_lookup)
419
+ `OTHER_LDFLAGS=-F${framework} -framework NodeMobile`,
420
+ 'CODE_SIGNING_ALLOWED=NO', `CONFIGURATION_BUILD_DIR=${output}`,
421
+ //One root for the projects of the package: gyp makes a project of each of its gyp files, whose
422
+ //SHARED_INTERMEDIATE_DIR is in the root of that project, so the files an action of one writes there (the SQLite
423
+ //that better-sqlite3 copies) are looked for in the root of another, and the headers of the SDK are found instead
424
+ `SYMROOT=${path.join(work, 'symroot')}`
425
+ ];
426
+ const built = await runCommand('xcodebuild', ['-project', 'binding.xcodeproj', '-configuration', 'Release', '-alltargets', '-sdk', sdk, ...settings, 'build'], path.join(copy, 'build'), env);
427
+ if (built.code !== 0)
428
+ throw new Error(`The addons of ${item.name} cannot be built for iOS (${sdk}): ${built.output.trim().split('\n').filter((line) => /error/i.test(line)).slice(-8).join('\n') || built.output.trim().split('\n').slice(-8).join('\n')}`);
429
+ const addons = (await nodeFiles(output)).filter((file) => path.dirname(file) === output);
430
+ if (!addons.length)
431
+ throw new Error(`The build of ${item.name} for iOS (${sdk}) made no addon`);
432
+ return addons;
433
+ }
434
+ /**
435
+ * Make an addon built for iOS a framework: its library named after it (its install name in the frameworks of the
436
+ * application), its Info.plist, signed ad hoc (the build of the application signs it with its identity)
437
+ * @param addon
438
+ * @param directory where the framework is made
439
+ * @param name
440
+ * @param version the version of its package
441
+ * @param sdk
442
+ */
443
+ async function makeFramework(addon, directory, name, version, sdk) {
444
+ const framework = path.join(directory, `${name}.framework`);
445
+ await rm(framework, { recursive: true, force: true });
446
+ await mkdir(framework, { recursive: true });
447
+ await cp(addon, path.join(framework, name));
448
+ await writeFile(path.join(framework, 'Info.plist'), frameworkInfo(name, version, sdk));
449
+ for (const [command, args] of [['install_name_tool', ['-id', `@rpath/${name}.framework/${name}`, path.join(framework, name)]], ['codesign', ['--force', '--sign', '-', framework]]]) {
450
+ const result = await runCommand(command, args, directory);
451
+ if (result.code !== 0)
452
+ throw new Error(`The framework ${name} cannot be made (${command}): ${result.output.trim()}`);
453
+ }
454
+ }
455
+ /**
456
+ * Build the native addons of a mobile application of iOS: each package of addons built for the SDKs from its sources
457
+ * (node-gyp, xcodebuild, linking the framework of the Node.js of the application), each addon made a framework, which
458
+ * the project of iOS embeds in the application; its files in the native tree replaced by placeholders (no library of
459
+ * the App Store outside of its frameworks), which the entry of the project loads from the frameworks of the application
460
+ * (MOBILE_ADDONS_FILE: the project is in the application, next to its frameworks). The frameworks of the SDKs made
461
+ * before are removed
462
+ * @param project
463
+ * @param stage the Node.js project
464
+ * @param directory its native tree
465
+ * @param files the files of the tree
466
+ * @param frameworks the directory of the frameworks, a directory per SDK (the NodeAddons of the project of iOS)
467
+ * @param node the export of the libnode of iOS
468
+ * @param options
469
+ * @internal
470
+ */
471
+ export async function prepareIosAddons(project, stage, directory, files, frameworks, node, options = {}) {
472
+ if (process.platform !== 'darwin')
473
+ throw new Error('The native addons are built for iOS on macOS (Xcode)');
474
+ const sdks = options.sdks?.length ? options.sdks : Object.keys(IOS_SDKS);
475
+ const packages = addonPackages(files);
476
+ const result = { built: [], prebuilt: [], frameworks: [], libraries: [] };
477
+ const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-mobile-addons-'));
478
+ try {
479
+ for (const item of packages) {
480
+ if (!existsSync(path.join(item.source, 'binding.gyp')))
481
+ throw new Error(`${item.name} has no sources to build its addons for iOS (binding.gyp)`);
482
+ }
483
+ //The version of Xcode, which builds them
484
+ const xcode = await runCommand('xcodebuild', ['-version'], work);
485
+ if (xcode.code !== 0)
486
+ throw new Error(`Xcode builds the native addons for iOS: ${xcode.output.trim()}`);
487
+ const mapping = {};
488
+ for (const item of packages) {
489
+ const version = JSON.parse(await readFile(path.join(item.source, 'package.json'), 'utf8')).version ?? '1.0.0';
490
+ const names = new Set();
491
+ for (const sdk of sdks) {
492
+ const addons = await cachedBuild(item, sdk, xcode.output.trim(), node, async () => {
493
+ options.progress?.(`The addons of ${item.name} built for iOS (${sdk})`);
494
+ const build = path.join(work, `${result.built.length}`);
495
+ await mkdir(build, { recursive: true });
496
+ return buildIos(project, item, sdk, node, build);
497
+ });
498
+ for (const addon of addons) {
499
+ const name = frameworkName(item.name, addon);
500
+ await makeFramework(addon, path.join(frameworks, sdk), name, version, sdk);
501
+ result.frameworks.push(`${sdk}/${name}.framework`);
502
+ names.add(path.basename(addon));
503
+ }
504
+ result.built.push(`${item.name} (${sdk})`);
505
+ }
506
+ //The files of the addons in the tree: placeholders of the frameworks, the others (of other systems) removed
507
+ const placeholders = item.files.filter((file) => names.has(path.basename(file)));
508
+ if (item.target)
509
+ for (const name of names)
510
+ placeholders.push(`${item.target}/build/Release/${name}`);
511
+ for (const file of item.files)
512
+ if (!names.has(path.basename(file)))
513
+ await rm(path.join(directory, ...file.split('/')), { force: true });
514
+ for (const file of new Set(placeholders)) {
515
+ const target = path.join(directory, ...file.split('/'));
516
+ await mkdir(path.dirname(target), { recursive: true });
517
+ await writeFile(target, '');
518
+ const name = frameworkName(item.name, file);
519
+ mapping[path.relative(directory, target).split(path.sep).join('/')] = `${name}.framework/${name}`;
520
+ }
521
+ }
522
+ await writeFile(path.join(stage, MOBILE_ADDONS_FILE), `${JSON.stringify({ directory: '../Frameworks', files: mapping }, null, 2)}\n`);
523
+ }
524
+ finally {
525
+ await rm(work, { recursive: true, force: true });
526
+ }
527
+ return result;
528
+ }
529
+ /**
530
+ * The native SDK of HarmonyOS (its LLVM and its sysroot): OHOS_NDK_HOME, the native SDK of DEVECO_SDK_HOME, else the
531
+ * one of DevEco Studio (macOS) or of its command line tools (~/command-line-tools)
532
+ * @returns the directory of its compilers and its version
533
+ */
534
+ function harmonyNdk() {
535
+ const candidates = [
536
+ process.env.OHOS_NDK_HOME,
537
+ process.env.DEVECO_SDK_HOME ? path.join(process.env.DEVECO_SDK_HOME, 'default', 'openharmony', 'native') : undefined,
538
+ '/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native',
539
+ path.join(os.homedir(), 'command-line-tools', 'sdk', 'default', 'openharmony', 'native')
540
+ ];
541
+ const native = candidates.find((directory) => !!directory && existsSync(path.join(directory, 'llvm', 'bin', 'aarch64-unknown-linux-ohos-clang')));
542
+ if (!native)
543
+ throw new Error('The native SDK of HarmonyOS builds the native addons for HarmonyOS: install DevEco Studio (or its command line tools), or give the directory of the native SDK (OHOS_NDK_HOME)');
544
+ let version = 'unknown';
545
+ try {
546
+ version = JSON.parse(readFileSync(path.join(native, 'oh-uni-package.json'), 'utf8')).version ?? version;
547
+ }
548
+ catch {
549
+ //A native SDK without its manifest: its builds are cached as unknown
550
+ }
551
+ return { bin: path.join(native, 'llvm', 'bin'), version: version };
552
+ }
553
+ /**
554
+ * The name of the library of an addon in the HAP: libaddon-<package>-<file>.so, in letters, digits, hyphens and
555
+ * underscores (the libraries made before are the libaddon-*.so of the directory)
556
+ * @param name the name of its package
557
+ * @param file its file
558
+ * @internal
559
+ */
560
+ export function harmonyLibraryName(name, file) {
561
+ return `libaddon-${`${name}-${path.basename(file, '.node')}`.replace(/^@/, '').replace(/[^A-Za-z0-9_]+/g, '-').replace(/^-+|-+$/g, '')}.so`;
562
+ }
563
+ /**
564
+ * Build the addons of a package for HarmonyOS with node-gyp (a copy of the package), cross-compiled by the LLVM of the
565
+ * native SDK of HarmonyOS against the headers and the library of libnode (the addons link libnode.so, which binds them
566
+ * to the Node-API of the Node.js of the application)
567
+ * @param project
568
+ * @param item
569
+ * @param node the export of the libnode of HarmonyOS
570
+ * @param bin the compilers of the native SDK
571
+ * @param work a directory of the build
572
+ * @returns the addons built (in build/Release of the copy)
573
+ */
574
+ async function buildHarmony(project, item, node, bin, work) {
575
+ const copy = path.join(work, path.basename(item.source));
576
+ await cp(item.source, copy, { recursive: true, filter: (file) => path.relative(item.source, file) !== 'build' });
577
+ const compiler = path.join(bin, 'aarch64-unknown-linux-ohos-clang');
578
+ const env = {
579
+ ...process.env,
580
+ NODE_PATH: buildModules(item.source),
581
+ GYP_CROSSCOMPILE: '1',
582
+ CC: compiler,
583
+ CXX: `${compiler}++`,
584
+ LINK: `${compiler}++`,
585
+ AR: path.join(bin, 'llvm-ar'),
586
+ CC_target: compiler,
587
+ CXX_target: `${compiler}++`,
588
+ LINK_target: `${compiler}++`,
589
+ AR_target: path.join(bin, 'llvm-ar'),
590
+ CC_host: process.env.CC_host ?? 'cc',
591
+ CXX_host: process.env.CXX_host ?? 'c++',
592
+ LINK_host: process.env.LINK_host ?? 'c++',
593
+ AR_host: process.env.AR_host ?? 'ar',
594
+ LDFLAGS: `-L${path.join(node, 'libs', 'arm64-v8a')} -lnode`
595
+ };
596
+ const args = [nodeGyp(project), 'rebuild', '--arch=arm64', `--nodedir=${await addonNodeHeaders(node, work)}`, '--jobs=max', '--', '-f', 'make-linux', '-DOS=openharmony'];
597
+ const built = await runCommand(process.execPath, args, copy, env);
598
+ if (built.code !== 0)
599
+ throw new Error(`The addons of ${item.name} cannot be built for HarmonyOS: ${built.output.trim().split('\n').slice(-8).join('\n')}`);
600
+ const addons = await nodeFiles(path.join(copy, 'build', 'Release'));
601
+ if (!addons.length)
602
+ throw new Error(`The build of ${item.name} for HarmonyOS made no addon (build/Release)`);
603
+ for (const addon of addons) {
604
+ if (!binaryArchitectures(await readFile(addon)).includes('arm64'))
605
+ throw new Error(`${path.basename(addon)} of ${item.name} is not built for HarmonyOS (arm64)`);
606
+ }
607
+ return addons;
608
+ }
609
+ /**
610
+ * Build the native addons of a mobile application of HarmonyOS: each package of addons built from its sources (node-gyp,
611
+ * the LLVM of the native SDK, linking libnode.so), each addon made a library of the HAP (libaddon-<package>-<file>.so in
612
+ * its libs/arm64-v8a: the addons cannot be loaded from the resources of the HAP); its files in the native tree replaced
613
+ * by placeholders, which the entry of the project loads from the libraries of the HAP (MOBILE_ADDONS_FILE)
614
+ * @param project
615
+ * @param stage the Node.js project
616
+ * @param directory its native tree
617
+ * @param files the files of the tree
618
+ * @param libraries the directory of the libraries of the HAP (the libs/arm64-v8a of its module)
619
+ * @param node the export of the libnode of HarmonyOS
620
+ * @param options
621
+ * @internal
622
+ */
623
+ export async function prepareHarmonyAddons(project, stage, directory, files, libraries, node, options = {}) {
624
+ if (process.platform === 'win32')
625
+ throw new Error('The native addons are built for HarmonyOS on macOS and on Linux (node-gyp, make and the native SDK)');
626
+ const packages = addonPackages(files);
627
+ const result = { built: [], prebuilt: [], frameworks: [], libraries: [] };
628
+ for (const item of packages) {
629
+ if (!existsSync(path.join(item.source, 'binding.gyp')))
630
+ throw new Error(`${item.name} has no sources to build its addons for HarmonyOS (binding.gyp)`);
631
+ }
632
+ const ndk = harmonyNdk();
633
+ const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-mobile-addons-'));
634
+ try {
635
+ await mkdir(libraries, { recursive: true });
636
+ const mapping = {};
637
+ for (const item of packages) {
638
+ const addons = await cachedBuild(item, 'harmony', `ohos ${ndk.version}`, node, async () => {
639
+ options.progress?.(`The addons of ${item.name} built for HarmonyOS`);
640
+ const build = path.join(work, `${result.built.length}`);
641
+ await mkdir(build, { recursive: true });
642
+ return buildHarmony(project, item, node, ndk.bin, build);
643
+ });
644
+ const names = new Set();
645
+ for (const addon of addons) {
646
+ const library = harmonyLibraryName(item.name, addon);
647
+ if (result.libraries.includes(library))
648
+ throw new Error(`Two addons are made the library ${library} of HarmonyOS: rename one of their files`);
649
+ await cp(addon, path.join(libraries, library));
650
+ result.libraries.push(library);
651
+ names.add(path.basename(addon));
652
+ }
653
+ result.built.push(`${item.name} (harmony)`);
654
+ //The files of the addons in the tree: placeholders of the libraries, the others (of other systems) removed
655
+ const placeholders = item.files.filter((file) => names.has(path.basename(file)));
656
+ if (item.target)
657
+ for (const name of names)
658
+ placeholders.push(`${item.target}/build/Release/${name}`);
659
+ for (const file of item.files)
660
+ if (!names.has(path.basename(file)))
661
+ await rm(path.join(directory, ...file.split('/')), { force: true });
662
+ for (const file of new Set(placeholders)) {
663
+ const target = path.join(directory, ...file.split('/'));
664
+ await mkdir(path.dirname(target), { recursive: true });
665
+ await writeFile(target, '');
666
+ mapping[path.relative(directory, target).split(path.sep).join('/')] = harmonyLibraryName(item.name, file);
667
+ }
668
+ }
669
+ await writeFile(path.join(stage, MOBILE_ADDONS_FILE), `${JSON.stringify({ directory: HARMONY_LIBRARIES, files: mapping }, null, 2)}\n`);
670
+ }
671
+ finally {
672
+ await rm(work, { recursive: true, force: true });
673
+ }
674
+ return result;
675
+ }