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,71 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { DARWIN_CC } from './darwinToolchain.js';
4
+
5
+ // On a Mac, every bridge step bound for the web image runs with @crossbind/bridge-tools, which crossbind has as an
6
+ // optional dependency there: its SWIG in this process, and Xcode's clang reading headers as the image's em++ does. The
7
+ // two make the same bridges, so a macOS or iOS build needs no Docker. An Android bridge is read with the NDK, in its
8
+ // image.
9
+ const PACKAGE = '@crossbind/bridge-tools';
10
+ // clang reads CPATH and CPLUS_INCLUDE_PATH despite -nostdinc, so it gets only these.
11
+ const CLANG_ENVIRONMENT = ['PATH', 'HOME', 'TMPDIR'];
12
+ // As much as run() takes from a command.
13
+ const OUTPUT_LIMIT = 512 * 1024 * 1024;
14
+ const require = createRequire(import.meta.url);
15
+
16
+ export const usesBridgeTools = (target, host = process.platform) => host === 'darwin' && target?.platform !== 'android';
17
+
18
+ export function missingToolsMessage(error, version = require('../../package.json').version, node = process.versions.node) {
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
+ }
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}`;
26
+ }
27
+ if (error?.code === 'ENOENT') {
28
+ return `crossbind: ${PACKAGE} has not been built in this checkout: pnpm --filter ${PACKAGE} build (needs Docker)`;
29
+ }
30
+ return `crossbind: ${PACKAGE} does not load: ${error?.message}`;
31
+ }
32
+
33
+ let loaded;
34
+ export function loadBridgeTools() {
35
+ if (!loaded) {
36
+ try {
37
+ loaded = require(PACKAGE);
38
+ } catch (error) {
39
+ throw new Error(missingToolsMessage(error), { cause: error });
40
+ }
41
+ }
42
+ return loaded;
43
+ }
44
+
45
+ // As run() fails for a command that exits with an error: the output shown, the exit code in the cause.
46
+ export function runSwig(args, cwd, tools = loadBridgeTools()) {
47
+ let result;
48
+ try {
49
+ result = tools.swig(args, { cwd });
50
+ } catch (error) {
51
+ if (error.output) console.error(error.output);
52
+ throw error;
53
+ }
54
+ if (result.status === 0) return;
55
+ if (result.output) console.error(result.output);
56
+ throw new Error(`crossbind: command failed (swig) with exit code ${result.status}`, { cause: { status: result.status } });
57
+ }
58
+
59
+ export const clangEnvironment = (env = process.env) => Object.fromEntries(CLANG_ENVIRONMENT.filter((key) => env[key] !== undefined).map((key) => [key, env[key]]));
60
+
61
+ export function runPreprocessor(args, cwd, tools = loadBridgeTools(), exec = execFileSync) {
62
+ try {
63
+ exec(DARWIN_CC, [...tools.preprocessorArgs, ...args], {
64
+ cwd, env: clangEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: OUTPUT_LIMIT,
65
+ });
66
+ } catch (error) {
67
+ if (error?.stdout?.length) console.log(error.stdout.toString());
68
+ if (error?.stderr?.length) console.error(error.stderr.toString());
69
+ throw new Error(`crossbind: command failed (clang) with exit code ${error?.status ?? 'unknown'}`, { cause: error });
70
+ }
71
+ }
@@ -0,0 +1,30 @@
1
+ // The changes a publisher makes to crossbind.config.js's export for the build it publishes, as JSON in the build's
2
+ // environment: its own process reads the config again.
3
+ export const CONFIG_CHANGES = 'CROSSBIND_CONFIG_CHANGES';
4
+
5
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+
7
+ // A JSON merge patch of a value, as RFC 7386 has it: an object's keys merge into the value's, null removes a key, and
8
+ // any other value takes the place of the one it patches.
9
+ export function mergePatch(target, patch) {
10
+ if (!isObject(patch)) return patch;
11
+ const base = isObject(target) ? target : {};
12
+ return Object.fromEntries([
13
+ ...Object.entries(base).filter(([key]) => !Object.hasOwn(patch, key)),
14
+ ...Object.entries(patch).filter(([, value]) => value !== null).map(([key, value]) => [key, mergePatch(base[key], value)]),
15
+ ]);
16
+ }
17
+
18
+ // The changes the environment gives a build, or null for none.
19
+ export function configChangesOf(env = process.env) {
20
+ const text = env[CONFIG_CHANGES];
21
+ if (!text) return null;
22
+ let changes;
23
+ try {
24
+ changes = JSON.parse(text);
25
+ } catch (error) {
26
+ throw new Error(`crossbind: ${CONFIG_CHANGES} holds no JSON: ${error.message}`, { cause: error });
27
+ }
28
+ if (!isObject(changes)) throw new Error(`crossbind: ${CONFIG_CHANGES} is no object of changes to crossbind.config.js.`);
29
+ return changes;
30
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { parseAst } from 'rollup/parseAst';
3
4
 
4
5
  // The kinds of crossbind.config whose default export can be an object literal to write into.
@@ -12,14 +13,23 @@ const propertyOf = (object, name) => object.properties.find((property) => proper
12
13
  && (property.key.name ?? property.key.value) === name);
13
14
  // A spread may carry the key, and would win over one written before it.
14
15
  const hasSpread = (object) => object.properties.some((property) => property.type === 'SpreadElement');
16
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
17
+ const ESCAPES = {
18
+ '\\': '\\\\', "'": "\\'", '\n': '\\n', '\r': '\\r', '\t': '\\t', '\u2028': '\\u2028', '\u2029': '\\u2029',
19
+ };
20
+ // A string in single quotes, with what would end its line or its text escaped.
21
+ // eslint-disable-next-line no-control-regex
22
+ const quoted = (text) => `'${text.replace(/[\\'\u0000-\u001f\u2028\u2029]/g, (char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)}'`;
23
+ // A key as one writes it: a name as it is, anything else quoted.
24
+ const keyOf = (key) => (IDENTIFIER.test(key) ? key : quoted(key));
15
25
  // A value as one writes it in a config by hand: { fileTimestamp: ['C617.1'] }.
16
26
  function literal(value) {
17
- if (typeof value === 'string') return `'${value.replace(/[\\']/g, '\\$&')}'`;
27
+ if (typeof value === 'string') return quoted(value);
18
28
  if (Array.isArray(value)) return `[${value.map(literal).join(', ')}]`;
19
- if (value !== null && typeof value === 'object') return `{ ${Object.entries(value).map(([key, item]) => `${key}: ${literal(item)}`).join(', ')} }`;
29
+ if (value !== null && typeof value === 'object') return `{ ${Object.entries(value).map(([key, item]) => `${keyOf(key)}: ${literal(item)}`).join(', ')} }`;
20
30
  return JSON.stringify(value);
21
31
  }
22
- export const entry = (key, value) => `${key}: ${literal(value)}`;
32
+ export const entry = (key, value) => `${keyOf(key)}: ${literal(value)}`;
23
33
 
24
34
  // How far an object's keys sit in from the line it opens on.
25
35
  function indentUnit(source, object) {
@@ -28,37 +38,144 @@ function indentUnit(source, object) {
28
38
  return indentOf(source, first.start).slice(indentOf(source, object.start).length) || DEFAULT_INDENT;
29
39
  }
30
40
 
31
- // The config's source with `key: value` first in its app section, which it makes where there is none, or null where it
32
- // cannot place it safely: the default export must be an object literal, its app one too, without the key or a spread.
33
- export function withAppOption(source, key, value) {
41
+ // `key: value` where the rest of a key's path makes the objects the value goes in: window: { width: 800 }.
42
+ const nestedEntry = ([key, ...rest], value) => entry(key, rest.reduceRight((inner, name) => ({ [name]: inner }), value));
43
+
44
+ // The source with the entry of `keys` first in `object`, or in the object it holds under the first key, which it makes
45
+ // where there is none, or null where it cannot place it safely: a spread in an object a key goes in may carry the key.
46
+ function withEntry(source, { object, property }, keys, value, unit) {
47
+ const [key, ...rest] = keys;
48
+ const found = propertyOf(object, key);
49
+ if (found) {
50
+ if (rest.length === 0 || found.value.type !== 'ObjectExpression') return null;
51
+ return withEntry(source, { object: found.value, property: found }, rest, value, unit);
52
+ }
53
+ if (hasSpread(object)) return null;
54
+ const [first] = object.properties;
55
+ if (!first && property) {
56
+ const indent = indentOf(source, property.start);
57
+ return `${source.slice(0, object.start)}{\n${indent}${unit}${nestedEntry(keys, value)},\n${indent}}${source.slice(object.end)}`;
58
+ }
59
+ if (!first || isOneLine(source, object.start, first.start)) return insert(source, object.start + 1, ` ${nestedEntry(keys, value)},`);
60
+ const indent = indentOf(source, first.start);
61
+ if (rest.length === 0) return insert(source, object.start + 1, `\n${indent}${entry(key, value)},`);
62
+ // A section the object lacks opens on lines of its own, as the object writes its keys.
63
+ return insert(source, object.start + 1, `\n${indent}${key}: {\n${indent}${unit}${nestedEntry(rest, value)},\n${indent}},`);
64
+ }
65
+
66
+ // The config's source with the option a key's path names, as ['app', 'id'], first in the object it goes in, or null
67
+ // where it cannot place it safely: the default export and each object on the way must be object literals, the last
68
+ // without the key.
69
+ export function withOption(source, keys, value) {
34
70
  const exported = parseAst(source).body.find((node) => node.type === 'ExportDefaultDeclaration')?.declaration;
35
71
  if (exported?.type !== 'ObjectExpression') return null;
36
- const unit = indentUnit(source, exported);
37
- const text = entry(key, value);
38
- const app = propertyOf(exported, 'app');
39
- if (!app) {
40
- if (hasSpread(exported)) return null;
41
- const [first] = exported.properties;
42
- if (!first || isOneLine(source, exported.start, first.start)) return insert(source, exported.start + 1, ` app: { ${text} },`);
43
- const indent = indentOf(source, first.start);
44
- return insert(source, exported.start + 1, `\n${indent}app: {\n${indent}${unit}${text},\n${indent}},`);
72
+ return withEntry(source, { object: exported, property: null }, keys, value, indentUnit(source, exported));
73
+ }
74
+
75
+ // Every name the module's code holds, bound or read, which a name it gains must not take.
76
+ function namesIn(node, names = new Set()) {
77
+ if (Array.isArray(node)) node.forEach((child) => namesIn(child, names));
78
+ if (typeof node?.type !== 'string') return names;
79
+ if (node.type === 'Identifier') names.add(node.name);
80
+ Object.values(node).filter((child) => child && typeof child === 'object').forEach((child) => namesIn(child, names));
81
+ return names;
82
+ }
83
+
84
+ const isCallOf = (identifier) => (element) => element?.type === 'CallExpression' && element.callee.type === 'Identifier'
85
+ && element.callee.name === identifier;
86
+ // A hashbang and the line comments that open a module, as // @ts-check, stay first in it.
87
+ const TOP_LINES = /^(?:#![^\n]*\n)?(?:\/\/[^\n]*\n)*/;
88
+
89
+ // The name, or with Extension after it and a number after that, the first the module does not hold.
90
+ function freeName(name, taken) {
91
+ for (let count = 0; ; count += 1) {
92
+ const candidate = count === 0 ? name : `${name}Extension${count === 1 ? '' : count}`;
93
+ if (!taken.has(candidate)) return candidate;
45
94
  }
46
- if (app.value.type !== 'ObjectExpression' || propertyOf(app.value, key) || hasSpread(app.value)) return null;
47
- const [first] = app.value.properties;
48
- if (!first) {
49
- const indent = indentOf(source, app.start);
50
- return `${source.slice(0, app.value.start)}{\n${indent}${unit}${text},\n${indent}}${source.slice(app.value.end)}`;
95
+ }
96
+
97
+ // Where a call goes first in an extensions list, as the list writes its entries: on a line of its own where they are.
98
+ // Where there is no list, a list of it goes first in the object.
99
+ function listEdit(source, eol, object, list, call) {
100
+ if (!list) {
101
+ const [first] = object.properties;
102
+ if (!first || isOneLine(source, object.start, first.start)) return { at: object.start + 1, text: ` extensions: [${call}],` };
103
+ return { at: object.start + 1, text: `${eol}${indentOf(source, first.start)}extensions: [${call}],` };
51
104
  }
52
- if (isOneLine(source, app.value.start, first.start)) return insert(source, app.value.start + 1, ` ${text},`);
53
- return insert(source, app.value.start + 1, `\n${indentOf(source, first.start)}${text},`);
105
+ const [first] = list.elements;
106
+ if (!first) return { at: list.start + 1, text: call };
107
+ if (isOneLine(source, list.start, first.start)) return { at: list.start + 1, text: `${call}, ` };
108
+ return { at: list.start + 1, text: `${eol}${indentOf(source, first.start)}${call},` };
109
+ }
110
+
111
+ // Where the import of an extension goes: after the module's last import, else after the lines that open it.
112
+ function importEdit(source, eol, program, identifier, specifier) {
113
+ const line = `import ${identifier} from ${literal(specifier)};`;
114
+ const last = program.body.filter((node) => node.type === 'ImportDeclaration').at(-1);
115
+ if (last) return { at: last.end, text: `${eol}${line}` };
116
+ const at = TOP_LINES.exec(source)[0].length;
117
+ return { at, text: /^\r?\n/.test(source.slice(at)) ? `${line}${eol}` : `${line}${eol}${eol}` };
118
+ }
119
+
120
+ // The config's source with the extension a module gives imported under name and called first in its extensions list,
121
+ // and the name its import binds, or null where it cannot place them safely: the default export must be an object
122
+ // literal and its extensions an array literal, without a spread after them that may carry a list of its own. A config
123
+ // that imports the module lists it under that import, and one that lists it already stays as it is.
124
+ export function withExtension(source, specifier, name) {
125
+ const program = parseAst(source);
126
+ const exported = program.body.find((node) => node.type === 'ExportDefaultDeclaration')?.declaration;
127
+ if (exported?.type !== 'ObjectExpression') return null;
128
+ const property = propertyOf(exported, 'extensions');
129
+ const isSpreadAfter = exported.properties.slice(exported.properties.indexOf(property) + 1).some((node) => node.type === 'SpreadElement');
130
+ if ((property && property.value.type !== 'ArrayExpression') || isSpreadAfter) return null;
131
+ const list = property?.value;
132
+ const imported = program.body.find((node) => node.type === 'ImportDeclaration' && node.source.value === specifier)
133
+ ?.specifiers.find((specifierNode) => specifierNode.type === 'ImportDefaultSpecifier')?.local.name;
134
+ const identifier = imported ?? freeName(name, namesIn(program));
135
+ if (list?.elements.some(isCallOf(identifier))) return { source, identifier, isListed: true };
136
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
137
+ const edits = [
138
+ listEdit(source, eol, exported, list, `${identifier}()`),
139
+ ...(imported ? [] : [importEdit(source, eol, program, identifier, specifier)]),
140
+ ];
141
+ // From the end, so each edit lands where the source had its place.
142
+ const changed = edits.sort((a, b) => b.at - a.at).reduce((text, edit) => insert(text, edit.at, edit.text), source);
143
+ return { source: changed, identifier, isListed: false };
54
144
  }
55
145
 
56
- // Writes `key: value` into the project's crossbind.config, and says whether it could.
57
- export function writeAppOption(project, key, value) {
58
- const file = CONFIG_EXTENSIONS.map((extension) => `${project}/crossbind.config.${extension}`).find((candidate) => fs.existsSync(candidate));
146
+ export const configFileOf = (project) => CONFIG_EXTENSIONS.map((extension) => path.join(project, `crossbind.config.${extension}`))
147
+ .find((candidate) => fs.existsSync(candidate));
148
+
149
+ // Writes the extension a module gives into the project's crossbind.config, and says under which name and whether the
150
+ // config listed it already, or null where it could not: a config the change would leave unreadable stays as it is.
151
+ export function writeExtension(project, specifier, name) {
152
+ const file = configFileOf(project);
153
+ if (!file) return null;
154
+ const written = withExtension(fs.readFileSync(file, 'utf8'), specifier, name);
155
+ if (written === null) return null;
156
+ if (!written.isListed) {
157
+ try {
158
+ parseAst(written.source);
159
+ } catch {
160
+ return null;
161
+ }
162
+ fs.writeFileSync(file, written.source);
163
+ }
164
+ return { file, identifier: written.identifier, isListed: written.isListed };
165
+ }
166
+
167
+ // Writes the option a key's path names into the project's crossbind.config, and says whether it could: a config the
168
+ // change would leave unreadable stays as it is.
169
+ export function writeOption(project, keys, value) {
170
+ const file = configFileOf(project);
59
171
  if (!file) return false;
60
- const changed = withAppOption(fs.readFileSync(file, 'utf8'), key, value);
172
+ const changed = withOption(fs.readFileSync(file, 'utf8'), keys, value);
61
173
  if (changed === null) return false;
174
+ try {
175
+ parseAst(changed);
176
+ } catch {
177
+ return false;
178
+ }
62
179
  fs.writeFileSync(file, changed);
63
180
  return true;
64
181
  }
@@ -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
  }
@@ -0,0 +1,172 @@
1
+ // What a publisher, an installer, a package source and a command of an extension's give, as crossbind takes them.
2
+
3
+ // A name prefixes the extension's functions and names its C++ entry, crossbind_extension_<name>.
4
+ export const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
5
+
6
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+
8
+ // The platforms a publisher takes apps of, by the command line's names, and desktop for the three of them.
9
+ export const PUBLISH_PLATFORMS = ['android', 'ios', 'macos', 'linux', 'windows'];
10
+ const DESKTOP_PLATFORMS = ['macos', 'linux', 'windows'];
11
+
12
+ // The platforms a publisher or an installer names, one or a list of those it may, desktop for the three of them.
13
+ function checkedPlatforms(where, platform, allowed) {
14
+ const named = [platform].flat();
15
+ if (named.length === 0 || !named.every((one) => [...allowed, 'desktop'].includes(one))) {
16
+ throw new Error(`crossbind: ${where}.platform is no platform or list of them, of ${[...allowed, 'desktop'].join(', ')}.`);
17
+ }
18
+ return [...new Set(named.flatMap((one) => (one === 'desktop' ? DESKTOP_PLATFORMS : [one])))];
19
+ }
20
+
21
+ // The steps an object gives are functions.
22
+ function assertSteps(where, object, steps) {
23
+ const notSteps = steps.filter((step) => object[step] !== undefined && typeof object[step] !== 'function');
24
+ if (notSteps.length > 0) throw new Error(`crossbind: ${where}'s ${notSteps.join(' and ')} ${notSteps.length > 1 ? 'are' : 'is'} no function.`);
25
+ }
26
+ const PUBLISHER_KEYS = ['platform', 'isDefault', 'store', 'options', 'after', 'build', 'check', 'buildNumber', 'buildEnv', 'upload'];
27
+ const PUBLISHER_STEPS = ['build', 'check', 'buildNumber', 'buildEnv', 'upload'];
28
+ // The steps of a publisher that builds, which one after another publisher leaves to that one.
29
+ const BUILD_STEPS = ['build', 'buildNumber', 'buildEnv'];
30
+ // The options `crossbind app publish` has of its own, with crossbind's, and those `vite build` takes, which the command
31
+ // line hands Vite: no publisher's takes the name of one.
32
+ const COMMAND_OPTIONS = ['platform', 'help', 'version'];
33
+ const VITE_OPTIONS = [
34
+ 'app', 'assetsDir', 'assetsInlineLimit', 'base', 'clearScreen', 'config', 'configLoader', 'debug', 'emptyOutDir', 'filter',
35
+ 'logLevel', 'manifest', 'minify', 'mode', 'outDir', 'profile', 'sourcemap', 'ssr', 'ssrManifest', 'target', 'watch',
36
+ ];
37
+ // An option's name, as JavaScript writes it, whose --kebab-case the command line takes.
38
+ const OPTION_NAME = /^[a-z][a-zA-Z0-9]*$/;
39
+ const OPTION_KEYS = ['help', 'value', 'default'];
40
+
41
+ // An option as the command line takes it, which is a flag or takes a value.
42
+ function checkedOption(where, name, option) {
43
+ const at = `${where}.options.${name}`;
44
+ if (!OPTION_NAME.test(name) || COMMAND_OPTIONS.includes(name)) {
45
+ throw new Error(`crossbind: ${where}.options names ${name}, which is no option of its own: one of letters and digits, starting with a lowercase letter, but ${COMMAND_OPTIONS.slice(0, -1).join(', ')} and ${COMMAND_OPTIONS.at(-1)}.`);
46
+ }
47
+ if (!isObject(option) || typeof option.help !== 'string') throw new Error(`crossbind: ${at} is no option, which is { help } for a flag and { help, value } for one that takes a value.`);
48
+ const unknown = Object.keys(option).find((key) => !OPTION_KEYS.includes(key));
49
+ if (unknown) throw new Error(`crossbind: ${at} takes ${OPTION_KEYS.join(', ')}; ${unknown} is none of them.`);
50
+ if (option.value !== undefined && typeof option.value !== 'string') throw new Error(`crossbind: ${at}.value is no name of the value it takes, as '<track>'.`);
51
+ if (option.default !== undefined && (option.value === undefined || typeof option.default !== 'string')) {
52
+ throw new Error(`crossbind: ${at}.default is no value of an option that takes one: a string, beside its value.`);
53
+ }
54
+ return { help: option.help, value: option.value ?? null, default: option.default ?? null };
55
+ }
56
+
57
+ // A publisher's option, which takes no name of an option of the Vite build its publish runs.
58
+ function checkedPublishOption(where, name, option) {
59
+ if (VITE_OPTIONS.includes(name)) {
60
+ throw new Error(`crossbind: ${where}.options names ${name}, which is an option of the Vite build the publish runs, the command line's --${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}: name it otherwise.`);
61
+ }
62
+ return checkedOption(where, name, option);
63
+ }
64
+
65
+ // A publisher as crossbind runs it: the platforms it takes apps of, whether a publish of those platforms that names no
66
+ // store goes to it, the name its store goes by, the options it takes on the command line, the publisher it comes after, where it does, and its steps, which keep the object as their this.
67
+ export function checkedPublisher(publisher, where) {
68
+ if (!isObject(publisher)) throw new Error(`crossbind: ${where} is no object.`);
69
+ const unknown = Object.keys(publisher).find((key) => !PUBLISHER_KEYS.includes(key));
70
+ if (unknown) throw new Error(`crossbind: ${where} takes ${PUBLISHER_KEYS.join(', ')}; ${unknown} is none of them.`);
71
+ const platforms = checkedPlatforms(where, publisher.platform, PUBLISH_PLATFORMS);
72
+ if (publisher.isDefault !== undefined && typeof publisher.isDefault !== 'boolean') throw new Error(`crossbind: ${where}.isDefault is no boolean.`);
73
+ if (publisher.store !== undefined && typeof publisher.store !== 'string') throw new Error(`crossbind: ${where}.store is no name.`);
74
+ if (publisher.after !== undefined && (typeof publisher.after !== 'string' || !NAME.test(publisher.after))) {
75
+ throw new Error(`crossbind: ${where}.after is no name of a publisher.`);
76
+ }
77
+ assertSteps(where, publisher, PUBLISHER_STEPS);
78
+ if (!publisher.upload) throw new Error(`crossbind: ${where} has no upload, the step that sends the app to its store.`);
79
+ const ownBuild = BUILD_STEPS.filter((step) => publisher[step]);
80
+ if (publisher.after !== undefined && ownBuild.length > 0) {
81
+ throw new Error(`crossbind: ${where} comes after ${publisher.after}, whose build it publishes from, so it takes no ${ownBuild.join(' or ')}.`);
82
+ }
83
+ const options = publisher.options ?? {};
84
+ if (!isObject(options)) throw new Error(`crossbind: ${where}.options is no object of options.`);
85
+ const steps = PUBLISHER_STEPS.filter((step) => publisher[step]).map((step) => [step, publisher[step].bind(publisher)]);
86
+ return {
87
+ platforms,
88
+ ...(publisher.isDefault ? { isDefault: true } : {}),
89
+ store: publisher.store ?? null,
90
+ options: Object.fromEntries(Object.entries(options).map(([name, option]) => [name, checkedPublishOption(where, name, option)])),
91
+ after: publisher.after ?? null,
92
+ ...Object.fromEntries(steps),
93
+ };
94
+ }
95
+
96
+ const INSTALLER_STEPS = ['check', 'make'];
97
+ const INSTALLER_KEYS = ['platform', 'isLast', 'licenses', ...INSTALLER_STEPS];
98
+
99
+ // An installer as crossbind runs it: the desktop platforms whose apps it makes what installs, whether it runs after the
100
+ // others, whatever app.installers names, and its steps, which keep the object as their this.
101
+ export function checkedInstaller(installer, where) {
102
+ if (!isObject(installer)) throw new Error(`crossbind: ${where} is no object.`);
103
+ const unknown = Object.keys(installer).find((key) => !INSTALLER_KEYS.includes(key));
104
+ if (unknown) throw new Error(`crossbind: ${where} takes ${INSTALLER_KEYS.join(', ')}; ${unknown} is none of them.`);
105
+ const platforms = checkedPlatforms(where, installer.platform, DESKTOP_PLATFORMS);
106
+ if (installer.isLast !== undefined && typeof installer.isLast !== 'boolean') throw new Error(`crossbind: ${where}.isLast is no boolean.`);
107
+ assertSteps(where, installer, INSTALLER_STEPS);
108
+ if (!installer.make) throw new Error(`crossbind: ${where} has no make, the step that makes what installs the app.`);
109
+ const steps = INSTALLER_STEPS.filter((step) => installer[step]).map((step) => [step, installer[step].bind(installer)]);
110
+ return { platforms, ...(installer.isLast ? { isLast: true } : {}), ...Object.fromEntries(steps) };
111
+ }
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
+
150
+ const COMMAND_KEYS = ['help', 'options', 'run'];
151
+
152
+ // The commands an extension adds under crossbind app, by their names: each its line of help, the options it takes, as a
153
+ // publisher's, and the step that does its work, which keeps the object as its this.
154
+ export function checkedCommands(commands, where) {
155
+ if (!isObject(commands)) throw new Error(`crossbind: ${where} is no object of commands, as { 'my-key': { help, run } }.`);
156
+ return Object.fromEntries(Object.entries(commands).map(([name, command]) => {
157
+ const at = `${where}.${name}`;
158
+ if (!NAME.test(name)) throw new Error(`crossbind: ${where} names ${name}, which is no command of lowercase letters and digits, which dashes may join.`);
159
+ if (!isObject(command)) throw new Error(`crossbind: ${at} is no command, which is { help, run }.`);
160
+ const unknown = Object.keys(command).find((key) => !COMMAND_KEYS.includes(key));
161
+ if (unknown) throw new Error(`crossbind: ${at} takes ${COMMAND_KEYS.join(', ')}; ${unknown} is none of them.`);
162
+ if (typeof command.help !== 'string' || command.help === '') throw new Error(`crossbind: ${at} has no help, its line in crossbind app --help.`);
163
+ if (typeof command.run !== 'function') throw new Error(`crossbind: ${at} has no run, the step that does its work.`);
164
+ const options = command.options ?? {};
165
+ if (!isObject(options)) throw new Error(`crossbind: ${at}.options is no object of options.`);
166
+ return [name, {
167
+ help: command.help,
168
+ options: Object.fromEntries(Object.entries(options).map(([option, value]) => [option, checkedOption(at, option, value)])),
169
+ run: command.run.bind(command),
170
+ }];
171
+ }));
172
+ }
@@ -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]));
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
- const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', '""'] };
3
+ // Windows opens it without a shell, whose & would end the command inside the address.
4
+ const OPENERS = { darwin: ['open'], win32: ['rundll32', 'url.dll,FileProtocolHandler'] };
4
5
 
5
6
  // Opens an address in the user's browser where the system can; whoever calls it prints the address too.
6
7
  export default function openUrl(url) {