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
package/src/program.js ADDED
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs';
2
+ import { Command, Option } from 'commander';
3
+
4
+ import {
5
+ deleteAccount as deleteCloudAccount, fetchUsage, login as loginToCloud, logout as logoutOfCloud, usageLines,
6
+ } from './actions/cloudCommands.js';
7
+ import { cloudUrl } from './utils/cloudAccount.js';
8
+ import flattenConfigForTable from './utils/flattenConfigForTable.js';
9
+ import loadSystemConfig, { systemConfigPath } from './utils/systemConfig.js';
10
+ import systemKeys from './utils/systemKeys.js';
11
+ import writeJson from './utils/writeJson.js';
12
+
13
+ const packageJSON = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
14
+
15
+ // Build failures bubble up as exceptions with a `cause` chain (see actions/run.js);
16
+ // show the operator a single line — DEBUG=1 for the full stack — and exit non-zero.
17
+ export const handleFatal = (e) => {
18
+ if (process.env.DEBUG) {
19
+ console.error(e);
20
+ } else {
21
+ let message = e?.message || String(e);
22
+ if (e?.cause?.message) message += `\n caused by: ${e.cause.message.split('\n')[0]}`;
23
+ console.error(message);
24
+ }
25
+ process.exit(1);
26
+ };
27
+
28
+ export const createProgram = () => new Command()
29
+ .name('crossbind')
30
+ .description('Compile C++ files to WebAssembly and native platforms.')
31
+ .version(packageJSON.version)
32
+ .showHelpAfterError();
33
+
34
+ export function addSystemCommands(program) {
35
+ program.command('login')
36
+ .description('sign in to crossbind cloud with your GitHub account')
37
+ .action(async () => {
38
+ const url = cloudUrl(loadSystemConfig());
39
+ const { login } = await loginToCloud({ url });
40
+ console.log(`crossbind: signed in to ${url} as ${login}.`);
41
+ });
42
+
43
+ program.command('logout')
44
+ .description('sign out of crossbind cloud on this machine')
45
+ .option('--all', 'sign out every machine signed in to this account')
46
+ .action(async (options) => {
47
+ const url = cloudUrl(loadSystemConfig());
48
+ const result = await logoutOfCloud({ url, all: Boolean(options.all) });
49
+ if (!result) {
50
+ console.log(`crossbind: not signed in to ${url}.`);
51
+ } else if (result.revoked) {
52
+ console.log(`crossbind: signed ${result.login} out${options.all ? ' on every machine' : ''}.`);
53
+ } else {
54
+ console.warn(`crossbind: signed out here, but crossbind cloud did not revoke the token (${result.reason.replace(/^crossbind: /, '')}). Sign in again and run crossbind logout --all to revoke it.`);
55
+ }
56
+ });
57
+
58
+ program.command('usage')
59
+ .description('show what you used of crossbind cloud this month and what is left')
60
+ .action(async () => {
61
+ usageLines(await fetchUsage({ url: cloudUrl(loadSystemConfig()) })).forEach((line) => console.log(line));
62
+ });
63
+
64
+ program.command('account')
65
+ .description('manage your crossbind cloud account')
66
+ .command('delete')
67
+ .description('delete your crossbind cloud account, with the sign-in of every machine (https://crossbind.dev/privacy/)')
68
+ .option('--yes', 'delete it; without this, nothing is deleted')
69
+ .action(async (options) => {
70
+ const url = cloudUrl(loadSystemConfig());
71
+ if (!options.yes) {
72
+ throw new Error(`crossbind: this deletes your crossbind cloud account at ${url}, with the sign-in of every machine. Run crossbind account delete --yes to go ahead.`);
73
+ }
74
+ const { login } = await deleteCloudAccount({ url });
75
+ console.log(`crossbind: deleted the crossbind cloud account of ${login}.`);
76
+ });
77
+
78
+ const commandConfig = program.command('config')
79
+ .description('manage the crossbind configuration files');
80
+
81
+ commandConfig.command('get')
82
+ .description('get the crossbind system configuration')
83
+ .argument('<key>', 'configuration key to read')
84
+ .action((key) => getSystemConfig(key));
85
+
86
+ commandConfig.command('set')
87
+ .description('set the crossbind system configuration')
88
+ .argument('<key>', 'configuration key to set')
89
+ .argument('<value>', 'value to assign')
90
+ .action((key, value) => setSystemConfig(key, value));
91
+
92
+ commandConfig.command('delete')
93
+ .description('delete the crossbind system configuration')
94
+ .argument('<key>', 'configuration key to remove')
95
+ .action((key) => deleteSystemConfig(key));
96
+
97
+ commandConfig.command('list')
98
+ .description('list the crossbind configurations')
99
+ .addOption(new Option('-t, --type <type>', 'config type').default('system').choices(['all', 'system', 'project']))
100
+ .action((options) => listSystemConfig(options.type));
101
+
102
+ commandConfig.command('keys')
103
+ .description('list all available system configuration keys for crossbind')
104
+ .action(() => listSystemKeys());
105
+ }
106
+
107
+ function listSystemKeys() {
108
+ console.info('Available configurations:');
109
+ console.table(systemKeys);
110
+ }
111
+
112
+ async function getSystemConfig(systemKey) {
113
+ if (!systemKey) {
114
+ await listSystemConfig('system');
115
+ return;
116
+ }
117
+ const system = loadSystemConfig();
118
+ const key = systemKey.toUpperCase();
119
+ const output = {};
120
+ if (key in system) {
121
+ output[key] = { value: system[key] || 'undefined', default: systemKeys[key].default };
122
+ }
123
+ Object.keys(systemKeys).filter((k) => k.includes(key)).forEach((k) => {
124
+ if (k in system) {
125
+ output[k] = { value: system[k] || 'undefined', default: systemKeys[k].default };
126
+ }
127
+ });
128
+
129
+ console.table(output);
130
+ }
131
+
132
+ function setSystemConfig(key, value) {
133
+ if (!systemKeys[key]) {
134
+ throw new Error(`Configuration ${key} is not available. Please choose from the following available configurations: ${Object.keys(systemKeys).join(', ')}`);
135
+ }
136
+ if (systemKeys[key].options && !systemKeys[key].options.includes(value)) {
137
+ throw new Error(`Value ${value} is not available. Please choose from the following available values: ${systemKeys[key].options.join(', ')}`);
138
+ }
139
+
140
+ writeJson(systemConfigPath(), { ...loadSystemConfig(), [key]: value });
141
+ }
142
+
143
+ function deleteSystemConfig(key) {
144
+ if (!systemKeys[key]) {
145
+ throw new Error(`Configuration ${key} is not available. Please choose from the following available configurations: ${Object.keys(systemKeys).join(', ')}`);
146
+ }
147
+ const { [key]: deleted, ...system } = loadSystemConfig();
148
+ writeJson(systemConfigPath(), system);
149
+ }
150
+
151
+ async function listSystemConfig(type) {
152
+ // Importing the state loads the project, which fails outside one, so only a listing of the project does.
153
+ const { system: systemConfig, ...projectConfig } = type === 'system'
154
+ ? { system: loadSystemConfig() }
155
+ : (await import('./state/index.js')).default.config;
156
+ if (type === 'all' || type === 'system') {
157
+ console.log('System Configuration');
158
+ console.table(systemConfig);
159
+ }
160
+
161
+ if (type === 'all') {
162
+ console.log('');
163
+ }
164
+
165
+ if (type === 'all' || type === 'project') {
166
+ console.log(`Project Configuration (paths relative to ${projectConfig.paths.base})`);
167
+ console.table(flattenConfigForTable(projectConfig, { base: projectConfig.paths.base }));
168
+ }
169
+ }
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import loadJson from '../utils/loadJson.js';
3
3
  import writeJson from '../utils/writeJson.js';
4
- import { TARGETS, targetPathOf, nodeAddonNamesOf, nativeCommandNamesOf } from '../utils/targets.js';
4
+ import {
5
+ TARGETS, targetPathOf, nodeAddonNamesOf, nativeCommandNamesOf, wasiNamesOf, appNamesOf,
6
+ } from '../utils/targets.js';
5
7
  import { findCargoModuleImportsIn, writeCargoMarker } from '../utils/cargoImport.js';
6
8
  import { exportWasiSdkPath } from '../utils/wasiToolchain.js';
7
9
  import loadConfig, { assertBinarySelection } from './loadConfig.js';
@@ -33,6 +35,7 @@ async function initProcessState() {
33
35
  DOCKER_REGISTRY_MIRROR: 'CROSSBIND_REGISTRY_MIRROR',
34
36
  DOCKER_IMAGE_WEB: 'CROSSBIND_IMAGE_WEB',
35
37
  DOCKER_IMAGE_ANDROID: 'CROSSBIND_IMAGE_ANDROID',
38
+ DOCKER_IMAGE_ANDROID_APP: 'CROSSBIND_IMAGE_ANDROID_APP',
36
39
  DOCKER_IMAGE_LINUX: 'CROSSBIND_IMAGE_LINUX',
37
40
  DOCKER_IMAGE_WINDOWS: 'CROSSBIND_IMAGE_WINDOWS',
38
41
  }).forEach(([key, envKey]) => {
@@ -52,8 +55,7 @@ async function initProcessState() {
52
55
  target.dataTxtName = `${state.config.general.name}-${target.path}.${target.runtimeEnv}.data.txt`;
53
56
  }
54
57
  if (target.platform === 'wasi') {
55
- // A wasi build is a single command module - no JS glue, no preload.
56
- target.wasmName = `${state.config.general.name}-${target.path}.wasm`;
58
+ Object.assign(target, wasiNamesOf(target, state.config.general.name));
57
59
  }
58
60
  if (target.runtimeEnv === 'node' && target.platform !== 'wasm') {
59
61
  Object.assign(target, nodeAddonNamesOf(target, state.config.general.name));
@@ -61,6 +63,9 @@ async function initProcessState() {
61
63
  if (target.runtimeEnv === 'native') {
62
64
  Object.assign(target, nativeCommandNamesOf(target, state.config.general.name));
63
65
  }
66
+ if (target.runtimeEnv === 'app') {
67
+ Object.assign(target, appNamesOf(target, state.config.general.name));
68
+ }
64
69
  });
65
70
 
66
71
  setAllDependecyPaths();
@@ -1,6 +1,5 @@
1
- import os from 'node:os';
2
1
  import fs from 'node:fs';
3
- import systemKeys from '../utils/systemKeys.js';
2
+ import loadSystemConfig, { systemConfigPath } from '../utils/systemConfig.js';
4
3
  import loadJs from '../utils/loadJs.js';
5
4
  import {
6
5
  resolveDependencyOverride, resolveDependencyReplace, restampIdentity, resolveExcludedNames, getOverrideKey,
@@ -32,7 +31,7 @@ export function assertBinarySelection(config) {
32
31
  export default async function loadConfig(configDir = process.cwd(), configName = 'crossbind.config') {
33
32
  const config = await loadJs(configDir, configName) || {};
34
33
  if (config.excludedDependencies?.length) {
35
- logger.info('crossbind: `excludedDependencies` in crossbind.config.js is no longer read — declare exclusions in crossbind.overrides.js as { <name>: false }.');
34
+ logger.info('crossbind: `excludedDependencies` in crossbind.config.js is no longer read — declare exclusions in crossbind.overrides.js as { <name>: { exclude: true } }.');
36
35
  }
37
36
  const overrides = await loadJs(configDir, 'crossbind.overrides');
38
37
  const exclude = resolveExcludedNames(overrides);
@@ -83,14 +82,8 @@ export default async function loadConfig(configDir = process.cwd(), configName =
83
82
  }
84
83
  });
85
84
 
86
- output.paths.systemConfig = `${os.homedir()}/.crossbind.json`;
87
- output.system = loadJson(output.paths.systemConfig) || {};
88
-
89
- Object.entries(systemKeys).forEach(([key, value]) => {
90
- if (!(key in output.system)) {
91
- output.system[key] = value.default;
92
- }
93
- });
85
+ output.paths.systemConfig = systemConfigPath();
86
+ output.system = loadSystemConfig();
94
87
 
95
88
  return output;
96
89
  }
@@ -140,6 +133,8 @@ export function getFilledConfig(config, options = { isDepend: false }) {
140
133
  targetSpecs: config.targetSpecs || [],
141
134
  build: config.build || {},
142
135
  target: config.target || {},
136
+ // An app's identity: its bundle id, and on iOS the Apple team that signs it.
137
+ app: config.app || {},
143
138
  extensions: config.extensions || [],
144
139
  package: null,
145
140
  functions: config.functions || {},
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import state from '../state/index.js';
4
+ import { followCargoLock, recordCargoLock } from './cargoLock.js';
3
5
  import { cargoTripleFor } from './cargoTarget.js';
4
6
  import { isMtWasm, assertMtRustToolchain, cargoTargetDirFor, cargoBuildInvocation } from './rustMt.js';
5
7
  import runCargo from './runCargo.js';
@@ -78,7 +80,9 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
78
80
  ...crates.map((c) => `${c.replaceAll('_', '-')}-crossbind-app = { path = "${relPath(superDir, `${root}/${c}`)}" }`),
79
81
  '',
80
82
  '[profile.release]',
81
- 'panic = "abort"',
83
+ // Unwinding, as in a package's bridge, whose shims raise a panic as a JS exception: an abort runtime here could
84
+ // end the process on a panic in any crate. wasm still aborts, as its std does.
85
+ 'panic = "unwind"',
82
86
  '',
83
87
  ...crates.flatMap((c) => [`[profile.release.package.${c.replaceAll('_', '-')}-crossbind-app]`, 'codegen-units = 1', '']),
84
88
  '[workspace]',
@@ -110,6 +114,7 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
110
114
  manifestPath: `${superDir}/Cargo.toml`,
111
115
  sysroot,
112
116
  });
117
+ followCargoLock(state.config.paths.project, superDir);
113
118
  const build = runCargo(args, {
114
119
  rustflags, panic, allowUnstable, target,
115
120
  });
@@ -118,5 +123,6 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
118
123
  }
119
124
  const libFile = `${targetDir}/${triple}/release/libcrossbind_app_super.a`;
120
125
  if (!fs.existsSync(libFile)) throw new Error(`crossbind: expected cargo output ${libFile} not found`);
126
+ recordCargoLock(state.config.paths.project, superDir);
121
127
  return [libFile];
122
128
  }
@@ -4,14 +4,16 @@ import { parseMacroDump, stripComments, declaresAtFileScope } from './swigInterf
4
4
  // SWIG binds no function-like macro, variadic function or mutable global. crossbind binds them for the names an app
5
5
  // imports by name: a function-like macro takes the parameter types of the one function it calls, a macro naming a
6
6
  // function binds that function under its own name, a variadic function takes its extra arguments by their JavaScript
7
- // type, and a mutable global binds as a handle to its address. A macro binds only through a function SWIG bound, the
8
- // one place that proves the name is a single declared function.
7
+ // type, a mutable global binds as a handle to its address, and a constant SWIG drops for a cast in its value binds as
8
+ // the number the compiler makes of it. A macro binds only through a function SWIG bound, the one place that proves the
9
+ // name is a single declared function.
9
10
 
10
11
  const C_IDENTIFIER = /^[A-Za-z_]\w*$/;
11
12
  const MACRO_LINE = /^\/\/ crossbind:macro ([A-Za-z_]\w*) (#define .*)$/gm;
12
13
  const ALIAS_LINE = /^\/\/ crossbind:alias ([A-Za-z_]\w*) ([A-Za-z_]\w*)$/gm;
14
+ const CONSTANT_LINE = /^\/\/ crossbind:constant ([A-Za-z_]\w*)$/gm;
13
15
  const NOTE_MARKER = '// crossbind:note ';
14
- const EXTRA_LINES = /^(?:\/\/ crossbind:(?:macro|alias|note) .*\n)+\n/m;
16
+ const EXTRA_LINES = /^(?:\/\/ crossbind:(?:macro|alias|constant|note) .*\n)+\n/m;
15
17
  // The arguments one call may add to a variadic function: each registration count dispatches over 2^n argument kinds.
16
18
  const MAX_VARIADIC_ARGUMENTS = 6;
17
19
  const VARIADIC_WARNING = /^Variable length arguments are not supported by embind, (\S+) skipped\.$/;
@@ -90,9 +92,66 @@ export function macroCall(macro) {
90
92
  // The identifier an object-like macro stands for, as in `#define iconv_open libiconv_open`.
91
93
  const identifierOf = (macro) => (macro && !macro.params && C_IDENTIFIER.test(macro.body) ? macro.body : null);
92
94
 
95
+ const NUMBER_TYPE_WORDS = new Set(['unsigned', 'signed', 'short', 'long', 'int', 'char', 'float', 'double', 'bool', '_Bool']);
96
+ const NUMBER_TYPEDEFS = new Set([
97
+ 'size_t', 'ssize_t', 'ptrdiff_t', 'intptr_t', 'uintptr_t', 'intmax_t', 'uintmax_t', 'off_t', 'wchar_t',
98
+ 'int8_t', 'uint8_t', 'int16_t', 'uint16_t', 'int32_t', 'uint32_t', 'int64_t', 'uint64_t',
99
+ ]);
100
+ const VALUE_TOKEN = /0[xX][\dA-Fa-f]+[uUlL]*|\d+(?:\.\d*)?(?:[eE][-+]?\d+)?[uUlLfF]*|'(?:\\.|[^'\\])+'|[A-Za-z_]\w*|<<|>>|\S/g;
101
+ const OPERATOR = /^(?:<<|>>|[-+*/%&|^~!()<>=?:])$/;
102
+ const LITERAL = /^(?:\d|')/;
103
+ const CAST_OPERAND = /^(?:\d|'|[-+~!(])/;
104
+
105
+ // A type named by number words, a standard number typedef, or a typedef of one that the header declares.
106
+ function isNumberType(words, headerText, depth = 0) {
107
+ if (words.every((word) => NUMBER_TYPE_WORDS.has(word))) return true;
108
+ if (words.length !== 1 || depth > 3) return false;
109
+ if (NUMBER_TYPEDEFS.has(words[0])) return true;
110
+ const declared = stripComments(headerText).match(new RegExp(`\\btypedef\\s+([A-Za-z_][\\w\\s]*?)\\s+${words[0]}\\s*;`));
111
+ return Boolean(declared) && isNumberType(declared[1].trim().split(/\s+/), headerText, depth + 1);
112
+ }
113
+
114
+ // The tokens of an object-like macro's value with the object-like macros in it spelled out; null for anything else.
115
+ function expandedTokens(name, macros, depth = 0) {
116
+ const macro = macros.get(name);
117
+ if (!macro || macro.params || depth > 16) return null;
118
+ const tokens = [];
119
+ for (const token of macro.body.match(VALUE_TOKEN) ?? []) {
120
+ if (!C_IDENTIFIER.test(token) || !macros.has(token)) {
121
+ tokens.push(token);
122
+ continue;
123
+ }
124
+ const inner = expandedTokens(token, macros, depth + 1);
125
+ if (!inner) return null;
126
+ tokens.push('(', ...inner, ')');
127
+ }
128
+ return tokens;
129
+ }
130
+
131
+ // SWIG drops a #define whose value holds a name, so every cast. A value of numbers, operators and casts binds through
132
+ // the compiler when every cast is to a number type: { numbers }, or null for a value with no cast or with other names.
133
+ function castConstant(name, macros, headerText) {
134
+ const tokens = expandedTokens(name, macros);
135
+ if (!tokens) return null;
136
+ let casts = 0;
137
+ let numbers = true;
138
+ for (let at = 0; at < tokens.length; at += 1) {
139
+ if (LITERAL.test(tokens[at]) || OPERATOR.test(tokens[at])) continue;
140
+ const close = tokens.indexOf(')', at);
141
+ const words = tokens.slice(at, close);
142
+ const isCast = tokens[at - 1] === '(' && close > at && CAST_OPERAND.test(tokens[close + 1] ?? '')
143
+ && words.every((word) => word === '*' || C_IDENTIFIER.test(word));
144
+ if (!isCast) return null;
145
+ casts += 1;
146
+ numbers &&= !words.includes('*') && isNumberType(words, headerText);
147
+ at = close;
148
+ }
149
+ return casts ? { numbers } : null;
150
+ }
151
+
93
152
  // What the interface carries for the names an app imports by name, read by the bridge once SWIG has run: their
94
153
  // function-like macros and their macros naming a function, and the functions they reach, which SWIG has to bind.
95
- export function interfaceExtras(names, macros) {
154
+ export function interfaceExtras(names, macros, headerText = '') {
96
155
  if (names === ALL_NAMES || !names.length) return { lines: [], functions: [] };
97
156
  const lines = [];
98
157
  const functions = [];
@@ -104,6 +163,10 @@ export function interfaceExtras(names, macros) {
104
163
  if (target && target !== name && !macros.has(target) && !C_KEYWORDS.has(target)) {
105
164
  lines.push(`// crossbind:alias ${name} ${target}`);
106
165
  functions.push(target);
166
+ } else {
167
+ const constant = castConstant(name, macros, headerText);
168
+ if (constant?.numbers) lines.push(`// crossbind:constant ${name}`);
169
+ else if (constant) lines.push(`${NOTE_MARKER}${name}: a constant whose value has a cast binds only when every cast is to a number type the header declares; skipped.`);
107
170
  }
108
171
  continue;
109
172
  }
@@ -168,7 +231,8 @@ export function bridgeExtras({ interfaceText = '', named = [], bridgeText = '',
168
231
  const globalWarnings = warningsAbout(warnings, GLOBAL_WARNING, imported);
169
232
  const globals = [...globalWarnings.keys()].filter((name) => declaresAtFileScope(headerText, name));
170
233
  const answered = new Set([...variadics.map((name) => variadicWarnings.get(name)), ...globals.map((name) => globalWarnings.get(name))]);
171
- if (!macros.length && !aliases.length && !variadics.length && !globals.length) return { code: '', exports: [], answered, notes };
234
+ const constants = [...interfaceText.matchAll(CONSTANT_LINE)].map(([, name]) => name).filter((name) => !bridgeText.includes(`bindConstant("${name}"`));
235
+ if (!macros.length && !aliases.length && !variadics.length && !globals.length && !constants.length) return { code: '', exports: [], answered, notes };
172
236
 
173
237
  // The macros register ahead of the fallbacks below, which would make a function a variadic shares its name with an
174
238
  // overload set.
@@ -184,10 +248,13 @@ export function bridgeExtras({ interfaceText = '', named = [], bridgeText = '',
184
248
  `EMSCRIPTEN_BINDINGS(CrossbindExtras_${module}) {`,
185
249
  ...variadics.map((name) => ` { using F = decltype(crossbind::variadicType(&${name}, 0)); crossbind::bindVariadic<F, static_cast<F>(&${name}), ${MAX_VARIADIC_ARGUMENTS}>("${name}"); }`),
186
250
  ...globals.map((name) => ` crossbind::bindGlobal("${name}", &${name});`),
251
+ ...constants.map((name) => ` crossbind::bindConstant("${name}", ${name});`),
187
252
  '}',
188
253
  '',
189
254
  ].join('\n');
190
- const exports = [...macros.map(({ name }) => name), ...aliases.map(({ name }) => name), ...variadics, ...globals, ...(variadics.length ? ['vaDouble'] : [])];
255
+ const exports = [
256
+ ...macros.map(({ name }) => name), ...aliases.map(({ name }) => name), ...variadics, ...globals, ...constants, ...(variadics.length ? ['vaDouble'] : []),
257
+ ];
191
258
  return { code, exports, answered, notes };
192
259
  }
193
260
 
@@ -261,6 +328,7 @@ template<typename R> constexpr bool isJsResult = std::is_void_v<R> || isJsArgume
261
328
  template<typename T> auto argumentFromJs(const emscripten::val &value) {
262
329
  using U = std::remove_cv_t<T>;
263
330
  if constexpr (std::is_same_v<U, const char *>) return fromJsCString<const char>(value);
331
+ else if constexpr (isFunctionPointer<U>) return fromJsCallback<U>(value);
264
332
  else if constexpr (std::is_pointer_v<U>) return fieldFromJs<U>(value);
265
333
  else if constexpr (std::is_same_v<U, bool>) {
266
334
  double number = 0;
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import writeIfChanged from './writeIfChanged.js';
4
+
5
+ // The versions of every crate the app's Rust links, next to crossbind.config.js and committed like conan.lock, since
6
+ // the manifests cargo resolves are generated under the cache. Not Cargo.lock: a package whose crate sits at the
7
+ // project root already has one.
8
+ const lockOf = (projectDir) => path.join(projectDir, 'crossbind.cargo.lock');
9
+
10
+ // Before cargo resolves a generated manifest, so it keeps every version the project pins.
11
+ export function followCargoLock(projectDir, manifestDir) {
12
+ const lock = lockOf(projectDir);
13
+ if (fs.existsSync(lock)) writeIfChanged(path.join(manifestDir, 'Cargo.lock'), fs.readFileSync(lock, 'utf8'));
14
+ }
15
+
16
+ // After a build: what cargo resolved, a crate the lock did not list yet included.
17
+ export function recordCargoLock(projectDir, manifestDir) {
18
+ writeIfChanged(lockOf(projectDir), fs.readFileSync(path.join(manifestDir, 'Cargo.lock'), 'utf8'));
19
+ }
@@ -225,6 +225,8 @@ export function emitCppDts(model, exportNames, mode = 'sync') {
225
225
  for (const method of cls.methods) {
226
226
  out.push(` ${method.isStatic ? 'static ' : ''}${method.name}(${method.args.map((a) => `${a.name}: ${a.type}`).join(', ')}): ${wrap(method.ret)};`);
227
227
  }
228
+ // Frees the object now instead of when the garbage collector reclaims the handle (lifecycle-and-types.md).
229
+ out.push(` delete(): ${wrap('void')};`);
228
230
  out.push('}');
229
231
  }
230
232
  for (const name of exportNames) {
@@ -14,19 +14,45 @@ const RETRYABLE_STATUS = new Set([408, 429]);
14
14
  const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
15
15
  const retryable = (error) => Object.assign(error, { retryable: true });
16
16
 
17
- export default async function downloadAndExtractFile(url, output, sha256, options = {}) {
18
- const archive = path.basename(url);
17
+ // A recipe names its source with getURL and may name mirrors that serve the same archive under the same pin.
18
+ export function sourcesOf({ getURL, getMirrors }, version) {
19
+ return [getURL(version), ...(getMirrors?.(version) ?? [])];
20
+ }
21
+
22
+ // `urls` is one URL or a list of them: the first names the archive, the others are mirrors of it.
23
+ export default async function downloadAndExtractFile(urls, output, sha256, options = {}) {
24
+ const sources = [urls].flat();
25
+ const archive = path.basename(sources[0]);
19
26
  if (fs.existsSync(`${output}/source`) && sourceCameFrom(output, archive)) {
20
27
  return false;
21
28
  }
29
+ sources.forEach((url) => assertHttps(url));
22
30
  fs.rmSync(`${output}/source`, { recursive: true, force: true });
23
- const filePath = await downloadFile(url, output, options);
24
- verifyIntegrity(filePath, url, sha256);
31
+ const { filePath, url } = await downloadFromAny(sources, output, sha256, { ...options, name: archive });
25
32
  extractArchive(filePath, url, output);
26
33
  fs.writeFileSync(`${output}/source.archive`, archive);
27
34
  return true;
28
35
  }
29
36
 
37
+ // A source that cannot serve the archive, or serves bytes the pin does not match, hands over to the next one.
38
+ async function downloadFromAny(sources, output, sha256, options) {
39
+ const failures = [];
40
+ for (const url of sources) {
41
+ try {
42
+ const filePath = await downloadFile(url, output, options);
43
+ verifyIntegrity(filePath, url, sha256);
44
+ return { filePath, url };
45
+ } catch (err) {
46
+ failures.push(err);
47
+ }
48
+ }
49
+ if (failures.length === 1) throw failures[0];
50
+ throw new Error(
51
+ `crossbind: no source of ${options.name} could be used:\n${failures.map((err) => ` - ${err.message}`).join('\n')}`,
52
+ { cause: failures.at(-1) },
53
+ );
54
+ }
55
+
30
56
  // A cached source tree only counts when it came from the archive the recipe names now, or a
31
57
  // nativeVersion bump would keep compiling the old tree. Trees from before the marker are judged
32
58
  // by the archive that was downloaded next to them.
@@ -37,20 +63,22 @@ function sourceCameFrom(output, archive) {
37
63
  }
38
64
 
39
65
  // Extraction runs through the system tar (GNU tar and bsdtar both detect gzip/bzip2/xz, and
40
- // every platform the engine builds on ships one). Extracting into a scratch directory keeps
41
- // the archive from choosing where its files land, and gives the upstream root folder - which
42
- // recipes address as build/source - one place to be renamed from.
66
+ // every platform the engine builds on ships one), and a zip archive, which GNU tar cannot read,
67
+ // through unzip. Extracting into a scratch directory keeps the archive from choosing where its
68
+ // files land, and gives the upstream root folder - which recipes address as build/source - one
69
+ // place to be renamed from.
43
70
  function extractArchive(filePath, url, output) {
44
71
  const work = `${output}/.extract`;
45
72
  fs.rmSync(work, { recursive: true, force: true });
46
73
  mkdirSync(work, { recursive: true });
47
74
  try {
48
- const tar = spawnSync('tar', ['-xf', filePath, '-C', work], { encoding: 'utf8' });
49
- if (tar.error) {
50
- throw new Error(`crossbind: cannot run tar to extract ${filePath}: ${tar.error.message}`);
75
+ const [tool, args] = /\.(zip|aar)$/.test(filePath) ? ['unzip', ['-q', filePath, '-d', work]] : ['tar', ['-xf', filePath, '-C', work]];
76
+ const extracted = spawnSync(tool, args, { encoding: 'utf8' });
77
+ if (extracted.error) {
78
+ throw new Error(`crossbind: cannot run ${tool} to extract ${filePath}: ${extracted.error.message}`);
51
79
  }
52
- if (tar.status !== 0) {
53
- throw new Error(`crossbind: extracting ${filePath} (from ${url}) failed: ${(tar.stderr || '').trim() || `tar exited ${tar.status}`}`);
80
+ if (extracted.status !== 0) {
81
+ throw new Error(`crossbind: extracting ${filePath} (from ${url}) failed: ${(extracted.stderr || '').trim() || `${tool} exited ${extracted.status}`}`);
54
82
  }
55
83
  const entries = fs.readdirSync(work, { withFileTypes: true });
56
84
  if (entries.length === 0) {
@@ -95,9 +123,9 @@ export function assertHttps(target, context = target) {
95
123
 
96
124
  // fetch follows redirects itself, which release downloads rely on (github and the osgeo
97
125
  // mirrors both bounce), so the download needs no redirect library of its own.
98
- export async function downloadFile(url, folder, { retryDelaysMs = RETRY_DELAYS_MS } = {}) {
126
+ export async function downloadFile(url, folder, { retryDelaysMs = RETRY_DELAYS_MS, name = path.basename(url) } = {}) {
99
127
  mkdirSync(folder, { recursive: true });
100
- const dest = `${folder}/${path.basename(url)}`;
128
+ const dest = `${folder}/${name}`;
101
129
  if (fs.existsSync(dest)) return dest;
102
130
 
103
131
  assertHttps(url);
@@ -0,0 +1,35 @@
1
+ // embind frees an object when JavaScript's handle to it is collected only behind a smart pointer. A value a call
2
+ // returns, which embind copied for JavaScript, and an object JavaScript made through a raw constructor (a vector, a
3
+ // Rust class) were freed by .delete() alone, though they are as much JavaScript's own. This rewrite marks both on the
4
+ // count a handle shares with its clones and gives them the same finalizer. A copy is known only where its converter
5
+ // makes the handle, since embind then types the handle as a pointer to the object's own class. A raw pointer stays
6
+ // its library's. The debug glue's leak warning goes: a collected handle is freed by design. Without
7
+ // FinalizationRegistry (Hermes) nothing is registered, as before.
8
+
9
+ // The release glue spells each on one line, the debug glue one statement per line with comments.
10
+ const FINALIZER = /if\s*\(hasSmartPtr\)\s*\{[\s\S]*?finalizationRegistry\.register\(handle,\s*info,\s*handle\);?/;
11
+ const OWNED_FINALIZER = 'if(hasSmartPtr||$$.count.owned){finalizationRegistry.register(handle,{$$},handle);';
12
+ const LEAK_WARNING = /console\.warn\(info\.leakWarning\);?\s*/;
13
+ const FROM_WIRE = /function RegisteredPointer_fromWireType\(ptr\)\s*\{/g;
14
+ const OWN_FROM_WIRE = 'function crossbindOwnHandle(handle){var $$=handle.$$;if(!$$.smartPtr&&!$$.count.owned){$$.count.owned=true;'
15
+ + 'if(finalizationRegistry)finalizationRegistry.register(handle,{$$},handle)}return handle}'
16
+ + 'function RegisteredPointer_fromWireType(ptr){var handle=crossbindHandleFromWire.call(this,ptr);'
17
+ + 'return this.isReference&&handle?crossbindOwnHandle(handle):handle}'
18
+ + 'function crossbindHandleFromWire(ptr){';
19
+ const CONSTRUCTED = /return body\.apply\(this,\s*args\);?/g;
20
+ const OWN_CONSTRUCTED = 'return crossbindOwnHandle(body.apply(this,args));';
21
+
22
+ const count = (text, pattern) => text.match(pattern)?.length ?? 0;
23
+
24
+ // Returns the rewritten glue and whether it holds embind classes this rewrite does not recognise.
25
+ export function releaseOwnedHandles(glue) {
26
+ if (!glue.includes('attachFinalizer')) return { text: glue, missed: false };
27
+ const recognised = count(glue, FROM_WIRE) === 1 && count(glue, CONSTRUCTED) === 1;
28
+ // Functions, not strings: `$$` in a replacement string is a lone `$`.
29
+ const text = glue
30
+ .replace(FINALIZER, () => OWNED_FINALIZER)
31
+ .replace(LEAK_WARNING, '')
32
+ .replace(FROM_WIRE, () => OWN_FROM_WIRE)
33
+ .replace(CONSTRUCTED, () => OWN_CONSTRUCTED);
34
+ return { text, missed: !recognised || !text.includes(OWNED_FINALIZER) };
35
+ }
@@ -1,18 +1,25 @@
1
1
  import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import { getContentHash, getFileHash } from './hash.js';
4
5
  import { collectRustSources } from './inputStamp.js';
5
6
  import resolveEmbindRustRoot from './resolveEmbindRust.js';
7
+ import { HOST_BUILT_PLATFORMS } from './targets.js';
8
+
9
+ const { toolchains } = createRequire(import.meta.url)('../assets/toolchain-digests.json');
6
10
 
7
11
  // Every cargo staticlib bundles the embind-rs it was built from. An archive from another one links stale glue, and next
8
12
  // to an archive built after it every embind_rs symbol is defined twice, so a prebuilt records the one it came from.
9
13
  export const EMBIND_RS_FINGERPRINT_FILE = 'crossbind-embind-rs.fingerprint';
10
14
 
11
- // Paths count relative to the crate, so an archive built from the same embind-rs elsewhere matches.
12
- export function getEmbindRsFingerprint() {
15
+ // Paths count relative to the crate, so an archive built from the same embind-rs elsewhere matches. An archive an image
16
+ // built carries that image's Rust std too, whose second copy from another rustc lld refuses, so the image's rustc
17
+ // counts as well; Apple's linker takes the mix, and the host's own rustc builds those.
18
+ export function getEmbindRsFingerprint(target) {
13
19
  const crate = `${resolveEmbindRustRoot()}/crate`;
14
20
  const files = [...collectRustSources([crate]), `${crate}/Cargo.toml`];
15
- return getContentHash(files.map((file) => `${path.relative(crate, file)}:${getFileHash(file)}`).sort().join('\n'));
21
+ const embindRs = getContentHash(files.map((file) => `${path.relative(crate, file)}:${getFileHash(file)}`).sort().join('\n'));
22
+ return !target || HOST_BUILT_PLATFORMS.includes(target.platform) ? embindRs : getContentHash(`${embindRs}\nrustc ${toolchains.rust}`);
16
23
  }
17
24
 
18
25
  export function isEmbindRsFingerprintStale(prebuiltDir, fingerprint) {
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import withDirLock from './dirLock.js';
5
+ import downloadAndExtractFile from './downloadAndExtractFile.js';
6
+
7
+ // The Hermes React Native publishes on Maven Central for Apple platforms: the macOS framework, the iOS xcframework,
8
+ // hermesc and the headers. A new version also moves third_party/jsi of @crossbind/core-app-host to its tag, and the
9
+ // Hermes of crossbind's android-app, linux and windows images.
10
+ export const HERMES = {
11
+ version: '250829098.0.17',
12
+ sha256: '2967ee51b41bd433b3027dda875e96194354e2b4d715f9f186fd0c04ccef309d',
13
+ license: 'MIT',
14
+ };
15
+
16
+ export const hermesUrl = (version) => `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-release.tar.gz`;
17
+
18
+ export const hermesDir = () => path.join(os.homedir(), '.crossbind', 'hermes', HERMES.version);
19
+
20
+ // The release's destroot, downloaded once and checked against the pinned sha256.
21
+ export default async function resolveHermes() {
22
+ const dir = hermesDir();
23
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
24
+ await withDirLock(`${dir}.lock`, () => downloadAndExtractFile(hermesUrl(HERMES.version), dir, HERMES.sha256));
25
+ return `${dir}/source/destroot`;
26
+ }
27
+
28
+ // Linux and Windows have no published Hermes, so crossbind's linux and windows images build the release's source, with
29
+ // their static libc++: JSI hands std types across, and the host and the project's archives build there too.
30
+ export const HERMES_SOURCE = {
31
+ url: `https://github.com/facebook/hermes/archive/refs/tags/hermes-v${HERMES.version}.tar.gz`,
32
+ sha256: '37e91915c67ce0c457f69b2de00fcaf28fcf755ab7de6e4f33f1d6ee9efcffed',
33
+ };
34
+
35
+ // Android's Hermes is React Native's AAR, which an app's Gradle build takes for the libraries it packages, and which
36
+ // crossbind's android-app image holds, with its headers and hermesc, for the app's host.
37
+ export const HERMES_ANDROID = {
38
+ url: `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-android/${HERMES.version}/hermes-android-${HERMES.version}-release.aar`,
39
+ sha256: '6fb440b29aadb5925bed1109338da61623c54c3508020bb2cecebcaaa80e53b8',
40
+ };