crossbind 2.0.0-beta.69 → 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 (49) hide show
  1. package/package.json +3 -2
  2. package/src/actions/addExtension.js +6 -1
  3. package/src/actions/buildApp.js +10 -5
  4. package/src/actions/buildDependencies.js +3 -3
  5. package/src/actions/createImportedBridges.js +10 -8
  6. package/src/actions/createInterface.js +23 -21
  7. package/src/actions/createLib.js +3 -3
  8. package/src/actions/getAllBridges.js +20 -5
  9. package/src/actions/getDependLibs.js +5 -0
  10. package/src/actions/getLinkInputs.js +2 -1
  11. package/src/actions/gradleProject.js +13 -2
  12. package/src/actions/licenses.js +30 -24
  13. package/src/actions/{prepareConanDependencies.js → preparePackageSources.js} +13 -12
  14. package/src/actions/writeRuntimeEntry.js +2 -2
  15. package/src/actions/xcodeProject.js +12 -6
  16. package/src/app/index.d.ts +19 -0
  17. package/src/app/index.js +25 -1
  18. package/src/assets/node-import-hooks.mjs +7 -6
  19. package/src/bin.js +17 -9
  20. package/src/index.js +2 -2
  21. package/src/integration/getCrossbindScript.js +3 -2
  22. package/src/integration/getDependFilePath.js +10 -10
  23. package/src/state/{attachConanDependencies.js → attachPackageSources.js} +20 -16
  24. package/src/state/index.js +6 -5
  25. package/src/state/loadConfig.js +10 -4
  26. package/src/state/refreshPackageSources.js +19 -0
  27. package/src/utils/appExtensions.js +44 -28
  28. package/src/utils/appOptions.js +31 -10
  29. package/src/utils/appRustCrates.js +2 -2
  30. package/src/utils/bridgeTools.js +6 -1
  31. package/src/utils/cppDts.js +11 -11
  32. package/src/utils/extensionSteps.js +38 -1
  33. package/src/utils/nativeImports.js +9 -2
  34. package/src/utils/nodeDevBuild.js +2 -1
  35. package/src/utils/packageSourceApi.js +87 -0
  36. package/src/utils/packageSourceImport.js +69 -0
  37. package/src/utils/packageSourceInstall.js +162 -0
  38. package/src/utils/{conanStage.js → packageSourceStage.js} +64 -90
  39. package/src/utils/packageSources.js +108 -0
  40. package/src/utils/printableText.js +5 -0
  41. package/src/utils/runPackageSource.js +251 -0
  42. package/src/utils/rustBridgeGen.js +3 -3
  43. package/src/utils/targetToolchain.js +77 -0
  44. package/src/state/refreshConanDependencies.js +0 -17
  45. package/src/utils/conanDependencies.js +0 -66
  46. package/src/utils/conanImport.js +0 -58
  47. package/src/utils/conanInstall.js +0 -154
  48. package/src/utils/conanProfile.js +0 -203
  49. package/src/utils/runConan.js +0 -257
@@ -3,12 +3,13 @@ import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
4
  import { carriedPath, isOutside } from './appFiles.js';
5
5
  import {
6
- NAME, checkedCommands, checkedInstaller, checkedPublisher,
6
+ NAME, checkedCommands, checkedInstaller, checkedPackageSource, checkedPublisher,
7
7
  } from './extensionSteps.js';
8
8
  import { checkedActivityTypes, checkedFileTypes, checkedLinks, checkedShortcutItems } from './appLaunches.js';
9
9
  import { checkedPrivacy, checkedUrlSchemes } from './appOptions.js';
10
10
  import { checkedTargetKind, checkedTargetOptions, isHeader } from './appTargets.js';
11
11
  import { isUnderBase } from './replaceBasePathForDocker.js';
12
+ import { printable } from './printableText.js';
12
13
 
13
14
  // What an app extension compiles with on every platform, and on each: its sources, the folders of its own their
14
15
  // includes are found in, and the macros they are compiled with.
@@ -41,7 +42,7 @@ const LINK_NAME = /^[A-Za-z0-9][A-Za-z0-9_.+-]*$/;
41
42
  // A class in a package, which the activity's package can name.
42
43
  const JAVA_CLASS = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/;
43
44
 
44
- export { PUBLISH_PLATFORMS, checkedInstaller, checkedPublisher } from './extensionSteps.js';
45
+ export { PUBLISH_PLATFORMS, checkedInstaller, checkedPackageSource, checkedPublisher } from './extensionSteps.js';
45
46
 
46
47
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
47
48
  const isEntitlement = (value) => typeof value === 'boolean' || typeof value === 'string'
@@ -338,6 +339,9 @@ export function checkedExtension(extension, root, where) {
338
339
  },
339
340
  }),
340
341
  ...(extension.commands === undefined ? {} : { commands: checkedCommands(extension.commands, `${where}'s commands`) }),
342
+ ...(extension.packageSource === undefined ? {} : {
343
+ packageSource: checkedPackageSource(extension.packageSource, `${where}'s packageSource`, extension.name),
344
+ }),
341
345
  ...(extension.fromApp === undefined ? {} : { fromApp: extension.fromApp.bind(extension) }),
342
346
  ...checkedOwnKeys(extension, where),
343
347
  };
@@ -352,11 +356,14 @@ export function checkedExtension(extension, root, where) {
352
356
  // extension written for any of them builds. A minor version that adds to the contract raises the newest, and a major
353
357
  // version that breaks it the oldest.
354
358
  export const OLDEST_EXTENSION_CONTRACT = 1;
355
- export const EXTENSION_CONTRACT = 1;
359
+ export const EXTENSION_CONTRACT = 2;
360
+ // What a later version added to an extension, by the version that did.
361
+ const ADDED_IN = { packageSource: 2 };
356
362
 
357
363
  // The contract an extension is written for, or the several it is written for alike, is one this crossbind takes: one
358
- // written for others alone stops the build, naming what to update.
359
- function checkedContract({ contract }, where, update) {
364
+ // written for others alone stops the build, naming what to update, as one that gives what its contract has not.
365
+ function checkedContract(extension, where, update) {
366
+ const { contract } = extension;
360
367
  if (contract === undefined) {
361
368
  throw new Error(`crossbind: ${where} names no contract, the version of crossbind's extension contract it is written for, as contract: ${EXTENSION_CONTRACT} is for this crossbind: ${update}.`);
362
369
  }
@@ -364,8 +371,12 @@ function checkedContract({ contract }, where, update) {
364
371
  if (versions.length === 0 || versions.some((version) => !Number.isInteger(version) || version < 1) || new Set(versions).size !== versions.length) {
365
372
  throw new Error(`crossbind: ${where}'s contract ${JSON.stringify(contract)} is no version of crossbind's extension contract, as 1 or [1, 2] is.`);
366
373
  }
367
- if (versions.some((version) => version >= OLDEST_EXTENSION_CONTRACT && version <= EXTENSION_CONTRACT)) return;
368
374
  const named = versions.join(' and ');
375
+ const early = Object.entries(ADDED_IN).find(([key, since]) => extension[key] !== undefined && versions.some((version) => version < since));
376
+ if (early) {
377
+ throw new Error(`crossbind: ${where} gives a ${early[0]}, which crossbind's extension contract has from its version ${early[1]} on, and is written for ${named}: name contract: ${early[1]}.`);
378
+ }
379
+ if (versions.some((version) => version >= OLDEST_EXTENSION_CONTRACT && version <= EXTENSION_CONTRACT)) return;
369
380
  if (versions.every((version) => version > EXTENSION_CONTRACT)) {
370
381
  throw new Error(`crossbind: ${where} is written for crossbind's extension contract ${named}, newer than this crossbind's ${EXTENSION_CONTRACT}: update crossbind.`);
371
382
  }
@@ -407,7 +418,7 @@ export function extensionPackage(root, base) {
407
418
 
408
419
  // What an extension of crossbind's build once hooked into, which no build runs.
409
420
  const OLD_HOOKS = ['loadConfig', 'buildWasm', 'createLib'];
410
- const LISTED_KEYS = ['name', 'contract', 'root', 'app', 'options', 'fromApp', 'publisher', 'installer', 'commands', 'replaces'];
421
+ const LISTED_KEYS = ['name', 'contract', 'root', 'app', 'options', 'fromApp', 'publisher', 'installer', 'commands', 'packageSource', 'replaces'];
411
422
 
412
423
  // An extension crossbind.config.js lists, as its package's crossbind.extension.js gives it: its name, the folder its
413
424
  // files are in, which a build sees under the project's base alone, and what it adds to an app.
@@ -433,10 +444,27 @@ export function listedExtension(extension, where, base) {
433
444
 
434
445
  const OWN_PACKAGE = /^@crossbind\/extension-(.+)$/;
435
446
 
436
- // The names of the extension packages among the host's dependencies.
447
+ // The names of the extension packages among a package's dependencies: the host's, or crossbind's own.
437
448
  export const ownPackages = (appHostRoot) => Object.keys(JSON.parse(fs.readFileSync(path.join(appHostRoot, 'package.json'), 'utf8')).dependencies ?? {})
438
449
  .filter((name) => OWN_PACKAGE.test(name)).sort();
439
450
 
451
+ // One of those packages, as its crossbind.extension.js gives it, named as its package. `fix` is what brings it in step
452
+ // with crossbind.
453
+ export function broughtExtension(load, name, fix) {
454
+ const where = `${name}'s crossbind.extension.js`;
455
+ let extension;
456
+ try {
457
+ extension = load(`${name}/crossbind.extension.js`).default();
458
+ } catch (error) {
459
+ throw new Error(`crossbind: cannot read ${where}: ${error.message}: install the project's packages, or ${fix}.`, { cause: error });
460
+ }
461
+ checkedContract(extension ?? {}, where, fix);
462
+ const checked = checkedExtension(extension, extension?.root, where);
463
+ const expected = OWN_PACKAGE.exec(name)[1];
464
+ if (checked.name !== expected) throw new Error(`crossbind: ${where} names the extension ${checked.name}, not ${expected} as its package.`);
465
+ return checked;
466
+ }
467
+
440
468
  // crossbind's own extensions: the extension packages among the host's dependencies, which an app has by having the host,
441
469
  // each named as its package. crossbind finds them as it finds the embind runtime, through the host, as pnpm shows them to
442
470
  // it alone.
@@ -444,20 +472,7 @@ export function ownExtensions(appHostRoot) {
444
472
  const load = createRequire(path.join(appHostRoot, 'package.json'));
445
473
  const packages = ownPackages(appHostRoot);
446
474
  if (packages.length === 0) throw new Error(`crossbind: the app host at ${appHostRoot} brings no extensions: update @crossbind/core-app-host to match crossbind.`);
447
- return packages.map((name) => {
448
- const where = `${name}'s crossbind.extension.js`;
449
- let extension;
450
- try {
451
- extension = load(`${name}/crossbind.extension.js`).default();
452
- } catch (error) {
453
- throw new Error(`crossbind: cannot read ${where}: ${error.message}: install the project's packages, or update @crossbind/core-app-host to match crossbind.`, { cause: error });
454
- }
455
- checkedContract(extension ?? {}, where, 'update @crossbind/core-app-host to match crossbind');
456
- const checked = checkedExtension(extension, extension?.root, where);
457
- const expected = OWN_PACKAGE.exec(name)[1];
458
- if (checked.name !== expected) throw new Error(`crossbind: ${where} names the extension ${checked.name}, not ${expected} as its package.`);
459
- return checked;
460
- });
475
+ return packages.map((name) => broughtExtension(load, name, 'update @crossbind/core-app-host to match crossbind'));
461
476
  }
462
477
 
463
478
  // Whether an option of the app's config asks for anything, as false, null and an empty list or object do not.
@@ -482,9 +497,10 @@ export function withoutNamed(own, app = {}) {
482
497
  return own.filter((extension) => !left.includes(extension));
483
498
  }
484
499
 
485
- // A failure of an extension's step: crossbind's own message as it is, else one that names the extension and the step.
486
- const stepFailure = (name, which, error) => (error instanceof Error && error.message.startsWith('crossbind:') ? error
487
- : new Error(`crossbind: ${name}'s ${which} failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
500
+ // A failure of an extension's step: crossbind's own message as it is, else one that names the extension and the step,
501
+ // without the control characters a file or a tool's output it quotes may hold.
502
+ export const stepFailure = (name, which, error) => (error instanceof Error && error.message.startsWith('crossbind:') ? error
503
+ : new Error(`crossbind: ${name}'s ${which} failed: ${printable(error instanceof Error ? error.message : String(error))}`, { cause: error }));
488
504
 
489
505
  // A step of an extension's publisher or installer, whose failure stops the run.
490
506
  export async function extensionStep(name, which, run) {
@@ -515,10 +531,10 @@ export const withFromApp = (extensions, apiOf) => extensions.flatMap((extension)
515
531
  return options === null ? [] : [{ ...extension, options }];
516
532
  });
517
533
 
518
- // The extensions crossbind.config.js lists that add to an app: all but those that give a publisher or an installer
519
- // alone, which leave the host's own of their name as it is.
534
+ // The extensions crossbind.config.js lists that add to an app: all but those that give a publisher, an installer or a
535
+ // package source alone, which leave the host's own of their name as it is.
520
536
  export const appExtensionsOf = (listed) => listed
521
- .filter((extension) => (!extension.publisher && !extension.installer) || Object.keys(extension.app).length > 0);
537
+ .filter((extension) => (!extension.publisher && !extension.installer && !extension.packageSource) || Object.keys(extension.app).length > 0);
522
538
 
523
539
  // The host's own extensions an app has, then those crossbind.config.js lists, each with the roles it holds, its own name
524
540
  // where it says nothing else: a role a listed one replaces leaves the host's that held it, which the app leaves out once
@@ -2,7 +2,8 @@
2
2
  // its page opens files.
3
3
  import { carriedPath } from './appFiles.js';
4
4
 
5
- const WINDOW_DEFAULTS = { width: 1024, height: 768, minWidth: 0, minHeight: 0, maxWidth: 0, maxHeight: 0, resizable: true, fullscreen: false, backgroundColor: null };
5
+ const WINDOW_DEFAULTS = { width: 1024, height: 768, minWidth: 0, minHeight: 0, maxWidth: 0, maxHeight: 0, resizable: true, fullscreen: false, backgroundColor: null, titleBar: 'visible' };
6
+ const TITLE_BARS = ['visible', 'hidden'];
6
7
  // Larger than any display, and far within the int a host takes a size as.
7
8
  const MAX_WINDOW_SIZE = 16384;
8
9
  // Windows keeps each of a file version's four numbers in 16 bits.
@@ -82,8 +83,9 @@ export function appCategory(config) {
82
83
  return { darwin: `public.app-category.${category}`, linux: APP_CATEGORIES[category] };
83
84
  }
84
85
 
85
- // The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes and
86
- // whether it opens full screen. Its backgroundColor, which a phone takes as well, is appBackgroundColor's.
86
+ // The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes,
87
+ // whether it opens full screen and whether a macOS page lies under its title bar. Its backgroundColor, which a phone
88
+ // takes as well, is appBackgroundColor's.
87
89
  export function appWindow(config) {
88
90
  const window = config.app.window ?? {};
89
91
  const keys = Object.keys(WINDOW_DEFAULTS).join(', ');
@@ -102,6 +104,9 @@ export function appWindow(config) {
102
104
  ['resizable', 'fullscreen'].forEach((key) => {
103
105
  if (typeof options[key] !== 'boolean') throw new Error(`crossbind: app.window.${key} ${JSON.stringify(options[key])} is no boolean: set it to true or false.`);
104
106
  });
107
+ if (!TITLE_BARS.includes(options.titleBar)) {
108
+ throw new Error(`crossbind: app.window.titleBar ${JSON.stringify(options.titleBar)} is neither 'visible' nor 'hidden': 'hidden' lays a macOS app's page under its title bar, whose buttons stay.`);
109
+ }
105
110
  if (!options.resizable && (options.minWidth || options.minHeight || options.maxWidth || options.maxHeight)) {
106
111
  throw new Error('crossbind: app.window.minWidth, minHeight, maxWidth and maxHeight limit a window that resizes: drop them, or set app.window.resizable to true.');
107
112
  }
@@ -117,16 +122,29 @@ export function appWindow(config) {
117
122
  return options;
118
123
  }
119
124
 
120
- // The color an app shows until its page paints, and behind a page without a background of its own, on iOS and Android as
121
- // well: app.window.backgroundColor, else null, which leaves the system's.
122
- export function appBackgroundColor(config) {
125
+ const COLOR = /^#[0-9A-Fa-f]{6}$/;
126
+
127
+ // The colors an app shows until its page paints, and behind a page without a background of its own, on iOS and Android as
128
+ // well: app.window.backgroundColor, one color, or { light, dark }, the one while the system is light and the one while
129
+ // it is dark, which a phone follows; else null, which leaves the system's.
130
+ function backgroundColors(config) {
123
131
  const color = config.app.window?.backgroundColor ?? null;
124
- if (color !== null && (typeof color !== 'string' || !/^#[0-9A-Fa-f]{6}$/.test(color))) {
125
- throw new Error(`crossbind: app.window.backgroundColor ${JSON.stringify(color)} is no color of the form #rrggbb, as #1e1e1e is.`);
132
+ if (color === null) return null;
133
+ if (typeof color === 'string' && COLOR.test(color)) return { light: color, dark: null };
134
+ const isPair = typeof color === 'object' && !Array.isArray(color) && Object.keys(color).sort().join() === 'dark,light'
135
+ && [color.light, color.dark].every((one) => typeof one === 'string' && COLOR.test(one));
136
+ if (!isPair) {
137
+ throw new Error(`crossbind: app.window.backgroundColor ${JSON.stringify(color)} is no color of the form #rrggbb, as #1e1e1e is, nor { light, dark } of two, as { light: '#f2f2f2', dark: '#1e1e1e' } is.`);
126
138
  }
127
139
  return color;
128
140
  }
129
141
 
142
+ // The color, or the light one of the two, which a desktop shows whatever its system's appearance.
143
+ export const appBackgroundColor = (config) => backgroundColors(config)?.light ?? null;
144
+
145
+ // The color an iOS or Android app shows while its system is dark, where app.window.backgroundColor gives one.
146
+ export const appDarkBackgroundColor = (config) => backgroundColors(config)?.dark ?? null;
147
+
130
148
  const PERMISSIONS = ['camera', 'microphone', 'location'];
131
149
 
132
150
  // The devices the page may use, each with the reason for it, which macOS and iOS read from the Info.plist:
@@ -313,8 +331,8 @@ export function appInstallers(config) {
313
331
  return installers;
314
332
  }
315
333
 
316
- // A desktop host's window: its page's size and limits, whether it resizes and whether it opens full screen. A maximum of
317
- // one side alone leaves the other as large as any display.
334
+ // A desktop host's window: its page's size and limits, whether it resizes, whether it opens full screen and whether its
335
+ // page lies under its title bar. A maximum of one side alone leaves the other as large as any display.
318
336
  function windowDefines(window) {
319
337
  const most = window.maxWidth || window.maxHeight ? [
320
338
  `#define CROSSBIND_APP_WINDOW_MAX_WIDTH ${window.maxWidth || MAX_WINDOW_SIZE}`,
@@ -328,6 +346,7 @@ function windowDefines(window) {
328
346
  `#define CROSSBIND_APP_WINDOW_RESIZABLE ${window.resizable ? 1 : 0}`,
329
347
  ...most,
330
348
  ...(window.fullscreen ? ['#define CROSSBIND_APP_WINDOW_FULLSCREEN 1'] : []),
349
+ ...(window.titleBar === 'hidden' ? ['#define CROSSBIND_APP_WINDOW_TITLE_BAR_HIDDEN 1'] : []),
331
350
  ];
332
351
  }
333
352
 
@@ -339,10 +358,12 @@ export function appHeader(config, platform, appGroup = null) {
339
358
  const id = `#define CROSSBIND_APP_ID "${platform === 'android' ? androidAppId(config) : appId(config)}"`;
340
359
  const name = `#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`;
341
360
  const color = appBackgroundColor(config);
361
+ const dark = platform === 'ios' ? appDarkBackgroundColor(config) : null;
342
362
  const permissions = appPermissions(config);
343
363
  // An Android app's activity takes the color and the permissions from its Gradle project.
344
364
  const page = platform === 'android' ? [] : [
345
365
  ...(color ? [`#define CROSSBIND_APP_BACKGROUND_COLOR 0x${color.slice(1).toLowerCase()}`] : []),
366
+ ...(dark ? [`#define CROSSBIND_APP_BACKGROUND_COLOR_DARK 0x${dark.slice(1).toLowerCase()}`] : []),
346
367
  ...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
347
368
  ];
348
369
  const windows = platform === 'ios' && iosMultipleWindows(config) ? ['#define CROSSBIND_APP_IOS_MULTIPLE_WINDOWS 1'] : [];
@@ -23,9 +23,9 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
23
23
  .map((e) => e.name);
24
24
 
25
25
  // Prune bridges whose source of truth is gone. An app-local surface whose .rs was deleted
26
- // breaks cargo on the missing #[path] target; a crate_ bridge whose cargoDependencies entry
26
+ // breaks cargo on the missing #[path] target; a crate_ bridge whose packages.cargo entry
27
27
  // was removed silently keeps dead registrations in every later binary. crate_ pruning only
28
- // runs when the caller passes the current cargoDependencies - null means "don't judge".
28
+ // runs when the caller passes the crates declared now - null means "don't judge".
29
29
  crates = crates.filter((name) => {
30
30
  const dir = `${root}/${name}`;
31
31
  const libRs = `${dir}/src/lib.rs`;
@@ -15,8 +15,13 @@ const require = createRequire(import.meta.url);
15
15
 
16
16
  export const usesBridgeTools = (target, host = process.platform) => host === 'darwin' && target?.platform !== 'android';
17
17
 
18
- export function missingToolsMessage(error, version = require('../../package.json').version) {
18
+ export function missingToolsMessage(error, version = require('../../package.json').version, node = process.versions.node) {
19
19
  if (error?.code === 'MODULE_NOT_FOUND') {
20
+ // A package manager leaves out an optional dependency whose engines the Node it runs on does not meet.
21
+ const needed = Number(require('../../package.json').engines.node.match(/\d+/)[0]);
22
+ if (Number(node.split('.')[0]) < needed) {
23
+ return `crossbind: bridges on a Mac need ${PACKAGE}, which needs Node ${needed} or later, as crossbind does: under Node ${node} the package manager leaves it out. Install again with Node ${needed} or later.`;
24
+ }
20
25
  return `crossbind: bridges on a Mac need ${PACKAGE}, which installs with crossbind there unless optional dependencies are left out: npm install --save-dev ${PACKAGE}@${version}`;
21
26
  }
22
27
  if (error?.code === 'ENOENT') {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { stripComments } from './swigInterface.js';
4
4
  import writeIfChanged from './writeIfChanged.js';
5
- import { conanImportOfHeader, conanStageDir } from './conanImport.js';
5
+ import { sourceImportOfHeader, sourceStageDir } from './packageSourceImport.js';
6
6
 
7
7
  // Best-effort .d.ts for `.h` imports, the C++ analog of the Rust emitDts: parse the
8
8
  // binding-rules surface (classes, public methods, primitives/string/shared_ptr), skip
@@ -256,20 +256,20 @@ function declarationsOf(headerFile, exportNames, dtsMode, log) {
256
256
 
257
257
  // Mirror the declaration under <cache>/types/<project-relative>.d.ts - never next to the
258
258
  // user's header. Package headers are skipped: their types ship with the package itself, and a
259
- // header staged from Conan is typed by its conan: module instead.
260
- export function writeHeaderDts({ headerFile, exportsFile, projectPath, cacheDir, dtsMode = 'sync', log = () => {} }) {
259
+ // header staged from a package source is typed by its module instead, as conan:zlib/zlib.h is.
260
+ export function writeHeaderDts({ headerFile, exportsFile, projectPath, cacheDir, sources = [], dtsMode = 'sync', log = () => {} }) {
261
261
  const relative = path.relative(projectPath, headerFile);
262
- if (relative.startsWith('..') || headerFile.startsWith(`${conanStageDir(cacheDir)}/`)) return;
262
+ if (relative.startsWith('..') || sources.some((source) => headerFile.startsWith(`${sourceStageDir(cacheDir, source)}/`))) return;
263
263
  const exportNames = readExportNames(exportsFile, log);
264
264
  if (!exportNames) return;
265
265
  writeIfChanged(`${cacheDir}/types/${relative}.d.ts`, declarationsOf(headerFile, exportNames, dtsMode, log));
266
266
  }
267
267
 
268
- // A conan: specifier names no file the editor could map, so it is typed by an ambient module that
269
- // @crossbind/typescript-config includes. `declare` is an error inside one (TS1038).
270
- export function writeConanImportDts({ headerFile, exportsFile, declarationsFile = exportsFile, cacheDir, dtsMode = 'sync', log = () => {} }) {
271
- const conanImport = conanImportOfHeader(headerFile, cacheDir);
272
- const declared = conanImport && readExportNames(declarationsFile, log);
268
+ // A specifier such as conan:zlib/zlib.h names no file the editor could map, so it is typed by an ambient
269
+ // module, which @crossbind/typescript-config includes for conan. `declare` is an error inside one (TS1038).
270
+ export function writeSourceImportDts({ headerFile, exportsFile, declarationsFile = exportsFile, cacheDir, sources = [], dtsMode = 'sync', log = () => {} }) {
271
+ const sourceImport = sourceImportOfHeader(headerFile, cacheDir, sources);
272
+ const declared = sourceImport && readExportNames(declarationsFile, log);
273
273
  if (!declared) return;
274
274
  // The bridge also binds macros and variadic functions the app imports, which the whole header's SWIG run skips.
275
275
  const exportNames = [...new Set([...declared, ...(readExportNames(exportsFile, log) ?? [])])];
@@ -279,7 +279,7 @@ export function writeConanImportDts({ headerFile, exportsFile, declarationsFile
279
279
  .map((line) => (line ? ` ${line}` : line))
280
280
  .join('\n');
281
281
  writeIfChanged(
282
- `${conanStageDir(cacheDir)}/types/${conanImport.name}/${conanImport.header}.d.ts`,
283
- `declare module 'conan:${conanImport.name}/${conanImport.header}' {\n${body}\n}\n`,
282
+ `${sourceStageDir(cacheDir, sourceImport.source)}/types/${sourceImport.name}/${sourceImport.header}.d.ts`,
283
+ `declare module '${sourceImport.source}:${sourceImport.name}/${sourceImport.header}' {\n${body}\n}\n`,
284
284
  );
285
285
  }
@@ -1,4 +1,4 @@
1
- // What a publisher, an installer and a command of an extension's give, as crossbind takes them.
1
+ // What a publisher, an installer, a package source and a command of an extension's give, as crossbind takes them.
2
2
 
3
3
  // A name prefixes the extension's functions and names its C++ entry, crossbind_extension_<name>.
4
4
  export const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -110,6 +110,43 @@ export function checkedInstaller(installer, where) {
110
110
  return { platforms, ...(installer.isLast ? { isLast: true } : {}), ...Object.fromEntries(steps) };
111
111
  }
112
112
 
113
+ const PACKAGE_SOURCE_STEPS = ['check', 'inputs', 'install'];
114
+ const PACKAGE_SOURCE_KEYS = ['storeUnits', ...PACKAGE_SOURCE_STEPS];
115
+ // A package source's name starts its imports, its key in crossbind.config.js and its folders beside crossbind's own:
116
+ // one word, and none that means something there already: an import scheme of a bundler or a runtime, a folder of
117
+ // crossbind's, or a device's name, which Windows gives no folder.
118
+ const SOURCE_NAME = /^[a-z][a-z0-9]*$/;
119
+ const KEPT_SOURCE_NAMES = [
120
+ 'cargo', 'node', 'file', 'data', 'http', 'https', 'npm', 'virtual', 'blob', 'bun', 'deno', 'jsr', 'cloudflare', 'workerd',
121
+ 'all', 'android', 'build', 'config', 'deps', 'hermes', 'keys', 'secrets', 'sysroots', 'types',
122
+ 'con', 'prn', 'aux', 'nul',
123
+ ];
124
+ const STORE_SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/;
125
+ // Folders of a source's store that never change once written, as p/*/p names one in each folder of p.
126
+ const isStoreUnit = (unit) => typeof unit === 'string'
127
+ && unit.split('/').every((segment) => segment === '*' || STORE_SEGMENT.test(segment))
128
+ && unit.split('/').includes('*');
129
+
130
+ // A package source as crossbind runs it, under its extension's name: the folders of its store a remote runner sends
131
+ // back, and its steps, which keep the object as their this.
132
+ export function checkedPackageSource(packageSource, where, name) {
133
+ if (!isObject(packageSource)) throw new Error(`crossbind: ${where} is no object.`);
134
+ const unknown = Object.keys(packageSource).find((key) => !PACKAGE_SOURCE_KEYS.includes(key));
135
+ if (unknown) throw new Error(`crossbind: ${where} takes ${PACKAGE_SOURCE_KEYS.join(', ')}; ${unknown} is none of them.`);
136
+ if (!SOURCE_NAME.test(name) || KEPT_SOURCE_NAMES.includes(name)) {
137
+ throw new Error(`crossbind: ${where} makes ${name} the name of a package source, which starts its imports, as ${name}:<package>/<header>: one word of lowercase letters and digits, and none of ${KEPT_SOURCE_NAMES.join(', ')}.`);
138
+ }
139
+ const storeUnits = packageSource.storeUnits ?? [];
140
+ if (!Array.isArray(storeUnits) || !storeUnits.every(isStoreUnit)) {
141
+ throw new Error(`crossbind: ${where}.storeUnits is no list of the folders of its store that never change once written, each a path with a * for a folder's name, as 'p/*/p'.`);
142
+ }
143
+ assertSteps(where, packageSource, PACKAGE_SOURCE_STEPS);
144
+ if (!packageSource.check) throw new Error(`crossbind: ${where} has no check, the step that takes the project's packages.${name}.`);
145
+ if (!packageSource.install) throw new Error(`crossbind: ${where} has no install, the step that installs a target's packages.`);
146
+ const steps = PACKAGE_SOURCE_STEPS.filter((step) => packageSource[step]).map((step) => [step, packageSource[step].bind(packageSource)]);
147
+ return { storeUnits: [...storeUnits], ...Object.fromEntries(steps) };
148
+ }
149
+
113
150
  const COMMAND_KEYS = ['help', 'options', 'run'];
114
151
 
115
152
  // The commands an extension adds under crossbind app, by their names: each its line of help, the options it takes, as a
@@ -11,9 +11,16 @@ export function findImportSpecifiers(text) {
11
11
 
12
12
  export const nativeExtensions = ({ header, module }) => [...header, ...module, 'rs'];
13
13
 
14
- export const nativeSpecifierTest = (ext) => {
14
+ // Whether a specifier opens with one of the schemes an import names a store by, as cargo: and conan: are.
15
+ export const schemeTest = (schemes) => {
16
+ const scheme = new RegExp(`^(${schemes.join('|')}):`);
17
+ return (specifier) => scheme.test(specifier);
18
+ };
19
+
20
+ export const nativeSpecifierTest = (ext, schemes) => {
15
21
  const extension = new RegExp(`\\.(${nativeExtensions(ext).join('|')})$`);
16
- return (specifier) => /^(cargo|conan):/.test(specifier) || extension.test(specifier);
22
+ const hasScheme = schemeTest(schemes);
23
+ return (specifier) => hasScheme(specifier) || extension.test(specifier);
17
24
  };
18
25
 
19
26
  export function findNativeImportsIn(projectDir, isNative) {
@@ -6,6 +6,7 @@ import loadJson from './loadJson.js';
6
6
  import { computeInputStamp } from './inputStamp.js';
7
7
  import { findHeaderImportsIn } from './headerImports.js';
8
8
  import { findNativeImportsIn, nativeSpecifierTest } from './nativeImports.js';
9
+ import { importSchemesOf } from './packageSources.js';
9
10
 
10
11
  // Set on the build a dev start runs: that build is a Node process too, which NODE_OPTIONS may hand the dev hooks again.
11
12
  export const DEV_BUILD_ENV = 'CROSSBIND_DEV_BUILD';
@@ -49,7 +50,7 @@ export function devFormatOf(project) {
49
50
  export function nativeInputsFingerprint(config) {
50
51
  const { project, native, header, module: modules } = config.paths;
51
52
  const relativeTo = (importer) => upath.relative(project, importer);
52
- const imports = findNativeImportsIn(project, nativeSpecifierTest(config.ext))
53
+ const imports = findNativeImportsIn(project, nativeSpecifierTest(config.ext, importSchemesOf(config)))
53
54
  .map(({ importer, specifier }) => ({ importer: relativeTo(importer), specifier }));
54
55
  const names = findHeaderImportsIn(project, config.ext.header)
55
56
  .map(({ importer, specifier, names: taken }) => JSON.stringify([relativeTo(importer), specifier, taken]));
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import writeIfChanged from './writeIfChanged.js';
4
+ import logger from './logger.js';
5
+ import runPackageSource, { runProbe } from './runPackageSource.js';
6
+ import { printable } from './printableText.js';
7
+
8
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ // Enough of a failed build's log to reach the error the tool ends it with.
10
+ const LOG_TAIL_LINES = 60;
11
+
12
+ const isText = (value) => typeof value === 'string' && !value.includes('\0');
13
+
14
+ // A file the tool left in the work directory, as text, or null where there is none. Recipes write there too, so
15
+ // a link, which could name any file the machine's user reads, is refused, as is a path that leaves the directory.
16
+ function readOwn(name, dir, segments) {
17
+ const root = fs.realpathSync(dir);
18
+ const relative = segments.every(isText) ? path.relative(root, path.join(root, ...segments)) : '';
19
+ const file = path.join(root, relative);
20
+ const isInside = relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
21
+ if (isInside && !fs.existsSync(file)) return null;
22
+ if (!isInside || fs.realpathSync(file) !== file || !fs.statSync(file).isFile()) {
23
+ throw new Error(`crossbind: ${name} reads ${printable(segments.join('/'))} from its work directory, which is no file of its own there: a link, a folder, or a path that leaves the directory.`);
24
+ }
25
+ return fs.readFileSync(file, 'utf8');
26
+ }
27
+
28
+ function assertRun(name, command, env) {
29
+ if (!Array.isArray(command) || command.length === 0 || !command.every(isText)) {
30
+ throw new Error(`crossbind: ${name}'s run takes a program and its arguments as a list of strings.`);
31
+ }
32
+ const isEnv = env !== null && typeof env === 'object' && !Array.isArray(env)
33
+ && Object.entries(env).every(([key, value]) => ENV_NAME.test(key) && isText(value));
34
+ if (!isEnv) throw new Error(`crossbind: ${name}'s run takes env as variable names with their text.`);
35
+ }
36
+
37
+ function runFailure(name, step, target, program, result, logFile) {
38
+ if (result.error && result.error.code !== 'ENOBUFS') {
39
+ return new Error(`crossbind: could not run ${program} for ${target.path} (${result.error.message}).`);
40
+ }
41
+ let ending = `failed (exit code ${result.status})`;
42
+ if (result.error) ending = 'was stopped: it printed more than crossbind reads';
43
+ else if (result.signal) ending = `was stopped by ${result.signal}`;
44
+ // A tool that says why on stdout alone would end the message with nothing.
45
+ const printed = `${result.stderr ?? ''}`.trim() ? result.stderr : result.stdout;
46
+ const tail = printable(`${printed ?? ''}`).trim().split('\n').slice(-LOG_TAIL_LINES).join('\n');
47
+ return new Error(`crossbind: ${name} ${step} for ${target.path} ${ending}.${logFile ? ` Its whole log is in ${logFile}.` : ''}\n${tail}`);
48
+ }
49
+
50
+ // What a package source's steps get of crossbind. In install, with a log file, `run` runs the source's tool
51
+ // where the target's packages build (its image, a remote runner, or the host where isLocal says so), in the
52
+ // run's work directory, and what the tool writes to stderr is the install's log. In inputs the work directory
53
+ // is the source's probe directory and only a tool of the host can be asked.
54
+ export default function packageSourceApi({
55
+ source, config, target, work, logFile = null,
56
+ }) {
57
+ const { name } = source;
58
+ const isLocal = work.runner === 'LOCAL';
59
+ const step = logFile ? 'install' : 'inputs';
60
+ const log = [];
61
+
62
+ function run(command, { env = {} } = {}) {
63
+ assertRun(name, command, env);
64
+ if (!logFile && !isLocal) {
65
+ throw new Error(`crossbind: ${name}'s inputs runs ${command[0]}, which it can where api.isLocal is true alone: elsewhere the image says what runs.`);
66
+ }
67
+ const result = logFile ? runPackageSource(command, {
68
+ source, config, target, work, env,
69
+ }) : runProbe(command, { work, env });
70
+ if (logFile) {
71
+ log.push(`${result.stderr ?? ''}`);
72
+ writeIfChanged(logFile, log.join(''));
73
+ }
74
+ if (result.error || result.status !== 0) throw runFailure(name, step, target, command[0], result, logFile);
75
+ return Object.freeze({ stdout: `${result.stdout ?? ''}`, stderr: `${result.stderr ?? ''}` });
76
+ }
77
+
78
+ return Object.freeze({
79
+ name,
80
+ isLocal,
81
+ logFile,
82
+ work: Object.freeze({ dir: work.dir, path: work.toolPath, read: (...segments) => readOwn(name, work.dir, segments) }),
83
+ store: Object.freeze({ dir: work.store, path: work.storePath }),
84
+ log: (text) => logger.info(text),
85
+ run,
86
+ });
87
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+
3
+ // A package of a source, as an import and a folder name it. Lower-case, as Conan's are: a file system that
4
+ // ignores case holds one folder for two names that differ in case alone.
5
+ export const PACKAGE_NAME = /^[a-z0-9_][a-z0-9_+.-]*$/;
6
+ // No leading dot, so neither '.' nor '..' can climb out of the include directory.
7
+ const HEADER_SEGMENT = /^[A-Za-z0-9_+-][A-Za-z0-9_.+-]*$/;
8
+
9
+ export const sourceStageDir = (cacheDir, source) => `${cacheDir}/${source}`;
10
+ // Apart from the stage's own files, so no package name can land on one of them.
11
+ export const sourcePackageDir = (stageDir, name) => `${stageDir}/packages/${name}`;
12
+
13
+ // A source's name is its scheme and names all that is its own: packages.<name> in the config, <name>.lock and
14
+ // .crossbind/<name>. Where a project declares its packages, as a message names it, and what it declares there.
15
+ export const declarationOf = (source) => `packages.${source}`;
16
+ export const dependenciesOf = (config, source) => config.packages?.[source] ?? {};
17
+ // The crates a project declares: cargo is the core's own, declared beside the sources' packages.
18
+ export const cratesOf = (config) => dependenciesOf(config, 'cargo');
19
+
20
+ // The source whose scheme a specifier opens with, as conan:zlib/zlib.h names conan.
21
+ export const sourceOfImport = (config, specifier) => (config.packageSources ?? []).find(({ name }) => specifier.startsWith(`${name}:`));
22
+
23
+ export function parseSourceImport(source, specifier, headerExtensions) {
24
+ const [name, ...headerPath] = specifier.slice(source.length + 1).split('/');
25
+ if (!PACKAGE_NAME.test(name)) {
26
+ throw new Error(`crossbind: '${specifier}' - '${name}' is not a ${source} package name; write ${source}:<package>/<header>.`);
27
+ }
28
+ if (headerPath.length === 0) {
29
+ throw new Error(`crossbind: '${specifier}' names no header - import one of the package's headers as ${source}:${name}/<header>.`);
30
+ }
31
+ const bad = headerPath.find((segment) => !HEADER_SEGMENT.test(segment));
32
+ if (bad !== undefined) {
33
+ throw new Error(`crossbind: '${specifier}' - '${bad}' is not a header path segment.`);
34
+ }
35
+ const header = headerPath.join('/');
36
+ const extension = header.includes('.') ? header.split('.').at(-1) : '';
37
+ if (!headerExtensions.includes(extension)) {
38
+ throw new Error(`crossbind: '${specifier}' - a ${source}: import names a header (${headerExtensions.map((e) => `.${e}`).join(', ')}).`);
39
+ }
40
+ return { name, header };
41
+ }
42
+
43
+ // The inverse of resolveSourceHeader, for the editor types of a staged header.
44
+ export function sourceImportOfHeader(file, cacheDir, sources) {
45
+ const prefixOf = (source) => sourcePackageDir(sourceStageDir(cacheDir, source), '');
46
+ const source = sources.find((name) => file.startsWith(prefixOf(name)));
47
+ if (!source) return null;
48
+ const match = file.slice(prefixOf(source).length).match(/^([^/]+)\/dist\/prebuilt\/[^/]+\/include\/(.+)$/);
49
+ // Both end up in the module name of the generated types.
50
+ if (!match || !PACKAGE_NAME.test(match[1]) || !match[2].split('/').every((segment) => HEADER_SEGMENT.test(segment))) return null;
51
+ return { source, name: match[1], header: match[2] };
52
+ }
53
+
54
+ // Only declared packages are importable, as with cargo:; a package pulled in by another links but stays hidden.
55
+ export function resolveSourceHeader(config, source, specifier, target) {
56
+ const { name, header } = parseSourceImport(source, specifier, config.ext.header);
57
+ if (!Object.hasOwn(dependenciesOf(config, source), name)) {
58
+ throw new Error(`crossbind: '${specifier}' is not declared - add '${name}' to ${declarationOf(source)} in crossbind.config.`);
59
+ }
60
+ const dependency = config.allDependencies.find(({ general }) => general.packageSource?.name === source && general.packageSource.package.name === name);
61
+ if (!dependency) {
62
+ throw new Error(`crossbind: '${specifier}' - ${name} is declared in ${declarationOf(source)} but not installed; crossbind installs ${source} packages when a build starts.`);
63
+ }
64
+ const tried = [...new Set([target.releasePath, target.path].filter(Boolean))]
65
+ .map((targetPath) => `${dependency.paths.output}/prebuilt/${targetPath}/include/${header}`);
66
+ const found = tried.find((file) => fs.existsSync(file));
67
+ if (found) return found;
68
+ throw new Error(`crossbind: '${specifier}' - ${name} has no ${header}. Tried:\n${tried.map((file) => ` - ${file}`).join('\n')}`);
69
+ }