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
@@ -0,0 +1,434 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { rollup } from 'rollup';
7
+ import { nodeResolve } from '@rollup/plugin-node-resolve';
8
+ import commonjs from '@rollup/plugin-commonjs';
9
+ import virtual from '@rollup/plugin-virtual';
10
+ import run from './run.js';
11
+ import createLib from './createLib.js';
12
+ import getData from './getData.js';
13
+ import getEnv from './getEnv.js';
14
+ import { copyWindowsNotices, filesUnder, linkInputs } from './buildNode.js';
15
+ import { suppressNodeBuiltinWarnings } from './buildJs.js';
16
+ import { isNativeSourceNewerThan } from './isSourceNewer.js';
17
+ import { createNativeBridges } from './nativeBridges.js';
18
+ import { getTargetParams } from './target.js';
19
+ import writeXcodeProject, { HERMES_SLICES, IOS_SDKS } from './xcodeProject.js';
20
+ import writeGradleProject, { ANDROID_ABIS } from './gradleProject.js';
21
+ import { attachedDevice } from './androidDevice.js';
22
+ import { packageAndroidRelease } from './androidGradle.js';
23
+ import state from '../state/index.js';
24
+ import logger from '../utils/logger.js';
25
+ import findFiles from '../utils/findFiles.js';
26
+ import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
27
+ import { xmlPlist } from '../utils/plist.js';
28
+ import { writeNativeSourceStamp } from '../utils/nativeSourceStamp.js';
29
+ import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
30
+ import resolveAppHostRoot, { resolveAppEmbindJsiRoot } from '../utils/resolveAppHost.js';
31
+ import resolveHermes, { HERMES } from '../utils/hermes.js';
32
+ import { getDockerImage, imageRoleFor } from '../utils/pullDockerImage.js';
33
+ import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
34
+ import { chosenRunner } from '../utils/selectRunner.js';
35
+ import { DARWIN_DEPLOYMENT_TARGET } from '../utils/darwinToolchain.js';
36
+
37
+ const cpuCount = Math.max(1, os.cpus().length - 1);
38
+ const BRIDGE = 'AppBridge';
39
+ const LINUX_MULTIARCH = { x64: 'x86_64-linux-gnu', arm64: 'aarch64-linux-gnu' };
40
+ // Where the android-app, linux and windows images keep the Hermes of the release crossbind pins.
41
+ const IMAGE_HERMES = `/opt/crossbind/hermes/${HERMES.version}`;
42
+
43
+ // Where an app keeps its executable and its files: a macOS bundle, a Linux or Windows directory, or the app folder of an
44
+ // iOS app's Xcode project or of an Android app's Gradle project, which link the executable themselves.
45
+ export function appLayout(target) {
46
+ const { name } = state.config.general;
47
+ if (target.platform === 'darwin') return { executable: `Contents/MacOS/${name}`, resources: 'Contents/Resources' };
48
+ if (target.platform === 'ios') return { executable: null, resources: 'app' };
49
+ if (target.platform === 'android') return { executable: null, resources: 'app/src/main/assets/app' };
50
+ return { executable: target.platform === 'win32' ? `${name}.exe` : name, resources: 'resources' };
51
+ }
52
+
53
+ // The name a bundler gives the app beside the page it built. An iOS app is one Xcode project for both SDKs, and an
54
+ // Android app one Gradle project for both ABIs.
55
+ export function appOutputName(target) {
56
+ const { name } = state.config.general;
57
+ if (target.platform === 'darwin') return `${name}.app`;
58
+ if (['ios', 'android'].includes(target.platform)) return `${name}-${target.platform}`;
59
+ return `${name}-${target.platform}-${target.arch}`;
60
+ }
61
+
62
+ // An Android, Linux or Windows app builds in one of crossbind's images, which a local or remote runner does not have.
63
+ export function assertAppRunner(target) {
64
+ if (['darwin', 'ios'].includes(target.platform)) return;
65
+ const runner = chosenRunner(state.config.system);
66
+ if (['DOCKER_RUN', 'DOCKER_EXEC'].includes(runner)) return;
67
+ const app = { android: 'an Android', win32: 'a Windows' }[target.platform] ?? 'a Linux';
68
+ throw new Error(`crossbind: ${app} app builds in crossbind's ${imageRoleFor(target)} image, which RUNNER=${runner} does not use. Build it with RUNNER=DOCKER_RUN.`);
69
+ }
70
+
71
+ // The app a bundler builds: this machine's desktop app, or with CROSSBIND_APP_PLATFORM an iOS app for both SDKs, an
72
+ // Android app for both ABIs, or a Linux or Windows app for both arches, which builds in Docker on any machine. During
73
+ // development only what runs it: the Simulator, the attached device, or this machine.
74
+ export function appTargetParams({ dev = false } = {}, env = process.env) {
75
+ const platform = env.CROSSBIND_APP_PLATFORM;
76
+ const app = (platforms, archs) => {
77
+ platforms.forEach((name) => assertAppRunner({ platform: name, runtimeEnv: 'app' }));
78
+ return getTargetParams({ platform: platforms, arch: archs, runtime: ['mt'], runtimeEnv: ['app'] }, true);
79
+ };
80
+ if (!platform) return app([process.platform], [process.arch]);
81
+ if (platform === 'android') return app(['android'], dev ? [attachedDevice().abi] : ANDROID_ABIS);
82
+ if (platform === 'linux') {
83
+ if (dev && process.platform !== 'linux') {
84
+ throw new Error('crossbind: a Linux app runs on Linux, and so does its development session; build it here with crossbind app build -p linux.');
85
+ }
86
+ return app(['linux'], dev ? [process.arch] : Object.keys(LINUX_MULTIARCH));
87
+ }
88
+ if (platform === 'macos') {
89
+ if (process.platform !== 'darwin') throw new Error('crossbind: macOS apps build on macOS.');
90
+ return app(['darwin'], [process.arch]);
91
+ }
92
+ if (platform === 'windows') {
93
+ if (dev && process.platform !== 'win32') {
94
+ throw new Error('crossbind: a Windows app runs on Windows, and so does its development session; build it here with crossbind app build -p windows.');
95
+ }
96
+ return app(['win32'], dev ? [process.arch] : ['x64', 'arm64']);
97
+ }
98
+ if (platform !== 'ios') {
99
+ throw new Error(`crossbind: CROSSBIND_APP_PLATFORM=${platform} names no platform an app builds for. Set it to android, ios, linux, macos or windows, or leave it unset for this machine's desktop app.`);
100
+ }
101
+ if (process.platform !== 'darwin') throw new Error('crossbind: iOS apps build on macOS, with Xcode.');
102
+ return getTargetParams({ platform: ['ios'], arch: dev ? ['iphonesimulator'] : IOS_SDKS, runtime: ['mt'], runtimeEnv: ['app'] }, true);
103
+ }
104
+
105
+ // An app's bundle id: the project's app.id, else one made from the project's name.
106
+ function appId() {
107
+ const id = state.config.app.id ?? `dev.crossbind.${state.config.general.name.replace(/[^A-Za-z0-9.-]/g, '-')}`;
108
+ if (!/^[A-Za-z0-9.-]+$/.test(id)) {
109
+ throw new Error(`crossbind: app.id ${JSON.stringify(id)} is no bundle id, which takes letters, digits, hyphens and dots, as com.example.app does.`);
110
+ }
111
+ return id;
112
+ }
113
+
114
+ // The Apple team that signs an iOS app: the project's app.team, else the machine's XCODE_DEVELOPMENT_TEAM, else none.
115
+ function appTeam() {
116
+ const team = state.config.app.team ?? state.config.system.XCODE_DEVELOPMENT_TEAM ?? '';
117
+ if (team && !/^[A-Z0-9]{10}$/.test(team)) {
118
+ throw new Error(`crossbind: ${JSON.stringify(team)} (app.team or XCODE_DEVELOPMENT_TEAM) is no Apple team id, which is ten capital letters and digits.`);
119
+ }
120
+ return team;
121
+ }
122
+
123
+ // An Android app's id: the project's app.id, else one made from the project's name.
124
+ function androidAppId() {
125
+ const id = state.config.app.id ?? `dev.crossbind.${state.config.general.name.replace(/[^A-Za-z0-9.]/g, '_')}`;
126
+ if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(id)) {
127
+ throw new Error(`crossbind: ${JSON.stringify(id)} (app.id, else made from the project's name) is no Android application id, which takes dot-separated parts of letters, digits and underscores that each start with a letter, as com.example.app does. Set app.id in crossbind.config.js.`);
128
+ }
129
+ return id;
130
+ }
131
+
132
+ // Apple takes dot-separated integers alone as an app's version.
133
+ const appVersion = () => (state.config.package?.version ?? '1.0.0').replace(/[-+].*$/, '');
134
+
135
+ // Android takes any name for an app's version, and a positive integer that grows from release to release as its code.
136
+ function androidVersion() {
137
+ const [major = 0, minor = 0, patch = 0] = appVersion().split('.').map(Number);
138
+ return { name: state.config.package?.version ?? '1.0.0', code: Math.max(1, major * 1000000 + minor * 1000 + patch) };
139
+ }
140
+
141
+ // The apps this run built or found current. A part of an iOS app that an earlier run built may be stale.
142
+ const currentApps = new Set();
143
+
144
+ // What a host compiles and links against beside the project's archives: Hermes, on Linux WebKitGTK and on Windows the
145
+ // WebView2 SDK's headers. Apple's Hermes is React Native's release on this machine; the others are in the image the host
146
+ // builds in, android-app, linux or windows, whose reference stands for them in the fingerprint. An Android host shares
147
+ // the C++ runtime of Hermes' libraries.
148
+ async function hostInputs(target) {
149
+ if (['darwin', 'ios'].includes(target.platform)) {
150
+ const hermes = await resolveHermes();
151
+ return { hermes, cmakeArgs: [], inputs: [hermes] };
152
+ }
153
+ assertAppRunner(target);
154
+ const inputs = [getDockerImage(imageRoleFor(target), target.platform === 'android' ? 'linux/amd64' : undefined)];
155
+ if (target.platform === 'android') {
156
+ return { hermes: `${IMAGE_HERMES}/android`, cmakeArgs: ['-DANDROID_STL=c++_shared'], inputs };
157
+ }
158
+ if (target.platform === 'win32') {
159
+ return { hermes: `${IMAGE_HERMES}/win32-${target.arch}`, cmakeArgs: ['-DCROSSBIND_WEBVIEW2_INCLUDE=/opt/crossbind/webview2/include'], inputs };
160
+ }
161
+ return {
162
+ hermes: `${IMAGE_HERMES}/linux-${target.arch}`,
163
+ cmakeArgs: [
164
+ `-DCROSSBIND_GTK_SYSROOT=/opt/crossbind/webkitgtk/${LINUX_MULTIARCH[target.arch]}`,
165
+ `-DCROSSBIND_GTK_MULTIARCH=${LINUX_MULTIARCH[target.arch]}`,
166
+ ],
167
+ inputs,
168
+ };
169
+ }
170
+
171
+ // The app host package's scripts import crossbind's own runtime modules under crossbind/runtime/.
172
+ const runtimeModules = () => ({
173
+ name: 'crossbind-runtime-modules',
174
+ resolveId: (id) => (id.startsWith('crossbind/runtime/')
175
+ ? `${state.config.paths.cli}/assets/js-runtime/${id.slice('crossbind/runtime/'.length)}`
176
+ : null),
177
+ });
178
+
179
+ async function bundle(input, file, plugins) {
180
+ const build = await rollup({ input, plugins, onwarn: suppressNodeBuiltinWarnings });
181
+ await build.write({ file, format: 'iife' });
182
+ await build.close();
183
+ }
184
+
185
+ const infoPlist = (name, version) => xmlPlist({
186
+ CFBundleExecutable: name,
187
+ CFBundleIdentifier: appId(),
188
+ CFBundleName: name,
189
+ CFBundlePackageType: 'APPL',
190
+ CFBundleShortVersionString: version,
191
+ CFBundleVersion: version,
192
+ LSMinimumSystemVersion: DARWIN_DEPLOYMENT_TARGET,
193
+ NSPrincipalClass: 'NSApplication',
194
+ NSHighResolutionCapable: true,
195
+ });
196
+
197
+ // The host's bytecode and the data the packages ship, beside the page's folder.
198
+ function copyAppFiles(dir, hbc, target) {
199
+ fs.mkdirSync(`${dir}/page`, { recursive: true });
200
+ fs.copyFileSync(hbc, `${dir}/host.hbc`);
201
+ Object.entries(getData('data', target)).forEach(([source, dataName]) => {
202
+ if (fs.existsSync(source)) fs.cpSync(source, `${dir}/data/${dataName}`, { recursive: true });
203
+ });
204
+ }
205
+
206
+ // Everything but the page: the host and the app's files, and on macOS Hermes beside them.
207
+ function assembleApp(target, { executable, hbc, hermes }) {
208
+ const app = `${state.config.paths.build}/${target.appName}`;
209
+ const { name } = state.config.general;
210
+ const layout = appLayout(target);
211
+ fs.rmSync(app, { recursive: true, force: true });
212
+ fs.mkdirSync(path.dirname(`${app}/${layout.executable}`), { recursive: true });
213
+ fs.copyFileSync(executable, `${app}/${layout.executable}`);
214
+ copyAppFiles(`${app}/${layout.resources}`, hbc, target);
215
+ if (target.platform !== 'darwin') return;
216
+ fs.writeFileSync(`${app}/Contents/Info.plist`, infoPlist(name, appVersion()));
217
+ const framework = `${app}/Contents/Frameworks/hermesvm.framework`;
218
+ fs.cpSync(`${hermes}/Library/Frameworks/macosx/hermesvm.framework`, framework, { recursive: true, verbatimSymlinks: true });
219
+ // The release carries Intel and Apple silicon; an app of one arch needs its own half.
220
+ const library = `${framework}/Versions/Current/hermesvm`;
221
+ run(null, ['lipo', library, '-thin', target.arch === 'x64' ? 'x86_64' : 'arm64', '-output', `${library}.thin`], null, target);
222
+ fs.renameSync(`${library}.thin`, library);
223
+ }
224
+
225
+ // What an iOS SDK target brings to the app's Xcode project: the host and the archives it links, copied beside a link
226
+ // line that names them under {lib}, its slice of Hermes, and the app's files.
227
+ function assembleIosPart(target, { library, linkArgs, hbc, hermes }) {
228
+ const part = `${state.config.paths.build}/${target.appName}`;
229
+ fs.rmSync(part, { recursive: true, force: true });
230
+ fs.mkdirSync(`${part}/lib`, { recursive: true });
231
+ let count = 0;
232
+ const place = (file) => {
233
+ const name = `${count++}-${path.basename(file)}`;
234
+ fs.copyFileSync(file, `${part}/lib/${name}`);
235
+ return `{lib}/${name}`;
236
+ };
237
+ const args = [`-Wl,-force_load,${place(library)}`, ...linkArgs.map((arg) => {
238
+ const archive = arg.match(/^(-Wl,-force_load,)?(\/.+\.a)$/);
239
+ if (archive) return `${archive[1] ?? ''}${place(archive[2])}`;
240
+ if (arg.endsWith('.a')) throw new Error(`crossbind: the iOS app cannot take ${arg} into its Xcode project, as it names no absolute path.`);
241
+ return arg;
242
+ })];
243
+ fs.writeFileSync(`${part}/link.json`, JSON.stringify(args, null, 2));
244
+ fs.cpSync(`${hermes}/Library/Frameworks/universal/hermesvm.xcframework/${HERMES_SLICES[target.arch]}/hermesvm.framework`,
245
+ `${part}/hermesvm.framework`, { recursive: true, verbatimSymlinks: true });
246
+ copyAppFiles(`${part}/app`, hbc, target);
247
+ }
248
+
249
+ // What an Android ABI target brings to the app's Gradle project: the host, the libraries beside it and the packages'
250
+ // shared libraries it links, and the app's files.
251
+ function assembleAndroidPart(target, { libDir, sharedLibs, hbc }) {
252
+ const part = `${state.config.paths.build}/${target.appName}`;
253
+ fs.rmSync(part, { recursive: true, force: true });
254
+ fs.mkdirSync(`${part}/lib`, { recursive: true });
255
+ [...['libcrossbind_app.so', 'libjsi.so', 'libc++_shared.so'].map((file) => `${libDir}/${file}`), ...sharedLibs]
256
+ .forEach((file) => fs.copyFileSync(file, `${part}/lib/${path.basename(file)}`));
257
+ copyAppFiles(`${part}/app`, hbc, target);
258
+ }
259
+
260
+ // The parts of an iOS or Android app this run has for its build type, one per SDK or ABI.
261
+ const appParts = (target) => state.targets
262
+ .filter((t) => t.platform === target.platform && t.runtimeEnv === 'app' && t.buildType === target.buildType && currentApps.has(t.appName))
263
+ .map((t) => ({ arch: t.arch, dir: `${state.config.paths.build}/${t.appName}` }));
264
+
265
+ // A Linux or Windows app also ships as one archive, next to its directory: a Windows one as a zip, made in the windows
266
+ // image.
267
+ function archiveApp(app, target) {
268
+ if (target.platform === 'win32') {
269
+ if (!isUnderBase(app, state.config.paths.base)) {
270
+ throw new Error(`crossbind: the Windows app ${app} is outside ${state.config.paths.base}, the project's base and all the windows container sees. Build the app into a directory under it.`);
271
+ }
272
+ fs.rmSync(`${app}.zip`, { force: true });
273
+ run(null, ['zip', '-q', '-r', '-X', `${path.basename(app)}.zip`, path.basename(app)], null, target, { workdir: path.dirname(app) });
274
+ return;
275
+ }
276
+ const result = spawnSync('tar', ['-czf', `${app}.tar.gz`, '-C', path.dirname(app), path.basename(app)], {
277
+ encoding: 'utf8',
278
+ env: { ...process.env, COPYFILE_DISABLE: '1' },
279
+ });
280
+ if (result.status !== 0) throw new Error(`crossbind: cannot archive ${app}: ${result.stderr || result.error?.message}`);
281
+ }
282
+
283
+ // Apple silicon runs signed code only, and a bundle's signature covers every file in it, so an app is signed once the
284
+ // page is in: ad hoc, which runs on the machine that built it.
285
+ function signApp(app, target) {
286
+ run(null, ['codesign', '--force', '--sign', '-', '--timestamp=none', `${app}/Contents/Frameworks/hermesvm.framework`], null, target);
287
+ run(null, ['codesign', '--force', '--sign', '-', '--timestamp=none', app], null, target);
288
+ }
289
+
290
+ // Where a build without a bundler puts an app: under its own name, or for iOS and Android as the project of its build
291
+ // type.
292
+ export function appOutputPath(target) {
293
+ const { output } = state.config.paths;
294
+ if (!['ios', 'android'].includes(target.platform)) return `${output}/${target.appName}`;
295
+ return `${output}/${appOutputName(target)}${target.buildType === 'debug' ? '.debug' : ''}`;
296
+ }
297
+
298
+ // The built app with a page's files at `to`: the bundler's output for its page, or the project's output for a build
299
+ // with none. An iOS app is its Xcode project and an Android app its Gradle project, with every SDK or ABI built so far.
300
+ export function publishApp(target, { to = appOutputPath(target), page = null } = {}) {
301
+ const built = `${state.config.paths.build}/${target.appName}`;
302
+ if (target.platform === 'ios') {
303
+ const parts = appParts(target).map(({ arch, dir }) => ({ sdk: arch, dir }));
304
+ writeXcodeProject(to, { parts, identity: { id: appId(), team: appTeam() }, version: appVersion() });
305
+ } else if (target.platform === 'android') {
306
+ writeGradleProject(to, { parts: appParts(target), id: androidAppId(), version: androidVersion() });
307
+ } else if (path.resolve(to) !== path.resolve(built)) {
308
+ fs.rmSync(to, { recursive: true, force: true });
309
+ fs.cpSync(built, to, { recursive: true, verbatimSymlinks: true });
310
+ }
311
+ if (page) {
312
+ // The app may land inside the page's own directory, which it then leaves out, with every app built there and what
313
+ // packages it.
314
+ const apps = new Set([path.basename(to), ...state.targets.filter((t) => t.runtimeEnv === 'app').map(appOutputName)]);
315
+ const isApp = (entry) => entry.endsWith('.app') || apps.has(entry.replace(/\.(tar\.gz|zip|apk|aab)$/, ''));
316
+ fs.readdirSync(page).filter((entry) => !isApp(entry)).forEach((entry) => {
317
+ fs.cpSync(`${page}/${entry}`, `${to}/${appLayout(target).resources}/page/${entry}`, { recursive: true });
318
+ });
319
+ }
320
+ if (target.platform === 'darwin') signApp(to, target);
321
+ else if (target.platform === 'android' && target.buildType === 'release') packageAndroidRelease(to);
322
+ else if (!['ios', 'android'].includes(target.platform)) archiveApp(to, target);
323
+ return to;
324
+ }
325
+
326
+ // Builds an app's host from the project's archives and the given bridges, its Hermes bytecode and the page runtime the
327
+ // page loads as crossbind.js, and puts them into an app without a page.
328
+ export default async function buildApp(target, { bridges = [], force = false } = {}) {
329
+ if (['darwin', 'ios'].includes(target.platform) && process.platform !== 'darwin') {
330
+ logger.info(`[${target.path}] app skipped (macOS and iOS apps build on a macOS host)`);
331
+ return false;
332
+ }
333
+ if (state.config.export.type === 'cargo') {
334
+ logger.info(`[${target.path}] app skipped (cargo package - staticlib only)`);
335
+ return false;
336
+ }
337
+
338
+ const buildType = target.buildType === 'release' ? 'Release' : 'Debug';
339
+ const { build } = state.config.paths;
340
+ const appHostRoot = resolveAppHostRoot();
341
+ const jsiRoot = resolveAppEmbindJsiRoot();
342
+ const { hermes, cmakeArgs, inputs } = await hostInputs(target);
343
+
344
+ // The bridges compile against the embind runtime, which speaks the JSI of the Hermes the host links. Android's
345
+ // Hermes comes without JSI's headers, which the host package has from the same release.
346
+ const headerDirs = [`${jsiRoot}/cpp/src`, `${hermes}/include`, ...(target.platform === 'android' ? [`${appHostRoot}/third_party/jsi/include`] : [])];
347
+ createLib(target, BRIDGE, {
348
+ buildSource: false,
349
+ nativeGlob: [`${state.config.paths.cli}/assets/cpp-runtime/commonBridges.cpp`, ...bridges],
350
+ headerDirs,
351
+ // Headers in the image count by their path, which names the Hermes release.
352
+ inputs: headerDirs.flatMap((dir) => (fs.existsSync(dir) ? findFiles('**/*.h', { cwd: dir }) : [dir])),
353
+ });
354
+ const { libs, linkArgs, extraSources } = linkInputs(target, { bridge: BRIDGE });
355
+
356
+ const fingerprintFile = `${build}/${target.appName}.fingerprint`;
357
+ const fingerprint = getContentHash(JSON.stringify({
358
+ builder: getFilesFingerprint([fileURLToPath(import.meta.url)]),
359
+ hermes: HERMES.version,
360
+ inputs,
361
+ linkArgs,
362
+ libs: libs.map((lib) => {
363
+ const stat = fs.existsSync(lib) ? fs.statSync(lib) : null;
364
+ return { lib, size: stat ? stat.size : null, mtimeMs: stat ? stat.mtimeMs : null };
365
+ }),
366
+ env: getEnv(target),
367
+ data: getData('data', target),
368
+ version: state.config.package?.version,
369
+ runtime: getFilesFingerprint(filesUnder([
370
+ `${appHostRoot}/cpp`, `${appHostRoot}/js`, `${appHostRoot}/third_party`, `${jsiRoot}/cpp/src`, `${jsiRoot}/js`,
371
+ `${state.config.paths.cli}/assets/js-runtime/adapters`,
372
+ ])),
373
+ extraSources: getFilesFingerprint(extraSources),
374
+ }));
375
+ const isChanged = !fs.existsSync(fingerprintFile) || fs.readFileSync(fingerprintFile, { encoding: 'utf8' }) !== fingerprint;
376
+ if (!force && !isChanged && fs.existsSync(`${build}/${target.appName}`) && fs.existsSync(`${build}/${target.appJsName}`)) {
377
+ logger.cachedStep(target, 'app');
378
+ currentApps.add(target.appName);
379
+ return false;
380
+ }
381
+
382
+ const platformPrefix = `App-${buildType}`;
383
+ const workDir = `${build}/${platformPrefix}/${target.path}`;
384
+ const { name } = state.config.general;
385
+ // An Android app's activity loads its host by this one name.
386
+ const hostName = target.platform === 'android' ? 'crossbind_app' : name;
387
+ logger.startStep(target, 'app');
388
+ resetCMakeCacheForSource(workDir, `${appHostRoot}/cpp`, target);
389
+ run(null, [
390
+ // An iOS host builds with crossbind's iOS toolchain file rather than as an Xcode project of its own.
391
+ target.platform === 'ios' ? 'ios-cmake' : 'cmake', `${appHostRoot}/cpp`,
392
+ `-DCMAKE_BUILD_TYPE=${buildType}`,
393
+ `-DCROSSBIND_APP_FILE=${hostName}`,
394
+ `-DCROSSBIND_JSI_ROOT=${jsiRoot}`,
395
+ `-DCROSSBIND_HERMES=${hermes}`,
396
+ `-DCROSSBIND_EXTRA_SOURCES=${extraSources.join(';')}`,
397
+ `-DCROSSBIND_LINK_ARGS=${linkArgs.join(';')}`,
398
+ `-DCROSSBIND_LINK_DEPENDS=${libs.join(';')}`,
399
+ ...cmakeArgs,
400
+ ], platformPrefix, target);
401
+ run(null, ['cmake', '--build', '.', '-j', String(cpuCount)], platformPrefix, target, { exclusive: true });
402
+ if (target.platform === 'win32') copyWindowsNotices(target);
403
+
404
+ const systemConfig = `export default ${JSON.stringify({ env: getEnv(target) })};`;
405
+ await bundle(`${appHostRoot}/js/host.js`, `${workDir}/host.js`, [
406
+ virtual({ 'crossbind/systemConfig': systemConfig }), runtimeModules(), nodeResolve(), commonjs(),
407
+ ]);
408
+ // Without -Xes6-block-scoping, hermesc compiles let and const as var, and closures made in a loop share one binding.
409
+ run(null, [`${hermes}/bin/hermesc`, '-O', '-Xes6-block-scoping', '-emit-binary', '-out', `${workDir}/host.hbc`, `${workDir}/host.js`],
410
+ platformPrefix, target);
411
+ await bundle(`${appHostRoot}/js/page.js`, `${build}/${target.appJsName}`, []);
412
+ if (target.platform === 'ios') {
413
+ assembleIosPart(target, { library: `${workDir}/lib${name}.a`, linkArgs, hbc: `${workDir}/host.hbc`, hermes });
414
+ } else if (target.platform === 'android') {
415
+ assembleAndroidPart(target, { libDir: workDir, sharedLibs: libs.filter((lib) => lib.endsWith('.so')), hbc: `${workDir}/host.hbc` });
416
+ } else {
417
+ assembleApp(target, { executable: `${workDir}/${path.basename(appLayout(target).executable)}`, hbc: `${workDir}/host.hbc`, hermes });
418
+ }
419
+ logger.doneStep(target, 'app');
420
+
421
+ fs.writeFileSync(fingerprintFile, fingerprint);
422
+ writeNativeSourceStamp(`${build}/${target.appName}`, state.config.paths.native);
423
+ currentApps.add(target.appName);
424
+ return true;
425
+ }
426
+
427
+ // The app of the project as it stands, for a bundler plugin, which needs the host before the page asks for anything:
428
+ // the bridges a plain build binds, found in the project's headers and sources, then its own archive and the host.
429
+ export async function buildProjectApp(target) {
430
+ const bridges = createNativeBridges([target]).headers.flatMap((header) => header.bridge ?? []);
431
+ const force = isNativeSourceNewerThan(`${state.config.paths.build}/${target.appName}`);
432
+ createLib(target, 'Source', { force, buildSource: true });
433
+ return buildApp(target, { bridges, force });
434
+ }
Binary file
@@ -83,12 +83,13 @@ async function buildMissingCargoDependencies(buildTargets, targetParams) {
83
83
  const bridgeTarget = buildTargets.some(isWasm) ? state.targets.find(isWasm) : undefined;
84
84
  const extraTarget = bridgeTarget && !buildTargets.some((target) => target.path === bridgeTarget.path) ? bridgeTarget : undefined;
85
85
  const targets = extraTarget ? [...buildTargets, extraTarget] : buildTargets;
86
- // A prebuilt from another embind-rs counts as missing: linked, it carries stale glue or clashes with the others.
87
- const fingerprint = getEmbindRsFingerprint();
86
+ // A prebuilt from another embind-rs, or for an image's target from another rustc, counts as missing: linked, it
87
+ // carries stale glue or clashes with the others.
88
88
  const prebuiltOf = (dep, target) => [target.path, target.releasePath]
89
89
  .map((targetPath) => `${dep.paths.output}/prebuilt/${targetPath}`)
90
90
  .find((dir) => fs.existsSync(dir)) ?? `${dep.paths.output}/prebuilt/${target.path}`;
91
- const isBuilt = (dep, target) => dep.functions.isEnabled(target) && !isEmbindRsFingerprintStale(prebuiltOf(dep, target), fingerprint);
91
+ const isBuilt = (dep, target) => dep.functions.isEnabled(target)
92
+ && !isEmbindRsFingerprintStale(prebuiltOf(dep, target), getEmbindRsFingerprint(target));
92
93
  const missing = cargoDependencies.filter((dep) => targets.some((target) => !isBuilt(dep, target)));
93
94
 
94
95
  for (const dep of missing) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import replace from 'replace';
3
- import downloadAndExtractFile from '../utils/downloadAndExtractFile.js';
3
+ import downloadAndExtractFile, { sourcesOf } from '../utils/downloadAndExtractFile.js';
4
4
  import copyToDistSource from '../utils/copyToDistSource.js';
5
5
  import { getBuildTargets } from './target.js';
6
6
  import state from '../state/index.js';
@@ -14,8 +14,8 @@ export default async function buildExternal(targetParams, options = {}) {
14
14
  throw new Error(`crossbind: "${state.config.general?.name}" has a crossbind.build recipe but no "nativeVersion" in package.json — cannot build from source.`);
15
15
  }
16
16
 
17
- const { getURL, sha256, replaceList, copyToSource, copyToDist } = state.config.build;
18
- const isNewlyCreated = await downloadAndExtractFile(getURL(version), state.config.paths.build, sha256);
17
+ const { sha256, replaceList, copyToSource, copyToDist } = state.config.build;
18
+ const isNewlyCreated = await downloadAndExtractFile(sourcesOf(state.config.build, version), state.config.paths.build, sha256);
19
19
  const sourcePath = `${state.config.paths.build}/source`;
20
20
  if (isNewlyCreated && replaceList) {
21
21
  replaceList.forEach(({ regex, replacement, paths }) => {
@@ -5,7 +5,7 @@ import { nodeResolve } from '@rollup/plugin-node-resolve';
5
5
  import commonjs from '@rollup/plugin-commonjs';
6
6
  import virtual from '@rollup/plugin-virtual';
7
7
  import state from '../state/index.js';
8
- import getData from './getData.js';
8
+ import getEnv from './getEnv.js';
9
9
 
10
10
  const SUPPRESSED_WARN_CODES = new Set([
11
11
  'MISSING_GLOBAL_NAME',
@@ -53,14 +53,7 @@ options.edge = options.browser;
53
53
 
54
54
  export default async function buildJS(target) {
55
55
  const entryJS = `${state.config.paths.cli}/assets/js-runtime/${target.runtimeEnv}.js`;
56
- const rawEnv = getData('env', target);
57
- const resolvedEnv = Object.fromEntries(
58
- Object.entries(rawEnv).map(([key, value]) => [
59
- key,
60
- typeof value === 'function' ? value(state, target) : value,
61
- ]),
62
- );
63
- const env = JSON.stringify(resolvedEnv);
56
+ const env = JSON.stringify(getEnv(target));
64
57
  const systemConfig = `export default {
65
58
  env: ${env},
66
59
  useWorker: !!globalThis.Worker,
@@ -54,7 +54,7 @@ export default function buildLib(targetParams, options = {}) {
54
54
  fs.copyFileSync(modulePath, `${state.config.paths.output}/prebuilt/${target.path}/swig/${fileName}`);
55
55
  });
56
56
  writeSourceFingerprint(libdir, sourceFingerprint);
57
- if (isCargo) writeEmbindRsFingerprint(libdir, getEmbindRsFingerprint());
57
+ if (isCargo) writeEmbindRsFingerprint(libdir, getEmbindRsFingerprint(target));
58
58
  isChanged = true;
59
59
  } else {
60
60
  logger.cachedStep(target, 'lib');
@@ -8,6 +8,8 @@ import virtual from '@rollup/plugin-virtual';
8
8
  import run from './run.js';
9
9
  import getLinkInputs from './getLinkInputs.js';
10
10
  import getData from './getData.js';
11
+ import getEnv from './getEnv.js';
12
+ import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
11
13
  import { suppressNodeBuiltinWarnings } from './buildJs.js';
12
14
  import state from '../state/index.js';
13
15
  import logger from '../utils/logger.js';
@@ -22,7 +24,7 @@ import toolchainNoticesDir from '../utils/toolchainNotices.js';
22
24
  const cpuCount = Math.max(1, os.cpus().length - 1);
23
25
  const WINDOWS_IMAGE_NOTICES = '/opt/licenses/llvm-mingw';
24
26
 
25
- function filesUnder(dirs) {
27
+ export function filesUnder(dirs) {
26
28
  return dirs.flatMap((dir) => (fs.existsSync(dir)
27
29
  ? fs.readdirSync(dir, { recursive: true })
28
30
  .map((file) => `${dir}/${file}`)
@@ -30,18 +32,21 @@ function filesUnder(dirs) {
30
32
  : []));
31
33
  }
32
34
 
33
- function linkInputs(target) {
35
+ // The addon's link, and an app host's, whose bridges come from an archive of their own.
36
+ export function linkInputs(target, { bridge } = {}) {
37
+ const isMachO = ['darwin', 'ios'].includes(target.platform);
34
38
  const {
35
39
  libs, appRustLibs, wholeArchiveAll, wholeArchiveNames, rustKeepFlags, hasRust,
36
40
  } = getLinkInputs(target, {
37
41
  // The linker loads the archive member that defines a -u symbol; Mach-O prefixes C names with _.
38
- keepFlag: (name) => `-Wl,-u,${target.platform === 'darwin' ? '_' : ''}crossbind_keep_${name}`,
42
+ keepFlag: (name) => `-Wl,-u,${isMachO ? '_' : ''}crossbind_keep_${name}`,
43
+ bridge,
39
44
  });
40
45
  // The app's Rust goes first: what a package's kept member needs from std and rustc's allocator shims is then
41
46
  // already the app's, and the package's own copies stay in its archive instead of defining them twice.
42
47
  const ordered = [...appRustLibs, ...libs.filter((lib) => !appRustLibs.includes(lib))];
43
48
  const linkArgs = [
44
- ...buildLinkLibArgs(ordered, { wholeArchiveAll, wholeArchiveNames, forceLoad: target.platform === 'darwin' }),
49
+ ...buildLinkLibArgs(ordered, { wholeArchiveAll, wholeArchiveNames, forceLoad: isMachO }),
45
50
  ...rustKeepFlags,
46
51
  // The system libraries the archives need, declared by the packages that bring them in.
47
52
  ...(getData('binary', target)?.addonFlags ?? []),
@@ -52,11 +57,6 @@ function linkInputs(target) {
52
57
  return { libs, linkArgs, extraSources };
53
58
  }
54
59
 
55
- function envOf(target) {
56
- return Object.fromEntries(Object.entries(getData('env', target))
57
- .map(([key, value]) => [key, typeof value === 'function' ? value(state, target) : value]));
58
- }
59
-
60
60
  // A package publishes its addons in <package>-<platform>-<arch> by listing them as optional
61
61
  // dependencies, which is also how npm learns to install only the one for its machine.
62
62
  function listsAddonPackage(target) {
@@ -82,7 +82,7 @@ export function nodeLoaderConfig(target) {
82
82
  const addonPackage = addonPackagePattern(served);
83
83
  const loaded = addonPackage ? served.filter(listsAddonPackage) : served;
84
84
  return {
85
- env: Object.fromEntries(loaded.map((t) => [`${t.platform}-${t.arch}`, envOf(t)])),
85
+ env: Object.fromEntries(loaded.map((t) => [`${t.platform}-${t.arch}`, getEnv(t)])),
86
86
  general: { name: state.config.general.name },
87
87
  paths: { addon: target.addonPattern, ...(addonPackage ? { addonPackage } : {}) },
88
88
  };
@@ -119,6 +119,13 @@ export function bundleNodeLoader(target) {
119
119
  return bundleLoader(target, resolveEmbindNapiRoot(), nodeLoaderConfig(target));
120
120
  }
121
121
 
122
+ // The mingw-w64 runtime and winpthreads in a Windows addon or app ask for their notices in binary
123
+ // distributions. The windows image carries them; a host toolchain may not.
124
+ export function copyWindowsNotices(target) {
125
+ const notices = toolchainNoticesDir('win32');
126
+ run(null, ['sh', '-c', `if [ -d ${WINDOWS_IMAGE_NOTICES} ]; then mkdir -p ${notices} && cp ${WINDOWS_IMAGE_NOTICES}/* ${notices}/; fi`], null, target);
127
+ }
128
+
122
129
  // Links one Node-API addon from the project's archives and bundles the loader that picks the
123
130
  // addon matching the running process.
124
131
  export default async function buildNode(target, options = {}) {
@@ -162,6 +169,7 @@ export default async function buildNode(target, options = {}) {
162
169
 
163
170
  const platformPrefix = `Node-${buildType}`;
164
171
  logger.startStep(target, 'addon');
172
+ resetCMakeCacheForSource(`${build}/${platformPrefix}/${target.path}`, `${napiRoot}/cpp`, target);
165
173
  run(null, [
166
174
  'cmake', `${napiRoot}/cpp`,
167
175
  `-DCMAKE_BUILD_TYPE=${buildType}`,
@@ -172,12 +180,7 @@ export default async function buildNode(target, options = {}) {
172
180
  `-DCROSSBIND_LINK_DEPENDS=${libs.join(';')}`,
173
181
  ], platformPrefix, target);
174
182
  run(null, ['cmake', '--build', '.', '-j', String(cpuCount)], platformPrefix, target, { exclusive: true });
175
- if (target.platform === 'win32') {
176
- // The mingw-w64 runtime and winpthreads in the addon ask for their notices in binary
177
- // distributions. The windows image carries them; a host toolchain may not.
178
- const notices = toolchainNoticesDir('win32');
179
- run(null, ['sh', '-c', `if [ -d ${WINDOWS_IMAGE_NOTICES} ]; then mkdir -p ${notices} && cp ${WINDOWS_IMAGE_NOTICES}/* ${notices}/; fi`], null, target);
180
- }
183
+ if (target.platform === 'win32') copyWindowsNotices(target);
181
184
  fs.copyFileSync(`${build}/${platformPrefix}/${target.path}/${target.addonName}`, `${build}/${target.addonName}`);
182
185
  logger.doneStep(target, 'addon');
183
186