crossbind 2.0.0-beta.67 → 2.0.0-beta.69
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/README.md +33 -24
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +69 -25
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +229 -67
- package/src/actions/buildNode.js +6 -1
- package/src/actions/buildWasm.js +0 -9
- package/src/actions/createInterface.js +17 -8
- package/src/actions/createLib.js +5 -7
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/getCmakeParameters.js +2 -3
- package/src/actions/gradleProject.js +199 -16
- package/src/actions/iosDevice.js +200 -0
- package/src/actions/iosSimulator.js +3 -3
- package/src/actions/licenses.js +83 -34
- package/src/actions/linuxPackages.js +48 -0
- package/src/actions/mavenVerification.js +37 -0
- package/src/actions/publish.js +220 -0
- package/src/actions/uploadKey.js +134 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +63 -17
- package/src/actions/xcodeTargets.js +122 -0
- package/src/actions/xcodebuild.js +65 -0
- package/src/app/index.d.ts +126 -133
- package/src/app/index.js +126 -188
- package/src/assets/configure/install.sh +0 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +79 -26
- package/src/index.js +5 -1
- package/src/program.js +35 -4
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- package/src/utils/appApiHeader.js +4 -0
- package/src/utils/appExtensions.js +761 -0
- package/src/utils/appFiles.js +58 -0
- package/src/utils/appLaunches.js +249 -0
- package/src/utils/appOptions.js +144 -95
- package/src/utils/appTargets.js +168 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +181 -0
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +12 -0
- package/src/utils/password.js +34 -0
- package/src/utils/plist.js +5 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/systemConfig.js +19 -0
- package/src/utils/systemKeys.js +16 -0
- package/vitest.config.js +6 -1
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<h1>crossbind</h1>
|
|
7
7
|
|
|
8
8
|
<p>
|
|
9
|
-
<strong>
|
|
9
|
+
<strong>Build and ship JS apps. Add C++ and Rust as needed.</strong><br>
|
|
10
10
|
Build, package, and run native libraries across browsers, Node.js, edge
|
|
11
|
-
runtimes, iOS, Android, and WASI.
|
|
11
|
+
runtimes, iOS, Android, and WASI, or ship them in a desktop or mobile app.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p>
|
|
@@ -28,10 +28,12 @@
|
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
30
|
crossbind makes native libraries as easy to distribute and consume as
|
|
31
|
-
JavaScript packages. A C++ header, an app-local Rust file,
|
|
32
|
-
crate becomes an importable module; crossbind
|
|
33
|
-
resolves the transitive native dependency graph,
|
|
34
|
-
for every target.
|
|
31
|
+
JavaScript packages. A C++ header, an app-local Rust file, a declared Cargo
|
|
32
|
+
crate, or a ConanCenter package becomes an importable module; crossbind
|
|
33
|
+
generates the binding layer, resolves the transitive native dependency graph,
|
|
34
|
+
and selects the right artifact for every target. It also builds a web page and
|
|
35
|
+
the native code it imports into an app for macOS, Linux, Windows, iOS, and
|
|
36
|
+
Android.
|
|
35
37
|
|
|
36
38
|
> **Note** — crossbind 2.0 is currently published on the npm `beta` channel.
|
|
37
39
|
> The commands below use `@beta` deliberately.
|
|
@@ -133,11 +135,13 @@ native call:
|
|
|
133
135
|
import { initNative, hello } from './native/hello.h';
|
|
134
136
|
|
|
135
137
|
await initNative();
|
|
136
|
-
console.log(hello('crossbind'));
|
|
138
|
+
console.log(await hello('crossbind'));
|
|
137
139
|
```
|
|
138
140
|
|
|
139
141
|
`initNative()` is exported by every generated native module. One call starts
|
|
140
|
-
the runtime and binds all native modules imported by the application.
|
|
142
|
+
the runtime and binds all native modules imported by the application. In a
|
|
143
|
+
browser the module runs in a Web Worker by default, so each call returns a
|
|
144
|
+
promise.
|
|
141
145
|
|
|
142
146
|
## Add crossbind to an existing Vite app
|
|
143
147
|
|
|
@@ -177,8 +181,8 @@ or start from the closest project in [`examples/`](https://github.com/crossbind/
|
|
|
177
181
|
## How it works
|
|
178
182
|
|
|
179
183
|
```text
|
|
180
|
-
C++ / Rust source header, .rs,
|
|
181
|
-
or prebuilt port
|
|
184
|
+
C++ / Rust source header, .rs, cargo:
|
|
185
|
+
or prebuilt port or conan: import
|
|
182
186
|
| |
|
|
183
187
|
v |
|
|
184
188
|
versioned native package graph |
|
|
@@ -191,11 +195,13 @@ or start from the closest project in [`examples/`](https://github.com/crossbind/
|
|
|
191
195
|
+ binding generation
|
|
192
196
|
+ native build and link
|
|
193
197
|
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
v
|
|
199
|
+
browser, Node.js, edge JavaScript + Wasm
|
|
200
|
+
Node.js, Electron Node-API addon
|
|
201
|
+
iOS, Android native library + JSI
|
|
202
|
+
desktop and mobile apps your page + native host
|
|
203
|
+
macOS, Linux, Windows native executable
|
|
204
|
+
WASI runtimes command component
|
|
199
205
|
```
|
|
200
206
|
|
|
201
207
|
There are two configuration surfaces:
|
|
@@ -212,18 +218,21 @@ contract.
|
|
|
212
218
|
|
|
213
219
|
## Runtimes
|
|
214
220
|
|
|
215
|
-
| Runtime | Output
|
|
216
|
-
| ------------ |
|
|
217
|
-
| Browser | JavaScript loader + WebAssembly
|
|
218
|
-
| Node.js | JavaScript loader + WebAssembly
|
|
219
|
-
| Edge | JavaScript loader + WebAssembly
|
|
220
|
-
| React Native | Native iOS/Android libraries over JSI
|
|
221
|
-
|
|
|
221
|
+
| Runtime | Output | Important behavior |
|
|
222
|
+
| ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
223
|
+
| Browser | JavaScript loader + WebAssembly | Single-threaded by default; multithreaded builds need COOP/COEP headers in production |
|
|
224
|
+
| Node.js | JavaScript loader + WebAssembly, or a Node-API addon | Supports CommonJS or ESM and reads the host filesystem; the addon loads in Electron too |
|
|
225
|
+
| Edge | JavaScript loader + WebAssembly | Single-threaded, memory-backed, with no OPFS or nested worker mode |
|
|
226
|
+
| React Native | Native iOS/Android libraries over JSI | No WebAssembly or browser isolation headers |
|
|
227
|
+
| Apps | An app for macOS, Linux, Windows, iOS, and Android | The page runs in the system webview, native code in the app's own process; no Node.js |
|
|
228
|
+
| Executables | Native programs for macOS, Linux, and Windows | No JavaScript host |
|
|
229
|
+
| WASI | One `wasm32-wasip3` command component | No JavaScript host; run with a compatible WASI runtime such as wasmtime 47+ |
|
|
222
230
|
|
|
223
231
|
Threading and worker execution are independent choices:
|
|
224
232
|
|
|
225
233
|
- `target.runtime: 'mt'` enables a multithreaded build.
|
|
226
|
-
-
|
|
234
|
+
- Browsers run the module in a Web Worker by default;
|
|
235
|
+
`initNative({ useWorker: false })` keeps it on the page thread.
|
|
227
236
|
- Browser OPFS persistence requires `useWorker: true`.
|
|
228
237
|
- Browser `mt` builds require COOP/COEP response headers in production.
|
|
229
238
|
|
|
@@ -304,7 +313,7 @@ for the supported type surface and configuration.
|
|
|
304
313
|
## Documentation
|
|
305
314
|
|
|
306
315
|
- [Guide](https://crossbind.dev/guide/) — product concepts, quick start,
|
|
307
|
-
runtimes, bundlers, bindings, packages, and troubleshooting.
|
|
316
|
+
runtimes, apps, bundlers, bindings, packages, and troubleshooting.
|
|
308
317
|
- [Runtime and configuration API](https://github.com/crossbind/crossbind/blob/main/docs/api/README.md)
|
|
309
318
|
— canonical build-time and runtime behavior.
|
|
310
319
|
- [Integration playbooks](https://github.com/crossbind/crossbind/blob/main/docs/playbooks/integration/README.md)
|
package/package.json
CHANGED
|
@@ -1,62 +1,72 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
"name": "crossbind",
|
|
3
|
+
"version": "2.0.0-beta.69",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"homepage": "https://crossbind.dev",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/crossbind/crossbind.git"
|
|
9
|
+
},
|
|
10
|
+
"description": "Bind C++ to JavaScript without extra code. crossbind supports WebAssembly and React Native for cross-platform compatibility.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=26"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"crossbind": "./src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"main": "src/index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.js",
|
|
21
|
+
"./node/dev": "./src/node/dev.mjs",
|
|
22
|
+
"./app": {
|
|
23
|
+
"types": "./src/app/index.d.ts",
|
|
24
|
+
"default": "./src/app/index.js"
|
|
11
25
|
},
|
|
12
|
-
"
|
|
13
|
-
|
|
26
|
+
"./app/core": {
|
|
27
|
+
"types": "./src/app/index.d.ts",
|
|
28
|
+
"default": "./src/app/index.js"
|
|
14
29
|
},
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"android",
|
|
59
|
-
"ios",
|
|
60
|
-
"mobile"
|
|
61
|
-
]
|
|
62
|
-
}
|
|
30
|
+
"./*": "./*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
34
|
+
"vitest": "^5.0.1"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@rollup/plugin-commonjs": "^29.0.3",
|
|
38
|
+
"@rollup/plugin-json": "^6.1.0",
|
|
39
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
40
|
+
"@rollup/plugin-virtual": "^3.0.2",
|
|
41
|
+
"comlink": "^4.4.2",
|
|
42
|
+
"commander": "^15.0.0",
|
|
43
|
+
"glob": "^14.0.3",
|
|
44
|
+
"log-update": "^8.0.0",
|
|
45
|
+
"picocolors": "^1.1.1",
|
|
46
|
+
"pngjs": "^7.0.0",
|
|
47
|
+
"replace": "^1.2.2",
|
|
48
|
+
"rollup": "^4.63.4",
|
|
49
|
+
"spdx-expression-parse": "^5.0.0",
|
|
50
|
+
"upath": "^3.0.8"
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@crossbind/bridge-tools": "2.0.0-beta.69"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"webassembly",
|
|
57
|
+
"react-native",
|
|
58
|
+
"bind",
|
|
59
|
+
"c++",
|
|
60
|
+
"emscripten",
|
|
61
|
+
"embind",
|
|
62
|
+
"swig",
|
|
63
|
+
"android",
|
|
64
|
+
"ios",
|
|
65
|
+
"mobile"
|
|
66
|
+
],
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:watch": "vitest",
|
|
70
|
+
"test:coverage": "vitest run --coverage"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { configFileOf, writeExtension } from '../utils/configWriter.js';
|
|
5
|
+
import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
|
|
6
|
+
import { ownPackages } from '../utils/appExtensions.js';
|
|
7
|
+
import resolveAppHostRoot from '../utils/resolveAppHost.js';
|
|
8
|
+
|
|
9
|
+
// The lockfiles by which a project names the package manager it is installed with.
|
|
10
|
+
const LOCKFILES = [
|
|
11
|
+
['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'],
|
|
12
|
+
['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'],
|
|
13
|
+
];
|
|
14
|
+
const MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
15
|
+
// A package as a registry names it, with a version, tag or range after an @; a tarball's file name is none.
|
|
16
|
+
const REGISTRY_SPEC = /^((?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*)(?:@.+)?$/;
|
|
17
|
+
const TARBALL = /\.(tgz|tar|tar\.gz)$/;
|
|
18
|
+
// What a package may be given as on Windows, which runs npm, pnpm and yarn in a shell: nothing the shell reads more into.
|
|
19
|
+
const WINDOWS_SPEC = /^[\w@/\\.~:+=#-]+$/;
|
|
20
|
+
// What a package's crossbind.extension may name: a module of its own, as ./crossbind.extension.js.
|
|
21
|
+
const EXTENSION_ENTRY = /^\.\/(?:[\w-][\w.-]*\/)*[\w-][\w.-]*\.m?js$/;
|
|
22
|
+
|
|
23
|
+
// Whether a package's exports let another import a module of its by its subpath, as Node resolves one: a package
|
|
24
|
+
// without exports gives every file, and one whose exports name no subpath gives its main module alone.
|
|
25
|
+
function isExported(exports, entry) {
|
|
26
|
+
if (exports === undefined) return true;
|
|
27
|
+
if (exports === null || typeof exports !== 'object' || Array.isArray(exports)) return false;
|
|
28
|
+
if (Object.hasOwn(exports, entry)) return exports[entry] !== null;
|
|
29
|
+
const pattern = Object.keys(exports).filter((key) => key.startsWith('./') && key.includes('*'))
|
|
30
|
+
.sort((a, b) => b.length - a.length)
|
|
31
|
+
.find((key) => {
|
|
32
|
+
const [head, tail] = key.split('*');
|
|
33
|
+
return entry.length >= head.length + tail.length && entry.startsWith(head) && entry.endsWith(tail);
|
|
34
|
+
});
|
|
35
|
+
return pattern !== undefined && exports[pattern] !== null;
|
|
36
|
+
}
|
|
37
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
38
|
+
// The words module code keeps, which can name no import.
|
|
39
|
+
const RESERVED = new Set(('arguments await break case catch class const continue debugger default delete do else enum eval '
|
|
40
|
+
+ 'export extends false finally for function if implements import in instanceof interface let new null package private '
|
|
41
|
+
+ 'protected public return static super switch this throw true try typeof var void while with yield').split(' '));
|
|
42
|
+
|
|
43
|
+
const foldersUp = (dir) => (path.dirname(dir) === dir ? [dir] : [dir, ...foldersUp(path.dirname(dir))]);
|
|
44
|
+
|
|
45
|
+
// The package manager a project is installed with: the one its nearest lockfile names, else the one that runs
|
|
46
|
+
// crossbind, else npm.
|
|
47
|
+
export function packageManagerOf(project, userAgent) {
|
|
48
|
+
for (const dir of foldersUp(project)) {
|
|
49
|
+
const lockfile = LOCKFILES.find(([file]) => fs.existsSync(path.join(dir, file)));
|
|
50
|
+
if (lockfile) return lockfile[1];
|
|
51
|
+
}
|
|
52
|
+
const launcher = userAgent?.split('/')[0];
|
|
53
|
+
return MANAGERS.includes(launcher) ? launcher : 'npm';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The package a spec names on a registry, or null where it is a path, a tarball or a repository.
|
|
57
|
+
export function registryName(spec) {
|
|
58
|
+
return TARBALL.test(spec) ? null : (REGISTRY_SPEC.exec(spec)?.[1] ?? null);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The name a config imports an extension under: its package's, without the scope and an extension- prefix, in
|
|
62
|
+
// camelCase, with Extension after a word module code keeps.
|
|
63
|
+
export function importNameOf(packageName) {
|
|
64
|
+
const words = packageName.replace(/^@[^/]+\//, '').replace(/^(crossbind-)?extension-/, '').split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
|
65
|
+
const camel = words.map((word, i) => (i === 0 ? word : `${word[0].toUpperCase()}${word.slice(1)}`)).join('');
|
|
66
|
+
if (!/^[a-zA-Z]/.test(camel)) return `extension${camel}`;
|
|
67
|
+
return RESERVED.has(camel) ? `${camel}Extension` : camel;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dependencyNames = (manifest) => {
|
|
71
|
+
const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
72
|
+
return new Set(DEPENDENCY_FIELDS.flatMap((field) => Object.keys(pkg[field] ?? {})));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// The folder of an installed package, as Node finds it from the project: in the nearest node_modules that has it.
|
|
76
|
+
const packageDir = (project, name) => foldersUp(project).map((dir) => path.join(dir, 'node_modules', name))
|
|
77
|
+
.find((dir) => fs.existsSync(path.join(dir, 'package.json')));
|
|
78
|
+
|
|
79
|
+
const commonFolder = (a, b) => {
|
|
80
|
+
const [partsA, partsB] = [a.split(path.sep), b.split(path.sep)];
|
|
81
|
+
const length = partsA.findIndex((part, i) => part !== partsB[i]);
|
|
82
|
+
return partsA.slice(0, length === -1 ? partsA.length : length).join(path.sep) || path.sep;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Installs the spec into the project with its package manager, as its owner would at a terminal.
|
|
86
|
+
function install(manager, spec, project, platform, spawn) {
|
|
87
|
+
const args = [manager === 'npm' ? 'install' : 'add', spec];
|
|
88
|
+
const command = [manager, ...args].join(' ');
|
|
89
|
+
console.log(`crossbind: ${command}`);
|
|
90
|
+
// On Windows npm, pnpm and yarn are .cmd files, which a shell alone runs; WINDOWS_SPEC keeps what it reads out of the
|
|
91
|
+
// spec.
|
|
92
|
+
const result = platform === 'win32'
|
|
93
|
+
? spawn(command, { cwd: project, stdio: 'inherit', shell: true })
|
|
94
|
+
: spawn(manager, args, { cwd: project, stdio: 'inherit' });
|
|
95
|
+
if (result.error) {
|
|
96
|
+
throw new Error(`crossbind: ${manager} did not start (${result.error.message}): install it, or install ${spec} with the package manager you use and run crossbind add with its name.`);
|
|
97
|
+
}
|
|
98
|
+
if (result.status !== 0) {
|
|
99
|
+
throw new Error(`crossbind: ${command} failed, so crossbind.config.js lists nothing new: install ${spec} yourself, then run crossbind add with its name.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The spec as a package manager would take it, or why not: a spec that opens with a dash is an option to it.
|
|
104
|
+
function assertSpec(spec, platform) {
|
|
105
|
+
if (spec.startsWith('-')) throw new Error(`crossbind: crossbind add takes a package, and ${spec} is an option to a package manager.`);
|
|
106
|
+
if (platform === 'win32' && !WINDOWS_SPEC.test(spec)) {
|
|
107
|
+
throw new Error(`crossbind: on Windows crossbind add takes a package's name, with a version or tag after an @, or its path, without spaces, quotes or ^&|<>%!() for the shell its package manager runs in to read: install ${spec} yourself, then run crossbind add with its name.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// An extension an app has by having the host is none to add. A project without a host yet has none of them.
|
|
112
|
+
function assertNotOwn(spec) {
|
|
113
|
+
const name = registryName(spec);
|
|
114
|
+
let root;
|
|
115
|
+
try {
|
|
116
|
+
root = resolveAppHostRoot();
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (ownPackages(root).includes(name)) {
|
|
121
|
+
throw new Error(`crossbind: an app has ${name} by having @crossbind/core-app-host, which brings it, so there is nothing to add; an extension takes its place with replaces, as extensions.md says.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The installed package the spec named: the one the install added to package.json, as a spec's own text names no package
|
|
126
|
+
// for every package manager, else the one a registry spec names, which package.json had already.
|
|
127
|
+
function installedName(spec, before, after, manifest) {
|
|
128
|
+
const added = [...after].filter((name) => !before.has(name));
|
|
129
|
+
const registry = registryName(spec);
|
|
130
|
+
const name = added.length === 1 ? added[0] : (added.length === 0 && after.has(registry) && registry);
|
|
131
|
+
if (!name) {
|
|
132
|
+
throw new Error(`crossbind: crossbind add cannot tell which package ${spec} is, as ${manifest} gained ${added.length === 0 ? 'none' : 'more than one'}: run crossbind add with its name once this project's package.json lists it.`);
|
|
133
|
+
}
|
|
134
|
+
return { name, isAdded: added.length === 1 };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Installs an extension's package into an app and lists it in crossbind.config.js's extensions; a package that
|
|
138
|
+
// package.json has already, given by its name alone, is listed without installing it again.
|
|
139
|
+
export default function addExtension(spec, {
|
|
140
|
+
config, env = process.env, platform = process.platform, spawn = spawnSync,
|
|
141
|
+
}) {
|
|
142
|
+
if (config.target.runtimeEnv !== 'app') {
|
|
143
|
+
throw new Error("crossbind: an extension builds into an app, which this project is not: set target: { runtimeEnv: 'app' } in crossbind.config.js, as the desktop-app template does.");
|
|
144
|
+
}
|
|
145
|
+
assertSpec(spec, platform);
|
|
146
|
+
assertNotOwn(spec);
|
|
147
|
+
const { project, base } = config.paths;
|
|
148
|
+
const manifest = path.join(project, 'package.json');
|
|
149
|
+
if (!fs.existsSync(manifest)) throw new Error(`crossbind: crossbind add installs into the project's package.json, which ${project} has none of.`);
|
|
150
|
+
const manager = packageManagerOf(project, env.npm_config_user_agent);
|
|
151
|
+
const before = dependencyNames(manifest);
|
|
152
|
+
if (registryName(spec) === spec && before.has(spec)) console.log(`crossbind: package.json has ${spec} already.`);
|
|
153
|
+
else install(manager, spec, project, platform, spawn);
|
|
154
|
+
const { name, isAdded } = installedName(spec, before, dependencyNames(manifest), manifest);
|
|
155
|
+
const dir = packageDir(project, name);
|
|
156
|
+
if (!dir) throw new Error(`crossbind: ${name} is in package.json, but not installed: run ${manager} install.`);
|
|
157
|
+
const removal = isAdded ? ` ${manager} ${manager === 'npm' ? 'uninstall' : 'remove'} ${name} removes it.` : '';
|
|
158
|
+
const own = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
159
|
+
const entry = own.crossbind?.extension;
|
|
160
|
+
if (typeof entry !== 'string') {
|
|
161
|
+
throw new Error(`crossbind: ${name} is installed, but it is no crossbind extension: its package.json has no crossbind.extension.${removal}`);
|
|
162
|
+
}
|
|
163
|
+
if (!EXTENSION_ENTRY.test(entry)) {
|
|
164
|
+
throw new Error(`crossbind: ${name}'s crossbind.extension, ${JSON.stringify(entry)}, is no module of its own as ./crossbind.extension.js is, so crossbind.config.js lists nothing new.${removal}`);
|
|
165
|
+
}
|
|
166
|
+
if (!fs.statSync(path.join(dir, entry), { throwIfNoEntry: false })?.isFile()) {
|
|
167
|
+
throw new Error(`crossbind: ${name}'s crossbind.extension names ${entry}, which its package has not, so crossbind.config.js lists nothing new.${removal}`);
|
|
168
|
+
}
|
|
169
|
+
if (!isExported(own.exports, entry)) {
|
|
170
|
+
throw new Error(`crossbind: ${name}'s package.json exports no ${entry}, which crossbind.config.js would import, so it lists nothing new: its exports has to give it, as "./crossbind.extension.js": "./crossbind.extension.js" does.${removal}`);
|
|
171
|
+
}
|
|
172
|
+
// A build sees paths.base alone, and an extension's root is where its files are, links followed.
|
|
173
|
+
const real = fs.realpathSync(dir);
|
|
174
|
+
if (!isUnderBase(real, base)) {
|
|
175
|
+
// A Windows project's path comes with forward slashes, its real path with backslashes.
|
|
176
|
+
const home = path.resolve(project);
|
|
177
|
+
const above = path.relative(home, commonFolder(home, real)).split(path.sep).join('/') || '.';
|
|
178
|
+
throw new Error(`crossbind: ${name} is in ${real}, outside paths.base, ${base}, which is all a build sees, so crossbind.config.js lists nothing new: set paths: { base: '${above}' } in it, a folder above both, and run crossbind add ${name} again.`);
|
|
179
|
+
}
|
|
180
|
+
const specifier = `${name}/${entry.slice(2)}`;
|
|
181
|
+
const identifier = importNameOf(name);
|
|
182
|
+
const written = writeExtension(project, specifier, identifier);
|
|
183
|
+
if (!written) {
|
|
184
|
+
throw new Error(`crossbind: ${name} is installed, but ${path.basename(configFileOf(project) ?? 'crossbind.config.js')} is no config crossbind can list it in safely: write import ${identifier} from '${specifier}' and extensions: [${identifier}()] into it yourself.`);
|
|
185
|
+
}
|
|
186
|
+
const file = path.basename(written.file);
|
|
187
|
+
console.log(written.isListed
|
|
188
|
+
? `crossbind: ${file} lists ${name} already, as ${written.identifier}().`
|
|
189
|
+
: `crossbind: listed ${name} in ${file} as ${written.identifier}(), which crossbind app build builds in.`);
|
|
190
|
+
return { name, ...written };
|
|
191
|
+
}
|
|
@@ -8,11 +8,15 @@ import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
|
|
|
8
8
|
import { DOCKER_BASE, isUnderBase } from '../utils/replaceBasePathForDocker.js';
|
|
9
9
|
import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
|
|
10
10
|
import { getContentHash } from '../utils/hash.js';
|
|
11
|
+
import { readSecret } from '../utils/keychain.js';
|
|
12
|
+
import { secretFile } from '../utils/secretFile.js';
|
|
11
13
|
|
|
12
14
|
const ROLE = 'android-app';
|
|
13
15
|
const PLATFORM = 'linux/amd64';
|
|
14
16
|
const KEYSTORE = '/tmp/crossbind/keystore';
|
|
15
|
-
|
|
17
|
+
// An extension's Kotlin compiles inside Gradle's own process: with the container's home /, the Kotlin daemon's client
|
|
18
|
+
// walked files for hours looking for a daemon to use.
|
|
19
|
+
const GRADLE = ['gradle', '--offline', '--no-watch-fs', '--console=plain', '-q', '-Pkotlin.compiler.execution.strategy=in-process'];
|
|
16
20
|
const OUTPUT_LIMIT = 64 * 1024 * 1024;
|
|
17
21
|
const FAILURE_LINES = 40;
|
|
18
22
|
|
|
@@ -25,8 +29,12 @@ export const SIGNING = {
|
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
|
|
32
|
+
// A keystore's base64 text, as CI's secrets give it, in place of its file's path: no path runs to a hundred of base64's
|
|
33
|
+
// letters alone.
|
|
34
|
+
const KEYSTORE_TEXT = /^[A-Za-z0-9+/=\s]{100,}$/;
|
|
28
35
|
|
|
29
|
-
// The keystore of the upload key, or null for none: a release is then signed with the debug key.
|
|
36
|
+
// The keystore of the upload key, or null for none: a release is then signed with the debug key. The keystore is its
|
|
37
|
+
// file, or its base64 text, which goes to a file for the image to mount.
|
|
30
38
|
export function releaseSigning(env = process.env) {
|
|
31
39
|
if (!env[SIGNING.keystore]) {
|
|
32
40
|
const stray = [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]);
|
|
@@ -35,11 +43,32 @@ export function releaseSigning(env = process.env) {
|
|
|
35
43
|
}
|
|
36
44
|
const missing = [SIGNING.alias, SIGNING.password].filter((name) => !env[name]);
|
|
37
45
|
if (missing.length) throw new Error(`crossbind: ${SIGNING.keystore} is set, but ${listed(missing)} not.`);
|
|
38
|
-
const
|
|
46
|
+
const given = env[SIGNING.keystore];
|
|
47
|
+
const isText = !fs.existsSync(given) && KEYSTORE_TEXT.test(given);
|
|
48
|
+
const file = isText ? secretFile('upload.keystore', Buffer.from(given.replace(/\s/g, ''), 'base64')) : path.resolve(given);
|
|
39
49
|
if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${SIGNING.keystore} names ${file}, which is no file.`);
|
|
40
50
|
return { file, names: [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]) };
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
// This machine's upload key, which ~/.crossbind.json names, for a build whose environment names none: its file and alias,
|
|
54
|
+
// and its password from the environment, else the keychain, else none yet.
|
|
55
|
+
export function machineUploadKey(env = process.env, system = state.config.system ?? {}) {
|
|
56
|
+
if (env[SIGNING.keystore] || !system.ANDROID_KEYSTORE) return null;
|
|
57
|
+
return {
|
|
58
|
+
keystore: system.ANDROID_KEYSTORE,
|
|
59
|
+
alias: env[SIGNING.alias] || system.ANDROID_KEY_ALIAS || 'upload',
|
|
60
|
+
password: env[SIGNING.password] || readSecret(path.resolve(system.ANDROID_KEYSTORE)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The environment a build signs with this machine's upload key in.
|
|
65
|
+
export function uploadKeyEnv({ keystore, alias, password }) {
|
|
66
|
+
if (!password) {
|
|
67
|
+
throw new Error(`crossbind: this machine's upload key, ${keystore}, needs its password: set ${SIGNING.password}, or build on a terminal, which asks for it.`);
|
|
68
|
+
}
|
|
69
|
+
return { [SIGNING.keystore]: keystore, [SIGNING.alias]: alias, [SIGNING.password]: password };
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
// Gradle's caches and the Android tools' home outlive a container; the second keeps the debug key, which a new one would
|
|
44
73
|
// replace, so that a later build still installs over the last.
|
|
45
74
|
function homes() {
|
|
@@ -47,11 +76,16 @@ function homes() {
|
|
|
47
76
|
return [[path.join(root, 'gradle'), '/var/cache/crossbind/gradle'], [path.join(root, 'home'), '/var/cache/crossbind/android']];
|
|
48
77
|
}
|
|
49
78
|
|
|
50
|
-
// A
|
|
51
|
-
// the
|
|
52
|
-
|
|
79
|
+
// A project whose extensions ask Maven for libraries carries the verification metadata of every file it resolves, and
|
|
80
|
+
// resolves what the image's cache lacks online; any other builds offline against the cache alone.
|
|
81
|
+
const isOnline = (project) => fs.existsSync(path.join(project, 'gradle', 'verification-metadata.xml'));
|
|
82
|
+
const gradleOf = (project) => (isOnline(project) ? GRADLE.filter((arg) => arg !== '--offline') : GRADLE);
|
|
83
|
+
|
|
84
|
+
// A container of crossbind's android-app image, with no network but for an online project: the project under the
|
|
85
|
+
// build's base, the homes, and the keystore read-only, its passwords passed by name from this environment.
|
|
86
|
+
function containerArgs(signing, isNetworked = false) {
|
|
53
87
|
return [
|
|
54
|
-
...DOCKER_RUN_SECURITY_ARGS, '--network', 'none', '--platform', PLATFORM, '--user', getOsUserAndGroupId(),
|
|
88
|
+
...DOCKER_RUN_SECURITY_ARGS, ...(isNetworked ? [] : ['--network', 'none']), '--platform', PLATFORM, '--user', getOsUserAndGroupId(),
|
|
55
89
|
'-v', `${state.config.paths.base}:${DOCKER_BASE}`,
|
|
56
90
|
...homes().flatMap(([host, container]) => {
|
|
57
91
|
fs.mkdirSync(host, { recursive: true });
|
|
@@ -61,8 +95,8 @@ function containerArgs(signing) {
|
|
|
61
95
|
];
|
|
62
96
|
}
|
|
63
97
|
|
|
64
|
-
function docker(args, what) {
|
|
65
|
-
const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT });
|
|
98
|
+
function docker(args, what, env = process.env) {
|
|
99
|
+
const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT, env });
|
|
66
100
|
if (result.error) throw new Error(`crossbind: cannot run docker for ${what}: ${result.error.message}`, { cause: result.error });
|
|
67
101
|
if (result.status !== 0) {
|
|
68
102
|
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split('\n').slice(-FAILURE_LINES).join('\n');
|
|
@@ -79,43 +113,53 @@ function inContainer(project) {
|
|
|
79
113
|
return path.posix.join(DOCKER_BASE, ...path.relative(state.config.paths.base, dir).split(path.sep));
|
|
80
114
|
}
|
|
81
115
|
|
|
82
|
-
// An Android app as the files it ships: its release APK, and with an upload key
|
|
83
|
-
// the Gradle project as <project>.apk and <project>.aab.
|
|
116
|
+
// An Android app as the files it ships: its release APK, and with an upload key, the environment's or this machine's,
|
|
117
|
+
// also the bundle Google Play takes, beside the Gradle project as <project>.apk and <project>.aab.
|
|
84
118
|
export function packageAndroidRelease(project) {
|
|
85
|
-
const
|
|
119
|
+
const key = machineUploadKey();
|
|
120
|
+
const env = key ? { ...process.env, ...uploadKeyEnv(key) } : process.env;
|
|
121
|
+
const signing = releaseSigning(env);
|
|
86
122
|
pullDockerImage(ROLE, PLATFORM);
|
|
87
|
-
docker(['run', '--rm', ...containerArgs(signing), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
|
|
88
|
-
...
|
|
123
|
+
docker(['run', '--rm', ...containerArgs(signing, isOnline(project)), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
|
|
124
|
+
...gradleOf(project), '--no-daemon', 'assembleRelease', ...(signing ? ['bundleRelease'] : [])], `packaging ${path.basename(project)}`, env);
|
|
89
125
|
const outputs = `${project}/app/build/outputs`;
|
|
90
126
|
fs.copyFileSync(`${outputs}/apk/release/app-release.apk`, `${project}.apk`);
|
|
91
127
|
fs.rmSync(`${project}.aab`, { force: true });
|
|
128
|
+
// The zip of native debug symbols Google Play takes, each ABI's libraries at its root, which the image's JDK makes.
|
|
129
|
+
fs.rmSync(`${project}-symbols.zip`, { force: true });
|
|
130
|
+
if (fs.existsSync(`${project}/symbols`)) {
|
|
131
|
+
docker(['run', '--rm', ...containerArgs(null), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
|
|
132
|
+
'jar', '--create', '--no-manifest', '--file', `../${path.basename(project)}-symbols.zip`, '-C', 'symbols', '.'], `zipping the symbols of ${path.basename(project)}`);
|
|
133
|
+
}
|
|
92
134
|
if (signing) {
|
|
93
135
|
fs.copyFileSync(`${outputs}/bundle/release/app-release.aab`, `${project}.aab`);
|
|
94
136
|
} 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.
|
|
137
|
+
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. crossbind app upload-key makes an upload key, which signs it and makes the bundle too; or set ${SIGNING.keystore}, ${SIGNING.alias} and ${SIGNING.password} to sign it with yours.`);
|
|
96
138
|
}
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
// During development one container per project, whose Gradle daemon stays warm from one build to the next; it goes with
|
|
100
|
-
// the process.
|
|
142
|
+
// the process, and comes anew when the project comes to need the network, or no longer does.
|
|
101
143
|
let devContainer = null;
|
|
102
144
|
|
|
103
|
-
function startDevContainer() {
|
|
145
|
+
function startDevContainer(isNetworked) {
|
|
104
146
|
const name = `crossbind-android-app-${getContentHash(state.config.paths.project).slice(0, 12)}`;
|
|
105
147
|
pullDockerImage(ROLE, PLATFORM);
|
|
106
|
-
// One a crashed session left behind holds the name.
|
|
148
|
+
// One a crashed session, or the project's need of the network before, left behind holds the name.
|
|
107
149
|
spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' });
|
|
108
|
-
docker(['run', '--detach', '--rm', '--init', '--name', name, ...containerArgs(null), getDockerImage(ROLE, PLATFORM), 'sleep', 'infinity'],
|
|
150
|
+
docker(['run', '--detach', '--rm', '--init', '--name', name, ...containerArgs(null, isNetworked), getDockerImage(ROLE, PLATFORM), 'sleep', 'infinity'],
|
|
109
151
|
'starting the development container');
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
if (!devContainer) {
|
|
153
|
+
process.once('exit', () => spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' }));
|
|
154
|
+
// A closed terminal ends the session through its exit hooks, as Ctrl-C does.
|
|
155
|
+
process.once('SIGHUP', () => process.exit(129));
|
|
156
|
+
}
|
|
157
|
+
return { name, isNetworked };
|
|
114
158
|
}
|
|
115
159
|
|
|
116
160
|
// Builds an Android app's Gradle project as a debug APK, signed with the debug key the homes keep, and returns the APK.
|
|
117
161
|
export function buildApk(project) {
|
|
118
|
-
devContainer
|
|
119
|
-
docker(['exec', '-w', inContainer(project), devContainer, ...
|
|
162
|
+
if (devContainer?.isNetworked !== isOnline(project)) devContainer = startDevContainer(isOnline(project));
|
|
163
|
+
docker(['exec', '-w', inContainer(project), devContainer.name, ...gradleOf(project), 'assembleDebug'], `building ${path.basename(project)}`);
|
|
120
164
|
return path.join(project, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
|
121
165
|
}
|