crossbind 2.0.0-beta.64 → 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.
- package/package.json +1 -1
- package/src/actions/androidDevice.js +77 -0
- package/src/actions/androidGradle.js +121 -0
- package/src/actions/buildApp.js +434 -0
- package/src/actions/buildDependencies.js +4 -3
- package/src/actions/buildExternal.js +3 -3
- package/src/actions/buildLib.js +1 -1
- package/src/actions/buildNode.js +15 -10
- package/src/actions/createInterface.js +1 -2
- package/src/actions/createLib.js +4 -1
- package/src/actions/getCmakeParameters.js +2 -1
- package/src/actions/getDependLibs.js +8 -2
- package/src/actions/getLinkInputs.js +10 -7
- package/src/actions/gradleProject.js +110 -0
- package/src/actions/iosSimulator.js +63 -0
- package/src/actions/licenses.js +160 -14
- package/src/actions/nativeBridges.js +35 -0
- package/src/actions/run.js +3 -3
- package/src/actions/target.js +2 -1
- package/src/actions/xcodeProject.js +152 -0
- package/src/assets/js-runtime/adapters/worker-comlink.js +8 -1
- package/src/assets/toolchain-digests.json +24 -18
- package/src/bin.js +75 -32
- package/src/index.js +6 -0
- package/src/integration/getCrossbindScript.js +15 -1
- package/src/state/index.js +5 -1
- package/src/state/loadConfig.js +2 -0
- package/src/utils/appRustCrates.js +3 -1
- package/src/utils/downloadAndExtractFile.js +42 -14
- package/src/utils/embindRsFingerprint.js +10 -3
- package/src/utils/hermes.js +40 -0
- package/src/utils/plist.js +42 -0
- package/src/utils/pullDockerImage.js +9 -3
- package/src/utils/replaceBasePathForDocker.js +8 -0
- package/src/utils/resolveAppHost.js +21 -0
- package/src/utils/selectRunner.js +1 -1
- package/src/utils/systemKeys.js +4 -0
- package/src/utils/targets.js +19 -3
- package/vitest.config.js +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { ANDROID_ABIS } from './gradleProject.js';
|
|
6
|
+
import { EXEC_MAX_BUFFER } from './run.js';
|
|
7
|
+
|
|
8
|
+
const ACTIVITY = 'dev.crossbind.app.CrossbindActivity';
|
|
9
|
+
|
|
10
|
+
// The adb that reaches the device: the one on PATH, else the platform tools of ANDROID_HOME's SDK or Android Studio's.
|
|
11
|
+
// crossbind installs none.
|
|
12
|
+
function adbPath() {
|
|
13
|
+
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT
|
|
14
|
+
|| path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Android/sdk' : 'Android/Sdk');
|
|
15
|
+
const found = [...(process.env.PATH ?? '').split(path.delimiter).map((dir) => path.join(dir, 'adb')), path.join(sdk, 'platform-tools', 'adb')]
|
|
16
|
+
.find((file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile());
|
|
17
|
+
if (!found) {
|
|
18
|
+
throw new Error('crossbind: an Android app runs on a device through adb, which is not on PATH nor in an Android SDK. Install Android\'s platform tools: `brew install --cask android-platform-tools` on macOS, `sudo apt install adb` on Debian and Ubuntu, or Android Studio.');
|
|
19
|
+
}
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tool(program, args, options = {}) {
|
|
24
|
+
const result = spawnSync(program, args, { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, ...options });
|
|
25
|
+
if (result.status !== 0) {
|
|
26
|
+
throw new Error(`crossbind: ${path.basename(program)} ${args.join(' ')} failed: ${`${result.stdout ?? ''}${result.stderr ?? ''}`.trim() || result.error?.message}`);
|
|
27
|
+
}
|
|
28
|
+
return result.stdout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const adb = (args) => tool(adbPath(), args);
|
|
32
|
+
// adb shell hands the device's shell one line, which splits it again.
|
|
33
|
+
const quoted = (text) => `'${String(text).replace(/'/g, "'\\''")}'`;
|
|
34
|
+
|
|
35
|
+
// The device an app runs on in development and in tests: the one ANDROID_SERIAL names, else the only one attached, and
|
|
36
|
+
// the ABI of it the app builds for. crossbind starts no emulator.
|
|
37
|
+
export function attachedDevice() {
|
|
38
|
+
const serials = adb(['devices']).split('\n').slice(1).map((line) => line.trim().split('\t'))
|
|
39
|
+
.filter(([, status]) => status === 'device').map(([serial]) => serial);
|
|
40
|
+
const serial = process.env.ANDROID_SERIAL || (serials.length === 1 ? serials[0] : null);
|
|
41
|
+
if (!serial) {
|
|
42
|
+
throw new Error(serials.length === 0
|
|
43
|
+
? 'crossbind: no Android device is attached. Start an emulator, in Android Studio\'s Device Manager or with `emulator -avd <name>`, or connect a phone with USB debugging on.'
|
|
44
|
+
: `crossbind: ${serials.length} Android devices are attached (${serials.join(', ')}). Set ANDROID_SERIAL to the one the app runs on.`);
|
|
45
|
+
}
|
|
46
|
+
const abis = adb(['-s', serial, 'shell', 'getprop', 'ro.product.cpu.abilist']).trim().split(',');
|
|
47
|
+
const abi = abis.find((one) => ANDROID_ABIS.includes(one));
|
|
48
|
+
if (!abi) throw new Error(`crossbind: the Android device ${serial} runs ${abis.join(', ')}, and apps build for ${ANDROID_ABIS.join(' and ')}.`);
|
|
49
|
+
return { serial, abi };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Installs an APK on the attached device and opens it, with `env` as the activity's extras; a dev server's page on this
|
|
53
|
+
// machine reaches the device through adb reverse. The process returned carries the app's log, its page's console among
|
|
54
|
+
// it, and ends with the app; close() ends the app.
|
|
55
|
+
export default function openOnDevice(apk, { env = {}, stdio = 'pipe' } = {}) {
|
|
56
|
+
const { serial } = attachedDevice();
|
|
57
|
+
const id = JSON.parse(fs.readFileSync(path.join(path.dirname(apk), 'output-metadata.json'), 'utf8')).applicationId;
|
|
58
|
+
try {
|
|
59
|
+
adb(['-s', serial, 'install', '-r', '-d', apk]);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error;
|
|
62
|
+
// As the one Google Play installs: only an uninstall replaces it, which takes its data, so the developer runs it.
|
|
63
|
+
throw new Error(`crossbind: ${id} on ${serial} is signed with another key than this build, so it cannot be updated; \`adb -s ${serial} uninstall ${id}\` removes it, with its data, and the next run installs this one.`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
const devPort = env.CROSSBIND_APP_URL && new URL(env.CROSSBIND_APP_URL).port;
|
|
66
|
+
if (devPort) adb(['-s', serial, 'reverse', `tcp:${devPort}`, `tcp:${devPort}`]);
|
|
67
|
+
const extras = Object.entries(env).flatMap(([key, value]) => ['--es', key, quoted(value)]);
|
|
68
|
+
adb(['-s', serial, 'shell', 'am', 'start', '-S', '-W', '-n', `${id}/${ACTIVITY}`, ...extras]);
|
|
69
|
+
const pid = spawnSync(adbPath(), ['-s', serial, 'shell', 'pidof', id], { encoding: 'utf8' }).stdout?.trim();
|
|
70
|
+
if (!pid) throw new Error(`crossbind: ${id} stopped as it started on ${serial}; its log is in \`adb logcat\`.`);
|
|
71
|
+
// The log of the app's process alone, without the divider logcat starts with, until the process ends. The pattern
|
|
72
|
+
// that ends logcat names it first, so it matches not the shell whose command line holds it too.
|
|
73
|
+
const logcat = `logcat -v raw --pid=${pid} -s Crossbind:V`;
|
|
74
|
+
const child = spawn(adbPath(), ['-s', serial, 'shell',
|
|
75
|
+
`${logcat} | grep -v '^--------- ' & while [ -d /proc/${pid} ]; do sleep 1; done; pkill -f '^${logcat}'`], { stdio });
|
|
76
|
+
return { child, close: () => spawnSync(adbPath(), ['-s', serial, 'shell', 'am', 'force-stop', id]) };
|
|
77
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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 state from '../state/index.js';
|
|
6
|
+
import getOsUserAndGroupId from '../utils/getOsUserAndGroupId.js';
|
|
7
|
+
import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
|
|
8
|
+
import { DOCKER_BASE, isUnderBase } from '../utils/replaceBasePathForDocker.js';
|
|
9
|
+
import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
|
|
10
|
+
import { getContentHash } from '../utils/hash.js';
|
|
11
|
+
|
|
12
|
+
const ROLE = 'android-app';
|
|
13
|
+
const PLATFORM = 'linux/amd64';
|
|
14
|
+
const KEYSTORE = '/tmp/crossbind/keystore';
|
|
15
|
+
const GRADLE = ['gradle', '--offline', '--no-watch-fs', '--console=plain', '-q'];
|
|
16
|
+
const OUTPUT_LIMIT = 64 * 1024 * 1024;
|
|
17
|
+
const FAILURE_LINES = 40;
|
|
18
|
+
|
|
19
|
+
// The upload key a release is signed with, named by the environment alone so that no secret reaches a file.
|
|
20
|
+
export const SIGNING = {
|
|
21
|
+
keystore: 'CROSSBIND_ANDROID_KEYSTORE',
|
|
22
|
+
alias: 'CROSSBIND_ANDROID_KEY_ALIAS',
|
|
23
|
+
password: 'CROSSBIND_ANDROID_KEYSTORE_PASSWORD',
|
|
24
|
+
keyPassword: 'CROSSBIND_ANDROID_KEY_PASSWORD',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
|
|
28
|
+
|
|
29
|
+
// The keystore of the upload key, or null for none: a release is then signed with the debug key.
|
|
30
|
+
export function releaseSigning(env = process.env) {
|
|
31
|
+
if (!env[SIGNING.keystore]) {
|
|
32
|
+
const stray = [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]);
|
|
33
|
+
if (stray.length) throw new Error(`crossbind: ${listed(stray)} set without ${SIGNING.keystore}, the keystore of the upload key.`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const missing = [SIGNING.alias, SIGNING.password].filter((name) => !env[name]);
|
|
37
|
+
if (missing.length) throw new Error(`crossbind: ${SIGNING.keystore} is set, but ${listed(missing)} not.`);
|
|
38
|
+
const file = path.resolve(env[SIGNING.keystore]);
|
|
39
|
+
if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${SIGNING.keystore} names ${file}, which is no file.`);
|
|
40
|
+
return { file, names: [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Gradle's caches and the Android tools' home outlive a container; the second keeps the debug key, which a new one would
|
|
44
|
+
// replace, so that a later build still installs over the last.
|
|
45
|
+
function homes() {
|
|
46
|
+
const root = path.join(os.homedir(), '.crossbind', 'android');
|
|
47
|
+
return [[path.join(root, 'gradle'), '/var/cache/crossbind/gradle'], [path.join(root, 'home'), '/var/cache/crossbind/android']];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A container of crossbind's android-app image, with no network: the project under the build's base, the homes, and
|
|
51
|
+
// the keystore read-only, its passwords passed by name from this environment.
|
|
52
|
+
function containerArgs(signing) {
|
|
53
|
+
return [
|
|
54
|
+
...DOCKER_RUN_SECURITY_ARGS, '--network', 'none', '--platform', PLATFORM, '--user', getOsUserAndGroupId(),
|
|
55
|
+
'-v', `${state.config.paths.base}:${DOCKER_BASE}`,
|
|
56
|
+
...homes().flatMap(([host, container]) => {
|
|
57
|
+
fs.mkdirSync(host, { recursive: true });
|
|
58
|
+
return ['-v', `${host}:${container}`];
|
|
59
|
+
}),
|
|
60
|
+
...(signing ? ['-v', `${signing.file}:${KEYSTORE}:ro`, '-e', `${SIGNING.keystore}=${KEYSTORE}`, ...signing.names.flatMap((name) => ['-e', name])] : []),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function docker(args, what) {
|
|
65
|
+
const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT });
|
|
66
|
+
if (result.error) throw new Error(`crossbind: cannot run docker for ${what}: ${result.error.message}`, { cause: result.error });
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split('\n').slice(-FAILURE_LINES).join('\n');
|
|
69
|
+
throw new Error(`crossbind: ${what} failed in the android-app image with exit code ${result.status}:\n${output}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The container sees the project's base alone, under its own POSIX path whatever the host's separators.
|
|
74
|
+
function inContainer(project) {
|
|
75
|
+
const dir = path.resolve(project);
|
|
76
|
+
if (!isUnderBase(dir, state.config.paths.base)) {
|
|
77
|
+
throw new Error(`crossbind: the Android project ${dir} is outside ${state.config.paths.base}, the project's base and all the android-app container sees. Build the app into a directory under it.`);
|
|
78
|
+
}
|
|
79
|
+
return path.posix.join(DOCKER_BASE, ...path.relative(state.config.paths.base, dir).split(path.sep));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// An Android app as the files it ships: its release APK, and with an upload key also the bundle Google Play takes, beside
|
|
83
|
+
// the Gradle project as <project>.apk and <project>.aab.
|
|
84
|
+
export function packageAndroidRelease(project) {
|
|
85
|
+
const signing = releaseSigning();
|
|
86
|
+
pullDockerImage(ROLE, PLATFORM);
|
|
87
|
+
docker(['run', '--rm', ...containerArgs(signing), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
|
|
88
|
+
...GRADLE, '--no-daemon', 'assembleRelease', ...(signing ? ['bundleRelease'] : [])], `packaging ${path.basename(project)}`);
|
|
89
|
+
const outputs = `${project}/app/build/outputs`;
|
|
90
|
+
fs.copyFileSync(`${outputs}/apk/release/app-release.apk`, `${project}.apk`);
|
|
91
|
+
fs.rmSync(`${project}.aab`, { force: true });
|
|
92
|
+
if (signing) {
|
|
93
|
+
fs.copyFileSync(`${outputs}/bundle/release/app-release.aab`, `${project}.aab`);
|
|
94
|
+
} else {
|
|
95
|
+
console.warn(`crossbind: ${path.basename(project)}.apk is signed with this machine's debug key: it installs for testing, and Google Play takes no app signed so. Set ${SIGNING.keystore}, ${SIGNING.alias} and ${SIGNING.password} to sign it with your upload key and make the bundle too.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// During development one container per project, whose Gradle daemon stays warm from one build to the next; it goes with
|
|
100
|
+
// the process.
|
|
101
|
+
let devContainer = null;
|
|
102
|
+
|
|
103
|
+
function startDevContainer() {
|
|
104
|
+
const name = `crossbind-android-app-${getContentHash(state.config.paths.project).slice(0, 12)}`;
|
|
105
|
+
pullDockerImage(ROLE, PLATFORM);
|
|
106
|
+
// One a crashed session left behind holds the name.
|
|
107
|
+
spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' });
|
|
108
|
+
docker(['run', '--detach', '--rm', '--init', '--name', name, ...containerArgs(null), getDockerImage(ROLE, PLATFORM), 'sleep', 'infinity'],
|
|
109
|
+
'starting the development container');
|
|
110
|
+
process.once('exit', () => spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' }));
|
|
111
|
+
// A closed terminal ends the session through its exit hooks, as Ctrl-C does.
|
|
112
|
+
process.once('SIGHUP', () => process.exit(129));
|
|
113
|
+
return name;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Builds an Android app's Gradle project as a debug APK, signed with the debug key the homes keep, and returns the APK.
|
|
117
|
+
export function buildApk(project) {
|
|
118
|
+
devContainer ??= startDevContainer();
|
|
119
|
+
docker(['exec', '-w', inContainer(project), devContainer, ...GRADLE, 'assembleDebug'], `building ${path.basename(project)}`);
|
|
120
|
+
return path.join(project, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
|
121
|
+
}
|
|
@@ -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
|
+
}
|