crossbind 2.0.0-beta.63 → 2.0.0-beta.65

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 (77) hide show
  1. package/AGENTS.md +2 -1
  2. package/package.json +2 -2
  3. package/src/actions/androidDevice.js +77 -0
  4. package/src/actions/androidGradle.js +121 -0
  5. package/src/actions/buildApp.js +434 -0
  6. package/src/actions/buildBinTools.js +0 -0
  7. package/src/actions/buildDependencies.js +4 -3
  8. package/src/actions/buildExternal.js +3 -3
  9. package/src/actions/buildJs.js +2 -9
  10. package/src/actions/buildLib.js +1 -1
  11. package/src/actions/buildNode.js +19 -16
  12. package/src/actions/buildWasiCommand.js +10 -5
  13. package/src/actions/buildWasm.js +24 -3
  14. package/src/actions/createImportedBridges.js +7 -0
  15. package/src/actions/createInterface.js +4 -5
  16. package/src/actions/createLib.js +17 -2
  17. package/src/actions/getCmakeParameters.js +2 -1
  18. package/src/actions/getData.js +2 -1
  19. package/src/actions/getDependLibs.js +8 -2
  20. package/src/actions/getEnv.js +7 -0
  21. package/src/actions/getLinkInputs.js +10 -7
  22. package/src/actions/gradleProject.js +110 -0
  23. package/src/actions/iosSimulator.js +63 -0
  24. package/src/actions/licenses.js +160 -14
  25. package/src/actions/nativeBridges.js +35 -0
  26. package/src/actions/run.js +3 -3
  27. package/src/actions/runnerCommands.js +16 -4
  28. package/src/actions/target.js +2 -1
  29. package/src/actions/writeRuntimeEntry.js +18 -7
  30. package/src/actions/xcodeProject.js +152 -0
  31. package/src/assets/cpp-runtime/node.cpp +18 -1
  32. package/src/assets/js-runtime/adapters/worker-comlink.js +58 -0
  33. package/src/assets/js-runtime/core.js +5 -4
  34. package/src/assets/toolchain-digests.json +25 -19
  35. package/src/assets/wasi-runtime/http/README.md +17 -0
  36. package/src/assets/wasi-runtime/http/bindings/crossbind_http.c +2537 -0
  37. package/src/assets/wasi-runtime/http/bindings/crossbind_http.h +826 -0
  38. package/src/assets/wasi-runtime/http/bindings/crossbind_http_component_type.o +0 -0
  39. package/src/assets/wasi-runtime/http/handler.cpp +298 -0
  40. package/src/assets/wasi-runtime/http/include/crossbind/http.h +31 -0
  41. package/src/assets/wasi-runtime/http/wit/deps/LICENSE.md +8 -0
  42. package/src/assets/wasi-runtime/http/wit/deps/cli.wit +28 -0
  43. package/src/assets/wasi-runtime/http/wit/deps/clocks.wit +43 -0
  44. package/src/assets/wasi-runtime/http/wit/deps/http.wit +509 -0
  45. package/src/assets/wasi-runtime/http/wit/deps/random.wit +18 -0
  46. package/src/assets/wasi-runtime/http/wit/world.wit +6 -0
  47. package/src/bin.js +102 -209
  48. package/src/cli.js +14 -0
  49. package/src/index.js +6 -0
  50. package/src/integration/getCrossbindScript.js +19 -4
  51. package/src/program.js +169 -0
  52. package/src/state/index.js +8 -3
  53. package/src/state/loadConfig.js +6 -11
  54. package/src/utils/appRustCrates.js +7 -1
  55. package/src/utils/bridgeExtras.js +74 -6
  56. package/src/utils/cargoLock.js +19 -0
  57. package/src/utils/cppDts.js +2 -0
  58. package/src/utils/downloadAndExtractFile.js +42 -14
  59. package/src/utils/embindObjectLifetime.js +35 -0
  60. package/src/utils/embindRsFingerprint.js +10 -3
  61. package/src/utils/hermes.js +40 -0
  62. package/src/utils/packageLicense.js +4 -2
  63. package/src/utils/plist.js +42 -0
  64. package/src/utils/pullDockerImage.js +9 -3
  65. package/src/utils/replaceBasePathForDocker.js +8 -0
  66. package/src/utils/resetCMakeCacheForSource.js +26 -0
  67. package/src/utils/resolveAppHost.js +21 -0
  68. package/src/utils/rustBridgeGen.js +13 -4
  69. package/src/utils/rustMt.js +5 -0
  70. package/src/utils/rustSysroot.js +7 -5
  71. package/src/utils/selectRunner.js +1 -1
  72. package/src/utils/swigInterface.js +5 -4
  73. package/src/utils/systemConfig.js +13 -0
  74. package/src/utils/systemKeys.js +4 -0
  75. package/src/utils/targets.js +30 -3
  76. package/src/utils/wasiHttp.js +50 -0
  77. package/vitest.config.js +5 -0
@@ -7,14 +7,18 @@ import logger from '../utils/logger.js';
7
7
  import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
8
8
  import { buildLinkLibArgs } from '../utils/linkLayout.js';
9
9
  import { wasiCxxFlags, WASI_LINK_LIBS } from '../utils/wasiToolchain.js';
10
+ import { stageWasiHttpLink, wasiHttpFiles } from '../utils/wasiHttp.js';
10
11
 
11
12
  // Links a platform:'wasi' target into one command module: main() is the entry, deps are DCE'd, data lands as a real dir for --dir preopens.
13
+ // An http target links into a wasi:http/service component instead, whose entry is crossbind's handler export.
12
14
  export default async function buildWasiCommand(target, options = {}) {
13
15
  const isProd = target.buildType === 'release';
14
16
  const buildType = isProd ? 'Release' : 'Debug';
17
+ const isHttp = target.runtimeEnv === 'http';
18
+ const step = isHttp ? 'wasi http' : 'wasi command';
15
19
 
16
20
  if (state.config.export?.type === 'cargo') {
17
- logger.info(`[${target.path}] wasi command skipped (cargo packages have no cargo wasi triple)`);
21
+ logger.info(`[${target.path}] ${step} skipped (cargo packages have no cargo wasi triple)`);
18
22
  return false;
19
23
  }
20
24
 
@@ -52,17 +56,17 @@ export default async function buildWasiCommand(target, options = {}) {
52
56
  return { lib, size: stat ? stat.size : null, mtimeMs: stat ? stat.mtimeMs : null };
53
57
  }),
54
58
  data: getData('data', target),
55
- runtime: getFilesFingerprint([stubs]),
59
+ runtime: getFilesFingerprint([stubs, ...(isHttp ? wasiHttpFiles(state.config.paths.cli) : [])]),
56
60
  }));
57
61
  const linkChanged = !fs.existsSync(linkFingerprintFile)
58
62
  || fs.readFileSync(linkFingerprintFile, { encoding: 'utf8' }) !== linkFingerprint;
59
63
 
60
64
  if (!options.force && !linkChanged && fs.existsSync(`${state.config.paths.build}/${target.wasmName}`)) {
61
- logger.cachedStep(target, 'wasi command');
65
+ logger.cachedStep(target, step);
62
66
  return false;
63
67
  }
64
68
 
65
- logger.startStep(target, 'wasi command');
69
+ logger.startStep(target, step);
66
70
  const t0 = performance.now();
67
71
  // stubs.c lives outside the docker mount (paths.cli); stage it next to the artifact.
68
72
  const stagedStubs = `${state.config.paths.build}/crossbind-wasi-stubs.c`;
@@ -71,6 +75,7 @@ export default async function buildWasiCommand(target, options = {}) {
71
75
  run(null, [
72
76
  'wasi-clang++',
73
77
  stagedStubs,
78
+ ...(isHttp ? stageWasiHttpLink(state.config.paths.cli, `${state.config.paths.build}/crossbind-wasi-http`) : []),
74
79
  ...wasiCxxFlags(),
75
80
  ...(isProd ? ['-O3'] : ['-O0']),
76
81
  ...wasiFlags,
@@ -79,7 +84,7 @@ export default async function buildWasiCommand(target, options = {}) {
79
84
  '-o', `${state.config.paths.build}/${target.wasmName}`,
80
85
  ], null, target);
81
86
  const ms = Math.round(performance.now() - t0);
82
- logger.doneStep(target, 'wasi command', ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`);
87
+ logger.doneStep(target, step, ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`);
83
88
 
84
89
  // Dep data lands as a real folder next to the artifact for --dir preopens.
85
90
  Object.entries(getData('data', target)).forEach(([key, value]) => {
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import run from './run.js';
5
5
  import getLinkInputs from './getLinkInputs.js';
6
6
  import getData from './getData.js';
7
+ import getEnv from './getEnv.js';
7
8
  import buildJs from './buildJs.js';
8
9
  import triggerExtensions from './extensions.js';
9
10
  import state from '../state/index.js';
@@ -14,9 +15,10 @@ import { buildLinkLibArgs } from '../utils/linkLayout.js';
14
15
  import { guardBigIntArguments, guardEmbindArguments } from '../utils/embindArgumentGuards.js';
15
16
  import { separateCallArguments } from '../utils/embindCallArguments.js';
16
17
  import { queueJspiCalls } from '../utils/embindJspiQueue.js';
18
+ import { releaseOwnedHandles } from '../utils/embindObjectLifetime.js';
17
19
  import { spawnPthreadsFromMainScript } from '../utils/pthreadMainScript.js';
18
20
 
19
- const GLUE_REWRITES = ['../utils/embindArgumentGuards.js', '../utils/embindCallArguments.js', '../utils/embindJspiQueue.js', '../utils/pthreadMainScript.js']
21
+ const GLUE_REWRITES = ['../utils/embindArgumentGuards.js', '../utils/embindCallArguments.js', '../utils/embindJspiQueue.js', '../utils/embindObjectLifetime.js', '../utils/pthreadMainScript.js']
20
22
  .map((file) => fileURLToPath(new URL(file, import.meta.url)));
21
23
 
22
24
  // embind's bigint converter turns any Number into a BigInt, so 2^53+1 silently becomes 2^53.
@@ -64,6 +66,17 @@ function queueJspiEmbindCalls(target) {
64
66
  }
65
67
  }
66
68
 
69
+ // Objects JavaScript owns outside a smart pointer were freed only by .delete() (utils/embindObjectLifetime.js);
70
+ // each runtimeEnv links its own glue, so each one calls this after its link.
71
+ function releaseOwnedObjects(target) {
72
+ const gluePath = `${state.config.paths.build}/${target.rawJsName}`;
73
+ const { text, missed } = releaseOwnedHandles(fs.readFileSync(gluePath, 'utf8'));
74
+ fs.writeFileSync(gluePath, text);
75
+ if (missed) {
76
+ logger.error('embind object lifetime rewrite missed (emscripten glue format changed?): returned values and constructed vectors are freed only by .delete()');
77
+ }
78
+ }
79
+
67
80
  // A direct-mode mt init in a browser spawns its pthreads from "/undefined" (utils/pthreadMainScript.js).
68
81
  function pointPthreadSpawn(target) {
69
82
  const gluePath = `${state.config.paths.build}/${target.rawJsName}`;
@@ -173,6 +186,8 @@ export default async function buildWasm(target, options = {}) {
173
186
  return { lib, size: stat ? stat.size : null, mtimeMs: stat ? stat.mtimeMs : null };
174
187
  }),
175
188
  data: getData('data', target),
189
+ // The JS the link step writes carries the env.
190
+ env: getEnv(target),
176
191
  runtime: getFilesFingerprint(runtimeAssets),
177
192
  rustSources: getFilesFingerprint(rustSources),
178
193
  }));
@@ -233,6 +248,7 @@ export default async function buildWasm(target, options = {}) {
233
248
  guardArgumentConversions(target);
234
249
  separateEmbindCalls(target);
235
250
  queueJspiEmbindCalls(target);
251
+ releaseOwnedObjects(target);
236
252
  await buildJs(target);
237
253
  // fs.rmSync(`${state.config.paths.build}/${state.config.general.name}.js`);
238
254
  // fs.copyFileSync(`${state.config.paths.build}/${state.config.general.name}.browser.js`, `${state.config.paths.build}/${state.config.general.name}.js`);
@@ -257,12 +273,13 @@ export default async function buildWasm(target, options = {}) {
257
273
  // it any linked Rust package archive fails with undefined crossbind_* symbols.
258
274
  ...linkLibs, ...rustSources,
259
275
  '-s', 'WASM=1', '-s', 'MODULARIZE=1', '-s', 'DYNAMIC_EXECUTION=0',
260
- '-s', 'RESERVED_FUNCTION_POINTERS=200', // '-s', 'FORCE_FILESYSTEM=1',
276
+ '-s', 'RESERVED_FUNCTION_POINTERS=200',
261
277
  '-s', 'ALLOW_MEMORY_GROWTH=1',
262
278
  // See the GROWABLE_ARRAYBUFFERS note in the browser block (Firefox/WebKit TextDecoder).
263
279
  '-s', 'GROWABLE_ARRAYBUFFERS=0',
280
+ '-s', 'FORCE_FILESYSTEM=1',
264
281
  '-s', 'ENVIRONMENT=web',
265
- '-s', 'EXPORTED_RUNTIME_METHODS=["ENV", "getExceptionMessage"]',
282
+ '-s', 'EXPORTED_RUNTIME_METHODS=["FS", "ENV", "getExceptionMessage"]',
266
283
  '-fwasm-exceptions',
267
284
  '-sEXPORT_EXCEPTION_HANDLING_HELPERS',
268
285
  '-o', `${state.config.paths.build}/${target.rawJsName}`,
@@ -275,6 +292,7 @@ export default async function buildWasm(target, options = {}) {
275
292
  guardArgumentConversions(target);
276
293
  separateEmbindCalls(target);
277
294
  queueJspiEmbindCalls(target);
295
+ releaseOwnedObjects(target);
278
296
  await buildJs(target);
279
297
  logger.doneStep(target, 'js');
280
298
  }
@@ -292,6 +310,8 @@ export default async function buildWasm(target, options = {}) {
292
310
  // '-s', 'FETCH', '-sJSPI', '-pthread', '-sPTHREAD_POOL_SIZE=5',
293
311
  '-s', 'FORCE_FILESYSTEM=1',
294
312
  ...linkLibs, ...rustSources, `${state.config.paths.cli}/assets/cpp-runtime/node.cpp`,
313
+ // node.cpp answers fsync and fdatasync for host files.
314
+ '-Wl,--wrap=__wasi_fd_sync', '-Wl,--wrap=__syscall_fdatasync',
295
315
  '-s', 'WASM=1', '-s', 'MODULARIZE=1', '-s', 'DYNAMIC_EXECUTION=0',
296
316
  '-s', 'RESERVED_FUNCTION_POINTERS=200', // '-s', 'DISABLE_EXCEPTION_CATCHING=0', '-s', 'FORCE_FILESYSTEM=1',
297
317
  '-s', 'ALLOW_MEMORY_GROWTH=1',
@@ -311,6 +331,7 @@ export default async function buildWasm(target, options = {}) {
311
331
  guardArgumentConversions(target);
312
332
  separateEmbindCalls(target);
313
333
  queueJspiEmbindCalls(target);
334
+ releaseOwnedObjects(target);
314
335
  await buildJs(target);
315
336
  if (emccFlags.includes('FETCH')) {
316
337
  fs.appendFileSync(`${state.config.paths.build}/${target.jsName}`, 'var XMLHttpRequest = require(\'xhr2\');\n');
@@ -34,6 +34,13 @@ function resolveImport(specifier, importer, targets) {
34
34
  return null;
35
35
  }
36
36
 
37
+ // Whether a binary binds what the app's own sources import. A Node binary does: its module hooks resolve those
38
+ // imports. A browser binary only with --bind-imports: without a bundler a page cannot resolve a header import
39
+ // unless it maps the specifiers itself, as the playground's sandbox does. Nothing on an edge runtime resolves one.
40
+ export function bindsAppImports(target, { bindImports = false } = {}) {
41
+ return target.runtimeEnv === 'node' || (bindImports && target.runtimeEnv === 'browser');
42
+ }
43
+
37
44
  // The native files the app's own sources import, by real path, each under the first target whose packages ship it
38
45
  // and with the bare specifiers that name it, which Node cannot resolve on its own.
39
46
  export function findAppNativeImports(targets) {
@@ -21,16 +21,15 @@ import {
21
21
  } from '../utils/swigInterface.js';
22
22
  import writeIfChanged from '../utils/writeIfChanged.js';
23
23
  import { withDirLockSync } from '../utils/dirLock.js';
24
- import { imageRoleFor } from '../utils/pullDockerImage.js';
25
24
  import { conanImportOfHeader } from '../utils/conanImport.js';
26
25
  import run, { cxxPreprocessorFor } from './run.js';
27
26
  import isSourceCmakePackage from '../utils/isSourceCmakePackage.js';
28
27
 
29
28
  // Part of every interface hash, so interfaces cached by an older generator are rebuilt.
30
- const INTERFACE_FORMAT = 'swig-macros-3';
29
+ const INTERFACE_FORMAT = 'swig-macros-4';
31
30
  // Part of every bridge hash: each bridge carries the SWIG fork's runtime and all bridges of a module must share it, so
32
31
  // bridges from a fork before field bindings and constants are rebuilt.
33
- const BRIDGE_FORMAT = 'swig-extras-1';
32
+ const BRIDGE_FORMAT = 'swig-extras-2';
34
33
  const predefinedMacros = new Map();
35
34
  const typeDefinitions = new Map();
36
35
 
@@ -188,7 +187,7 @@ function createInterfaceFile(headerOrModuleFilePath, target, sourceDir, { consta
188
187
  const fileName = interfaceName(headerOrModuleFilePath, filePathWithoutExt.split('/').at(-1));
189
188
 
190
189
  const macros = collectSwigMacros(headerOrModuleFilePath, interfaceIncludes(headerPath, prelude), fileName, target, sourceDir, constants);
191
- const extras = interfaceExtras(named, macros.table);
190
+ const extras = interfaceExtras(named, macros.table, fs.readFileSync(headerOrModuleFilePath, 'utf8'));
192
191
  const content = buildInterfaceContent({
193
192
  moduleName: fileName.toUpperCase(),
194
193
  headerPath,
@@ -355,7 +354,7 @@ const RELEASE_DEFINE = '-DNDEBUG';
355
354
  // One bridge serves every desktop target, so SWIG and the macro table it reads come from the web image's em++,
356
355
  // which defines no operating system; an android bridge is read with the NDK it compiles with. The headers
357
356
  // still come from the target's own packages.
358
- const toolTargetFor = (target) => (imageRoleFor(target) === 'android' ? target : { ...target, platform: 'wasm' });
357
+ const toolTargetFor = (target) => (target?.platform === 'android' ? target : { ...target, platform: 'wasm' });
359
358
 
360
359
  function dumpMacros(outputFile, includes, includePath, target, defines = []) {
361
360
  const tool = toolTargetFor(target);
@@ -10,11 +10,13 @@ import triggerExtensions from './extensions.js';
10
10
  import state from '../state/index.js';
11
11
  import logger from '../utils/logger.js';
12
12
  import { WASI_EMULATION_LIBS, WASI_LINK_LIBS, wasiCFlags, wasiCxxFlags } from '../utils/wasiToolchain.js';
13
+ import { stageWasiHttpInclude, wasiHttpHeader } from '../utils/wasiHttp.js';
13
14
  import { getFilesFingerprint, getContentHash } from '../utils/hash.js';
14
15
  import { withDependencyBridges } from '../utils/dependencyBridges.js';
15
16
  import { targetArchiveFlags } from '../utils/archiveFlags.js';
16
17
  import { conanInputsOf } from '../utils/conanDependencies.js';
17
18
  import { nativeSourceStampChanged, writeNativeSourceStamp } from '../utils/nativeSourceStamp.js';
19
+ import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
18
20
 
19
21
  const cpuCount = Math.max(1, os.cpus().length - 1);
20
22
  const sharedPlatforms = ['android'];
@@ -56,7 +58,15 @@ export default function createLib(target, fileType, options = {}) {
56
58
  const conanInputs = conanInputsOf(state.config);
57
59
  const compileInputs = compileOptions.length ? { emccFlags: configEmccFlags, compileOptions } : configEmccFlags;
58
60
  const flagsInputs = sourceReplacements.length ? { compileInputs, sourceReplacements } : compileInputs;
59
- const flagsFingerprint = getContentHash(JSON.stringify(conanInputs ? [flagsInputs, conanInputs] : flagsInputs));
61
+ const keyInputs = conanInputs ? [flagsInputs, conanInputs] : flagsInputs;
62
+ // An app's wasi source compiles against <crossbind/http.h>, so a new one misses this cache too.
63
+ const httpHeader = target.platform === 'wasi' && !state.config.build.withBuildConfig
64
+ ? getFilesFingerprint([wasiHttpHeader(state.config.paths.cli)])
65
+ : null;
66
+ // An Android app links the project's archives from the build where React Native's shared library comes from.
67
+ const appArchives = target.platform === 'android' && target.runtimeEnv === 'app' ? 'archives' : null;
68
+ const extraKeys = [httpHeader, appArchives].filter(Boolean);
69
+ const flagsFingerprint = getContentHash(JSON.stringify(extraKeys.length ? [keyInputs, ...extraKeys] : keyInputs));
60
70
  const flagsChanged = !fs.existsSync(flagsFingerprintFile)
61
71
  || fs.readFileSync(flagsFingerprintFile, { encoding: 'utf8' }) !== flagsFingerprint;
62
72
 
@@ -142,7 +152,11 @@ export default function createLib(target, fileType, options = {}) {
142
152
  buildEnv.params.push('-e', e);
143
153
  });
144
154
  } else {
145
- buildParams = getCmakeParameters(target, { ...options, nativeGlob });
155
+ // The source of an http build includes <crossbind/http.h>.
156
+ const headerDirs = target.platform === 'wasi'
157
+ ? [...(options.headerDirs ?? []), stageWasiHttpInclude(state.config.paths.cli, `${buildPath}/crossbind-wasi-http`)]
158
+ : options.headerDirs;
159
+ buildParams = getCmakeParameters(target, { ...options, nativeGlob, headerDirs });
146
160
 
147
161
  triggerExtensions('createLib', 'setFlagWithoutBuildConfig', [buildEnv]);
148
162
 
@@ -229,6 +243,7 @@ export default function createLib(target, fileType, options = {}) {
229
243
  stageWasiStubs();
230
244
  buildParams.push(`-DCMAKE_EXE_LINKER_FLAGS=${[...wasiCxxFlags(), wasiStubObj, ...WASI_LINK_LIBS].join(' ')}`);
231
245
  }
246
+ resetCMakeCacheForSource(buildPath, cmakeDir, target);
232
247
  run(null, [
233
248
  target.platform === 'ios' && state.config.build?.useIOSCMake ? 'ios-cmake' : 'cmake', cmakeDir,
234
249
  `-DCMAKE_BUILD_TYPE=${buildType}`,
@@ -32,8 +32,9 @@ export default function getCmakeParameters(target, options = {}) {
32
32
  ...(options.headerDirs || []),
33
33
  ];
34
34
 
35
+ // React Native loads a library on Android as a shared one; an app's host links the project's archives into itself.
35
36
  const sharedPlatforms = ['android'];
36
- const buildType = sharedPlatforms.includes(target.platform) ? 'SHARED' : 'STATIC';
37
+ const buildType = sharedPlatforms.includes(target.platform) && target.runtimeEnv !== 'app' ? 'SHARED' : 'STATIC';
37
38
  const otherBuildType = buildType === 'STATIC' ? 'SHARED' : 'STATIC';
38
39
 
39
40
  const cmakeCompileOptions = [...new Set(getData('cmake', target)?.compileOptions || [])];
@@ -30,7 +30,8 @@ function getRecursiveData(obj, config, dependency, field, target) {
30
30
  obj[dKey] = [...(obj[dKey] || []), ...value];
31
31
  } else if (typeof value === 'object') {
32
32
  obj[dKey] = { ...(obj[dKey] || {}), ...value };
33
- } else {
33
+ } else if (!Object.hasOwn(obj, dKey)) {
34
+ // The app comes before its dependencies and a package before its own, so the nearest value stays.
34
35
  obj[dKey] = value;
35
36
  }
36
37
  }
@@ -11,9 +11,15 @@ export default function getDependLibs(target) {
11
11
  const ignoreLibNames = getFilteredTargetSpec(d?.targetSpecs, target).map(s => s.ignoreLibName).flat();
12
12
  d.export.libName.forEach((fileName) => {
13
13
  if (ignoreLibNames?.includes(fileName)) return;
14
- let libPaths = findFiles(`${d.paths.output}/prebuilt/${target.path}/lib/lib${fileName}.a`, { cwd: d.paths.project });
14
+ // On Android a package ships the shared library React Native packages, which an app carries beside
15
+ // its host.
16
+ const extensions = target.platform === 'android' ? ['a', 'so'] : ['a'];
17
+ const libsIn = (targetPath) => extensions
18
+ .map((extension) => findFiles(`${d.paths.output}/prebuilt/${targetPath}/lib/lib${fileName}.${extension}`, { cwd: d.paths.project }))
19
+ .find((found) => found.length > 0) ?? [];
20
+ let libPaths = libsIn(target.path);
15
21
  if (libPaths.length === 0) {
16
- libPaths = findFiles(`${d.paths.output}/prebuilt/${target.releasePath}/lib/lib${fileName}.a`, { cwd: d.paths.project });
22
+ libPaths = libsIn(target.releasePath);
17
23
  }
18
24
  dependLibs.push(...libPaths);
19
25
  });
@@ -0,0 +1,7 @@
1
+ import state from '../state/index.js';
2
+ import getData from './getData.js';
3
+
4
+ export default function getEnv(target) {
5
+ return Object.fromEntries(Object.entries(getData('env', target))
6
+ .map(([key, value]) => [key, typeof value === 'function' ? value(state, target) : value]));
7
+ }
@@ -3,20 +3,23 @@ import getDependLibs from './getDependLibs.js';
3
3
  import state from '../state/index.js';
4
4
  import buildAppRustCrates from '../utils/appRustCrates.js';
5
5
 
6
- // The archives of a final link (wasm, Node-API addon or native executable) and which of them must
6
+ // The archives of a final link (wasm, Node-API addon, native executable or app host) and which of them must
7
7
  // survive whole. keepFlag spells "keep this symbol" for the target's linker; a native executable
8
- // binds nothing, so it links no bridge.
9
- export default function getLinkInputs(target, { keepFlag, withBridge = true }) {
8
+ // binds nothing, so it links no bridge. An app host's bridges compile against another JSI, in an archive of their own.
9
+ export default function getLinkInputs(target, { keepFlag, withBridge = true, bridge = 'Bridge' }) {
10
10
  const buildType = target.buildType === 'release' ? 'Release' : 'Debug';
11
11
  const { build, output, cache } = state.config.paths;
12
12
  const { name } = state.config.general;
13
13
 
14
14
  // buildLib's cache is keyed on paths.output/prebuilt; after a cache clean the
15
15
  // build-dir copy can be gone while the output artifact is still valid — link it then.
16
- const sourceLibCandidates = [
17
- `${build}/Source-${buildType}/${target.path}/lib${name}.a`,
18
- `${output}/prebuilt/${target.path}/lib/lib${name}.a`,
16
+ // An Xcode build (iOS) leaves its archive in the install tree alone.
17
+ const builtLib = (prefix) => [
18
+ `${build}/${prefix}-${buildType}/${target.path}/lib${name}.a`,
19
+ `${build}/${prefix}-${buildType}/prebuilt/${target.path}/lib/lib${name}.a`,
19
20
  ];
21
+ const sourceLibCandidates = [...builtLib('Source'), `${output}/prebuilt/${target.path}/lib/lib${name}.a`];
22
+ const bridgeLibCandidates = builtLib(bridge);
20
23
  // App-local Rust surfaces (imported .rs files) arrive as ONE super staticlib; it is the
21
24
  // single fully-loaded Rust archive of the link (see the libstd rule below).
22
25
  const appRustLibs = buildAppRustCrates(target, cache, state.config.cargoDependencies ?? {});
@@ -24,7 +27,7 @@ export default function getLinkInputs(target, { keepFlag, withBridge = true }) {
24
27
  ...getDependLibs(target),
25
28
  ...appRustLibs,
26
29
  sourceLibCandidates.find((lib) => fs.existsSync(lib)) ?? sourceLibCandidates[0],
27
- ...(withBridge ? [`${build}/Bridge-${buildType}/${target.path}/lib${name}.a`] : []),
30
+ ...(withBridge ? [bridgeLibCandidates.find((lib) => fs.existsSync(lib)) ?? bridgeLibCandidates[0]] : []),
28
31
  ];
29
32
 
30
33
  // By default only the Bridge archive is kept whole (see linkLayout.js) so the linker
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import state from '../state/index.js';
4
+ import { ANDROID_API_LEVEL } from '../utils/androidToolchain.js';
5
+ import { HERMES } from '../utils/hermes.js';
6
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
7
+ import { SIGNING } from './androidGradle.js';
8
+
9
+ // The ABIs an Android app builds for: phones and Apple silicon's emulators, and the emulators of x86_64 machines.
10
+ export const ANDROID_ABIS = ['arm64-v8a', 'x86_64'];
11
+ // The Android release the app is compiled against and declares it was tested on.
12
+ const TARGET_SDK = 36;
13
+ export const ANDROIDX_WEBKIT = '1.17.1';
14
+
15
+ const groovyString = (text) => `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
16
+
17
+ const buildGradle = ({ id, label, version, abis }) => `// crossbind writes this project anew on every build: the module comes prebuilt in src/main/jniLibs/, and the app's
18
+ // files are in src/main/assets/app/.
19
+ plugins {
20
+ id 'com.android.application'
21
+ }
22
+
23
+ android {
24
+ namespace 'dev.crossbind.app'
25
+ compileSdk ${TARGET_SDK}
26
+
27
+ defaultConfig {
28
+ applicationId ${groovyString(id)}
29
+ // The module's libraries are built against this API level.
30
+ minSdk ${ANDROID_API_LEVEL}
31
+ targetSdk ${TARGET_SDK}
32
+ versionCode ${version.code}
33
+ versionName ${groovyString(version.name)}
34
+ manifestPlaceholders = [appName: ${groovyString(label)}]
35
+ ndk {
36
+ abiFilters ${abis.map(groovyString).join(', ')}
37
+ }
38
+ }
39
+
40
+ // A release is signed with the upload key crossbind's environment names, else with the debug key.
41
+ signingConfigs {
42
+ release {
43
+ def keystore = System.getenv('${SIGNING.keystore}')
44
+ if (keystore) {
45
+ storeFile file(keystore)
46
+ storePassword System.getenv('${SIGNING.password}')
47
+ keyAlias System.getenv('${SIGNING.alias}')
48
+ keyPassword System.getenv('${SIGNING.keyPassword}') ?: System.getenv('${SIGNING.password}')
49
+ }
50
+ }
51
+ }
52
+
53
+ buildTypes {
54
+ release {
55
+ signingConfig System.getenv('${SIGNING.keystore}') ? signingConfigs.release : signingConfigs.debug
56
+ }
57
+ }
58
+
59
+ // Release builds skip lint's vital checks, whose tools crossbind's android-app image leaves out: the module's code is
60
+ // crossbind's own.
61
+ lint {
62
+ checkReleaseBuilds false
63
+ }
64
+
65
+ packaging {
66
+ jniLibs {
67
+ // Hermes brings fbjni, and with it an older libc++_shared.so; the module's is the NDK's it was built with.
68
+ pickFirsts += ['**/libc++_shared.so']
69
+ // The libraries come stripped, and stripping them again would take an NDK of the plugin's own.
70
+ keepDebugSymbols += ['**/*.so']
71
+ }
72
+ }
73
+
74
+ // The page keeps all its files, an _app directory or a .well-known one among them, which Android leaves out by default.
75
+ androidResources {
76
+ ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
77
+ }
78
+
79
+ compileOptions {
80
+ sourceCompatibility JavaVersion.VERSION_17
81
+ targetCompatibility JavaVersion.VERSION_17
82
+ }
83
+ }
84
+
85
+ dependencies {
86
+ // Hermes' release build in every variant: it is the one the host links against.
87
+ implementation('com.facebook.hermes:hermes-android:${HERMES.version}') {
88
+ attributes {
89
+ attribute(com.android.build.api.attributes.BuildTypeAttr.ATTRIBUTE, objects.named(com.android.build.api.attributes.BuildTypeAttr, 'release'))
90
+ }
91
+ }
92
+ implementation 'androidx.webkit:webkit:${ANDROIDX_WEBKIT}'
93
+ }
94
+ `;
95
+
96
+ // An Android app as a Gradle project, written anew from the parts its ABI targets built: each brings its libraries,
97
+ // the first also the app's files. Gradle adds Hermes and androidx.webkit from Maven, and makes the APK or the bundle.
98
+ export default function writeGradleProject(to, { parts, id, version }) {
99
+ const { name } = state.config.general;
100
+ if (parts.length === 0) throw new Error(`crossbind: no part of the Android app ${name} is built, so there is no Gradle project to write.`);
101
+ fs.rmSync(to, { recursive: true, force: true });
102
+ fs.cpSync(path.join(resolveAppHostRoot(), 'android'), to, { recursive: true });
103
+ const main = path.join(to, 'app', 'src', 'main');
104
+ parts.forEach(({ arch, dir }) => fs.cpSync(path.join(dir, 'lib'), path.join(main, 'jniLibs', arch), { recursive: true }));
105
+ fs.cpSync(path.join(parts[0].dir, 'app'), path.join(main, 'assets', 'app'), { recursive: true });
106
+ fs.writeFileSync(path.join(to, 'app', 'build.gradle'), buildGradle({
107
+ id, label: name, version, abis: parts.map(({ arch }) => arch),
108
+ }));
109
+ return to;
110
+ }
@@ -0,0 +1,63 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { EXEC_MAX_BUFFER } from './run.js';
3
+ import state from '../state/index.js';
4
+ import logger from '../utils/logger.js';
5
+
6
+ let hasToldNoWindow = false;
7
+
8
+ function tool(program, args) {
9
+ const result = spawnSync(program, args, { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER });
10
+ if (result.status !== 0) {
11
+ throw new Error(`crossbind: ${program} ${args.join(' ')} failed: ${`${result.stdout ?? ''}${result.stderr ?? ''}`.trim() || result.error?.message}`);
12
+ }
13
+ return result.stdout;
14
+ }
15
+
16
+ // Newer iOS first: 27.0 before 18.5.
17
+ function byNewerIos(a, b) {
18
+ const index = a.version.findIndex((part, i) => part !== b.version[i]);
19
+ return index === -1 ? 0 : (b.version[index] ?? 0) - a.version[index];
20
+ }
21
+
22
+ // The Simulator an app opens in: one already booted, or else an iPhone of the newest iOS, booted for it.
23
+ function simulator() {
24
+ const { devices } = JSON.parse(tool('xcrun', ['simctl', 'list', 'devices', 'available', '--json']));
25
+ const all = Object.entries(devices)
26
+ .filter(([runtime]) => runtime.includes('.SimRuntime.iOS-'))
27
+ .flatMap(([runtime, list]) => list.map((device) => ({ ...device, version: runtime.split('.SimRuntime.iOS-')[1].split('-').map(Number) })));
28
+ const booted = all.find((device) => device.state === 'Booted');
29
+ if (booted) return booted.udid;
30
+ const [newest] = all.filter((device) => device.name.startsWith('iPhone')).sort(byNewerIos);
31
+ if (!newest) throw new Error('crossbind: no iPhone Simulator is installed. Add the iOS platform in Xcode, under Settings > Components.');
32
+ tool('xcrun', ['simctl', 'boot', newest.udid]);
33
+ tool('xcrun', ['simctl', 'bootstatus', newest.udid, '-b']);
34
+ return newest.udid;
35
+ }
36
+
37
+ // Builds an iOS app's Xcode project for the Simulator, and returns the app.
38
+ export function buildForSimulator(project, configuration = 'Release') {
39
+ const { name } = state.config.general;
40
+ const products = `${state.config.paths.build}/xcode`;
41
+ tool('xcodebuild', ['-project', `${project}/${name}.xcodeproj`, '-configuration', configuration, '-sdk', 'iphonesimulator',
42
+ `SYMROOT=${products}`, '-quiet', 'build']);
43
+ return `${products}/${configuration}-iphonesimulator/${name}.app`;
44
+ }
45
+
46
+ // Opens a Simulator build of an iOS app, with `env` in its environment. The process returned carries the app's output
47
+ // and ends with the app; close() ends the app.
48
+ export default function openInSimulator(app, { env = {}, show = false, stdio = 'pipe' } = {}) {
49
+ const device = simulator();
50
+ // Simulator.app only shows the device, which runs the app without it as well.
51
+ if (show && spawnSync('open', ['-b', 'com.apple.iphonesimulator']).status !== 0 && !hasToldNoWindow) {
52
+ hasToldNoWindow = true;
53
+ logger.info('crossbind: Simulator.app is not installed, so the Simulator shows no window; the app runs in it all the same.');
54
+ }
55
+ tool('xcrun', ['simctl', 'install', device, app]);
56
+ const bundleId = tool('plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', `${app}/Info.plist`]).trim();
57
+ const childEnv = { ...process.env };
58
+ Object.entries(env).forEach(([key, value]) => {
59
+ childEnv[`SIMCTL_CHILD_${key}`] = value;
60
+ });
61
+ const child = spawn('xcrun', ['simctl', 'launch', '--console-pty', '--terminate-running-process', device, bundleId], { env: childEnv, stdio });
62
+ return { child, close: () => spawnSync('xcrun', ['simctl', 'terminate', device, bundleId]) };
63
+ }