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.
Files changed (61) hide show
  1. package/README.md +33 -24
  2. package/package.json +69 -59
  3. package/src/actions/addExtension.js +191 -0
  4. package/src/actions/androidGradle.js +69 -25
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/appleSigning.js +32 -14
  8. package/src/actions/buildApp.js +229 -67
  9. package/src/actions/buildNode.js +6 -1
  10. package/src/actions/buildWasm.js +0 -9
  11. package/src/actions/createInterface.js +17 -8
  12. package/src/actions/createLib.js +5 -7
  13. package/src/actions/extensionApi.js +298 -0
  14. package/src/actions/extensionCommands.js +41 -0
  15. package/src/actions/extensionsInApp.js +19 -0
  16. package/src/actions/getCmakeParameters.js +2 -3
  17. package/src/actions/gradleProject.js +199 -16
  18. package/src/actions/iosDevice.js +200 -0
  19. package/src/actions/iosSimulator.js +3 -3
  20. package/src/actions/licenses.js +83 -34
  21. package/src/actions/linuxPackages.js +48 -0
  22. package/src/actions/mavenVerification.js +37 -0
  23. package/src/actions/publish.js +220 -0
  24. package/src/actions/uploadKey.js +134 -0
  25. package/src/actions/windowsSigning.js +1 -1
  26. package/src/actions/xcodeProject.js +63 -17
  27. package/src/actions/xcodeTargets.js +122 -0
  28. package/src/actions/xcodebuild.js +65 -0
  29. package/src/app/index.d.ts +126 -133
  30. package/src/app/index.js +126 -188
  31. package/src/assets/configure/install.sh +0 -0
  32. package/src/assets/toolchain-digests.json +20 -20
  33. package/src/bin.js +79 -26
  34. package/src/index.js +5 -1
  35. package/src/program.js +35 -4
  36. package/src/state/index.js +2 -1
  37. package/src/state/loadConfig.js +16 -5
  38. package/src/utils/appApiHeader.js +4 -0
  39. package/src/utils/appExtensions.js +761 -0
  40. package/src/utils/appFiles.js +58 -0
  41. package/src/utils/appLaunches.js +249 -0
  42. package/src/utils/appOptions.js +144 -95
  43. package/src/utils/appTargets.js +168 -0
  44. package/src/utils/ask.js +38 -0
  45. package/src/utils/bridgeTools.js +66 -0
  46. package/src/utils/configChanges.js +30 -0
  47. package/src/utils/configWriter.js +181 -0
  48. package/src/utils/extensionSteps.js +135 -0
  49. package/src/utils/hermes.js +24 -0
  50. package/src/utils/keychain.js +54 -0
  51. package/src/utils/openUrl.js +12 -0
  52. package/src/utils/password.js +34 -0
  53. package/src/utils/plist.js +5 -0
  54. package/src/utils/requiredReasonApis.js +77 -0
  55. package/src/utils/resolveAppHost.js +7 -0
  56. package/src/utils/secretFile.js +23 -0
  57. package/src/utils/systemConfig.js +19 -0
  58. package/src/utils/systemKeys.js +16 -0
  59. package/vitest.config.js +6 -1
  60. package/src/actions/appUpdate.js +0 -68
  61. package/src/actions/extensions.js +0 -7
@@ -12,7 +12,11 @@ import resolveAppHostRoot, { resolveAppEmbindJsiRoot } from '../utils/resolveApp
12
12
  import {
13
13
  HERMES, HERMES_ANDROID, HERMES_SOURCE, hermesUrl,
14
14
  } from '../utils/hermes.js';
15
+ import { installersFor } from './appInstaller.js';
16
+ import { DESKTOP_PLATFORMS } from './buildApp.js';
17
+ import extensionsInApp from './extensionsInApp.js';
15
18
  import { ANDROIDX_WEBKIT } from './gradleProject.js';
19
+ import { extensionPackage, extensionPart } from '../utils/appExtensions.js';
16
20
  import { shippedBundledLicense } from '../utils/bundledLicenses.js';
17
21
  import toolchainNoticesDir from '../utils/toolchainNotices.js';
18
22
 
@@ -208,13 +212,12 @@ function nodeRuntimeRows({ isAddon }) {
208
212
  }];
209
213
  }
210
214
 
211
- // What an app's host carries beside crossbind's runtime, pinned as the third_party READMEs of @crossbind/core-app-host
215
+ // What an app's host carries beside crossbind's runtime, pinned as the READMEs beside them in @crossbind/core-app-host
212
216
  // record: React Native's Hermes, JSI's jsi.cpp from the same release, webview/webview, and on Windows the WebView2 SDK's
213
217
  // headers.
214
218
  const WEBVIEW = { version: 'cbbdee44afff22867de9fd88a9fc8350d9bdd399', sha256: '10e972a2327b5681474f4aa4499e505eaa4ff659aa995380386f08fd6fc1b763' };
215
219
  const WEBVIEW2_SDK = { version: '1.0.4258.31', sha256: '56f7f4b8bf9aee4b8efefbbdd4f67d5f74ebd1b100ed0806da71bf76af481aa9' };
216
220
  const JSI_SHA256 = 'cb6274a22abc93ecc970066539815e716d3e47716e8f93a9971cf4eb5918ffc1';
217
- const MONOCYPHER = { version: '4.0.3', sha256: '8cc9bc341a66249016db9bd70e9142d8d0aef9945973744b1ac05dbc55d8ee66' };
218
221
  // The fbjni the Hermes release for Android depends on.
219
222
  const FBJNI_VERSION = '0.7.0';
220
223
 
@@ -273,8 +276,8 @@ function androidAppRows() {
273
276
  ];
274
277
  }
275
278
 
276
- // The WebView2 SDK headers a Windows host compiles against, and the NSIS its setup program carries. webview/webview
277
- // loads the WebView2 Runtime itself, without the SDK's loader.
279
+ // The WebView2 SDK headers a Windows host compiles against. webview/webview loads the WebView2 Runtime itself, without the
280
+ // SDK's loader.
278
281
  function windowsAppRows(hostRoot) {
279
282
  const license = 'BSD-3-Clause';
280
283
  return [{
@@ -290,22 +293,73 @@ function windowsAppRows(hostRoot) {
290
293
  sourceUrl: `https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/${WEBVIEW2_SDK.version}/microsoft.web.webview2.${WEBVIEW2_SDK.version}.nupkg`,
291
294
  licenseNotes: "the WebView2 SDK's headers from the windows toolchain image, whose interface IDs the host compiles in",
292
295
  licenseText: textOf(path.join(hostRoot, 'third_party', 'webview2', 'LICENSE')),
293
- }, {
294
- npmName: '@crossbind/core-app-host',
295
- name: 'nsis',
296
- version: '3.11',
297
- nativeVersion: '3.11',
298
- sha256: null,
299
- license: 'Zlib AND MIT',
300
- licenseDeclared: 'Zlib AND MIT',
301
- licenseSelected: null,
302
- isCopyleft: false,
303
- sourceUrl: 'https://sources.debian.org/src/nsis/3.11-1/',
304
- licenseNotes: "the installer stub and plugins the app's setup program carries, from Debian's nsis in the windows toolchain image: zlib/libpng, but for the System plugin's Call.S, MIT",
305
- licenseText: textOf(path.join(hostRoot, 'third_party', 'nsis', 'LICENSE')),
306
296
  }];
307
297
  }
308
298
 
299
+ const APP_PLATFORMS = ['darwin', 'ios', 'android', 'linux', 'win32'];
300
+
301
+ // The extensions an app of this platform has, of any platform's without one: crossbind's own that bring it code, and those
302
+ // crossbind.config.js lists.
303
+ function extensionsIn(platform) {
304
+ if (!platform) return [...new Map(APP_PLATFORMS.flatMap((one) => extensionsIn(one)).map((extension) => [extension.name, extension])).values()];
305
+ const listed = (state.config.extensions ?? []).map(({ name }) => name);
306
+ const hasCode = (extension) => {
307
+ const { native, java } = extensionPart([extension], platform);
308
+ return native.length > 0 || java.length > 0;
309
+ };
310
+ return extensionsInApp(platform).filter((extension) => listed.includes(extension.name) || hasCode(extension));
311
+ }
312
+
313
+ // A row of the code of others an extension carries, by what its licenses list says of it.
314
+ const carriedRow = (entry, root, notes) => ({
315
+ npmName: null,
316
+ name: entry.name,
317
+ version: null,
318
+ nativeVersion: entry.version,
319
+ sha256: entry.sha256 ?? null,
320
+ license: entry.license,
321
+ licenseDeclared: entry.license,
322
+ licenseSelected: null,
323
+ isCopyleft: isCopyleft(entry.license),
324
+ sourceUrl: entry.source ?? null,
325
+ licenseNotes: entry.notes ?? notes,
326
+ licenseText: textOf(path.join(root, entry.file)),
327
+ });
328
+
329
+ // What the installers of a desktop app carry into what they make.
330
+ const installerRows = (platform) => (DESKTOP_PLATFORMS[platform] ? installersFor({ platform }) : [])
331
+ .flatMap(({ name, root, installer }) => (installer.licenses ?? []).map((entry) => carriedRow(entry, root, `carried by what the installer ${name} makes`)));
332
+
333
+ // What an app's extensions bring into it: each extension package's own code, and the code of others each carries, as its
334
+ // app.licenses lists it. An extension in the project's own folders is the project's code, as the app is.
335
+ function extensionRows(platform) {
336
+ return extensionsIn(platform).flatMap((extension) => {
337
+ const own = extensionPackage(extension.root, state.config.paths.base);
338
+ const owner = own?.manifest.name ?? null;
339
+ const license = own?.manifest.license ?? null;
340
+ const shipped = own && packageLicenseFile(own.dir);
341
+ const packageRows = own && !own.manifest.private ? [{
342
+ npmName: owner,
343
+ name: owner,
344
+ version: own.manifest.version ?? null,
345
+ nativeVersion: null,
346
+ license,
347
+ licenseDeclared: license,
348
+ licenseSelected: null,
349
+ isCopyleft: Boolean(license) && isCopyleft(license),
350
+ sourceUrl: own.manifest.homepage ?? null,
351
+ licenseNotes: `the app extension ${extension.name}, which the app's host compiles in`,
352
+ licenseText: shipped ? textOf(shipped) : null,
353
+ }] : [];
354
+ const carried = (extension.app.licenses ?? []).map((entry) => carriedRow(
355
+ entry,
356
+ extension.root,
357
+ `carried by the app extension ${extension.name}${owner ? ` of ${owner}` : ''}, which the app's host compiles in`,
358
+ ));
359
+ return [...packageRows, ...carried];
360
+ });
361
+ }
362
+
309
363
  function appRuntimeRows(platform) {
310
364
  const hostRoot = resolveAppHostRoot();
311
365
  const hermesLicense = textOf(path.join(hostRoot, 'third_party', 'jsi', 'LICENSE'));
@@ -347,24 +401,9 @@ function appRuntimeRows(platform) {
347
401
  nativeVersion: WEBVIEW.version,
348
402
  sha256: WEBVIEW.sha256,
349
403
  sourceUrl: 'https://github.com/webview/webview',
350
- licenseNotes: 'webview/webview, with the local patch @crossbind/core-app-host lists',
404
+ licenseNotes: 'webview/webview, with the local patches @crossbind/core-app-host lists',
351
405
  licenseText: textOf(path.join(hostRoot, 'third_party', 'webview', 'LICENSE')),
352
406
  }]),
353
- // A desktop host checks its updates with Monocypher, which the linker leaves out of one without app.updater.
354
- ...(['ios', 'android'].includes(platform) || state.config.app?.updater === undefined ? [] : [{
355
- npmName: '@crossbind/core-app-host',
356
- name: 'monocypher',
357
- version: MONOCYPHER.version,
358
- nativeVersion: MONOCYPHER.version,
359
- sha256: MONOCYPHER.sha256,
360
- license: 'BSD-2-Clause OR CC0-1.0',
361
- licenseDeclared: 'BSD-2-Clause OR CC0-1.0',
362
- licenseSelected: null,
363
- isCopyleft: false,
364
- sourceUrl: `https://monocypher.org/download/monocypher-${MONOCYPHER.version}.tar.gz`,
365
- licenseNotes: "Monocypher's Ed25519 and SHA-512, compiled into the host, which check the app's updates",
366
- licenseText: textOf(path.join(hostRoot, 'third_party', 'monocypher', 'LICENCE.md')),
367
- }]),
368
407
  ];
369
408
  }
370
409
 
@@ -484,6 +523,16 @@ export default async function collectLicenseRows(platform = null, { runtimeEnv =
484
523
  if (platform === 'wasi') rows.push(...wasiToolchainRows());
485
524
  if (['linux', 'linuxmusl', 'win32'].includes(platform)) rows.push(...addonToolchainRows(platform));
486
525
  if (runtimeEnv === 'node') rows.push(...nodeRuntimeRows({ isAddon: platform !== null }));
487
- if (runtimeEnv === 'app') rows.push(...appRuntimeRows(platform));
526
+ if (runtimeEnv === 'app') {
527
+ rows.push(...appRuntimeRows(platform));
528
+ // A library two extensions carry, or one the app has already, is listed once for each version the app ships.
529
+ const versionOf = (row) => `${row.name}@${row.nativeVersion ?? row.version}`;
530
+ const listed = new Set(rows.map(versionOf));
531
+ for (const row of [...extensionRows(platform), ...installerRows(platform)]) {
532
+ if (listed.has(versionOf(row))) continue;
533
+ listed.add(versionOf(row));
534
+ rows.push(row);
535
+ }
536
+ }
488
537
  return rows.sort((a, b) => a.name.localeCompare(b.name));
489
538
  }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { downloadFile, verifyIntegrity } from '../utils/downloadAndExtractFile.js';
4
+
5
+ // The Ubuntu 22.04 packages a Linux extension lists beside crossbind's sysroot: each downloaded once into the build's
6
+ // folder from Launchpad, as the sysroot's own are, checked against its SHA-256 and unpacked into a sysroot of the
7
+ // extension's own for the target, whose headers its sources include and whose libraries the host links against. Its
8
+ // users' systems load the libraries at run time, as the app's Debian package's Depends asks for them.
9
+
10
+ const DEBIAN_ARCH = { x64: 'amd64', arm64: 'arm64' };
11
+ const MULTIARCH = { x64: 'x86_64-linux-gnu', arm64: 'aarch64-linux-gnu' };
12
+
13
+ export const packageUrl = (file) => `https://launchpad.net/ubuntu/+archive/primary/+files/${file}`;
14
+
15
+ // The packages of the target's architecture among the lines an extension lists.
16
+ export function packagesFor(target, lines) {
17
+ return lines.map((line) => line.split(' ')).filter(([arch]) => arch === DEBIAN_ARCH[target.arch]).map(([, file, sha256]) => ({ file, sha256 }));
18
+ }
19
+
20
+ // What a sysroot gives the target: the folders its sources include, usr/include and each folder in it, as a library's
21
+ // headers sit in one of its own, and the folder the host links from.
22
+ export function sysrootDirs(target, root) {
23
+ const include = path.join(root, 'usr', 'include');
24
+ const nested = fs.existsSync(include)
25
+ ? fs.readdirSync(include, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(include, entry.name))
26
+ : [];
27
+ return { includes: [include, ...nested], libDir: path.join(root, 'usr', 'lib', MULTIARCH[target.arch]) };
28
+ }
29
+
30
+ // Unpacks each extension's packages for the target, with unpack(deb, folder), which crossbind's Linux image runs.
31
+ export async function unpackPackages(target, compiled, { cache, workDir, unpack }) {
32
+ const sysroots = [];
33
+ for (const { name, packages } of compiled.filter((part) => part.packages.length > 0)) {
34
+ const own = packagesFor(target, packages);
35
+ if (own.length === 0) throw new Error(`crossbind: the extension ${name} lists Linux packages, but none of ${DEBIAN_ARCH[target.arch]}, which an ${target.arch} app takes.`);
36
+ const root = path.join(workDir, 'sysroot', name);
37
+ fs.rmSync(root, { recursive: true, force: true });
38
+ fs.mkdirSync(root, { recursive: true });
39
+ for (const { file, sha256 } of own) {
40
+ const url = packageUrl(file);
41
+ const deb = await downloadFile(url, cache);
42
+ verifyIntegrity(deb, url, sha256);
43
+ unpack(deb, root);
44
+ }
45
+ sysroots.push({ name, ...sysrootDirs(target, root) });
46
+ }
47
+ return sysroots;
48
+ }
@@ -0,0 +1,37 @@
1
+ // The verification metadata of an Android app whose extensions ask Maven for libraries: the android-app image's, which
2
+ // lists all crossbind's own project resolves, with each extension's components, as Gradle's
3
+ // --write-verification-metadata lists them. Gradle then checks every file it resolves, from the image's cache or online.
4
+
5
+ const COMPONENT = /<component group="([^"]+)" name="([^"]+)" version="([^"]+)"[^>]*>([\s\S]*?)<\/component>/g;
6
+ const ARTIFACT = /<artifact name="([^"]+)">[\s\S]*?<\/artifact>/g;
7
+ // What would leave a file unchecked, which an extension's metadata may not add to the app's.
8
+ const TRUST = /<(trusted-artifacts|trust|ignored-keys|trusted-keys)\b/;
9
+
10
+ export function mergedVerification(base, fragments) {
11
+ const components = new Map();
12
+ const add = (xml, owner) => {
13
+ for (const [, group, name, version, body] of xml.matchAll(COMPONENT)) {
14
+ const key = `${group}:${name}:${version}`;
15
+ const artifacts = components.get(key) ?? new Map();
16
+ for (const [block, artifact] of body.matchAll(ARTIFACT)) {
17
+ const known = artifacts.get(artifact);
18
+ if (known && known.block !== block) throw new Error(`crossbind: ${owner} gives ${artifact} of ${key} other checksums than ${known.owner} does.`);
19
+ if (!known) artifacts.set(artifact, { block, owner });
20
+ }
21
+ components.set(key, artifacts);
22
+ }
23
+ };
24
+ add(base, "the android-app image's verification metadata");
25
+ for (const { name, xml } of fragments) {
26
+ const owner = `the extension ${name}`;
27
+ if (TRUST.test(xml)) throw new Error(`crossbind: ${owner}'s verification metadata trusts files Gradle would then leave unchecked, which no extension's may.`);
28
+ if ([...xml.matchAll(COMPONENT)].length === 0) throw new Error(`crossbind: ${owner}'s verification metadata lists no component, as Gradle's --write-verification-metadata sha256 writes them.`);
29
+ add(xml, owner);
30
+ }
31
+ const listed = [...components.entries()].sort(([one], [other]) => (one < other ? -1 : 1)).map(([key, artifacts]) => {
32
+ const [group, name, version] = key.split(':');
33
+ const blocks = [...artifacts.values()].map(({ block }) => ` ${block}\n`).join('');
34
+ return ` <component group="${group}" name="${name}" version="${version}">\n${blocks} </component>\n`;
35
+ });
36
+ return `${base.slice(0, base.indexOf('<components>'))}<components>\n${listed.join('')} </components>\n</verification-metadata>\n`;
37
+ }
@@ -0,0 +1,220 @@
1
+ import state from '../state/index.js';
2
+ import loadConfig from '../state/loadConfig.js';
3
+ import {
4
+ DESKTOP_PLATFORMS, MAX_VERSION_CODE, appOutputOf, clearAppOutputs,
5
+ } from './buildApp.js';
6
+ import extensionApi from './extensionApi.js';
7
+ import {
8
+ PUBLISH_PLATFORMS, extensionStep, isJsonValue, ownExtensions, withListed, withoutNamed,
9
+ } from '../utils/appExtensions.js';
10
+ import { CONFIG_CHANGES } from '../utils/configChanges.js';
11
+ import { hasTerminal } from '../utils/ask.js';
12
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
13
+
14
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ const possessive = (name) => (name.endsWith('s') ? `${name}'` : `${name}'s`);
16
+ const listed = (names, word = 'or') => (names.length > 1 ? `${names.slice(0, -1).join(', ')} ${word} ${names.at(-1)}` : names[0]);
17
+ const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
18
+ const camel = (name) => name.replace(/-([a-z0-9])/g, (dash, letter) => letter.toUpperCase());
19
+ const storeName = ({ name, publisher }) => publisher.store ?? name;
20
+ // How many builds a publish makes at most, as its first publisher may ask for another.
21
+ const MAX_BUILDS = 4;
22
+
23
+ // A step of the publisher's, which its publisher may lack.
24
+ const step = ({ name, publisher }, which, ...args) => extensionStep(name, which, () => publisher[which]?.(...args));
25
+
26
+ // crossbind's own publishers, which the host brings as it brings its other extensions.
27
+ export const builtInPublishers = () => withoutNamed(ownExtensions(resolveAppHostRoot()), state.config?.app).filter(({ publisher }) => publisher);
28
+
29
+ // The publishers an app has: crossbind's own, each in the place of the app's extension of its name that has a
30
+ // publisher, then those of the app's other extensions.
31
+ export const publishersOf = (extensions) => withListed(builtInPublishers(), extensions.filter((extension) => extension.publisher));
32
+
33
+ // The store the command names first, before the options, else the last publisher that says it is the one of the platform
34
+ // -p, or CROSSBIND_APP_PLATFORM, names, and the arguments after it. A store named after the options, of those crossbind
35
+ // knows, is refused, as Vite would take it.
36
+ export function storeOf(args, platform, envPlatform, publishers = []) {
37
+ const [first, ...rest] = args;
38
+ const named = first !== undefined && !first.startsWith('-') ? first : null;
39
+ const late = named ? null : args.find((arg) => publishers.some(({ name }) => name === arg));
40
+ if (late) throw new Error(`crossbind: crossbind app publish takes the store first, before the options: crossbind app publish ${late} ${args.filter((arg) => arg !== late).join(' ')}.`);
41
+ const where = platform ?? envPlatform;
42
+ const usual = publishers.findLast(({ publisher }) => publisher.isDefault && publisher.platforms.includes(where));
43
+ return { store: named ?? usual?.name ?? null, rest: named ? rest : args };
44
+ }
45
+
46
+ // The publisher of a store, after those it comes after, first to last.
47
+ export function publishChain(publishers, store) {
48
+ if (!publishers.some(({ name }) => name === store)) {
49
+ throw new Error(`crossbind: crossbind app publish takes the store it sends the app to first: ${listed(publishers.map(({ name }) => name))}${store ? `, not ${store}` : ''}.`);
50
+ }
51
+ const chain = [];
52
+ for (let current = store; current;) {
53
+ const extension = publishers.find(({ name }) => name === current);
54
+ if (!extension) throw new Error(`crossbind: ${chain[0].name} comes after ${current}, which no publisher is named.`);
55
+ if (chain.includes(extension)) throw new Error(`crossbind: ${listed([extension, ...chain].map(({ name }) => name), 'and')} come after one another in a circle.`);
56
+ chain.unshift(extension);
57
+ current = extension.publisher.after;
58
+ }
59
+ return chain;
60
+ }
61
+
62
+ // The platform a publish builds for: the one -p names, which every publisher of it takes, else the only one they take,
63
+ // else CROSSBIND_APP_PLATFORM's, else this machine's desktop one.
64
+ export function publishPlatform(chain, platform, envPlatform, host = process.platform) {
65
+ const store = chain.at(-1).name;
66
+ const platforms = chain.reduce((taken, { publisher }) => taken.filter((one) => publisher.platforms.includes(one)), PUBLISH_PLATFORMS);
67
+ if (platforms.length === 0) throw new Error(`crossbind: ${store} comes after ${chain[0].name}, which takes apps of none of its platforms.`);
68
+ const takes = `${store} takes ${listed(platforms)} apps`;
69
+ if (platform) {
70
+ if (!platforms.includes(platform)) throw new Error(`crossbind: ${takes}, not ${possessive(platform)}, which -p names.`);
71
+ return platform;
72
+ }
73
+ if (platforms.length === 1) return platforms[0];
74
+ if (envPlatform) {
75
+ if (!platforms.includes(envPlatform)) throw new Error(`crossbind: ${takes}, not ${possessive(envPlatform)}, which CROSSBIND_APP_PLATFORM names.`);
76
+ return envPlatform;
77
+ }
78
+ if (platforms.includes(DESKTOP_PLATFORMS[host])) return DESKTOP_PLATFORMS[host];
79
+ throw new Error(`crossbind: ${takes}: name one with -p.`);
80
+ }
81
+
82
+ // The options of each publisher of a publish, as the command line gives them, and the arguments Vite takes. An option
83
+ // that another publisher takes is refused, naming whose it is; any other goes on to Vite.
84
+ export function publishOptions(chain, args, publishers) {
85
+ const kinds = new Map();
86
+ chain.forEach(({ name, publisher }) => Object.entries(publisher.options).forEach(([option, { value }]) => {
87
+ const first = kinds.get(option);
88
+ if (first && (first.value === null) !== (value === null)) {
89
+ throw new Error(`crossbind: ${first.name} and ${name} both take --${kebab(option)}, one as a flag and one with a value: one of them names it otherwise.`);
90
+ }
91
+ if (!first) kinds.set(option, { name, value });
92
+ }));
93
+ const given = new Map(chain.map(({ name }) => [name, new Map()]));
94
+ const foreign = new Map();
95
+ const viteArgs = [];
96
+ for (let index = 0; index < args.length; index += 1) {
97
+ const match = /^--([a-z][a-z0-9-]*)(?:=(.*))?$/s.exec(args[index]);
98
+ const option = match && camel(match[1]);
99
+ const owners = match ? chain.filter(({ publisher }) => Object.hasOwn(publisher.options, option)) : [];
100
+ if (owners.length === 0) {
101
+ const other = match && publishers.find(({ publisher }) => Object.hasOwn(publisher.options, option));
102
+ if (other) foreign.set(other.name, [...(foreign.get(other.name) ?? []), `--${match[1]}`]);
103
+ else viteArgs.push(args[index]);
104
+ continue;
105
+ }
106
+ const { value: placeholder } = owners.at(-1).publisher.options[option];
107
+ let value = true;
108
+ if (placeholder !== null && match[2] === undefined) {
109
+ value = args[index + 1];
110
+ index += 1;
111
+ if (value === undefined || value.startsWith('-')) throw new Error(`crossbind: --${match[1]} takes a value, as ${placeholder}.`);
112
+ } else if (placeholder !== null) {
113
+ value = match[2];
114
+ } else if (match[2] !== undefined) {
115
+ throw new Error(`crossbind: --${match[1]} takes no value.`);
116
+ }
117
+ owners.forEach(({ name }) => given.get(name).set(option, value));
118
+ }
119
+ if (foreign.size > 0) {
120
+ const whose = [...foreign].map(([owner, names]) => `${listed(names, 'and')} ${names.length > 1 ? 'are' : 'is'} ${possessive(owner)}`);
121
+ throw new Error(`crossbind: ${listed(whose, 'and')}, which ${chain.at(-1).name} takes none of.`);
122
+ }
123
+ const options = Object.fromEntries(chain.map(({ name, publisher }) => [name, Object.fromEntries(Object.entries(publisher.options)
124
+ .map(([option, { value, default: fallback }]) => [option, given.get(name).get(option) ?? (value === null ? false : fallback)]))]));
125
+ return { options, viteArgs };
126
+ }
127
+
128
+ // The help of the stores crossbind publishes to itself, with the options each takes after its name.
129
+ export function publishHelp(publishers = builtInPublishers()) {
130
+ const signature = (option, { value }) => `--${kebab(option)}${value ? ` ${value}` : ''}`;
131
+ const width = Math.max(...publishers.flatMap(({ publisher }) => Object.entries(publisher.options).map(([option, declared]) => signature(option, declared).length)));
132
+ return [
133
+ '',
134
+ 'Stores, each with the options it takes after its name:',
135
+ ...publishers.flatMap(({ name, publisher }) => [
136
+ ` ${name}: ${storeName({ name, publisher })}, for ${listed(publisher.platforms)} apps${publisher.isDefault ? ', which goes first for them' : ''}`,
137
+ ...Object.entries(publisher.options).map(([option, declared]) => ` ${signature(option, declared).padEnd(width)} ${declared.help}${declared.default ? ` (default: ${declared.default})` : ''}`),
138
+ ]),
139
+ "An app's extensions add stores of their own, each with the options its package gives.",
140
+ ].join('\n');
141
+ }
142
+
143
+ // The changes the first publisher makes to the config for its build: a JSON merge patch of crossbind.config.js's
144
+ // export.
145
+ async function buildChanges(extension, api) {
146
+ const { name } = extension;
147
+ const changes = await step(extension, 'build', api);
148
+ if (changes === null || changes === undefined) return null;
149
+ if (!isObject(changes) || !isJsonValue(changes)) {
150
+ throw new Error(`crossbind: ${name}'s build gives no changes to crossbind.config.js, which are an object of JSON values merged into its export, where null removes a key.`);
151
+ }
152
+ return changes;
153
+ }
154
+
155
+ // Hands the build's output to the uploads of the publishers after the first, each with what the one before it put out.
156
+ async function uploadAfter(chain, apis, sessions, output, firstPublished) {
157
+ let published = firstPublished;
158
+ for (const [index, extension] of chain.entries()) {
159
+ const { name } = extension;
160
+ if (index > 0) {
161
+ const answer = await step(extension, 'upload', apis[index], sessions[index], output, published);
162
+ if (answer?.isRebuildAsked) throw new Error(`crossbind: ${name} asks for another build, which ${chain[0].name}, whose build it publishes from, alone can ask for.`);
163
+ published = answer?.published;
164
+ }
165
+ }
166
+ }
167
+
168
+ // Sends an app to a store through its publisher, after those it comes after. All they need is checked before the build,
169
+ // which takes minutes: on a terminal they ask for what is missing and set it up; without one, as in CI, every missing
170
+ // piece is named at once. crossbind then builds the app once, with the first publisher's changes to the config, build
171
+ // number and environment, and hands its output to each one's upload in turn. The first may ask for another build, as
172
+ // an iOS app's does once its user has given Apple's reasons for an API it calls.
173
+ export default async function publish(args, { platform, envPlatform, runVite }) {
174
+ const publishers = publishersOf(state.config.extensions);
175
+ const { store, rest } = storeOf(args, platform, envPlatform, publishers);
176
+ const chain = publishChain(publishers, store);
177
+ const own = builtInPublishers().map(({ name }) => name);
178
+ chain.filter((extension) => own.includes(extension.name) && state.config.extensions.includes(extension))
179
+ .forEach(({ name, root }) => console.log(`crossbind: the extension ${name} of ${root} replaces crossbind's own publisher of its name.`));
180
+ const target = publishPlatform(chain, platform, envPlatform);
181
+ const { options, viteArgs } = publishOptions(chain, rest, publishers);
182
+ const run = { buildNumber: undefined };
183
+ const isGuided = hasTerminal();
184
+ const apis = chain.map(({ name }) => extensionApi(name, {
185
+ platform: target, options: options[name], isGuided, run,
186
+ }));
187
+ const [first] = chain;
188
+ const changes = await buildChanges(first, apis[0]);
189
+ if (changes) state.config = await loadConfig(undefined, undefined, { changes });
190
+ const checked = [];
191
+ for (const [index, extension] of chain.entries()) checked.push((await step(extension, 'check', apis[index])) ?? {});
192
+ const missing = checked.flatMap((result) => result.missing ?? []);
193
+ if (missing.length > 0) throw new Error(missing.join('\n'));
194
+ const sessions = checked.map(({ session }) => session);
195
+ run.buildNumber = (await step(first, 'buildNumber', apis[0], sessions[0])) ?? null;
196
+ if (run.buildNumber !== null && !(Number.isInteger(run.buildNumber) && run.buildNumber >= 1 && run.buildNumber <= MAX_VERSION_CODE)) {
197
+ throw new Error(`crossbind: ${first.name}'s buildNumber gives ${JSON.stringify(run.buildNumber)}, which is no build number: a whole number from 1 to ${MAX_VERSION_CODE}, as both stores take, or null for the app's own.`);
198
+ }
199
+ for (let builds = 1; ; builds += 1) {
200
+ clearAppOutputs();
201
+ const buildEnv = await step(first, 'buildEnv', apis[0], sessions[0]);
202
+ const exitCode = await runVite(['build', ...viteArgs], {
203
+ ...process.env,
204
+ ...(changes ? { [CONFIG_CHANGES]: JSON.stringify(changes) } : {}),
205
+ ...buildEnv,
206
+ CROSSBIND_APP_PLATFORM: target,
207
+ ...(run.buildNumber === null ? {} : { CROSSBIND_APP_BUILD_NUMBER: String(run.buildNumber) }),
208
+ });
209
+ if (exitCode !== 0) {
210
+ throw Object.assign(new Error(`crossbind: the build ended with exit code ${exitCode}, so nothing went to ${storeName(first)}.`), { exitCode });
211
+ }
212
+ const output = appOutputOf(target);
213
+ const answer = await step(first, 'upload', apis[0], sessions[0], output);
214
+ if (!answer?.isRebuildAsked) {
215
+ await uploadAfter(chain, apis, sessions, output, answer?.published);
216
+ return;
217
+ }
218
+ if (builds === MAX_BUILDS) throw new Error(`crossbind: ${first.name} asks for another build after ${MAX_BUILDS}, as many as a publish makes.`);
219
+ }
220
+ }
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import {
6
+ SIGNING, machineUploadKey, releaseSigning, uploadKeyEnv,
7
+ } from './androidGradle.js';
8
+ import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
9
+ import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
10
+ import getOsUserAndGroupId from '../utils/getOsUserAndGroupId.js';
11
+ import loadSystemConfig, { updateSystemConfig } from '../utils/systemConfig.js';
12
+ import { hasKeychain, readSecret, storeSecret } from '../utils/keychain.js';
13
+ import { PASSWORD_RULES, makePassword, passwordProblem } from '../utils/password.js';
14
+ import { askHidden, askLine, hasTerminal } from '../utils/ask.js';
15
+
16
+ const ROLE = 'android-app';
17
+ const PLATFORM = 'linux/amd64';
18
+ const ALIAS = 'upload';
19
+ // Nobody sees an upload key's name: an app installed from Google Play carries Google's signature.
20
+ const NAME = 'CN=Android upload key';
21
+ // Google Play wants a key valid past 2033; 10,000 days is 27 years.
22
+ const VALIDITY_DAYS = 10000;
23
+
24
+ // Where `crossbind app upload-key` makes this machine's upload key.
25
+ export const uploadKeyFile = () => path.join(os.homedir(), '.crossbind', 'keys', 'android-upload.p12');
26
+
27
+ // keytool in crossbind's android-app image, which an Android app's build pulls anyway: with no network, the key's folder
28
+ // alone, and the password by name in the environment, never on a command line.
29
+ function keytool(keystore, command, args, password, { isReadOnly = false } = {}) {
30
+ pullDockerImage(ROLE, PLATFORM);
31
+ const result = spawnSync('docker', [
32
+ 'run', '--rm', ...DOCKER_RUN_SECURITY_ARGS, '--network', 'none', '--platform', PLATFORM, '--user', getOsUserAndGroupId(),
33
+ '-v', `${path.dirname(keystore)}:/keys${isReadOnly ? ':ro' : ''}`, '-e', SIGNING.password, getDockerImage(ROLE, PLATFORM),
34
+ 'keytool', command, '-keystore', `/keys/${path.basename(keystore)}`, ...args, '-storepass:env', SIGNING.password,
35
+ ], { encoding: 'utf8', env: { ...process.env, [SIGNING.password]: password } });
36
+ if (result.error) throw new Error(`crossbind: cannot run keytool in the ${ROLE} image: ${result.error.message}`, { cause: result.error });
37
+ return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
38
+ }
39
+
40
+ // The password the user types for a new key, held to the rules and typed twice the same.
41
+ async function typedPassword() {
42
+ console.error(`The upload key's password needs:\n${PASSWORD_RULES.map((rule) => ` - ${rule}`).join('\n')}`);
43
+ for (;;) {
44
+ const password = await askHidden('Password: ');
45
+ const problem = passwordProblem(password);
46
+ if (problem) {
47
+ console.error(`That one will not do: ${problem}.`);
48
+ } else if (await askHidden('Again: ') === password) {
49
+ return password;
50
+ } else {
51
+ console.error('The two did not match.');
52
+ }
53
+ }
54
+ }
55
+
56
+ // A new key's password: the environment's, else one crossbind makes or one the user types. Only a keychain keeps a made
57
+ // one, so without a keychain the user types their own.
58
+ async function newPassword(canKeep) {
59
+ const given = process.env[SIGNING.password];
60
+ if (given) {
61
+ const problem = passwordProblem(given);
62
+ if (problem) throw new Error(`crossbind: ${SIGNING.password} cannot lock an upload key: ${problem}.`);
63
+ return { password: given, isMade: false };
64
+ }
65
+ if (!hasTerminal()) throw new Error(`crossbind: an upload key needs a password: run this on a terminal, which asks for one, or set ${SIGNING.password}.`);
66
+ const choice = canKeep
67
+ ? await askLine("The upload key's password:\n 1) make a strong one for me (recommended)\n 2) I will type my own\nChoose 1 or 2 [1]: ")
68
+ : '2';
69
+ if (choice.trim() !== '2') return { password: makePassword(), isMade: true };
70
+ return { password: await typedPassword(), isMade: false };
71
+ }
72
+
73
+ // Makes a key this machine's upload key, its password kept in the system keychain where it can be.
74
+ function keep(keystore, alias, password, isKeychain) {
75
+ const isKept = isKeychain && storeSecret(keystore, password);
76
+ updateSystemConfig({ ANDROID_KEYSTORE: keystore, ANDROID_KEY_ALIAS: alias });
77
+ console.log(isKept
78
+ ? 'crossbind: the system keychain keeps its password, so builds sign without asking.'
79
+ : `crossbind: builds ask for its password, as no keychain keeps it; set ${SIGNING.password} where no one can type it, as in CI.`);
80
+ }
81
+
82
+ // Makes this machine's upload key, which signs its Android apps' bundles for Google Play. It never makes one over a key
83
+ // the machine has.
84
+ export async function makeUploadKey({ isKeychain = true } = {}) {
85
+ const system = loadSystemConfig();
86
+ if (system.ANDROID_KEYSTORE) {
87
+ throw new Error(`crossbind: this machine's upload key is ${system.ANDROID_KEYSTORE}, and crossbind never makes another over it, as Google Play takes an app's updates only signed with the key it knows. To make a new one anyway, drop it first with crossbind config delete ANDROID_KEYSTORE.`);
88
+ }
89
+ const file = uploadKeyFile();
90
+ if (fs.existsSync(file)) {
91
+ throw new Error(`crossbind: ${file} is there already, and crossbind never replaces an upload key: use it with crossbind app upload-key --use ${file}.`);
92
+ }
93
+ const canKeep = isKeychain && hasKeychain();
94
+ const { password, isMade } = await newPassword(canKeep);
95
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
96
+ const made = keytool(file, '-genkeypair', ['-storetype', 'PKCS12', '-alias', ALIAS, '-keyalg', 'RSA', '-keysize', '2048',
97
+ '-validity', String(VALIDITY_DAYS), '-dname', NAME], password);
98
+ if (made.status !== 0) throw new Error(`crossbind: keytool did not make the upload key:\n${made.output}`);
99
+ fs.chmodSync(file, 0o600);
100
+ console.log(`crossbind: your upload key is ${file}.`);
101
+ if (isMade) console.log(`crossbind: its password, shown this once: ${password}`);
102
+ keep(file, ALIAS, password, canKeep);
103
+ console.log(`crossbind: keep a copy of ${file} and of its password somewhere safe, as a password manager. Google keeps only the key's certificate: a lost key takes an upload key reset in Play Console, which takes effect 72 hours later.`);
104
+ }
105
+
106
+ // Makes a key the user has this machine's upload key, once it opens with its password and alias.
107
+ export async function useUploadKey(file, { alias = ALIAS, isKeychain = true } = {}) {
108
+ const keystore = path.resolve(file);
109
+ if (!fs.statSync(keystore, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${keystore} is no file.`);
110
+ const password = process.env[SIGNING.password] || (hasTerminal() ? await askHidden(`Password of ${keystore}: `) : '');
111
+ if (!password) throw new Error(`crossbind: ${keystore} needs its password: run this on a terminal, which asks for it, or set ${SIGNING.password}.`);
112
+ const opened = keytool(keystore, '-list', ['-alias', alias], password, { isReadOnly: true });
113
+ if (opened.status !== 0) throw new Error(`crossbind: ${keystore} did not open with that password and the alias ${alias}:\n${opened.output}`);
114
+ console.log(`crossbind: this machine's upload key is ${keystore}, alias ${alias}.`);
115
+ keep(keystore, alias, password, isKeychain);
116
+ }
117
+
118
+ // The environment an Android release builds in, with the environment's upload key, else this machine's, whose password
119
+ // the keychain keeps or the terminal gives, and whether a key signs it: checked before the native build, not after it.
120
+ export async function releaseSigningEnv() {
121
+ const key = machineUploadKey();
122
+ const password = key && !key.password && hasTerminal() ? await askHidden(`Password of this machine's upload key, ${key.keystore}: `) : null;
123
+ const env = key ? uploadKeyEnv({ ...key, password: key.password || password }) : {};
124
+ return { env, isSigned: releaseSigning({ ...process.env, ...env }) !== null };
125
+ }
126
+
127
+ // The password the keychain keeps for this machine's upload key, for another machine or CI.
128
+ export function showUploadKeyPassword() {
129
+ const { ANDROID_KEYSTORE: keystore } = loadSystemConfig();
130
+ if (!keystore) throw new Error('crossbind: this machine has no upload key: crossbind app upload-key makes one.');
131
+ const password = readSecret(keystore);
132
+ if (!password) throw new Error(`crossbind: no keychain keeps the password of ${keystore}.`);
133
+ console.log(password);
134
+ }
@@ -49,7 +49,7 @@ export function windowsSigning(env = process.env) {
49
49
  return null;
50
50
  }
51
51
  if (TOKENS.includes(storetype)) {
52
- throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is a hardware token, which crossbind's windows container does not reach. Sign with a cloud's key service, as Azure Trusted Signing (TRUSTEDSIGNING) or Azure Key Vault (AZUREKEYVAULT), or with a PKCS#12 file.`);
52
+ throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is a hardware token, which crossbind's windows container does not reach. Sign with a cloud's key service, as Azure Artifact Signing (TRUSTEDSIGNING) or Azure Key Vault (AZUREKEYVAULT), or with a PKCS#12 file.`);
53
53
  }
54
54
  if (![...FILE_STORES, ...CLOUDS].includes(storetype)) {
55
55
  throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is no store type jsign takes: ${[...FILE_STORES, ...CLOUDS].join(', ')}.`);