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
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { spawnSync } from 'node:child_process';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { rollup } from 'rollup';
7
6
  import { nodeResolve } from '@rollup/plugin-node-resolve';
@@ -20,8 +19,8 @@ import writeXcodeProject, { HERMES_SLICES, IOS_SDKS } from './xcodeProject.js';
20
19
  import writeGradleProject, { ANDROID_ABIS } from './gradleProject.js';
21
20
  import { attachedDevice } from './androidDevice.js';
22
21
  import { packageAndroidRelease } from './androidGradle.js';
23
- import writeInstaller, { assertUnderBase } from './appInstaller.js';
24
- import writeUpdate from './appUpdate.js';
22
+ import { compileSwift, swiftLinkArgs } from './appSwift.js';
23
+ import { unpackPackages } from './linuxPackages.js';
25
24
  import { APPLE_SIGNING, appleSigning, notarizeApp } from './appleSigning.js';
26
25
  import { WINDOWS_SIGNING, signWindows, windowsSigning } from './windowsSigning.js';
27
26
  import state from '../state/index.js';
@@ -30,8 +29,15 @@ import findFiles from '../utils/findFiles.js';
30
29
  import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
31
30
  import { xmlPlist } from '../utils/plist.js';
32
31
  import {
33
- appBackgroundColor, appCategory, appCopyright, appDisplayName, appHeader, appId, appPermissions, appUrlSchemes, usageDescriptions,
32
+ androidAppId, appBackgroundColor, appCategory, appCopyright, appDisplayName, appEncryption, appFiles, appHeader, appId, appPermissions, appPrivacy,
33
+ appUrlSchemes, iosAppGroup, iosMultipleWindows, urlSchemeOwners, usageDescriptions,
34
34
  } from '../utils/appOptions.js';
35
+ import { carriedFilesOf, copyCarriedFiles } from '../utils/appFiles.js';
36
+ import { activityTypeOwners, fileTypeOwners, linkOwners, macDocumentTypes, shortcutItemOwners } from '../utils/appLaunches.js';
37
+ import { unreasonedApis, unreasonedWarning } from '../utils/requiredReasonApis.js';
38
+ import {
39
+ appExtensionsOf, extensionCmakeArgs, extensionPart, mergedEntitlements, mergedPlist, mergedPrivacy, registrationSource,
40
+ } from '../utils/appExtensions.js';
35
41
  import { androidIcons, iosIcon, linuxIcon, macIcon, readAppIcon, windowsIcon } from '../utils/appIcon.js';
36
42
  import writeIfChanged from '../utils/writeIfChanged.js';
37
43
  import { writeNativeSourceStamp } from '../utils/nativeSourceStamp.js';
@@ -39,8 +45,10 @@ import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
39
45
  import resolveAppHostRoot, { resolveAppEmbindJsiRoot } from '../utils/resolveAppHost.js';
40
46
  import resolveHermes, { HERMES } from '../utils/hermes.js';
41
47
  import { getDockerImage, imageRoleFor } from '../utils/pullDockerImage.js';
48
+ import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
42
49
  import { chosenRunner } from '../utils/selectRunner.js';
43
50
  import { DARWIN_DEPLOYMENT_TARGET } from '../utils/darwinToolchain.js';
51
+ import extensionsInApp from './extensionsInApp.js';
44
52
 
45
53
  const cpuCount = Math.max(1, os.cpus().length - 1);
46
54
  const BRIDGE = 'AppBridge';
@@ -48,6 +56,17 @@ const LINUX_MULTIARCH = { x64: 'x86_64-linux-gnu', arm64: 'aarch64-linux-gnu' };
48
56
  // Where the android-app, linux and windows images keep the Hermes of the release crossbind pins.
49
57
  const IMAGE_HERMES = `/opt/crossbind/hermes/${HERMES.version}`;
50
58
 
59
+ // What the app's extensions add to an app of this platform.
60
+ const extensionsOf = (platform) => extensionPart(extensionsInApp(platform, (message) => console.warn(`crossbind: ${message}.`)), platform);
61
+
62
+ // The App Group an iOS app shares with the targets of its extensions' that ask for one, or none; app.ios.appGroup is
63
+ // checked all the same.
64
+ function appGroupOf(platform, extensions) {
65
+ if (platform !== 'ios') return null;
66
+ const group = iosAppGroup(state.config);
67
+ return extensions.targets.some(({ appGroup }) => appGroup) ? group : null;
68
+ }
69
+
51
70
  // Where an app keeps its executable and its files: a macOS bundle, a Linux or Windows directory, or the app folder of an
52
71
  // iOS app's Xcode project or of an Android app's Gradle project, which link the executable themselves.
53
72
  export function appLayout(target) {
@@ -78,7 +97,8 @@ export function assertAppRunner(target) {
78
97
 
79
98
  // The app a bundler builds: this machine's desktop app, or with CROSSBIND_APP_PLATFORM an iOS app for both SDKs, an
80
99
  // Android app for both ABIs, or a Linux or Windows app for both arches, which builds in Docker on any machine. During
81
- // development only what runs it: the Simulator, the attached device, or this machine.
100
+ // development only what runs it: the Simulator, or the iPhone or iPad CROSSBIND_APP_DEVICE names, the attached Android
101
+ // device, or this machine.
82
102
  export function appTargetParams({ dev = false } = {}, env = process.env) {
83
103
  const platform = env.CROSSBIND_APP_PLATFORM;
84
104
  const app = (platforms, archs) => {
@@ -107,11 +127,12 @@ export function appTargetParams({ dev = false } = {}, env = process.env) {
107
127
  throw new Error(`crossbind: CROSSBIND_APP_PLATFORM=${platform} names no platform an app builds for. Set it to android, ios, linux, macos or windows, or leave it unset for this machine's desktop app.`);
108
128
  }
109
129
  if (process.platform !== 'darwin') throw new Error('crossbind: iOS apps build on macOS, with Xcode.');
110
- return getTargetParams({ platform: ['ios'], arch: dev ? ['iphonesimulator'] : IOS_SDKS, runtime: ['mt'], runtimeEnv: ['app'] }, true);
130
+ const devSdk = env.CROSSBIND_APP_DEVICE ? 'iphoneos' : 'iphonesimulator';
131
+ return getTargetParams({ platform: ['ios'], arch: dev ? [devSdk] : IOS_SDKS, runtime: ['mt'], runtimeEnv: ['app'] }, true);
111
132
  }
112
133
 
113
134
  // The Apple team that signs an iOS app: the project's app.team, else the machine's XCODE_DEVELOPMENT_TEAM, else none.
114
- function appTeam() {
135
+ export function appTeam() {
115
136
  const team = state.config.app.team ?? state.config.system.XCODE_DEVELOPMENT_TEAM ?? '';
116
137
  if (team && !/^[A-Z0-9]{10}$/.test(team)) {
117
138
  throw new Error(`crossbind: ${JSON.stringify(team)} (app.team or XCODE_DEVELOPMENT_TEAM) is no Apple team id, which is ten capital letters and digits.`);
@@ -119,22 +140,29 @@ function appTeam() {
119
140
  return team;
120
141
  }
121
142
 
122
- // An Android app's id: the project's app.id, else one made from the project's name.
123
- function androidAppId() {
124
- const id = state.config.app.id ?? `dev.crossbind.${state.config.general.name.replace(/[^A-Za-z0-9.]/g, '_')}`;
125
- if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(id)) {
126
- throw new Error(`crossbind: ${JSON.stringify(id)} (app.id, else made from the project's name) is no Android application id, which takes dot-separated parts of letters, digits and underscores that each start with a letter, as com.example.app does. Set app.id in crossbind.config.js.`);
143
+ // Apple takes dot-separated integers alone as an app's version.
144
+ export const appVersion = () => (state.config.package?.version ?? '1.0.0').replace(/[-+].*$/, '');
145
+
146
+ const BUILD_NUMBER = 'CROSSBIND_APP_BUILD_NUMBER';
147
+ // Google Play takes no larger version code.
148
+ export const MAX_VERSION_CODE = 2100000000;
149
+
150
+ // The number CI or a publish gives a build, which iOS takes as its build and Android as its version code:
151
+ // CROSSBIND_APP_BUILD_NUMBER, a whole number both stores take, else none.
152
+ export function appBuildNumber(env = process.env) {
153
+ const value = env[BUILD_NUMBER];
154
+ if (!value) return null;
155
+ if (!/^[1-9]\d*$/.test(value) || Number(value) > MAX_VERSION_CODE) {
156
+ throw new Error(`crossbind: ${BUILD_NUMBER} ${JSON.stringify(value)} is no build number, which is a whole number from 1 to ${MAX_VERSION_CODE}, as both stores take.`);
127
157
  }
128
- return id;
158
+ return Number(value);
129
159
  }
130
160
 
131
- // Apple takes dot-separated integers alone as an app's version.
132
- const appVersion = () => (state.config.package?.version ?? '1.0.0').replace(/[-+].*$/, '');
133
-
134
- // Android takes any name for an app's version, and a positive integer that grows from release to release as its code.
135
- function androidVersion() {
161
+ // Android takes any name for an app's version, and a positive integer that grows from release to release as its code:
162
+ // the build number, else one made of the version's numbers.
163
+ export function androidVersion(env = process.env) {
136
164
  const [major = 0, minor = 0, patch = 0] = appVersion().split('.').map(Number);
137
- return { name: state.config.package?.version ?? '1.0.0', code: Math.max(1, major * 1000000 + minor * 1000 + patch) };
165
+ return { name: state.config.package?.version ?? '1.0.0', code: appBuildNumber(env) ?? Math.max(1, major * 1000000 + minor * 1000 + patch) };
138
166
  }
139
167
 
140
168
  // The apps this run built or found current. A part of an iOS app that an earlier run built may be stale.
@@ -181,11 +209,16 @@ async function bundle(input, file, plugins) {
181
209
  await build.close();
182
210
  }
183
211
 
184
- // A development build claims no scheme: the built app, of the same id, owns them, and a link it opens shows its own page.
212
+ // A development build claims no scheme, no file type and no user activity: the built app, of the same id, owns them, and
213
+ // a link it opens shows its own page.
185
214
  const urlSchemesOf = (target) => (target.buildType === 'release' ? appUrlSchemes(state.config) : []);
215
+ const fileTypesOf = (target) => (target.buildType === 'release' ? fileTypeOwners(state.config) : []);
216
+ const activityTypesOf = (target) => (target.buildType === 'release' ? activityTypeOwners(state.config).map(({ type }) => type) : []);
186
217
 
187
- const infoPlist = (name, version, hasIcon, urlSchemes) => xmlPlist({
218
+ // The app's Info.plist, with the keys its extensions give.
219
+ const infoPlist = (name, version, hasIcon, urlSchemes, fileTypes, activityTypes) => xmlPlist(mergedPlist({
188
220
  CFBundleDisplayName: appDisplayName(state.config),
221
+ ...(fileTypes.length > 0 ? { CFBundleDocumentTypes: macDocumentTypes(fileTypes) } : {}),
189
222
  CFBundleExecutable: name,
190
223
  // Contents/Resources/icon.icns.
191
224
  ...(hasIcon ? { CFBundleIconFile: 'icon' } : {}),
@@ -201,8 +234,12 @@ const infoPlist = (name, version, hasIcon, urlSchemes) => xmlPlist({
201
234
  NSHighResolutionCapable: true,
202
235
  // The app menu's About item shows it.
203
236
  ...(appCopyright(state.config) ? { NSHumanReadableCopyright: appCopyright(state.config) } : {}),
237
+ ...(activityTypes.length > 0 ? { NSUserActivityTypes: activityTypes } : {}),
204
238
  ...usageDescriptions(state.config, 'darwin'),
205
- });
239
+ }, extensionsOf('darwin').plist));
240
+
241
+ // The app's own files, as app.files lists them, by the project's folder.
242
+ const ownFiles = () => ({ root: state.config.paths.project, paths: appFiles(state.config) });
206
243
 
207
244
  // The host's bytecode and the data the packages ship, beside the page's folder.
208
245
  function copyAppFiles(dir, hbc, target) {
@@ -211,6 +248,7 @@ function copyAppFiles(dir, hbc, target) {
211
248
  Object.entries(getData('data', target)).forEach(([source, dataName]) => {
212
249
  if (fs.existsSync(source)) fs.cpSync(source, `${dir}/data/${dataName}`, { recursive: true });
213
250
  });
251
+ copyCarriedFiles(dir, extensionsOf(target.platform).files, ownFiles());
214
252
  }
215
253
 
216
254
  // Everything but the page: the host and the app's files, its icon, and on macOS Hermes beside them. A Windows host
@@ -225,7 +263,7 @@ function assembleApp(target, { executable, hbc, hermes, icon }) {
225
263
  copyAppFiles(`${app}/${layout.resources}`, hbc, target);
226
264
  if (target.platform === 'linux' && icon) fs.writeFileSync(`${app}/${layout.resources}/icon.png`, linuxIcon(icon));
227
265
  if (target.platform !== 'darwin') return;
228
- fs.writeFileSync(`${app}/Contents/Info.plist`, infoPlist(name, appVersion(), Boolean(icon), urlSchemesOf(target)));
266
+ fs.writeFileSync(`${app}/Contents/Info.plist`, infoPlist(name, appVersion(), Boolean(icon), urlSchemesOf(target), fileTypesOf(target), activityTypesOf(target)));
229
267
  if (icon) fs.writeFileSync(`${app}/Contents/Resources/icon.icns`, macIcon(icon));
230
268
  const framework = `${app}/Contents/Frameworks/hermesvm.framework`;
231
269
  fs.cpSync(`${hermes}/Library/Frameworks/macosx/hermesvm.framework`, framework, { recursive: true, verbatimSymlinks: true });
@@ -260,13 +298,16 @@ function assembleIosPart(target, { library, linkArgs, hbc, hermes }) {
260
298
  }
261
299
 
262
300
  // What an Android ABI target brings to the app's Gradle project: the host, the libraries beside it and the packages'
263
- // shared libraries it links, and the app's files.
301
+ // shared libraries it links, and the app's files. A release's host build left the packages' libraries stripped beside
302
+ // the host, and every library as linked in symbols/.
264
303
  function assembleAndroidPart(target, { libDir, sharedLibs, hbc }) {
265
304
  const part = `${state.config.paths.build}/${target.appName}`;
266
305
  fs.rmSync(part, { recursive: true, force: true });
267
306
  fs.mkdirSync(`${part}/lib`, { recursive: true });
268
- [...['libcrossbind_app.so', 'libjsi.so', 'libc++_shared.so'].map((file) => `${libDir}/${file}`), ...sharedLibs]
307
+ const shipped = sharedLibs.map((file) => (fs.existsSync(`${libDir}/${path.basename(file)}`) ? `${libDir}/${path.basename(file)}` : file));
308
+ [...['libcrossbind_app.so', 'libjsi.so', 'libc++_shared.so'].map((file) => `${libDir}/${file}`), ...shipped]
269
309
  .forEach((file) => fs.copyFileSync(file, `${part}/lib/${path.basename(file)}`));
310
+ if (fs.existsSync(`${libDir}/symbols`)) fs.cpSync(`${libDir}/symbols`, `${part}/symbols`, { recursive: true });
270
311
  copyAppFiles(`${part}/app`, hbc, target);
271
312
  }
272
313
 
@@ -275,20 +316,10 @@ const appParts = (target) => state.targets
275
316
  .filter((t) => t.platform === target.platform && t.runtimeEnv === 'app' && t.buildType === target.buildType && currentApps.has(t.appName))
276
317
  .map((t) => ({ arch: t.arch, dir: `${state.config.paths.build}/${t.appName}` }));
277
318
 
278
- // A Linux or Windows app also ships as one archive, next to its directory: a Windows one as a zip, made in the windows
279
- // image.
280
- function archiveApp(app, target) {
281
- if (target.platform === 'win32') {
282
- assertUnderBase(app, target);
283
- fs.rmSync(`${app}.zip`, { force: true });
284
- run(null, ['zip', '-q', '-r', '-X', `${path.basename(app)}.zip`, path.basename(app)], null, target, { workdir: path.dirname(app) });
285
- return;
286
- }
287
- const result = spawnSync('tar', ['-czf', `${app}.tar.gz`, '-C', path.dirname(app), path.basename(app)], {
288
- encoding: 'utf8',
289
- env: { ...process.env, COPYFILE_DISABLE: '1' },
290
- });
291
- if (result.status !== 0) throw new Error(`crossbind: cannot archive ${app}: ${result.stderr || result.error?.message}`);
319
+ // The container that signs a Windows app sees the project's base alone.
320
+ function assertUnderBase(app, target) {
321
+ if (isUnderBase(app, state.config.paths.base)) return;
322
+ throw new Error(`crossbind: the Windows app ${app} is outside ${state.config.paths.base}, the project's base and all the ${imageRoleFor(target)} container sees. Build the app into a directory under it.`);
292
323
  }
293
324
 
294
325
  const ENTITLEMENTS = {
@@ -303,10 +334,12 @@ const ENTITLEMENTS = {
303
334
  function signApp(app, target, signing) {
304
335
  const sign = signing ? ['--sign', signing.identity, '--options', 'runtime', '--timestamp'] : ['--sign', '-', '--timestamp=none'];
305
336
  run(null, ['codesign', '--force', ...sign, `${app}/Contents/Frameworks/hermesvm.framework`], null, target);
306
- // The hardened runtime keeps the app from the devices of app.permissions unless its signature claims them.
307
- const claims = Object.keys(appPermissions(state.config)).map((name) => ENTITLEMENTS[name]);
308
- const entitlements = claims.length > 0 ? `${state.config.paths.build}/${path.basename(app)}.entitlements` : null;
309
- if (entitlements) fs.writeFileSync(entitlements, xmlPlist(Object.fromEntries(claims.map((claim) => [claim, true]))));
337
+ // The hardened runtime keeps the app from the devices of app.permissions unless its signature claims them, beside what
338
+ // its extensions claim.
339
+ const devices = Object.fromEntries(Object.keys(appPermissions(state.config)).map((name) => [ENTITLEMENTS[name], true]));
340
+ const claims = mergedEntitlements([['app.permissions', devices], ...extensionsOf('darwin').entitlements]);
341
+ const entitlements = Object.keys(claims).length > 0 ? `${state.config.paths.build}/${path.basename(app)}.entitlements` : null;
342
+ if (entitlements) fs.writeFileSync(entitlements, xmlPlist(claims));
310
343
  run(null, ['codesign', '--force', ...sign, ...(entitlements ? ['--entitlements', entitlements] : []), app], null, target);
311
344
  }
312
345
 
@@ -327,6 +360,64 @@ function shipMacApp(app, signing) {
327
360
  else console.warn(`crossbind: ${path.basename(app)} is signed ad hoc, which opens it on this Mac alone. Set ${APPLE_SIGNING.identity} to sign it with your Developer ID, and ${notarize}.`);
328
361
  }
329
362
 
363
+ // App Store Connect refuses an iOS app whose archives call required reason API without app.privacy's reasons; the build
364
+ // names them, from the archives a device gets.
365
+ function warnUnreasonedApis(project, privacy) {
366
+ const device = `${project}/lib/iphoneos`;
367
+ if (!fs.existsSync(device)) return;
368
+ const archives = fs.readdirSync(device).filter((file) => file.endsWith('.a')).map((file) => `${device}/${file}`);
369
+ const warning = unreasonedWarning(unreasonedApis(archives, privacy));
370
+ if (warning) console.warn(warning);
371
+ }
372
+
373
+ // Where publishApp last put this project's apps, for `crossbind app publish`, which runs Vite and so cannot know the
374
+ // outDir it builds into: an iOS or Android app's project, and each arch's desktop app under its platform's name on the
375
+ // command line.
376
+ const appOutputsFile = () => `${state.config.paths.build}/app-outputs.json`;
377
+ const readAppOutputs = () => (fs.existsSync(appOutputsFile()) ? JSON.parse(fs.readFileSync(appOutputsFile(), 'utf8')) : {});
378
+ // What each desktop app's installers wrote beside it, as builds recorded them.
379
+ const shippedFiles = () => new Set(Object.values(readAppOutputs()).filter(Array.isArray).flat()
380
+ .flatMap(({ installers = [] }) => installers.flatMap(({ files }) => files)));
381
+ // The command line's names of the desktop platforms.
382
+ export const DESKTOP_PLATFORMS = { darwin: 'macos', linux: 'linux', win32: 'windows' };
383
+
384
+ // Forgets what the builds wrote, as a publish's own build writes it anew, but for the installers' files, which a folder the
385
+ // bundler kept holds still, for the page to leave out.
386
+ export function clearAppOutputs() {
387
+ const earlier = [...shippedFiles()];
388
+ if (earlier.length > 0) fs.writeFileSync(appOutputsFile(), JSON.stringify({ earlier: [{ installers: [{ files: earlier }] }] }));
389
+ else fs.rmSync(appOutputsFile(), { force: true });
390
+ }
391
+
392
+ // What a publisher's upload gets of the app the build wrote for a platform: a desktop platform's apps, one for each arch.
393
+ export function appOutputOf(platform) {
394
+ const output = readAppOutputs()[platform];
395
+ if (!output) throw new Error(`crossbind: the build wrote no ${platform} app, so there is nothing to publish.`);
396
+ return Object.values(DESKTOP_PLATFORMS).includes(platform) ? { apps: output } : output;
397
+ }
398
+
399
+ const madeFile = (file) => (fs.existsSync(file) ? file : null);
400
+
401
+ // What a publisher's upload takes of an app: an iOS app's project, an Android app's with its APK, bundle and native
402
+ // debug symbols where the build made them, and each arch's desktop app with its installers.
403
+ export function recordAppOutput(target, to, { installers = [] } = {}) {
404
+ const outputs = readAppOutputs();
405
+ const app = path.resolve(to);
406
+ const desktop = DESKTOP_PLATFORMS[target.platform];
407
+ const mobile = target.platform === 'android'
408
+ ? { project: app, apk: madeFile(`${app}.apk`), bundle: madeFile(`${app}.aab`), symbols: madeFile(`${app}-symbols.zip`) }
409
+ : { project: app };
410
+ const recorded = desktop
411
+ ? { [desktop]: [...(outputs[desktop] ?? []).filter((entry) => entry.app !== app), {
412
+ app,
413
+ arch: target.arch,
414
+ installers: installers.map(({ name, files }) => ({ name, files: files.map((file) => path.resolve(file)) })),
415
+ }] }
416
+ : { [target.platform]: mobile };
417
+ fs.mkdirSync(state.config.paths.build, { recursive: true });
418
+ fs.writeFileSync(appOutputsFile(), `${JSON.stringify({ ...outputs, ...recorded }, null, 4)}\n`);
419
+ }
420
+
330
421
  // Where a build without a bundler puts an app: under its own name, or for iOS and Android as the project of its build
331
422
  // type.
332
423
  export function appOutputPath(target) {
@@ -342,15 +433,34 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
342
433
  if (target.platform === 'ios') {
343
434
  const parts = appParts(target).map(({ arch, dir }) => ({ sdk: arch, dir }));
344
435
  const icon = readAppIcon(state.config);
436
+ const ownPrivacy = appPrivacy(state.config);
437
+ const build = appBuildNumber();
438
+ // The app's own options are checked first, then what its extensions add.
439
+ const options = {
440
+ parts, identity: { id: appId(state.config), team: appTeam() }, label: appDisplayName(state.config), version: appVersion(),
441
+ build: build === null ? appVersion() : String(build), icon: icon && iosIcon(icon), urlSchemes: appUrlSchemes(state.config),
442
+ fileTypes: fileTypeOwners(state.config), links: linkOwners(state.config).map(({ link }) => link),
443
+ activityTypes: activityTypeOwners(state.config).map(({ type }) => type), shortcutItems: shortcutItemOwners(state.config),
444
+ background: appBackgroundColor(state.config), usage: usageDescriptions(state.config, 'ios'), encryption: appEncryption(state.config),
445
+ multipleWindows: iosMultipleWindows(state.config),
446
+ };
447
+ const extensions = extensionsOf('ios');
448
+ const privacy = mergedPrivacy(ownPrivacy, extensions.privacy);
345
449
  writeXcodeProject(to, {
346
- parts, identity: { id: appId(state.config), team: appTeam() }, label: appDisplayName(state.config), version: appVersion(), icon: icon && iosIcon(icon),
347
- urlSchemes: appUrlSchemes(state.config), background: appBackgroundColor(state.config), usage: usageDescriptions(state.config, 'ios'),
450
+ ...options, privacy, entitlements: mergedEntitlements(extensions.entitlements), plist: extensions.plist, targets: extensions.targets,
451
+ appGroup: appGroupOf('ios', extensions),
348
452
  });
453
+ if (target.buildType === 'release') warnUnreasonedApis(to, privacy);
349
454
  } else if (target.platform === 'android') {
350
455
  const icon = readAppIcon(state.config);
351
- writeGradleProject(to, {
352
- parts: appParts(target), id: androidAppId(), label: appDisplayName(state.config), version: androidVersion(), icons: icon && androidIcons(icon),
456
+ const options = {
457
+ parts: appParts(target), id: androidAppId(state.config), label: appDisplayName(state.config), version: androidVersion(), icons: icon && androidIcons(icon),
353
458
  urlSchemes: appUrlSchemes(state.config), background: appBackgroundColor(state.config), permissions: Object.keys(appPermissions(state.config)),
459
+ fileTypes: fileTypeOwners(state.config), links: linkOwners(state.config).map(({ link }) => link), shortcutItems: shortcutItemOwners(state.config),
460
+ };
461
+ const extensions = extensionsOf('android');
462
+ writeGradleProject(to, {
463
+ ...options, java: extensions.java, extensionPermissions: extensions.permissions, manifests: extensions.manifests, resources: extensions.resources, maven: extensions.maven,
354
464
  });
355
465
  } else if (path.resolve(to) !== path.resolve(built)) {
356
466
  fs.rmSync(to, { recursive: true, force: true });
@@ -358,11 +468,14 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
358
468
  }
359
469
  if (page) {
360
470
  // The app may land inside the page's own directory, which it then leaves out, with every app built there and what
361
- // packages it.
362
- const apps = new Set([path.basename(to), ...state.targets.filter((t) => t.runtimeEnv === 'app')
363
- .flatMap((t) => [appOutputName(t), `${state.config.general.name}-${t.platform}-${t.arch}`])]);
364
- const isApp = (entry) => entry.endsWith('.app') || apps.has(entry.replace(/\.dmg$/, '.app'))
365
- || apps.has(entry.replace(/(\.tar\.gz|\.app\.zip|\.zip|\.apk|\.aab|\.deb|-setup\.exe|\.update\.json)$/, ''));
471
+ // packages it: what is named after a target of the app, and every installer's files as a build recorded them, which
472
+ // a folder the bundler left as it was holds still.
473
+ const apps = state.targets.filter((t) => t.runtimeEnv === 'app')
474
+ .flatMap((t) => [appOutputName(t), `${state.config.general.name}-${t.platform}-${t.arch}`]).filter((name) => !name.endsWith('.app'));
475
+ const shipped = shippedFiles();
476
+ const isApp = (entry) => entry === path.basename(to) || entry.endsWith('.app')
477
+ || apps.some((app) => entry === app || entry.startsWith(`${app}.`) || entry.startsWith(`${app}-`))
478
+ || shipped.has(path.resolve(page, entry));
366
479
  fs.readdirSync(page).filter((entry) => !isApp(entry)).forEach((entry) => {
367
480
  fs.cpSync(`${page}/${entry}`, `${to}/${appLayout(target).resources}/page/${entry}`, { recursive: true });
368
481
  });
@@ -373,15 +486,9 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
373
486
  if (page) shipMacApp(to, signing);
374
487
  } else if (target.platform === 'android' && target.buildType === 'release') {
375
488
  packageAndroidRelease(to);
376
- } else if (!['ios', 'android'].includes(target.platform)) {
377
- if (target.platform === 'win32') signWindowsApp(to, target, page);
378
- archiveApp(to, target);
379
- }
380
- // A desktop app with its page ships with what installs it, and with an updater's key with its update too.
381
- if (page && ['darwin', 'linux', 'win32'].includes(target.platform)) {
382
- writeInstaller(to, target);
383
- writeUpdate(to, target);
384
489
  }
490
+ if (['ios', 'android'].includes(target.platform)) recordAppOutput(target, to);
491
+ else if (target.platform === 'win32') signWindowsApp(to, target, page);
385
492
  return to;
386
493
  }
387
494
 
@@ -396,14 +503,32 @@ export default async function buildApp(target, { bridges = [], force = false } =
396
503
  logger.info(`[${target.path}] app skipped (cargo package - staticlib only)`);
397
504
  return false;
398
505
  }
399
- // Where the page opens files, a desktop host's title and window, a Windows one's version, and the app's icon: a bad
400
- // app section stops the build before anything compiles.
401
- const header = appHeader(state.config, target.platform);
506
+ // A desktop host's title and window, a Windows one's version, the app's icon and its extensions with their options, as
507
+ // where the page opens files: a bad app section stops the build before anything compiles.
402
508
  const icon = readAppIcon(state.config);
509
+ const extensions = extensionsOf(target.platform);
510
+ const header = appHeader(state.config, target.platform, appGroupOf(target.platform, extensions));
511
+ const carried = carriedFilesOf(extensions.files, ownFiles());
512
+ const carriedPaths = new Set(carried);
513
+ // Whether the extension that owns a launch has native code, or for the app's own one that holds the role.
514
+ const hasNative = (extension, role) => extensions.native.some(({ name, roles }) => (extension === null ? roles.includes(role) : name === extension));
515
+ // A desktop's system hands an app no https link: its owners there are those with native code, whose onOpenUrl a
516
+ // phone's code may share.
517
+ const isPhone = ['ios', 'android'].includes(target.platform);
518
+ const links = linkOwners(state.config).filter(({ extension }) => isPhone || hasNative(extension, 'links'));
519
+ // Another system continues no user activity: its owners there are those with native code, whose onUserActivity
520
+ // Apple's code may share.
521
+ const isApple = ['darwin', 'ios'].includes(target.platform);
522
+ const activities = activityTypeOwners(state.config).filter(({ extension }) => isApple || hasNative(extension, 'activities'));
523
+ // A desktop has no quick actions either.
524
+ const shortcuts = shortcutItemOwners(state.config).filter(({ extension }) => isPhone || hasNative(extension, 'shortcuts'));
525
+ const registration = registrationSource(extensions.native, urlSchemeOwners(state.config), fileTypeOwners(state.config), links, activities, shortcuts);
403
526
 
404
527
  const buildType = target.buildType === 'release' ? 'Release' : 'Debug';
405
528
  const { build } = state.config.paths;
406
529
  const appHostRoot = resolveAppHostRoot();
530
+ appExtensionsOf(state.config.extensions).filter(({ replaces }) => replaces)
531
+ .forEach(({ name, root, replaces }) => logger.info(`[${target.path}] the extension ${name} of ${root} takes the host's ${replaces.join(', ')}`));
407
532
  const jsiRoot = resolveAppEmbindJsiRoot();
408
533
  const { hermes, cmakeArgs, inputs } = await hostInputs(target);
409
534
 
@@ -418,6 +543,10 @@ export default async function buildApp(target, { bridges = [], force = false } =
418
543
  inputs: headerDirs.flatMap((dir) => (fs.existsSync(dir) ? findFiles('**/*.h', { cwd: dir }) : [dir])),
419
544
  });
420
545
  const { libs, linkArgs, extraSources } = linkInputs(target, { bridge: BRIDGE });
546
+ const sharedLibs = libs.filter((lib) => lib.endsWith('.so'));
547
+ // The extensions' sources compile with the host, which links what they need: a framework on macOS, a library elsewhere.
548
+ // An iOS host is a static library, whose frameworks the app's Xcode project links.
549
+ const hostLinkArgs = [...linkArgs, ...(target.platform === 'darwin' ? extensions.frameworks.map((framework) => `-framework ${framework}`) : extensions.libraries)];
421
550
 
422
551
  const fingerprintFile = `${build}/${target.appName}.fingerprint`;
423
552
  const fingerprint = getContentHash(JSON.stringify({
@@ -426,7 +555,8 @@ export default async function buildApp(target, { bridges = [], force = false } =
426
555
  // A desktop app carries its icon; an iOS or Android one gets it as its project is written.
427
556
  icon: ['ios', 'android'].includes(target.platform) ? null : icon?.hash ?? null,
428
557
  // A macOS app's bundle takes its id, name and version from it.
429
- infoPlist: target.platform === 'darwin' ? infoPlist(state.config.general.name, appVersion(), Boolean(icon), urlSchemesOf(target)) : null,
558
+ infoPlist: target.platform === 'darwin'
559
+ ? infoPlist(state.config.general.name, appVersion(), Boolean(icon), urlSchemesOf(target), fileTypesOf(target), activityTypesOf(target)) : null,
430
560
  hermes: HERMES.version,
431
561
  inputs,
432
562
  linkArgs,
@@ -436,12 +566,25 @@ export default async function buildApp(target, { bridges = [], force = false } =
436
566
  }),
437
567
  env: getEnv(target),
438
568
  data: getData('data', target),
569
+ // What the app carries, by its size and time, as a large model would take long to read at each build.
570
+ carried: carried.map((file) => {
571
+ const stat = fs.statSync(file);
572
+ return { file, size: stat.size, mtimeMs: stat.mtimeMs };
573
+ }),
439
574
  version: state.config.package?.version,
440
575
  runtime: getFilesFingerprint(filesUnder([
441
576
  `${appHostRoot}/cpp`, `${appHostRoot}/js`, `${appHostRoot}/third_party`, `${jsiRoot}/cpp/src`, `${jsiRoot}/js`,
442
577
  `${state.config.paths.cli}/assets/js-runtime/adapters`,
443
578
  ])),
444
579
  extraSources: getFilesFingerprint(extraSources),
580
+ // An iOS app's frameworks reach its Xcode project through what the host's build writes.
581
+ extensions: {
582
+ registration,
583
+ files: getFilesFingerprint(filesUnder(extensions.roots).filter((file) => !carriedPaths.has(path.resolve(file)))),
584
+ compiled: extensions.compiled,
585
+ link: hostLinkArgs.slice(linkArgs.length),
586
+ frameworks: extensions.frameworks,
587
+ },
445
588
  }));
446
589
  const isChanged = !fs.existsSync(fingerprintFile) || fs.readFileSync(fingerprintFile, { encoding: 'utf8' }) !== fingerprint;
447
590
  if (!force && !isChanged && fs.existsSync(`${build}/${target.appName}`) && fs.existsSync(`${build}/${target.appJsName}`)) {
@@ -459,11 +602,25 @@ export default async function buildApp(target, { bridges = [], force = false } =
459
602
  resetCMakeCacheForSource(workDir, `${appHostRoot}/cpp`, target);
460
603
  // The host includes it from its build directory; written only when it changed, it recompiles nothing otherwise.
461
604
  writeIfChanged(`${workDir}/crossbind_app.h`, header);
605
+ writeIfChanged(`${workDir}/extensions.cpp`, registration);
462
606
  // A Windows host's executable carries the app's icon, which its resource script takes from beside the header.
463
607
  if (target.platform === 'win32') {
464
608
  if (icon) writeIfChanged(`${workDir}/icon.ico`, windowsIcon(icon));
465
609
  else fs.rmSync(`${workDir}/icon.ico`, { force: true });
466
610
  }
611
+ // An Apple app's Swift, which CMake's Makefiles take none of: each extension's compiles first, its header joins the
612
+ // extension's includes, and its library the host's link, or an iOS app's Xcode project's.
613
+ const swift = ['darwin', 'ios'].includes(target.platform) ? compileSwift(target, extensions.compiled, workDir, buildType === 'Release') : [];
614
+ const swiftLink = swiftLinkArgs(target, swift.map(({ library }) => library));
615
+ // A Linux app's extensions' packages beside crossbind's sysroot, each extension's in a sysroot of its own, whose
616
+ // headers its sources include and whose libraries the host links against.
617
+ const sysroots = target.platform === 'linux' ? await unpackPackages(target, extensions.compiled, {
618
+ cache: `${build}/linux-packages`, workDir, unpack: (deb, root) => run(null, ['dpkg-deb', '-x', deb, root], platformPrefix, target),
619
+ }) : [];
620
+ const compiled = extensions.compiled.map((part) => ({
621
+ ...part, includes: [...part.includes, ...[...swift, ...sysroots].filter(({ name: owner }) => owner === part.name).flatMap(({ includes }) => includes)],
622
+ }));
623
+ const ownLink = target.platform === 'darwin' ? swiftLink : sysroots.flatMap(({ libDir }) => [`-L${libDir}`, `-Wl,-rpath-link,${libDir}`]);
467
624
  run(null, [
468
625
  // An iOS host builds with crossbind's iOS toolchain file rather than as an Xcode project of its own.
469
626
  target.platform === 'ios' ? 'ios-cmake' : 'cmake', `${appHostRoot}/cpp`,
@@ -472,8 +629,12 @@ export default async function buildApp(target, { bridges = [], force = false } =
472
629
  `-DCROSSBIND_JSI_ROOT=${jsiRoot}`,
473
630
  `-DCROSSBIND_HERMES=${hermes}`,
474
631
  `-DCROSSBIND_EXTRA_SOURCES=${extraSources.join(';')}`,
475
- `-DCROSSBIND_LINK_ARGS=${linkArgs.join(';')}`,
632
+ `-DCROSSBIND_EXTENSION_REGISTRATION=${workDir}/extensions.cpp`,
633
+ ...extensionCmakeArgs(compiled),
634
+ `-DCROSSBIND_LINK_ARGS=${[...hostLinkArgs, ...ownLink].join(';')}`,
476
635
  `-DCROSSBIND_LINK_DEPENDS=${libs.join(';')}`,
636
+ // An Android release ships the packages' shared libraries stripped as well, and keeps them as they come.
637
+ ...(target.platform === 'android' ? [`-DCROSSBIND_SHARED_LIBS=${sharedLibs.join(';')}`] : []),
477
638
  ...cmakeArgs,
478
639
  ], platformPrefix, target);
479
640
  run(null, ['cmake', '--build', '.', '-j', String(cpuCount)], platformPrefix, target, { exclusive: true });
@@ -488,9 +649,10 @@ export default async function buildApp(target, { bridges = [], force = false } =
488
649
  platformPrefix, target);
489
650
  await bundle(`${appHostRoot}/js/page.js`, `${build}/${target.appJsName}`, []);
490
651
  if (target.platform === 'ios') {
491
- assembleIosPart(target, { library: `${workDir}/lib${name}.a`, linkArgs, hbc: `${workDir}/host.hbc`, hermes });
652
+ const frameworks = extensions.frameworks.flatMap((framework) => ['-framework', framework]);
653
+ assembleIosPart(target, { library: `${workDir}/lib${name}.a`, linkArgs: [...linkArgs, ...frameworks, ...swiftLink], hbc: `${workDir}/host.hbc`, hermes });
492
654
  } else if (target.platform === 'android') {
493
- assembleAndroidPart(target, { libDir: workDir, sharedLibs: libs.filter((lib) => lib.endsWith('.so')), hbc: `${workDir}/host.hbc` });
655
+ assembleAndroidPart(target, { libDir: workDir, sharedLibs, hbc: `${workDir}/host.hbc` });
494
656
  } else {
495
657
  assembleApp(target, { executable: `${workDir}/${path.basename(appLayout(target).executable)}`, hbc: `${workDir}/host.hbc`, hermes, icon });
496
658
  }
@@ -24,11 +24,16 @@ import toolchainNoticesDir from '../utils/toolchainNotices.js';
24
24
  const cpuCount = Math.max(1, os.cpus().length - 1);
25
25
  const WINDOWS_IMAGE_NOTICES = '/opt/licenses/llvm-mingw';
26
26
 
27
+ // What a build's output, a package's own packages and a repository's history hold, which no build reads.
28
+ const UNREAD_FOLDERS = ['.crossbind', 'node_modules', '.git'];
29
+
30
+ // The files under these folders a build reads, a link that leads nowhere left out.
27
31
  export function filesUnder(dirs) {
28
32
  return dirs.flatMap((dir) => (fs.existsSync(dir)
29
33
  ? fs.readdirSync(dir, { recursive: true })
34
+ .filter((file) => !file.split(/[\\/]/).some((part) => UNREAD_FOLDERS.includes(part)))
30
35
  .map((file) => `${dir}/${file}`)
31
- .filter((file) => fs.statSync(file).isFile())
36
+ .filter((file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile())
32
37
  : []));
33
38
  }
34
39
 
@@ -6,7 +6,6 @@ import getLinkInputs from './getLinkInputs.js';
6
6
  import getData from './getData.js';
7
7
  import getEnv from './getEnv.js';
8
8
  import buildJs from './buildJs.js';
9
- import triggerExtensions from './extensions.js';
10
9
  import state from '../state/index.js';
11
10
  import resolveEmbindRustRoot from '../utils/resolveEmbindRust.js';
12
11
  import logger from '../utils/logger.js';
@@ -128,8 +127,6 @@ export default async function buildWasm(target, options = {}) {
128
127
  if (!isProd && !/-s\s*STACK_OVERFLOW_CHECK=/.test(set)) emccFlags.push('-sSTACK_OVERFLOW_CHECK=1');
129
128
  }
130
129
 
131
- triggerExtensions('buildWasm', 'beforeBuild', [emccFlags]);
132
-
133
130
  if (target.runtime === 'mt' && !emccFlags.includes('-pthread')) {
134
131
  emccFlags.push('-pthread');
135
132
  emccFlags.push('-sPTHREAD_POOL_SIZE=Math.min(navigator.hardwareConcurrency || 1, 2)');
@@ -203,8 +200,6 @@ export default async function buildWasm(target, options = {}) {
203
200
  logger.startStep(target, 'wasm');
204
201
  const t0 = performance.now();
205
202
 
206
- triggerExtensions('buildWasm', 'beforeBuildBrowser', [emccFlags]);
207
-
208
203
  const data = Object.entries(getData('data', target)).map(([key, value]) => ['--preload-file', `${key.replaceAll('@', '@@')}@/crossbind/${value}`]).flat();
209
204
  run('em++', [
210
205
  '-lembind',
@@ -260,8 +255,6 @@ export default async function buildWasm(target, options = {}) {
260
255
  logger.startStep(target, 'wasm');
261
256
  const t0 = performance.now();
262
257
 
263
- triggerExtensions('buildWasm', 'beforeBuildEdge', [emccFlags]);
264
-
265
258
  const data = Object.entries(getData('data', target)).map(([key, value]) => ['--preload-file', `${key.replaceAll('@', '@@')}@/crossbind/${value}`]).flat();
266
259
  run('em++', [
267
260
  '-lembind',
@@ -300,8 +293,6 @@ export default async function buildWasm(target, options = {}) {
300
293
  if (target.runtimeEnv === 'node') {
301
294
  logger.startStep(target, 'wasm');
302
295
 
303
- triggerExtensions('buildWasm', 'beforeBuildNodeJS', [emccFlags]);
304
-
305
296
  run('em++', [
306
297
  '-lembind',
307
298
  // The config's emccFlags follow the default level, so its -Oz or -Os wins (the last -O does).