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.
- package/README.md +33 -24
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +69 -25
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +229 -67
- package/src/actions/buildNode.js +6 -1
- package/src/actions/buildWasm.js +0 -9
- package/src/actions/createInterface.js +17 -8
- package/src/actions/createLib.js +5 -7
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/getCmakeParameters.js +2 -3
- package/src/actions/gradleProject.js +199 -16
- package/src/actions/iosDevice.js +200 -0
- package/src/actions/iosSimulator.js +3 -3
- package/src/actions/licenses.js +83 -34
- package/src/actions/linuxPackages.js +48 -0
- package/src/actions/mavenVerification.js +37 -0
- package/src/actions/publish.js +220 -0
- package/src/actions/uploadKey.js +134 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +63 -17
- package/src/actions/xcodeTargets.js +122 -0
- package/src/actions/xcodebuild.js +65 -0
- package/src/app/index.d.ts +126 -133
- package/src/app/index.js +126 -188
- package/src/assets/configure/install.sh +0 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +79 -26
- package/src/index.js +5 -1
- package/src/program.js +35 -4
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- package/src/utils/appApiHeader.js +4 -0
- package/src/utils/appExtensions.js +761 -0
- package/src/utils/appFiles.js +58 -0
- package/src/utils/appLaunches.js +249 -0
- package/src/utils/appOptions.js +144 -95
- package/src/utils/appTargets.js +168 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +181 -0
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +12 -0
- package/src/utils/password.js +34 -0
- package/src/utils/plist.js +5 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/systemConfig.js +19 -0
- package/src/utils/systemKeys.js +16 -0
- package/vitest.config.js +6 -1
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { IOS_DEPLOYMENT_TARGET } from './iosToolchain.js';
|
|
2
|
+
|
|
3
|
+
// Apple's app extensions an iOS app's extensions add, each a target of the app's Xcode project of its own, by their
|
|
4
|
+
// kinds: the extension point each runs at, which an other target names itself, the options it needs or may give, and
|
|
5
|
+
// the frameworks iOS starts it with, which its code may name nothing of, so that the linker would leave them out.
|
|
6
|
+
const KINDS = {
|
|
7
|
+
widget: { point: 'com.apple.widgetkit-extension', needs: [], takes: [] },
|
|
8
|
+
share: { point: 'com.apple.share-services', needs: ['principalClass', 'activation'], takes: [] },
|
|
9
|
+
'notification-service': { point: 'com.apple.usernotifications.service', needs: ['principalClass'], takes: [] },
|
|
10
|
+
'notification-content': {
|
|
11
|
+
point: 'com.apple.usernotifications.content-extension', needs: ['principalClass', 'categories'], takes: [], frameworks: ['UserNotifications', 'UserNotificationsUI'],
|
|
12
|
+
},
|
|
13
|
+
other: { point: null, needs: ['extensionPoint'], takes: ['principalClass'] },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// The frameworks a target links: its kind's, and its own.
|
|
17
|
+
export const targetFrameworks = (target) => [...new Set([...(KINDS[target.kind].frameworks ?? []), ...(target.frameworks ?? [])])];
|
|
18
|
+
|
|
19
|
+
// The widget targets whose Swift names no containerBackground, through which iOS 17 asks a widget's view for its
|
|
20
|
+
// background: a debug build draws "Please adopt containerBackground API" in place of one without it. read(target, file)
|
|
21
|
+
// gives a source's text.
|
|
22
|
+
export const widgetsWithoutBackground = (targets, read) => targets
|
|
23
|
+
.filter((target) => target.kind === 'widget')
|
|
24
|
+
.filter((target) => !target.sources.some((file) => file.endsWith('.swift') && read(target, file).includes('containerBackground')))
|
|
25
|
+
.map((target) => target.name);
|
|
26
|
+
const KIND_KEYS = ['principalClass', 'activation', 'categories', 'extensionPoint'];
|
|
27
|
+
const TARGET_KEYS = ['name', 'kind', 'sources', 'resources', 'displayName', 'plist', 'entitlements', 'frameworks', 'privacy', 'appGroup',
|
|
28
|
+
'deploymentTarget', ...KIND_KEYS, 'attributes'];
|
|
29
|
+
const NEEDED = {
|
|
30
|
+
principalClass: 'the class iOS makes as it starts the target',
|
|
31
|
+
activation: "what of the share sheet's items it takes, as { images: 10 }",
|
|
32
|
+
categories: 'the notification categories it shows',
|
|
33
|
+
extensionPoint: 'the extension point iOS runs it at, as com.apple.keyboard-service',
|
|
34
|
+
};
|
|
35
|
+
// What of the share sheet's items a share extension takes, by the keys of Apple's activation rule: a count of each kind,
|
|
36
|
+
// and text or none.
|
|
37
|
+
const ACTIVATION = {
|
|
38
|
+
attachments: 'NSExtensionActivationSupportsAttachmentsWithMaxCount',
|
|
39
|
+
files: 'NSExtensionActivationSupportsFileWithMaxCount',
|
|
40
|
+
images: 'NSExtensionActivationSupportsImageWithMaxCount',
|
|
41
|
+
movies: 'NSExtensionActivationSupportsMovieWithMaxCount',
|
|
42
|
+
text: 'NSExtensionActivationSupportsText',
|
|
43
|
+
urls: 'NSExtensionActivationSupportsWebURLWithMaxCount',
|
|
44
|
+
webPages: 'NSExtensionActivationSupportsWebPageWithMaxCount',
|
|
45
|
+
};
|
|
46
|
+
// The attributes crossbind writes from a target's options, and the Info.plist keys it writes for every target, the App
|
|
47
|
+
// Group, which the target's code reads, of one that asks for it.
|
|
48
|
+
const WRITTEN_ATTRIBUTES = { NSExtensionActivationRule: 'activation', UNNotificationExtensionCategory: 'categories' };
|
|
49
|
+
const APP_GROUP_KEY = 'CrossbindAppGroup';
|
|
50
|
+
const WRITTEN_KEYS = ['CFBundleDevelopmentRegion', 'CFBundleDisplayName', 'CFBundleExecutable', 'CFBundleIdentifier', 'CFBundleInfoDictionaryVersion',
|
|
51
|
+
'CFBundleName', 'CFBundlePackageType', 'CFBundleShortVersionString', 'CFBundleVersion', 'NSExtension', APP_GROUP_KEY];
|
|
52
|
+
|
|
53
|
+
const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
54
|
+
const CLASS = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
55
|
+
const EXTENSION_POINT = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
|
|
56
|
+
const VERSION = /^\d+\.\d+$/;
|
|
57
|
+
// ExtensionKit's points, whose extensions are of another product, in the app's Extensions, which crossbind makes none of.
|
|
58
|
+
const EXTENSION_KIT_POINTS = ['com.apple.appintents-extension'];
|
|
59
|
+
// What a target compiles apart from its headers, and the sources of Objective-C, beside which a class goes by its own name.
|
|
60
|
+
export const isHeader = (file) => /\.(h|hpp)$/.test(file);
|
|
61
|
+
const isObjectiveC = (file) => /\.(m|mm)$/.test(file);
|
|
62
|
+
const kindOf = (kind) => `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind} target`;
|
|
63
|
+
|
|
64
|
+
const isBelow = (version, floor) => {
|
|
65
|
+
const [major, minor] = version.split('.').map(Number);
|
|
66
|
+
const [floorMajor, floorMinor] = floor.split('.').map(Number);
|
|
67
|
+
return major < floorMajor || (major === floorMajor && minor < floorMinor);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// A target's name and kind, of the keys a target takes alone.
|
|
71
|
+
export function checkedTargetKind(at, target) {
|
|
72
|
+
const unknown = Object.keys(target).find((key) => !TARGET_KEYS.includes(key));
|
|
73
|
+
if (unknown) throw new Error(`crossbind: ${at} takes ${TARGET_KEYS.join(', ')}; ${unknown} is none of them.`);
|
|
74
|
+
if (typeof target.name !== 'string' || !NAME.test(target.name)) {
|
|
75
|
+
throw new Error(`crossbind: ${at}.name ${JSON.stringify(target.name)} is no name of lowercase letters and digits, which dashes may join.`);
|
|
76
|
+
}
|
|
77
|
+
if (!Object.hasOwn(KINDS, target.kind)) throw new Error(`crossbind: ${at}.kind ${JSON.stringify(target.kind)} is none of ${Object.keys(KINDS).join(', ')}.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkedActivation(at, activation) {
|
|
81
|
+
const isTaken = (key, value) => Object.hasOwn(ACTIVATION, key) && (key === 'text' ? value === true : Number.isInteger(value) && value >= 1);
|
|
82
|
+
if (activation === null || typeof activation !== 'object' || Array.isArray(activation) || Object.keys(activation).length === 0
|
|
83
|
+
|| !Object.entries(activation).every(([key, value]) => isTaken(key, value))) {
|
|
84
|
+
throw new Error(`crossbind: ${at}.activation is no object of ${Object.keys(ACTIVATION).join(', ')}, each a count from 1 and text true, as { images: 10, urls: 1 } is.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What a target's kind takes and the rest of its own keys, once its sources and the values every part takes are checked.
|
|
89
|
+
export function checkedTargetOptions(at, target, sources) {
|
|
90
|
+
const { kind } = target;
|
|
91
|
+
for (const key of KIND_KEYS) {
|
|
92
|
+
const isGiven = target[key] !== undefined;
|
|
93
|
+
if (isGiven && !KINDS[kind].needs.includes(key) && !KINDS[kind].takes.includes(key)) throw new Error(`crossbind: ${at}, ${kindOf(kind)}, takes no ${key}.`);
|
|
94
|
+
if (!isGiven && KINDS[kind].needs.includes(key)) throw new Error(`crossbind: ${at}, ${kindOf(kind)}, has no ${key}: ${NEEDED[key]}.`);
|
|
95
|
+
}
|
|
96
|
+
if (kind === 'widget' && !sources.some((file) => file.endsWith('.swift'))) {
|
|
97
|
+
throw new Error(`crossbind: ${at} is a widget, whose SwiftUI needs a Swift source among its sources.`);
|
|
98
|
+
}
|
|
99
|
+
if (target.principalClass !== undefined && (typeof target.principalClass !== 'string' || !CLASS.test(target.principalClass))) {
|
|
100
|
+
throw new Error(`crossbind: ${at}.principalClass ${JSON.stringify(target.principalClass)} is no class name.`);
|
|
101
|
+
}
|
|
102
|
+
if (target.activation !== undefined) checkedActivation(at, target.activation);
|
|
103
|
+
if (target.categories !== undefined
|
|
104
|
+
&& (!Array.isArray(target.categories) || target.categories.length === 0 || !target.categories.every((id) => typeof id === 'string' && id !== ''))) {
|
|
105
|
+
throw new Error(`crossbind: ${at}.categories is no list of the notification categories the target shows.`);
|
|
106
|
+
}
|
|
107
|
+
if (target.extensionPoint !== undefined && (typeof target.extensionPoint !== 'string' || !EXTENSION_POINT.test(target.extensionPoint))) {
|
|
108
|
+
throw new Error(`crossbind: ${at}.extensionPoint ${JSON.stringify(target.extensionPoint)} is no extension point, as com.apple.keyboard-service is.`);
|
|
109
|
+
}
|
|
110
|
+
if (EXTENSION_KIT_POINTS.includes(target.extensionPoint)) {
|
|
111
|
+
throw new Error(`crossbind: ${at}.extensionPoint ${target.extensionPoint} is ExtensionKit's, whose extensions crossbind makes none of yet.`);
|
|
112
|
+
}
|
|
113
|
+
// The attributes of an option its kind takes, which an other target gives itself.
|
|
114
|
+
const written = Object.keys(target.attributes ?? {}).find((key) => Object.hasOwn(WRITTEN_ATTRIBUTES, key) && KINDS[kind].needs.includes(WRITTEN_ATTRIBUTES[key]));
|
|
115
|
+
if (written) throw new Error(`crossbind: ${at}.attributes gives ${written}, which crossbind writes from the target's ${WRITTEN_ATTRIBUTES[written]}.`);
|
|
116
|
+
const own = Object.keys(target.plist ?? {}).find((key) => WRITTEN_KEYS.includes(key));
|
|
117
|
+
if (own) throw new Error(`crossbind: ${at}.plist gives ${own}, which crossbind writes for each target.`);
|
|
118
|
+
const files = ['Info.plist', ...(target.privacy ? ['PrivacyInfo.xcprivacy'] : [])];
|
|
119
|
+
const resource = (target.resources ?? []).find((file) => files.includes(file.split('/').at(-1)));
|
|
120
|
+
if (resource) throw new Error(`crossbind: ${at}.resources names ${resource}, a file crossbind writes into the target's bundle.`);
|
|
121
|
+
const version = target.deploymentTarget;
|
|
122
|
+
if (version !== undefined && (typeof version !== 'string' || !VERSION.test(version))) {
|
|
123
|
+
throw new Error(`crossbind: ${at}.deploymentTarget ${JSON.stringify(version)} is no iOS version, as '17.0' is.`);
|
|
124
|
+
}
|
|
125
|
+
if (version !== undefined && isBelow(version, IOS_DEPLOYMENT_TARGET)) {
|
|
126
|
+
throw new Error(`crossbind: ${at}.deploymentTarget ${version} is below the app's iOS ${IOS_DEPLOYMENT_TARGET}, which each of its targets runs on as well.`);
|
|
127
|
+
}
|
|
128
|
+
if (target.appGroup !== undefined && typeof target.appGroup !== 'boolean') throw new Error(`crossbind: ${at}.appGroup is no boolean.`);
|
|
129
|
+
if (target.displayName !== undefined && (typeof target.displayName !== 'string' || target.displayName.trim() === '')) {
|
|
130
|
+
throw new Error(`crossbind: ${at}.displayName is no name the system shows.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The Swift module a target's sources make, whose name takes no dash.
|
|
135
|
+
export const targetModule = (name) => name.replace(/-/g, '_');
|
|
136
|
+
|
|
137
|
+
// A target's Info.plist, as crossbind writes it, with the target's own keys: its bundle, by Xcode's settings, its
|
|
138
|
+
// extension point, principal class and attributes, and the App Group it asks for. The principal class of a target
|
|
139
|
+
// without Objective-C is Swift's, in its module, as Xcode's templates name it; one beside Objective-C goes by the name its
|
|
140
|
+
// runtime knows.
|
|
141
|
+
export function targetPlist(target, label, appGroup) {
|
|
142
|
+
const hasObjectiveC = target.sources.some(isObjectiveC);
|
|
143
|
+
const attributes = {
|
|
144
|
+
...(target.kind === 'notification-content' ? { UNNotificationExtensionInitialContentSizeRatio: 1 } : {}),
|
|
145
|
+
...target.attributes,
|
|
146
|
+
...(target.activation ? { NSExtensionActivationRule: Object.fromEntries(Object.entries(target.activation).map(([key, value]) => [ACTIVATION[key], value])) } : {}),
|
|
147
|
+
...(target.categories ? { UNNotificationExtensionCategory: target.categories.length === 1 ? target.categories[0] : target.categories } : {}),
|
|
148
|
+
};
|
|
149
|
+
const principal = target.principalClass && (hasObjectiveC ? target.principalClass : `$(PRODUCT_MODULE_NAME).${target.principalClass}`);
|
|
150
|
+
return {
|
|
151
|
+
CFBundleDevelopmentRegion: 'en',
|
|
152
|
+
CFBundleDisplayName: target.displayName ?? label,
|
|
153
|
+
CFBundleExecutable: '$(EXECUTABLE_NAME)',
|
|
154
|
+
CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
|
|
155
|
+
CFBundleInfoDictionaryVersion: '6.0',
|
|
156
|
+
CFBundleName: '$(PRODUCT_NAME)',
|
|
157
|
+
CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
|
|
158
|
+
CFBundleShortVersionString: '$(MARKETING_VERSION)',
|
|
159
|
+
CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
|
|
160
|
+
NSExtension: {
|
|
161
|
+
NSExtensionPointIdentifier: target.extensionPoint ?? KINDS[target.kind].point,
|
|
162
|
+
...(principal ? { NSExtensionPrincipalClass: principal } : {}),
|
|
163
|
+
...(Object.keys(attributes).length > 0 ? { NSExtensionAttributes: attributes } : {}),
|
|
164
|
+
},
|
|
165
|
+
...(target.appGroup ? { [APP_GROUP_KEY]: appGroup } : {}),
|
|
166
|
+
...target.plist,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/utils/ask.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
|
|
4
|
+
// Whether someone is at a terminal to answer a question, as no one is in CI or behind a pipe.
|
|
5
|
+
export const hasTerminal = () => Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
6
|
+
|
|
7
|
+
function ask(question, { input, output, isHidden }) {
|
|
8
|
+
output.write(question);
|
|
9
|
+
// A hidden answer is echoed into nothing, while the terminal, in raw mode, shows none of it either.
|
|
10
|
+
const echo = isHidden ? new Writable({ write: (chunk, encoding, done) => done() }) : output;
|
|
11
|
+
const reader = readline.createInterface({ input, output: echo, terminal: isHidden });
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let isAnswered = false;
|
|
14
|
+
reader.once('line', (line) => {
|
|
15
|
+
isAnswered = true;
|
|
16
|
+
reader.close();
|
|
17
|
+
if (isHidden) output.write('\n');
|
|
18
|
+
resolve(line);
|
|
19
|
+
});
|
|
20
|
+
reader.once('SIGINT', () => {
|
|
21
|
+
reader.close();
|
|
22
|
+
output.write('\n');
|
|
23
|
+
reject(new Error('crossbind: stopped at the question.'));
|
|
24
|
+
});
|
|
25
|
+
reader.once('close', () => {
|
|
26
|
+
if (!isAnswered) reject(new Error('crossbind: the terminal closed before an answer.'));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// An answer typed on the terminal that the terminal does not show, as a password.
|
|
32
|
+
export const askHidden = (question, { input = process.stdin, output = process.stderr } = {}) => ask(question, { input, output, isHidden: true });
|
|
33
|
+
|
|
34
|
+
// An answer typed on the terminal.
|
|
35
|
+
export const askLine = (question, { input = process.stdin, output = process.stderr } = {}) => ask(question, { input, output, isHidden: false });
|
|
36
|
+
|
|
37
|
+
// A yes or no typed on the terminal: yes unless it starts with n.
|
|
38
|
+
export const askYesNo = async (question, streams) => !/^\s*n/i.test(await askLine(question, streams));
|
|
@@ -0,0 +1,66 @@
|
|
|
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) {
|
|
19
|
+
if (error?.code === 'MODULE_NOT_FOUND') {
|
|
20
|
+
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
|
+
}
|
|
22
|
+
if (error?.code === 'ENOENT') {
|
|
23
|
+
return `crossbind: ${PACKAGE} has not been built in this checkout: pnpm --filter ${PACKAGE} build (needs Docker)`;
|
|
24
|
+
}
|
|
25
|
+
return `crossbind: ${PACKAGE} does not load: ${error?.message}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let loaded;
|
|
29
|
+
export function loadBridgeTools() {
|
|
30
|
+
if (!loaded) {
|
|
31
|
+
try {
|
|
32
|
+
loaded = require(PACKAGE);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(missingToolsMessage(error), { cause: error });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return loaded;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// As run() fails for a command that exits with an error: the output shown, the exit code in the cause.
|
|
41
|
+
export function runSwig(args, cwd, tools = loadBridgeTools()) {
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
result = tools.swig(args, { cwd });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.output) console.error(error.output);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (result.status === 0) return;
|
|
50
|
+
if (result.output) console.error(result.output);
|
|
51
|
+
throw new Error(`crossbind: command failed (swig) with exit code ${result.status}`, { cause: { status: result.status } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const clangEnvironment = (env = process.env) => Object.fromEntries(CLANG_ENVIRONMENT.filter((key) => env[key] !== undefined).map((key) => [key, env[key]]));
|
|
55
|
+
|
|
56
|
+
export function runPreprocessor(args, cwd, tools = loadBridgeTools(), exec = execFileSync) {
|
|
57
|
+
try {
|
|
58
|
+
exec(DARWIN_CC, [...tools.preprocessorArgs, ...args], {
|
|
59
|
+
cwd, env: clangEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: OUTPUT_LIMIT,
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.stdout?.length) console.log(error.stdout.toString());
|
|
63
|
+
if (error?.stderr?.length) console.error(error.stderr.toString());
|
|
64
|
+
throw new Error(`crossbind: command failed (clang) with exit code ${error?.status ?? 'unknown'}`, { cause: error });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseAst } from 'rollup/parseAst';
|
|
4
|
+
|
|
5
|
+
// The kinds of crossbind.config whose default export can be an object literal to write into.
|
|
6
|
+
const CONFIG_EXTENSIONS = ['js', 'mjs', 'cjs'];
|
|
7
|
+
const DEFAULT_INDENT = ' ';
|
|
8
|
+
|
|
9
|
+
const indentOf = (source, at) => source.slice(source.lastIndexOf('\n', at - 1) + 1).match(/^[ \t]*/)[0];
|
|
10
|
+
const isOneLine = (source, from, to) => !source.slice(from, to).includes('\n');
|
|
11
|
+
const insert = (source, at, text) => `${source.slice(0, at)}${text}${source.slice(at)}`;
|
|
12
|
+
const propertyOf = (object, name) => object.properties.find((property) => property.type === 'Property' && !property.computed
|
|
13
|
+
&& (property.key.name ?? property.key.value) === name);
|
|
14
|
+
// A spread may carry the key, and would win over one written before it.
|
|
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));
|
|
25
|
+
// A value as one writes it in a config by hand: { fileTimestamp: ['C617.1'] }.
|
|
26
|
+
function literal(value) {
|
|
27
|
+
if (typeof value === 'string') return quoted(value);
|
|
28
|
+
if (Array.isArray(value)) return `[${value.map(literal).join(', ')}]`;
|
|
29
|
+
if (value !== null && typeof value === 'object') return `{ ${Object.entries(value).map(([key, item]) => `${keyOf(key)}: ${literal(item)}`).join(', ')} }`;
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
export const entry = (key, value) => `${keyOf(key)}: ${literal(value)}`;
|
|
33
|
+
|
|
34
|
+
// How far an object's keys sit in from the line it opens on.
|
|
35
|
+
function indentUnit(source, object) {
|
|
36
|
+
const [first] = object.properties;
|
|
37
|
+
if (!first || isOneLine(source, object.start, first.start)) return DEFAULT_INDENT;
|
|
38
|
+
return indentOf(source, first.start).slice(indentOf(source, object.start).length) || DEFAULT_INDENT;
|
|
39
|
+
}
|
|
40
|
+
|
|
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) {
|
|
70
|
+
const exported = parseAst(source).body.find((node) => node.type === 'ExportDefaultDeclaration')?.declaration;
|
|
71
|
+
if (exported?.type !== 'ObjectExpression') return null;
|
|
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;
|
|
94
|
+
}
|
|
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}],` };
|
|
104
|
+
}
|
|
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 };
|
|
144
|
+
}
|
|
145
|
+
|
|
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);
|
|
171
|
+
if (!file) return false;
|
|
172
|
+
const changed = withOption(fs.readFileSync(file, 'utf8'), keys, value);
|
|
173
|
+
if (changed === null) return false;
|
|
174
|
+
try {
|
|
175
|
+
parseAst(changed);
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
fs.writeFileSync(file, changed);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// What a publisher, an installer 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 COMMAND_KEYS = ['help', 'options', 'run'];
|
|
114
|
+
|
|
115
|
+
// The commands an extension adds under crossbind app, by their names: each its line of help, the options it takes, as a
|
|
116
|
+
// publisher's, and the step that does its work, which keeps the object as its this.
|
|
117
|
+
export function checkedCommands(commands, where) {
|
|
118
|
+
if (!isObject(commands)) throw new Error(`crossbind: ${where} is no object of commands, as { 'my-key': { help, run } }.`);
|
|
119
|
+
return Object.fromEntries(Object.entries(commands).map(([name, command]) => {
|
|
120
|
+
const at = `${where}.${name}`;
|
|
121
|
+
if (!NAME.test(name)) throw new Error(`crossbind: ${where} names ${name}, which is no command of lowercase letters and digits, which dashes may join.`);
|
|
122
|
+
if (!isObject(command)) throw new Error(`crossbind: ${at} is no command, which is { help, run }.`);
|
|
123
|
+
const unknown = Object.keys(command).find((key) => !COMMAND_KEYS.includes(key));
|
|
124
|
+
if (unknown) throw new Error(`crossbind: ${at} takes ${COMMAND_KEYS.join(', ')}; ${unknown} is none of them.`);
|
|
125
|
+
if (typeof command.help !== 'string' || command.help === '') throw new Error(`crossbind: ${at} has no help, its line in crossbind app --help.`);
|
|
126
|
+
if (typeof command.run !== 'function') throw new Error(`crossbind: ${at} has no run, the step that does its work.`);
|
|
127
|
+
const options = command.options ?? {};
|
|
128
|
+
if (!isObject(options)) throw new Error(`crossbind: ${at}.options is no object of options.`);
|
|
129
|
+
return [name, {
|
|
130
|
+
help: command.help,
|
|
131
|
+
options: Object.fromEntries(Object.entries(options).map(([option, value]) => [option, checkedOption(at, option, value)])),
|
|
132
|
+
run: command.run.bind(command),
|
|
133
|
+
}];
|
|
134
|
+
}));
|
|
135
|
+
}
|