crossbind 2.0.0-beta.66 → 2.0.0-beta.68
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 +32 -23
- package/package.json +2 -2
- package/src/actions/androidGradle.js +44 -9
- package/src/actions/appInstaller.js +8 -5
- package/src/actions/appSetup.js +116 -0
- package/src/actions/appStoreConnect.js +188 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +83 -16
- package/src/actions/buildBinTools.js +0 -0
- package/src/actions/buildExternal.js +8 -1
- package/src/actions/createLib.js +5 -3
- package/src/actions/getCmakeParameters.js +2 -3
- package/src/actions/googlePlay.js +213 -0
- package/src/actions/googleSignIn.js +78 -0
- package/src/actions/gradleProject.js +3 -0
- package/src/actions/iosDevice.js +200 -0
- package/src/actions/playSetup.js +19 -0
- package/src/actions/publishAndroid.js +102 -0
- package/src/actions/publishIos.js +146 -0
- package/src/actions/uploadKey.js +134 -0
- package/src/actions/xcodeProject.js +30 -9
- package/src/app/index.d.ts +43 -1
- package/src/app/index.js +64 -3
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +81 -20
- package/src/index.js +3 -0
- package/src/program.js +3 -1
- package/src/utils/appApiHeader.js +4 -0
- package/src/utils/appOptions.js +77 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/configWriter.js +64 -0
- package/src/utils/embindArgumentGuards.js +8 -5
- package/src/utils/embindCallArguments.js +6 -6
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +11 -0
- package/src/utils/overrideDependency.js +3 -0
- package/src/utils/password.js +34 -0
- package/src/utils/recipePatches.js +61 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/sourceFingerprint.js +8 -1
- package/src/utils/systemConfig.js +6 -0
- package/src/utils/systemKeys.js +16 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<p>
|
|
9
9
|
<strong>Import C++ and Rust like JavaScript modules.</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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crossbind",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.68",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://crossbind.dev",
|
|
6
6
|
"repository": "https://github.com/crossbind/crossbind.git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@rollup/plugin-virtual": "^3.0.2",
|
|
39
39
|
"comlink": "^4.4.2",
|
|
40
40
|
"commander": "^15.0.0",
|
|
41
|
-
"glob": "^
|
|
41
|
+
"glob": "^14.0.3",
|
|
42
42
|
"log-update": "^8.0.0",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
44
|
"pngjs": "^7.0.0",
|
|
@@ -8,6 +8,8 @@ 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';
|
|
@@ -25,8 +27,12 @@ export const SIGNING = {
|
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
|
|
30
|
+
// 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
|
|
31
|
+
// letters alone.
|
|
32
|
+
const KEYSTORE_TEXT = /^[A-Za-z0-9+/=\s]{100,}$/;
|
|
28
33
|
|
|
29
|
-
// The keystore of the upload key, or null for none: a release is then signed with the debug key.
|
|
34
|
+
// The keystore of the upload key, or null for none: a release is then signed with the debug key. The keystore is its
|
|
35
|
+
// file, or its base64 text, which goes to a file for the image to mount.
|
|
30
36
|
export function releaseSigning(env = process.env) {
|
|
31
37
|
if (!env[SIGNING.keystore]) {
|
|
32
38
|
const stray = [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]);
|
|
@@ -35,11 +41,32 @@ export function releaseSigning(env = process.env) {
|
|
|
35
41
|
}
|
|
36
42
|
const missing = [SIGNING.alias, SIGNING.password].filter((name) => !env[name]);
|
|
37
43
|
if (missing.length) throw new Error(`crossbind: ${SIGNING.keystore} is set, but ${listed(missing)} not.`);
|
|
38
|
-
const
|
|
44
|
+
const given = env[SIGNING.keystore];
|
|
45
|
+
const isText = !fs.existsSync(given) && KEYSTORE_TEXT.test(given);
|
|
46
|
+
const file = isText ? secretFile('upload.keystore', Buffer.from(given.replace(/\s/g, ''), 'base64')) : path.resolve(given);
|
|
39
47
|
if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${SIGNING.keystore} names ${file}, which is no file.`);
|
|
40
48
|
return { file, names: [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]) };
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
// This machine's upload key, which ~/.crossbind.json names, for a build whose environment names none: its file and alias,
|
|
52
|
+
// and its password from the environment, else the keychain, else none yet.
|
|
53
|
+
export function machineUploadKey(env = process.env, system = state.config.system ?? {}) {
|
|
54
|
+
if (env[SIGNING.keystore] || !system.ANDROID_KEYSTORE) return null;
|
|
55
|
+
return {
|
|
56
|
+
keystore: system.ANDROID_KEYSTORE,
|
|
57
|
+
alias: env[SIGNING.alias] || system.ANDROID_KEY_ALIAS || 'upload',
|
|
58
|
+
password: env[SIGNING.password] || readSecret(path.resolve(system.ANDROID_KEYSTORE)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The environment a build signs with this machine's upload key in.
|
|
63
|
+
export function uploadKeyEnv({ keystore, alias, password }) {
|
|
64
|
+
if (!password) {
|
|
65
|
+
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.`);
|
|
66
|
+
}
|
|
67
|
+
return { [SIGNING.keystore]: keystore, [SIGNING.alias]: alias, [SIGNING.password]: password };
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
// Gradle's caches and the Android tools' home outlive a container; the second keeps the debug key, which a new one would
|
|
44
71
|
// replace, so that a later build still installs over the last.
|
|
45
72
|
function homes() {
|
|
@@ -61,8 +88,8 @@ function containerArgs(signing) {
|
|
|
61
88
|
];
|
|
62
89
|
}
|
|
63
90
|
|
|
64
|
-
function docker(args, what) {
|
|
65
|
-
const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT });
|
|
91
|
+
function docker(args, what, env = process.env) {
|
|
92
|
+
const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT, env });
|
|
66
93
|
if (result.error) throw new Error(`crossbind: cannot run docker for ${what}: ${result.error.message}`, { cause: result.error });
|
|
67
94
|
if (result.status !== 0) {
|
|
68
95
|
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split('\n').slice(-FAILURE_LINES).join('\n');
|
|
@@ -79,20 +106,28 @@ function inContainer(project) {
|
|
|
79
106
|
return path.posix.join(DOCKER_BASE, ...path.relative(state.config.paths.base, dir).split(path.sep));
|
|
80
107
|
}
|
|
81
108
|
|
|
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.
|
|
109
|
+
// An Android app as the files it ships: its release APK, and with an upload key, the environment's or this machine's,
|
|
110
|
+
// also the bundle Google Play takes, beside the Gradle project as <project>.apk and <project>.aab.
|
|
84
111
|
export function packageAndroidRelease(project) {
|
|
85
|
-
const
|
|
112
|
+
const key = machineUploadKey();
|
|
113
|
+
const env = key ? { ...process.env, ...uploadKeyEnv(key) } : process.env;
|
|
114
|
+
const signing = releaseSigning(env);
|
|
86
115
|
pullDockerImage(ROLE, PLATFORM);
|
|
87
116
|
docker(['run', '--rm', ...containerArgs(signing), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
|
|
88
|
-
...GRADLE, '--no-daemon', 'assembleRelease', ...(signing ? ['bundleRelease'] : [])], `packaging ${path.basename(project)}
|
|
117
|
+
...GRADLE, '--no-daemon', 'assembleRelease', ...(signing ? ['bundleRelease'] : [])], `packaging ${path.basename(project)}`, env);
|
|
89
118
|
const outputs = `${project}/app/build/outputs`;
|
|
90
119
|
fs.copyFileSync(`${outputs}/apk/release/app-release.apk`, `${project}.apk`);
|
|
91
120
|
fs.rmSync(`${project}.aab`, { force: true });
|
|
121
|
+
// The zip of native debug symbols Google Play takes, each ABI's libraries at its root, which the image's JDK makes.
|
|
122
|
+
fs.rmSync(`${project}-symbols.zip`, { force: true });
|
|
123
|
+
if (fs.existsSync(`${project}/symbols`)) {
|
|
124
|
+
docker(['run', '--rm', ...containerArgs(null), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
|
|
125
|
+
'jar', '--create', '--no-manifest', '--file', `../${path.basename(project)}-symbols.zip`, '-C', 'symbols', '.'], `zipping the symbols of ${path.basename(project)}`);
|
|
126
|
+
}
|
|
92
127
|
if (signing) {
|
|
93
128
|
fs.copyFileSync(`${outputs}/bundle/release/app-release.aab`, `${project}.aab`);
|
|
94
129
|
} 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.
|
|
130
|
+
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
131
|
}
|
|
97
132
|
}
|
|
98
133
|
|
|
@@ -6,7 +6,7 @@ import { appleSigning } from './appleSigning.js';
|
|
|
6
6
|
import { updaterSigning } from './appUpdate.js';
|
|
7
7
|
import { inWindowsImage, jsignScript, windowsSigning } from './windowsSigning.js';
|
|
8
8
|
import state from '../state/index.js';
|
|
9
|
-
import { appDisplayName, appId, appUrlSchemes } from '../utils/appOptions.js';
|
|
9
|
+
import { appCategory, appDisplayName, appId, appUrlSchemes } from '../utils/appOptions.js';
|
|
10
10
|
import { readAppIcon, windowsIcon } from '../utils/appIcon.js';
|
|
11
11
|
import { imageRoleFor } from '../utils/pullDockerImage.js';
|
|
12
12
|
import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
|
|
@@ -61,12 +61,13 @@ export function debianControl({ name, version, arch, author, description, label
|
|
|
61
61
|
|
|
62
62
|
// The desktop entry a Linux menu shows an app by. GTK names the app's window after the command that started it, and
|
|
63
63
|
// GNOME on Wayland shows the icon of the entry whose file has that name.
|
|
64
|
-
// A link of one of its schemes opens it with the link as its argument, %u.
|
|
65
|
-
export function desktopEntry({ label, command, hasIcon, urlSchemes = [] }) {
|
|
64
|
+
// A link of one of its schemes opens it with the link as its argument, %u. A menu shows it under its categories.
|
|
65
|
+
export function desktopEntry({ label, command, hasIcon, urlSchemes = [], categories = null }) {
|
|
66
66
|
const isLinked = urlSchemes.length > 0;
|
|
67
67
|
return [
|
|
68
68
|
'[Desktop Entry]', 'Type=Application', `Name=${label}`, `Exec=${command}${isLinked ? ' %u' : ''}`, ...(hasIcon ? [`Icon=${command}`] : []),
|
|
69
|
-
...(isLinked ? [`MimeType=${urlSchemes.map((scheme) => `x-scheme-handler/${scheme};`).join('')}`] : []),
|
|
69
|
+
...(isLinked ? [`MimeType=${urlSchemes.map((scheme) => `x-scheme-handler/${scheme};`).join('')}`] : []),
|
|
70
|
+
...(categories ? [`Categories=${categories};`] : []), 'Terminal=false', '',
|
|
70
71
|
].join('\n');
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -106,7 +107,9 @@ function debianPackage(app, target) {
|
|
|
106
107
|
const icon = `${app}/resources/icon.png`;
|
|
107
108
|
const hasIcon = fs.existsSync(icon);
|
|
108
109
|
if (hasIcon) fs.copyFileSync(icon, `${dir('usr/share/icons/hicolor/512x512/apps')}/${name}.png`);
|
|
109
|
-
fs.writeFileSync(`${dir('usr/share/applications')}/${name}.desktop`, desktopEntry({
|
|
110
|
+
fs.writeFileSync(`${dir('usr/share/applications')}/${name}.desktop`, desktopEntry({
|
|
111
|
+
label, command: name, hasIcon, urlSchemes: appUrlSchemes(state.config), categories: appCategory(state.config)?.linux,
|
|
112
|
+
}));
|
|
110
113
|
const { author, description } = state.config.package ?? {};
|
|
111
114
|
fs.writeFileSync(`${dir('DEBIAN')}/control`, debianControl({ name, version: packageVersion(), arch: target.arch, author, description, label }));
|
|
112
115
|
run(null, ['dpkg-deb', '--build', '--root-owner-group', root, `${app}.deb`], null, target);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import state from '../state/index.js';
|
|
2
|
+
import { askLine, askYesNo } from '../utils/ask.js';
|
|
3
|
+
import { entry, writeAppOption } from '../utils/configWriter.js';
|
|
4
|
+
import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
|
|
5
|
+
import { readAppIcon } from '../utils/appIcon.js';
|
|
6
|
+
|
|
7
|
+
// What only the project can answer, which `crossbind app publish` asks on a terminal and writes into crossbind.config.js
|
|
8
|
+
// once the user says yes.
|
|
9
|
+
|
|
10
|
+
// An id both stores take: dot-separated parts of letters and digits, each starting with a letter, as Apple's take no
|
|
11
|
+
// underscore and Google's no hyphen.
|
|
12
|
+
const STORE_APP_ID = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
|
|
13
|
+
|
|
14
|
+
const REASONS_PAGE = 'https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api';
|
|
15
|
+
// Apple's reasons an app may give, in short; that page has their rules. 0A2A.1 and C56D.1 are for third-party SDKs alone.
|
|
16
|
+
const REASONS = {
|
|
17
|
+
'DDA9.1': 'to show file timestamps to the user; they stay on the device',
|
|
18
|
+
'C617.1': "for the timestamps, size or other metadata of files in the app's container, app group container or CloudKit container",
|
|
19
|
+
'3B52.1': 'for those of files or folders the user granted access to, as with a document picker',
|
|
20
|
+
'35F9.1': 'to measure the time between events in the app, or for timers; only that time may leave the device',
|
|
21
|
+
'8FFB.1': "to turn the times of the app's events into absolute timestamps, which may leave the device; the boot time may not",
|
|
22
|
+
'3D61.1': 'in an optional bug report the user chooses to send, which shows it prominently',
|
|
23
|
+
'85F4.1': 'to show disk space to the user; it stays on the device',
|
|
24
|
+
'E174.1': 'to check for space before writing files, or to delete files when space runs low, in a way the user notices; it stays on the device',
|
|
25
|
+
'7D9E.1': 'in an optional bug report the user chooses to send, which shows it prominently',
|
|
26
|
+
'B728.1': 'in a health research app, to tell participants that low disk space hampers the research',
|
|
27
|
+
'3EC4.1': 'in a custom keyboard app, to find the keyboards active on the device',
|
|
28
|
+
'54BD.1': 'to show the right interface for the active keyboard in text fields, in a way the user notices; it stays on the device',
|
|
29
|
+
'CA92.1': 'for information only the app itself reads and writes',
|
|
30
|
+
'1C8F.1': "for information only the apps and extensions of the app's App Group read and write",
|
|
31
|
+
'AC6B.1': 'for the managed app configuration MDM sets, or the feedback MDM reads',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Writes app.<key> into crossbind.config.js once the user says yes, and into this run's config. One the config sets
|
|
35
|
+
// already is the user's to change.
|
|
36
|
+
async function writeAppAnswer(key, value) {
|
|
37
|
+
const text = entry(key, value);
|
|
38
|
+
if (state.config.app[key] !== undefined) throw new Error(`crossbind: set app.${text} in crossbind.config.js in place of the app.${key} there, and publish again.`);
|
|
39
|
+
if (!await askYesNo(`Write app.${text} into crossbind.config.js? [Y/n] `)) throw new Error(`crossbind: set app.${text} in crossbind.config.js to publish.`);
|
|
40
|
+
if (!writeAppOption(state.config.paths.project, key, value)) {
|
|
41
|
+
throw new Error(`crossbind: crossbind.config.js is no config crossbind can write app.${key} into safely: set app.${text} in it yourself.`);
|
|
42
|
+
}
|
|
43
|
+
state.config.app = { ...state.config.app, [key]: value };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Asks for the app's id, which the store keeps for good, under the name the store gives it.
|
|
47
|
+
export async function askAppId(store, term) {
|
|
48
|
+
console.log(`crossbind: ${store} knows an app by its ${term}, app.id, and keeps it for good.`);
|
|
49
|
+
for (;;) {
|
|
50
|
+
const id = (await askLine(`The ${term}, as com.example.myapp: `)).trim();
|
|
51
|
+
if (STORE_APP_ID.test(id)) {
|
|
52
|
+
await writeAppAnswer('id', id);
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
console.log(`That is no ${term} both stores take, which is dot-separated parts of letters and digits, each starting with a letter.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function iconProblem(icon) {
|
|
60
|
+
try {
|
|
61
|
+
readAppIcon({ ...state.config, app: { ...state.config.app, icon } });
|
|
62
|
+
return null;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return error.message;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Asks for the app's icon, without which App Store Connect takes no iOS app.
|
|
69
|
+
export async function askIcon() {
|
|
70
|
+
console.log('crossbind: App Store Connect takes no iOS app without an icon, app.icon: a square PNG, at best 1024 × 1024.');
|
|
71
|
+
for (;;) {
|
|
72
|
+
const icon = (await askLine("Its path, from the project's folder: ")).trim();
|
|
73
|
+
const problem = iconProblem(icon);
|
|
74
|
+
if (!problem) {
|
|
75
|
+
await writeAppAnswer('icon', icon);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log(problem);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Asks whether the app's encryption is iOS's own alone, which App Store Connect otherwise asks for every build. The answer
|
|
83
|
+
// is a legal statement, so there is no default.
|
|
84
|
+
export async function askEncryption() {
|
|
85
|
+
console.log('crossbind: App Store Connect asks for every build whether the app uses encryption that export rules do not exempt, until app.encryption answers it.');
|
|
86
|
+
for (;;) {
|
|
87
|
+
const answer = (await askLine('Does the app use only the encryption iOS provides, as HTTPS through iOS, or none at all? [y/n] ')).trim();
|
|
88
|
+
if (/^[yn]/i.test(answer)) {
|
|
89
|
+
await writeAppAnswer('encryption', /^n/i.test(answer));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
console.log('Answer y or n.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function askReasons(reasons) {
|
|
97
|
+
for (;;) {
|
|
98
|
+
const picks = (await askLine('Which apply, as 1 or 1,2? ')).split(',').map((pick) => pick.trim()).filter(Boolean);
|
|
99
|
+
const chosen = picks.map((pick) => reasons[Number(pick) - 1]);
|
|
100
|
+
if (chosen.length > 0 && chosen.every(Boolean)) return [...new Set(chosen)];
|
|
101
|
+
console.log(`Name one or more of 1 to ${reasons.length}, separated by commas.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Asks for Apple's reasons for each category of required reason API the app calls with none in app.privacy.
|
|
106
|
+
export async function askPrivacy(unreasoned) {
|
|
107
|
+
console.log(`crossbind: App Store Connect refuses an iOS app that calls required reason API without Apple's reasons for it (ITMS-91053), which app.privacy gives; ${REASONS_PAGE} has the rules of each.`);
|
|
108
|
+
const privacy = { ...state.config.app.privacy };
|
|
109
|
+
for (const { category, symbols } of unreasoned) {
|
|
110
|
+
const reasons = PRIVACY_CATEGORIES[category].reasons.filter((reason) => Object.hasOwn(REASONS, reason));
|
|
111
|
+
console.log(`The app calls ${category} API (${symbols.join(', ')}). Apple's reasons for it:`);
|
|
112
|
+
reasons.forEach((reason, index) => console.log(` ${index + 1}) ${reason} ${REASONS[reason]}`));
|
|
113
|
+
privacy[category] = await askReasons(reasons);
|
|
114
|
+
}
|
|
115
|
+
await writeAppAnswer('privacy', privacy);
|
|
116
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import state from '../state/index.js';
|
|
5
|
+
import { APPLE_SIGNING } from './appleSigning.js';
|
|
6
|
+
import { EXEC_MAX_BUFFER } from './run.js';
|
|
7
|
+
import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
|
|
8
|
+
import { xmlPlist } from '../utils/plist.js';
|
|
9
|
+
|
|
10
|
+
export const APP_STORE_CONNECT = 'https://appstoreconnect.apple.com/apps';
|
|
11
|
+
const DEVELOPER_ACCOUNT = 'https://developer.apple.com/account';
|
|
12
|
+
// The lines of a failed xcodebuild's output that say why; its log has the rest.
|
|
13
|
+
const REASON_LINES = 15;
|
|
14
|
+
|
|
15
|
+
// An upload to an app App Store Connect does not have yet, which only its site makes.
|
|
16
|
+
const MISSING_APP = {
|
|
17
|
+
pattern: /No suitable application records were found|App record with bundle identifier .* not found/i,
|
|
18
|
+
hint: ({ id }) => `App Store Connect has no app ${id}: create it at ${APP_STORE_CONNECT}, a new iOS app with ${id} as its bundle ID, which this publish registered with Apple.`,
|
|
19
|
+
page: APP_STORE_CONNECT,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// What a failed archive or upload means where xcodebuild's own words leave it unclear: what the user mends in Xcode or
|
|
23
|
+
// on Apple's sites, and the page where.
|
|
24
|
+
const CAUSES = [
|
|
25
|
+
{
|
|
26
|
+
pattern: /login details for account .* were rejected|No Accounts?\b|No Account for Team/i,
|
|
27
|
+
hint: ({ team }) => `Xcode is signed in to no account of team ${team}, or its sign-in has lapsed: sign in again in Xcode, under Settings › Accounts.`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern: /PLA Update available|Program License Agreement/i,
|
|
31
|
+
hint: () => `Apple has a new Program License Agreement, which the team's Account Holder accepts at ${DEVELOPER_ACCOUNT}.`,
|
|
32
|
+
page: DEVELOPER_ACCOUNT,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
pattern: /Cloud signing permission error/i,
|
|
36
|
+
hint: () => `the API key may not sign in the cloud, which takes its Admin role: use an Admin key, make an Apple Distribution certificate once in Xcode, under Settings › Accounts › Manage Certificates, or unset ${APPLE_SIGNING.key} to upload with Xcode's account.`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
pattern: /cannot be registered to your development team because it is not available/i,
|
|
40
|
+
hint: ({ id }) => `another team registered the bundle id ${id} with Apple first: set app.id in crossbind.config.js to an id of your own, as one under a domain you own.`,
|
|
41
|
+
// The id is in what was built, so the same step tried again fails the same way.
|
|
42
|
+
needsRebuild: true,
|
|
43
|
+
},
|
|
44
|
+
MISSING_APP,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Where a publish keeps the archive, its export and their logs.
|
|
48
|
+
const storeDir = () => `${state.config.paths.build}/app-store`;
|
|
49
|
+
|
|
50
|
+
const keyArgs = (key) => (key ? ['-authenticationKeyPath', key.key, '-authenticationKeyID', key.keyId, '-authenticationKeyIssuerID', key.issuer] : []);
|
|
51
|
+
|
|
52
|
+
// Runs xcodebuild with its output in `log`, which it returns. Its failure names the lines that say why, and what to do
|
|
53
|
+
// where those leave it unclear.
|
|
54
|
+
export function xcodebuild(what, args, log, facts) {
|
|
55
|
+
const out = fs.openSync(log, 'w');
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = spawnSync('xcodebuild', args, { stdio: ['ignore', out, out] });
|
|
59
|
+
} finally {
|
|
60
|
+
fs.closeSync(out);
|
|
61
|
+
}
|
|
62
|
+
if (result.error) throw new Error(`crossbind: cannot run xcodebuild, which comes with Xcode: ${result.error.message}`, { cause: result.error });
|
|
63
|
+
if (result.signal) throw new Error(`crossbind: ${what} stopped on ${result.signal}.`);
|
|
64
|
+
const output = fs.readFileSync(log, 'utf8');
|
|
65
|
+
if (result.status === 0) return output;
|
|
66
|
+
const reasons = [...new Set(output.split('\n').map((line) => line.trim()).filter((line) => /\berror\b|ITMS-/i.test(line)))].slice(-REASON_LINES);
|
|
67
|
+
const cause = CAUSES.find(({ pattern }) => pattern.test(output));
|
|
68
|
+
const lines = [`crossbind: ${what} failed; xcodebuild's log is ${log}.`, ...reasons.map((line) => ` ${line}`), ...(cause ? [`crossbind: ${cause.hint(facts)}`] : [])];
|
|
69
|
+
throw Object.assign(new Error(lines.join('\n')), { isMendable: Boolean(cause) && !cause.needsRebuild, page: cause?.page ?? null, reason: cause ?? null });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const archivedApp = (archive) => `${archive}/Products/Applications/${state.config.general.name}.app`;
|
|
73
|
+
|
|
74
|
+
// The entitlements file the Xcode project names, or null without one.
|
|
75
|
+
function projectEntitlements(project) {
|
|
76
|
+
const settings = fs.readFileSync(`${project}/${state.config.general.name}.xcodeproj/project.pbxproj`, 'utf8');
|
|
77
|
+
const file = settings.match(/"?CODE_SIGN_ENTITLEMENTS"?\s*=\s*"?([^";]+)"?;/)?.[1];
|
|
78
|
+
return file ? path.resolve(project, file.replace('$(SRCROOT)/', '')) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Signs an archived app ad hoc, its frameworks first, which takes no certificate. The signature carries the app's
|
|
82
|
+
// entitlements to the upload, which an unsigned app would lose, and the upload replaces it with its distribution one.
|
|
83
|
+
function signAdHoc(app, entitlements) {
|
|
84
|
+
const frameworks = fs.existsSync(`${app}/Frameworks`) ? fs.readdirSync(`${app}/Frameworks`).map((name) => `${app}/Frameworks/${name}`) : [];
|
|
85
|
+
[...frameworks, app].forEach((code) => {
|
|
86
|
+
const withEntitlements = code === app && entitlements ? ['--entitlements', entitlements, '--generate-entitlement-der'] : [];
|
|
87
|
+
const result = spawnSync('codesign', ['--force', '--sign', '-', '--timestamp=none', ...withEntitlements, code], { encoding: 'utf8' });
|
|
88
|
+
if (result.error || result.status !== 0) {
|
|
89
|
+
throw new Error(`crossbind: codesign could not sign ${code} ad hoc: ${(result.stderr || result.error?.message || '').trim()}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Archives the app of the iOS Xcode project crossbind wrote unsigned, then signs it ad hoc with the entitlements the
|
|
95
|
+
// project names. The upload signs it for distribution, so the archive needs no development certificate, profile, device
|
|
96
|
+
// or Apple account.
|
|
97
|
+
export function archiveIosApp(project, { facts }) {
|
|
98
|
+
const { name } = state.config.general;
|
|
99
|
+
const archive = `${storeDir()}/${name}.xcarchive`;
|
|
100
|
+
fs.rmSync(archive, { recursive: true, force: true });
|
|
101
|
+
fs.mkdirSync(storeDir(), { recursive: true });
|
|
102
|
+
xcodebuild('the archive', [
|
|
103
|
+
'archive', '-project', `${project}/${name}.xcodeproj`, '-scheme', name, '-configuration', 'Release', '-destination', 'generic/platform=iOS',
|
|
104
|
+
'-archivePath', archive, 'CODE_SIGNING_ALLOWED=NO',
|
|
105
|
+
], `${storeDir()}/archive.log`, facts);
|
|
106
|
+
signAdHoc(archivedApp(archive), projectEntitlements(project));
|
|
107
|
+
return archive;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const archivedBinary = (archive) => `${archivedApp(archive)}/${state.config.general.name}`;
|
|
111
|
+
|
|
112
|
+
// The reasons the archived app's privacy manifest gives, by category: what App Store Connect reads, whatever the config
|
|
113
|
+
// the build read said.
|
|
114
|
+
export function archivedPrivacy(archive) {
|
|
115
|
+
const manifest = `${archivedApp(archive)}/PrivacyInfo.xcprivacy`;
|
|
116
|
+
if (!fs.existsSync(manifest)) return {};
|
|
117
|
+
const result = spawnSync('plutil', ['-convert', 'json', '-o', '-', manifest], { encoding: 'utf8' });
|
|
118
|
+
if (result.status !== 0) throw new Error(`crossbind: cannot read ${manifest}: ${result.stderr || result.error?.message}`);
|
|
119
|
+
const names = Object.fromEntries(Object.entries(PRIVACY_CATEGORIES).map(([name, { type }]) => [type, name]));
|
|
120
|
+
return Object.fromEntries((JSON.parse(result.stdout).NSPrivacyAccessedAPITypes ?? [])
|
|
121
|
+
.filter(({ NSPrivacyAccessedAPIType: type }) => Object.hasOwn(names, type))
|
|
122
|
+
.map(({ NSPrivacyAccessedAPIType: type, NSPrivacyAccessedAPITypeReasons: reasons }) => [names[type], reasons]));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Hermes comes prebuilt, so the archive has no dSYM of it, which App Store Connect asks for.
|
|
126
|
+
export function addHermesDsym(archive, dsym) {
|
|
127
|
+
fs.cpSync(dsym, `${archive}/dSYMs/hermesvm.framework.dSYM`, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The options of an export for App Store Connect: an upload with the app's dSYMs, or an .ipa alone.
|
|
131
|
+
function writeExportOptions(file, { destination, team, isBuildManaged }) {
|
|
132
|
+
fs.writeFileSync(file, xmlPlist({
|
|
133
|
+
destination,
|
|
134
|
+
manageAppVersionAndBuildNumber: isBuildManaged,
|
|
135
|
+
method: 'app-store-connect',
|
|
136
|
+
signingStyle: 'automatic',
|
|
137
|
+
teamID: team,
|
|
138
|
+
uploadSymbols: destination === 'upload',
|
|
139
|
+
}));
|
|
140
|
+
return file;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Registers the app's bundle id with Apple, as App Store Connect's form for a new app lists registered ids alone. An
|
|
144
|
+
// upload asks for the app record before it signs, so it registers nothing; an export to an .ipa, with no build number
|
|
145
|
+
// to look up, signs at once, which registers the id.
|
|
146
|
+
function registerBundleId(archive, { team, key, facts }) {
|
|
147
|
+
const options = writeExportOptions(`${storeDir()}/RegisterOptions.plist`, { destination: 'export', team, isBuildManaged: false });
|
|
148
|
+
xcodebuild('the registration of the bundle id', [
|
|
149
|
+
'-exportArchive', '-archivePath', archive, '-exportOptionsPlist', options, '-exportPath', `${storeDir()}/register`, '-allowProvisioningUpdates',
|
|
150
|
+
...keyArgs(key),
|
|
151
|
+
], `${storeDir()}/register.log`, facts);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Exports the archive for App Store Connect and uploads it with its dSYMs, signed for distribution in the cloud by Xcode's
|
|
155
|
+
// account or an Admin key, else with an Apple Distribution certificate in the keychain, with the entitlements of its ad
|
|
156
|
+
// hoc signature, which the App ID gets the capabilities of. Xcode numbers the build unless CROSSBIND_APP_BUILD_NUMBER
|
|
157
|
+
// did, and writes nothing to -exportPath, which xcodebuild asks for all the same.
|
|
158
|
+
export function uploadIosArchive(archive, { team, key, isBuildManaged, facts }) {
|
|
159
|
+
const options = writeExportOptions(`${storeDir()}/ExportOptions.plist`, { destination: 'upload', team, isBuildManaged });
|
|
160
|
+
let output;
|
|
161
|
+
try {
|
|
162
|
+
output = xcodebuild('the upload', [
|
|
163
|
+
'-exportArchive', '-archivePath', archive, '-exportOptionsPlist', options, '-exportPath', `${storeDir()}/export`, '-allowProvisioningUpdates',
|
|
164
|
+
...keyArgs(key),
|
|
165
|
+
], `${storeDir()}/upload.log`, facts);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error.reason === MISSING_APP) {
|
|
168
|
+
console.log(`crossbind: App Store Connect has no app ${facts.id} yet, so this publish registers its bundle id with Apple first.`);
|
|
169
|
+
registerBundleId(archive, { team, key, facts });
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
// What App Store Connect warns of, as a dSYM it could not take, goes up with the app all the same.
|
|
174
|
+
output.split('\n').filter((line) => line.startsWith('warning: exportArchive')).forEach((line) => console.warn(`crossbind: ${line}`));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The teams of the accounts signed in to Xcode that upload to App Store Connect, which a free personal team does not,
|
|
178
|
+
// from Xcode's settings; `withPersonal` adds those free teams, which sign for their own devices.
|
|
179
|
+
export function xcodeTeams({ withPersonal = false } = {}) {
|
|
180
|
+
const settings = spawnSync('defaults', ['export', 'com.apple.dt.Xcode', '-'], { maxBuffer: EXEC_MAX_BUFFER });
|
|
181
|
+
if (settings.status !== 0) return [];
|
|
182
|
+
const teams = spawnSync('plutil', ['-extract', 'IDEProvisioningTeamByIdentifier', 'json', '-o', '-', '-'], {
|
|
183
|
+
input: settings.stdout, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER,
|
|
184
|
+
});
|
|
185
|
+
if (teams.status !== 0) return [];
|
|
186
|
+
const signing = Object.values(JSON.parse(teams.stdout)).flat().filter((team) => withPersonal || !team.isFreeProvisioningTeam);
|
|
187
|
+
return [...new Map(signing.map((team) => [team.teamID, { id: team.teamID, name: team.teamName }])).values()];
|
|
188
|
+
}
|