crossbind 2.0.0-beta.67 → 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 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, or a declared Cargo
32
- crate becomes an importable module; crossbind generates the binding layer,
33
- resolves the transitive native dependency graph, and selects the right artifact
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, or
181
- or prebuilt port cargo: import
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
- v v v
197
- browser / Node / edge iOS / Android WASI
198
- JavaScript + Wasm native library + JSI command component
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 | Important behavior |
216
- | ------------ | ------------------------------------- | ------------------------------------------------------------------------------------- |
217
- | Browser | JavaScript loader + WebAssembly | Single-threaded by default; multithreaded builds need COOP/COEP headers in production |
218
- | Node.js | JavaScript loader + WebAssembly | Supports CommonJS or ESM and reads the host filesystem |
219
- | Edge | JavaScript loader + WebAssembly | Single-threaded, memory-backed, with no OPFS or nested worker mode |
220
- | React Native | Native iOS/Android libraries over JSI | No WebAssembly or browser isolation headers |
221
- | WASI | One `wasm32-wasip3` command component | No JavaScript host; run with a compatible WASI runtime such as wasmtime 47+ |
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
- - `initNative({ useWorker: true })` moves the module into a browser Web Worker.
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.67",
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": "^13.0.6",
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 file = path.resolve(env[SIGNING.keystore]);
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 also the bundle Google Play takes, beside
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 signing = releaseSigning();
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. Set ${SIGNING.keystore}, ${SIGNING.alias} and ${SIGNING.password} to sign it with your upload key and make the bundle too.`);
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
 
@@ -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
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { secretFile } from '../utils/secretFile.js';
5
6
 
6
7
  // The Developer ID that signs a macOS app and the App Store Connect API key Apple notarizes it with, named by the
7
8
  // environment alone so that no secret reaches a file.
@@ -12,6 +13,10 @@ export const APPLE_SIGNING = {
12
13
  issuer: 'CROSSBIND_APPLE_API_ISSUER',
13
14
  };
14
15
  const NOTARY = [APPLE_SIGNING.key, APPLE_SIGNING.keyId, APPLE_SIGNING.issuer];
16
+ // App Store Connect gives a key's file as AuthKey_<key id>.p8, so the file's own name tells its ID.
17
+ const KEY_FILE = /^AuthKey_([A-Z0-9]{10})\.p8$/;
18
+ // A key's own text, as CI's secrets give it, in place of its file's path.
19
+ const KEY_TEXT = /^\s*-----BEGIN /;
15
20
 
16
21
  const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
17
22
 
@@ -30,16 +35,32 @@ function signingIdentities() {
30
35
  .map(([, hash, name]) => ({ hash, name }));
31
36
  }
32
37
 
38
+ // The App Store Connect API key the environment names, with which Apple notarizes a macOS app and xcodebuild uploads an
39
+ // iOS one in place of Xcode's account, or null: all of it or none, but for its ID where its file's name tells it. The key
40
+ // is its file, or its text, which goes to a file as the tools take files.
41
+ export function appStoreKey(env = process.env) {
42
+ const set = NOTARY.filter((name) => env[name]);
43
+ if (!set.length) return null;
44
+ const given = env[APPLE_SIGNING.key] ?? '';
45
+ const isText = KEY_TEXT.test(given);
46
+ const keyId = env[APPLE_SIGNING.keyId] || (isText ? undefined : path.basename(given).match(KEY_FILE)?.[1]);
47
+ const missing = NOTARY.filter((name) => !env[name] && !(name === APPLE_SIGNING.keyId && keyId));
48
+ if (missing.length) {
49
+ const byName = missing.includes(APPLE_SIGNING.keyId) && !isText ? ', the ID unless its file keeps the name App Store Connect gives it, AuthKey_<key id>.p8' : '';
50
+ throw new Error(`crossbind: ${listed(set)} set, but ${listed(missing)} not: an App Store Connect API key takes all three${byName}.`);
51
+ }
52
+ const key = isText ? secretFile('AuthKey.p8', `${given.trim()}\n`) : path.resolve(given);
53
+ if (!fs.statSync(key, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${APPLE_SIGNING.key} names ${key}, which is no file.`);
54
+ return { key, keyId, issuer: env[APPLE_SIGNING.issuer] };
55
+ }
56
+
33
57
  // The identity a macOS app is signed with and the key Apple notarizes it with, or null without an identity: the app is
34
- // then signed ad hoc, which opens it on the Mac that built it alone. codesign takes the identity's hash, which the
35
- // renewed certificates of one name do not share.
58
+ // then signed ad hoc, which opens it on the Mac that built it alone, even with the key, which iOS uploads take too.
59
+ // codesign takes the identity's hash, which the renewed certificates of one name do not share.
36
60
  export function appleSigning(env = process.env) {
37
- const notary = NOTARY.filter((name) => env[name]);
38
61
  const identity = env[APPLE_SIGNING.identity];
39
- if (!identity) {
40
- if (notary.length) throw new Error(`crossbind: ${listed(notary)} set without ${APPLE_SIGNING.identity}, the Developer ID that signs the app Apple notarizes.`);
41
- return null;
42
- }
62
+ if (!identity) return null;
63
+ const notary = appStoreKey(env);
43
64
  const found = signingIdentities().filter(({ hash, name }) => hash === identity.toUpperCase() || name.includes(identity));
44
65
  const matches = [...new Map(found.map((match) => [match.hash, match])).values()];
45
66
  if (!matches.length) throw new Error(`crossbind: ${APPLE_SIGNING.identity} names ${JSON.stringify(identity)}, which no valid code signing identity in the keychain is; \`security find-identity -v -p codesigning\` lists them.`);
@@ -47,13 +68,10 @@ export function appleSigning(env = process.env) {
47
68
  throw new Error(`crossbind: ${APPLE_SIGNING.identity} names ${JSON.stringify(identity)}, which ${matches.length} identities in the keychain are: ${matches.map(({ hash, name }) => `${hash} "${name}"`).join(', ')}. Set it to one of their hashes.`);
48
69
  }
49
70
  const [{ hash, name }] = matches;
50
- if (!notary.length) return { identity: hash, notary: null };
51
- const missing = NOTARY.filter((variable) => !env[variable]);
52
- if (missing.length) throw new Error(`crossbind: ${listed(notary)} set, but ${listed(missing)} not: notarizing takes all three.`);
53
- if (!name.startsWith('Developer ID Application:')) throw new Error(`crossbind: Apple notarizes an app signed with a Developer ID Application certificate, which ${JSON.stringify(name)} is not.`);
54
- const key = path.resolve(env[APPLE_SIGNING.key]);
55
- if (!fs.statSync(key, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${APPLE_SIGNING.key} names ${key}, which is no file.`);
56
- return { identity: hash, notary: { key, keyId: env[APPLE_SIGNING.keyId], issuer: env[APPLE_SIGNING.issuer] } };
71
+ if (notary && !name.startsWith('Developer ID Application:')) {
72
+ throw new Error(`crossbind: Apple notarizes an app signed with a Developer ID Application certificate, which ${JSON.stringify(name)} is not.`);
73
+ }
74
+ return { identity: hash, notary };
57
75
  }
58
76
 
59
77
  // The ticket Apple issues can take a moment to reach the servers stapler asks for it.