crossbind 2.0.0-beta.68 → 2.0.0-beta.72

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 (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +70 -59
  3. package/src/actions/addExtension.js +196 -0
  4. package/src/actions/androidGradle.js +26 -17
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/buildApp.js +177 -74
  8. package/src/actions/buildDependencies.js +3 -3
  9. package/src/actions/buildNode.js +6 -1
  10. package/src/actions/buildWasm.js +0 -9
  11. package/src/actions/createImportedBridges.js +10 -8
  12. package/src/actions/createInterface.js +40 -29
  13. package/src/actions/createLib.js +3 -7
  14. package/src/actions/extensionApi.js +298 -0
  15. package/src/actions/extensionCommands.js +41 -0
  16. package/src/actions/extensionsInApp.js +19 -0
  17. package/src/actions/getAllBridges.js +20 -5
  18. package/src/actions/getDependLibs.js +5 -0
  19. package/src/actions/getLinkInputs.js +2 -1
  20. package/src/actions/gradleProject.js +207 -16
  21. package/src/actions/iosDevice.js +2 -2
  22. package/src/actions/iosSimulator.js +3 -3
  23. package/src/actions/licenses.js +113 -58
  24. package/src/actions/linuxPackages.js +48 -0
  25. package/src/actions/mavenVerification.js +37 -0
  26. package/src/actions/{prepareConanDependencies.js → preparePackageSources.js} +13 -12
  27. package/src/actions/publish.js +220 -0
  28. package/src/actions/windowsSigning.js +1 -1
  29. package/src/actions/writeRuntimeEntry.js +2 -2
  30. package/src/actions/xcodeProject.js +60 -29
  31. package/src/actions/xcodeTargets.js +122 -0
  32. package/src/actions/xcodebuild.js +65 -0
  33. package/src/app/index.d.ts +144 -132
  34. package/src/app/index.js +147 -185
  35. package/src/assets/configure/install.sh +0 -0
  36. package/src/assets/node-import-hooks.mjs +7 -6
  37. package/src/assets/toolchain-digests.json +20 -20
  38. package/src/bin.js +37 -37
  39. package/src/index.js +4 -3
  40. package/src/integration/getCrossbindScript.js +3 -2
  41. package/src/integration/getDependFilePath.js +10 -10
  42. package/src/program.js +32 -3
  43. package/src/state/{attachConanDependencies.js → attachPackageSources.js} +20 -16
  44. package/src/state/index.js +8 -6
  45. package/src/state/loadConfig.js +26 -9
  46. package/src/state/refreshPackageSources.js +19 -0
  47. package/src/utils/appExtensions.js +777 -0
  48. package/src/utils/appFiles.js +58 -0
  49. package/src/utils/appLaunches.js +249 -0
  50. package/src/utils/appOptions.js +142 -112
  51. package/src/utils/appRustCrates.js +2 -2
  52. package/src/utils/appTargets.js +168 -0
  53. package/src/utils/bridgeTools.js +71 -0
  54. package/src/utils/configChanges.js +30 -0
  55. package/src/utils/configWriter.js +143 -26
  56. package/src/utils/cppDts.js +11 -11
  57. package/src/utils/extensionSteps.js +172 -0
  58. package/src/utils/nativeImports.js +9 -2
  59. package/src/utils/nodeDevBuild.js +2 -1
  60. package/src/utils/openUrl.js +2 -1
  61. package/src/utils/packageSourceApi.js +87 -0
  62. package/src/utils/packageSourceImport.js +69 -0
  63. package/src/utils/packageSourceInstall.js +162 -0
  64. package/src/utils/{conanStage.js → packageSourceStage.js} +64 -90
  65. package/src/utils/packageSources.js +108 -0
  66. package/src/utils/plist.js +5 -0
  67. package/src/utils/printableText.js +5 -0
  68. package/src/utils/resolveAppHost.js +7 -0
  69. package/src/utils/runPackageSource.js +251 -0
  70. package/src/utils/rustBridgeGen.js +3 -3
  71. package/src/utils/systemConfig.js +16 -3
  72. package/src/utils/targetToolchain.js +77 -0
  73. package/vitest.config.js +6 -1
  74. package/src/actions/appSetup.js +0 -116
  75. package/src/actions/appStoreConnect.js +0 -188
  76. package/src/actions/appUpdate.js +0 -68
  77. package/src/actions/extensions.js +0 -7
  78. package/src/actions/googlePlay.js +0 -213
  79. package/src/actions/googleSignIn.js +0 -78
  80. package/src/actions/playSetup.js +0 -19
  81. package/src/actions/publishAndroid.js +0 -102
  82. package/src/actions/publishIos.js +0 -146
  83. package/src/state/refreshConanDependencies.js +0 -17
  84. package/src/utils/conanDependencies.js +0 -66
  85. package/src/utils/conanImport.js +0 -58
  86. package/src/utils/conanInstall.js +0 -154
  87. package/src/utils/conanProfile.js +0 -203
  88. package/src/utils/runConan.js +0 -257
@@ -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
+ }
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import state, { setAllDependecyPaths } from '../state/index.js';
3
- import refreshConanDependencies from '../state/refreshConanDependencies.js';
4
- import installConanPackages from '../utils/conanInstall.js';
3
+ import refreshPackageSources from '../state/refreshPackageSources.js';
4
+ import installPackageSources from '../utils/packageSourceInstall.js';
5
+ import { declaredSources } from '../utils/packageSources.js';
5
6
  import { releaseTargetOf, HOST_BUILT_PLATFORMS } from '../utils/targets.js';
6
7
  import createXCFramework from './createXCFramework.js';
7
8
 
@@ -11,11 +12,11 @@ const isBuiltHere = (target) => process.platform === 'darwin' || !HOST_BUILT_PLA
11
12
 
12
13
  // iOS headers and archives come out of an xcframework per library with a slice per SDK (state/index.js).
13
14
  // One older than an archive it wraps was made before the package was staged again.
14
- function makeConanXCFrameworks(iosTargets) {
15
+ function makeXCFrameworks(iosTargets) {
15
16
  const archiveOf = (dependency, target, name) => `${dependency.paths.output}/prebuilt/${target.path}/lib/lib${name}.a`;
16
17
  const isFresh = (dependency, name) => mtimeOf(`${dependency.paths.project}/${name}.xcframework/Info.plist`)
17
18
  >= Math.max(...iosTargets.map((target) => mtimeOf(archiveOf(dependency, target, name))));
18
- state.config.allDependencies.filter((dependency) => dependency.general.conan).forEach((dependency) => {
19
+ state.config.allDependencies.filter((dependency) => dependency.general.packageSource).forEach((dependency) => {
19
20
  // A library its recipe names on another platform only has no slice here.
20
21
  const libName = dependency.export.libName.filter((name) => iosTargets.every((target) => fs.existsSync(archiveOf(dependency, target, name))));
21
22
  if (libName.every((name) => isFresh(dependency, name))) return;
@@ -29,11 +30,11 @@ function makeConanXCFrameworks(iosTargets) {
29
30
  });
30
31
  }
31
32
 
32
- // Conan packages are staged before any header is read: SWIG parses headers that include theirs, and an
33
- // interface made without its includes stays that way until the header itself changes. Release trees
34
- // only - a debug build links them like any prebuilt.
35
- export default async function prepareConanDependencies(targets) {
36
- if (Object.keys(state.config.conanDependencies ?? {}).length === 0) return;
33
+ // The packages of a project's sources are staged before any header is read: SWIG parses headers that
34
+ // include theirs, and an interface made without its includes stays that way until the header itself
35
+ // changes. Release trees only - a debug build links them like any prebuilt.
36
+ export default async function preparePackageSources(targets) {
37
+ if (declaredSources(state.config).length === 0) return;
37
38
  // createBridgeFile reads every header for the first wasm target unless it is told otherwise, as it
38
39
  // is by Metro, which builds the React Native bridges for the platform's own target, and by a Node
39
40
  // build, which reads them for one of its own (nodeBridgeTarget).
@@ -43,9 +44,9 @@ export default async function prepareConanDependencies(targets) {
43
44
  const releaseTargets = [...new Map([...targets, bridgeTarget, ...iosTargets].filter(Boolean).map((target) => [
44
45
  target.releasePath, releaseTargetOf(target),
45
46
  ])).values()].filter(isBuiltHere);
46
- await installConanPackages(state.config, releaseTargets);
47
+ await installPackageSources(state.config, releaseTargets);
47
48
  // Also when another process did the staging: this one may have loaded before the manifests existed.
48
- refreshConanDependencies(state.config);
49
+ refreshPackageSources(state.config);
49
50
  setAllDependecyPaths();
50
- if (iosTargets.length > 0) makeConanXCFrameworks(releaseTargets.filter((target) => target.platform === 'ios'));
51
+ if (iosTargets.length > 0) makeXCFrameworks(releaseTargets.filter((target) => target.platform === 'ios'));
51
52
  }
@@ -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
+ }
@@ -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(', ')}.`);
@@ -50,7 +50,7 @@ function nativesOf(headers, imports, dir) {
50
50
  }
51
51
 
52
52
  // The module `node --import` loads ahead of the app, so the app imports the bound headers themselves.
53
- export function writeImportHooks(entry, headers, imports, extensions) {
53
+ export function writeImportHooks(entry, headers, imports, extensions, schemes) {
54
54
  // Node runs the register file from its real path, so the files it names are relative to that.
55
55
  const dir = upath.normalize(fs.realpathSync(upath.dirname(entry)));
56
56
  const natives = nativesOf(headers, imports, dir);
@@ -59,7 +59,7 @@ export function writeImportHooks(entry, headers, imports, extensions) {
59
59
  '// Generated by crossbind - do not edit.',
60
60
  "import registerNativeImports from './hooks.mjs';",
61
61
  '',
62
- `registerNativeImports(new URL('./${upath.basename(entry)}', import.meta.url), ${JSON.stringify({ extensions, natives }, null, 4)});`,
62
+ `registerNativeImports(new URL('./${upath.basename(entry)}', import.meta.url), ${JSON.stringify({ extensions, schemes, natives }, null, 4)});`,
63
63
  '',
64
64
  ].join('\n'));
65
65
  }
@@ -3,13 +3,15 @@ import { spawnSync } from 'node:child_process';
3
3
  import state from '../state/index.js';
4
4
  import { IOS_DEPLOYMENT_TARGET } from '../utils/iosToolchain.js';
5
5
  import { textPlist, xmlPlist } from '../utils/plist.js';
6
- import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
6
+ import { mergedPlist } from '../utils/appExtensions.js';
7
+ import { appLinkParts, iosDocumentTypes, iosShortcutItems } from '../utils/appLaunches.js';
8
+ import { privacyManifest, writeTargets } from './xcodeTargets.js';
7
9
 
8
10
  // The SDKs an iOS app builds for, one target each, and the slice of Hermes' release each takes.
9
11
  export const IOS_SDKS = ['iphoneos', 'iphonesimulator'];
10
12
  export const HERMES_SLICES = { iphoneos: 'ios-arm64', iphonesimulator: 'ios-arm64_x86_64-simulator' };
11
13
  // What the host's own code needs from the system: Xcode adds it only for the sources it compiles itself.
12
- const SYSTEM_LIBS = ['-framework', 'UIKit', '-framework', 'WebKit', '-framework', 'CoreGraphics', '-lc++'];
14
+ const SYSTEM_LIBS = ['-framework', 'UIKit', '-framework', 'WebKit', '-framework', 'CoreGraphics', '-framework', 'Security', '-framework', 'UserNotifications', '-lc++'];
13
15
 
14
16
  const MAIN = `// crossbind writes this project anew on every build: the app comes prebuilt in lib/, and its files are in app/.
15
17
  int crossbind_app_main(int argc, char *argv[]);
@@ -19,7 +21,7 @@ int main(int argc, char *argv[]) { return crossbind_app_main(argc, argv); }
19
21
 
20
22
  const OBJECT_NAMES = ['project', 'mainGroup', 'products', 'target', 'product', 'main', 'info', 'app', 'hermes', 'mainBuild',
21
23
  'appBuild', 'hermesLink', 'hermesEmbed', 'sources', 'frameworks', 'resources', 'embed', 'projectDebug', 'projectRelease',
22
- 'targetDebug', 'targetRelease', 'projectConfigs', 'targetConfigs', 'assets', 'assetsBuild', 'privacy', 'privacyBuild'];
24
+ 'targetDebug', 'targetRelease', 'projectConfigs', 'targetConfigs', 'assets', 'assetsBuild', 'privacy', 'privacyBuild', 'embedTargets'];
23
25
  // Xcode names its objects by 24 hex digits. Fixed ones leave the project unchanged while what it holds is.
24
26
  const ID = Object.fromEntries(OBJECT_NAMES.map((name, index) => [name, `CB${(index + 1).toString(16).toUpperCase().padStart(22, '0')}`]));
25
27
 
@@ -40,7 +42,14 @@ const ORIENTATIONS = ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationL
40
42
  // The color set of app.window.backgroundColor, which the launch screen shows.
41
43
  const LAUNCH_COLOR = 'LaunchBackground';
42
44
 
43
- const infoPlist = (name, urlSchemes, hasBackground, usage, encryption) => xmlPlist({
45
+ // The entitlement of the domains whose sites' links iOS hands the app, once each site's apple-app-site-association names
46
+ // it, and the one of the App Groups it shares with the targets of its extensions'.
47
+ const ASSOCIATED_DOMAINS = 'com.apple.developer.associated-domains';
48
+ const APP_GROUPS = 'com.apple.security.application-groups';
49
+
50
+ // The app's Info.plist, with the keys its extensions give, and the App Group its native code reads where the targets of
51
+ // its extensions' share one.
52
+ const infoPlist = (name, urlSchemes, fileTypes, activityTypes, shortcutItems, hasBackground, usage, encryption, multipleWindows, appGroup, plist) => xmlPlist(mergedPlist({
44
53
  CFBundleDevelopmentRegion: 'en',
45
54
  CFBundleDisplayName: name,
46
55
  CFBundleExecutable: '$(EXECUTABLE_NAME)',
@@ -53,15 +62,20 @@ const infoPlist = (name, urlSchemes, hasBackground, usage, encryption) => xmlPli
53
62
  CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
54
63
  ...(encryption === null ? {} : { ITSAppUsesNonExemptEncryption: encryption }),
55
64
  LSRequiresIPhoneOS: true,
56
- UIApplicationSceneManifest: { UIApplicationSupportsMultipleScenes: false },
65
+ UIApplicationSceneManifest: { UIApplicationSupportsMultipleScenes: multipleWindows },
57
66
  UILaunchScreen: hasBackground ? { UIColorName: LAUNCH_COLOR } : {},
58
67
  UISupportedInterfaceOrientations: ORIENTATIONS,
59
68
  'UISupportedInterfaceOrientations~ipad': [...ORIENTATIONS, 'UIInterfaceOrientationPortraitUpsideDown'],
69
+ ...iosDocumentTypes(fileTypes, '$(PRODUCT_BUNDLE_IDENTIFIER)'),
70
+ ...(activityTypes.length > 0 ? { NSUserActivityTypes: activityTypes } : {}),
71
+ ...(shortcutItems.length > 0 ? { UIApplicationShortcutItems: iosShortcutItems(shortcutItems) } : {}),
60
72
  // The reasons of app.permissions, without which iOS lets the app use none of those devices.
61
73
  ...usage,
62
- });
74
+ ...(appGroup ? { CrossbindAppGroup: appGroup } : {}),
75
+ }, plist));
63
76
 
64
- function objects(name, targetSettings, hasAssets, hasPrivacy) {
77
+ // The project's objects: the app's target, and the targets its extensions add, which the app depends on and embeds.
78
+ function objects(name, targetSettings, hasAssets, hasPrivacy, added) {
65
79
  const file = (path, type) => ({ isa: 'PBXFileReference', lastKnownFileType: type, path, sourceTree: '<group>' });
66
80
  const phase = (isa, files, extra = {}) => ({ isa, buildActionMask: 2147483647, files, runOnlyForDeploymentPostprocessing: 0, ...extra });
67
81
  const configuration = (configurationName, buildSettings) => ({ isa: 'XCBuildConfiguration', buildSettings, name: configurationName });
@@ -77,9 +91,11 @@ function objects(name, targetSettings, hasAssets, hasPrivacy) {
77
91
  [ID.privacy]: file('PrivacyInfo.xcprivacy', 'text.xml'),
78
92
  [ID.privacyBuild]: { isa: 'PBXBuildFile', fileRef: ID.privacy },
79
93
  } : {};
94
+ const hasTargets = added.targets.length > 0;
80
95
  return {
81
96
  ...assets,
82
97
  ...privacy,
98
+ ...added.objects,
83
99
  [ID.main]: file('main.m', 'sourcecode.c.objc'),
84
100
  [ID.info]: file('Info.plist', 'text.plist.xml'),
85
101
  [ID.app]: file('app', 'folder'),
@@ -95,18 +111,19 @@ function objects(name, targetSettings, hasAssets, hasPrivacy) {
95
111
  [ID.frameworks]: phase('PBXFrameworksBuildPhase', [ID.hermesLink]),
96
112
  [ID.resources]: phase('PBXResourcesBuildPhase', [ID.appBuild, ...(hasAssets ? [ID.assetsBuild] : []), ...(hasPrivacy ? [ID.privacyBuild] : [])]),
97
113
  [ID.embed]: phase('PBXCopyFilesBuildPhase', [ID.hermesEmbed], { dstPath: '', dstSubfolderSpec: 10, name: 'Embed Frameworks' }),
114
+ ...(hasTargets ? { [ID.embedTargets]: phase('PBXCopyFilesBuildPhase', added.embedded, { dstPath: '', dstSubfolderSpec: 13, name: 'Embed Foundation Extensions' }) } : {}),
98
115
  [ID.mainGroup]: {
99
116
  isa: 'PBXGroup',
100
- children: [ID.main, ID.info, ID.app, ...(hasAssets ? [ID.assets] : []), ...(hasPrivacy ? [ID.privacy] : []), ID.hermes, ID.products],
117
+ children: [ID.main, ID.info, ID.app, ...(hasAssets ? [ID.assets] : []), ...(hasPrivacy ? [ID.privacy] : []), ID.hermes, ...added.groups, ID.products],
101
118
  sourceTree: '<group>',
102
119
  },
103
- [ID.products]: { isa: 'PBXGroup', children: [ID.product], name: 'Products', sourceTree: '<group>' },
120
+ [ID.products]: { isa: 'PBXGroup', children: [ID.product, ...added.products], name: 'Products', sourceTree: '<group>' },
104
121
  [ID.target]: {
105
122
  isa: 'PBXNativeTarget',
106
123
  buildConfigurationList: ID.targetConfigs,
107
- buildPhases: [ID.sources, ID.frameworks, ID.resources, ID.embed],
124
+ buildPhases: [ID.sources, ID.frameworks, ID.resources, ID.embed, ...(hasTargets ? [ID.embedTargets] : [])],
108
125
  buildRules: [],
109
- dependencies: [],
126
+ dependencies: added.dependencies,
110
127
  name,
111
128
  productName: name,
112
129
  productReference: ID.product,
@@ -123,7 +140,7 @@ function objects(name, targetSettings, hasAssets, hasPrivacy) {
123
140
  productRefGroup: ID.products,
124
141
  projectDirPath: '',
125
142
  projectRoot: '',
126
- targets: [ID.target],
143
+ targets: [ID.target, ...added.targets],
127
144
  },
128
145
  [ID.projectDebug]: configuration('Debug', PROJECT_SETTINGS),
129
146
  [ID.projectRelease]: configuration('Release', PROJECT_SETTINGS),
@@ -134,8 +151,9 @@ function objects(name, targetSettings, hasAssets, hasPrivacy) {
134
151
  };
135
152
  }
136
153
 
137
- // The asset catalog: the app's icon, from which Xcode makes every size, and the launch screen's color.
138
- function writeAssets(catalog, icon, background) {
154
+ // The asset catalog: the app's icon, from which Xcode makes every size, and the launch screen's color, with the one of
155
+ // the dark appearance where there is one.
156
+ function writeAssets(catalog, icon, background, darkBackground) {
139
157
  const info = { author: 'xcode', version: 1 };
140
158
  const assetSet = (set, contents) => {
141
159
  fs.mkdirSync(`${catalog}/${set}`, { recursive: true });
@@ -146,28 +164,37 @@ function writeAssets(catalog, icon, background) {
146
164
  assetSet('AppIcon.appiconset', { images: [{ filename: 'icon.png', idiom: 'universal', platform: 'ios', size: '1024x1024' }] });
147
165
  fs.writeFileSync(`${catalog}/AppIcon.appiconset/icon.png`, icon);
148
166
  }
167
+ const colorOf = (rgb) => {
168
+ const [red, green, blue] = [1, 3, 5].map((at) => `0x${rgb.slice(at, at + 2).toUpperCase()}`);
169
+ return { 'color-space': 'srgb', components: { alpha: '1.000', red, green, blue } };
170
+ };
149
171
  if (background) {
150
- const [red, green, blue] = [1, 3, 5].map((at) => `0x${background.slice(at, at + 2).toUpperCase()}`);
151
- assetSet(`${LAUNCH_COLOR}.colorset`, { colors: [{ color: { 'color-space': 'srgb', components: { alpha: '1.000', red, green, blue } }, idiom: 'universal' }] });
172
+ const dark = darkBackground ? [{ appearances: [{ appearance: 'luminosity', value: 'dark' }], color: colorOf(darkBackground), idiom: 'universal' }] : [];
173
+ assetSet(`${LAUNCH_COLOR}.colorset`, { colors: [{ color: colorOf(background), idiom: 'universal' }, ...dark] });
152
174
  }
153
175
  }
154
176
 
155
- // The privacy manifest of app.privacy, without which App Store Connect refuses an app that calls required reason API.
156
- const privacyManifest = (privacy) => xmlPlist({
157
- NSPrivacyAccessedAPITypes: Object.entries(privacy).map(([name, reasons]) => ({
158
- NSPrivacyAccessedAPIType: PRIVACY_CATEGORIES[name].type,
159
- NSPrivacyAccessedAPITypeReasons: reasons,
160
- })),
161
- });
162
-
163
177
  // An iOS app as an Xcode project, written anew from the parts its SDK targets built: each brings its archives and
164
178
  // their link line, and its slice of Hermes; the first also the app's files. Xcode signs the app and runs it, in the
165
- // Simulator or on a device, and makes every size of its icon from the one PNG given.
179
+ // Simulator or on a device, with the entitlements given, and makes every size of its icon from the one PNG given. The
180
+ // targets of Apple's app extensions its extensions add build with it, the App Group in the app's entitlements as well
181
+ // where one of them asks for it.
166
182
  export default function writeXcodeProject(to, {
167
- parts, identity, label, version, build = version, icon = null, urlSchemes = [], background = null, usage = {}, encryption = null, privacy = {},
183
+ parts, identity, label, version, build = version, icon = null, urlSchemes = [], fileTypes = [], links = [], activityTypes = [], shortcutItems = [],
184
+ background = null, darkBackground = null, usage = {}, encryption = null, multipleWindows = false, privacy = {}, entitlements = {}, plist = [], targets = [],
185
+ appGroup = null,
168
186
  }) {
169
187
  const { name } = state.config.general;
170
188
  if (parts.length === 0) throw new Error(`crossbind: no part of the iOS app ${name} is built, so there is no Xcode project to write.`);
189
+ // Xcode keeps what it builds of each target by the target's name, which a Mac's file system takes in either case.
190
+ const named = targets.find((target) => target.name.toLowerCase() === name.toLowerCase());
191
+ if (named) throw new Error(`crossbind: the extension ${named.owner} names an iOS target ${named.name}, as the app itself is named: Xcode builds no two targets of one name.`);
192
+ // The extensions' keys merge before the project goes, which a conflict between them then leaves as it was.
193
+ const group = appGroup && targets.some((target) => target.appGroup) ? appGroup : null;
194
+ const info = infoPlist(label, urlSchemes, fileTypes, activityTypes, shortcutItems, Boolean(background), usage, encryption, multipleWindows, group, plist);
195
+ const domains = links.map((link) => `applinks:${appLinkParts(link).host}`);
196
+ const joinedTo = (given, key, values) => (values.length > 0 ? { ...given, [key]: [...new Set([...(given[key] ?? []), ...values])] } : given);
197
+ const signed = joinedTo(joinedTo(entitlements, ASSOCIATED_DOMAINS, domains), APP_GROUPS, group ? [group] : []);
171
198
  fs.rmSync(to, { recursive: true, force: true });
172
199
  fs.mkdirSync(`${to}/${name}.xcodeproj`, { recursive: true });
173
200
  const linkFlags = {};
@@ -184,24 +211,28 @@ export default function writeXcodeProject(to, {
184
211
  if (result.status !== 0) throw new Error(`crossbind: cannot make Hermes' xcframework for ${to}: ${result.stderr || result.error?.message}`);
185
212
 
186
213
  fs.writeFileSync(`${to}/main.m`, MAIN);
187
- fs.writeFileSync(`${to}/Info.plist`, infoPlist(label, urlSchemes, Boolean(background), usage, encryption));
188
- writeAssets(`${to}/Assets.xcassets`, icon, background);
214
+ fs.writeFileSync(`${to}/Info.plist`, info);
215
+ writeAssets(`${to}/Assets.xcassets`, icon, background, darkBackground);
189
216
  const hasPrivacy = Object.keys(privacy).length > 0;
190
217
  if (hasPrivacy) fs.writeFileSync(`${to}/PrivacyInfo.xcprivacy`, privacyManifest(privacy));
218
+ const hasEntitlements = Object.keys(signed).length > 0;
219
+ if (hasEntitlements) fs.writeFileSync(`${to}/${name}.entitlements`, xmlPlist(signed));
191
220
  const targetSettings = {
192
221
  ...(icon ? { ASSETCATALOG_COMPILER_APPICON_NAME: 'AppIcon' } : {}),
193
222
  PRODUCT_NAME: name,
194
223
  PRODUCT_BUNDLE_IDENTIFIER: identity.id,
195
224
  DEVELOPMENT_TEAM: identity.team,
196
225
  CODE_SIGN_STYLE: 'Automatic',
226
+ ...(hasEntitlements ? { CODE_SIGN_ENTITLEMENTS: `${name}.entitlements` } : {}),
197
227
  INFOPLIST_FILE: 'Info.plist',
198
228
  MARKETING_VERSION: version,
199
229
  CURRENT_PROJECT_VERSION: build,
200
230
  LD_RUNPATH_SEARCH_PATHS: ['$(inherited)', '@executable_path/Frameworks'],
201
231
  ...linkFlags,
202
232
  };
233
+ const added = writeTargets(to, targets, { project: ID.project, identity, label, version, build, appGroup });
203
234
  fs.writeFileSync(`${to}/${name}.xcodeproj/project.pbxproj`, textPlist({
204
- archiveVersion: 1, classes: {}, objectVersion: 56, objects: objects(name, targetSettings, Boolean(icon || background), hasPrivacy), rootObject: ID.project,
235
+ archiveVersion: 1, classes: {}, objectVersion: 56, objects: objects(name, targetSettings, Boolean(icon || background), hasPrivacy, added), rootObject: ID.project,
205
236
  }));
206
237
  return to;
207
238
  }