crossbind 2.0.0-beta.68 → 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 +1 -1
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +26 -17
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/buildApp.js +172 -74
- 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 +0 -4
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/gradleProject.js +196 -16
- package/src/actions/iosDevice.js +2 -2
- 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/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +49 -24
- 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 +20 -28
- package/src/index.js +2 -1
- package/src/program.js +32 -3
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- 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 +109 -100
- package/src/utils/appTargets.js +168 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +143 -26
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/openUrl.js +2 -1
- package/src/utils/plist.js +5 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/systemConfig.js +16 -3
- package/vitest.config.js +6 -1
- package/src/actions/appSetup.js +0 -116
- package/src/actions/appStoreConnect.js +0 -188
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
- package/src/actions/googlePlay.js +0 -213
- package/src/actions/googleSignIn.js +0 -78
- package/src/actions/playSetup.js +0 -19
- package/src/actions/publishAndroid.js +0 -102
- package/src/actions/publishIos.js +0 -146
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { carriedPath, isOutside } from './appFiles.js';
|
|
5
|
+
import {
|
|
6
|
+
NAME, checkedCommands, checkedInstaller, checkedPublisher,
|
|
7
|
+
} from './extensionSteps.js';
|
|
8
|
+
import { checkedActivityTypes, checkedFileTypes, checkedLinks, checkedShortcutItems } from './appLaunches.js';
|
|
9
|
+
import { checkedPrivacy, checkedUrlSchemes } from './appOptions.js';
|
|
10
|
+
import { checkedTargetKind, checkedTargetOptions, isHeader } from './appTargets.js';
|
|
11
|
+
import { isUnderBase } from './replaceBasePathForDocker.js';
|
|
12
|
+
|
|
13
|
+
// What an app extension compiles with on every platform, and on each: its sources, the folders of its own their
|
|
14
|
+
// includes are found in, and the macros they are compiled with.
|
|
15
|
+
const COMPILED = ['sources', 'includes', 'defines'];
|
|
16
|
+
// What an app extension may add on each platform, by the names crossbind's app commands give them, beside what it
|
|
17
|
+
// compiles with and the files it carries: the frameworks an Apple host links, the libraries another links, an Apple
|
|
18
|
+
// app's entitlements and Info.plist keys, an iOS app's reasons for Apple's required reason API and the targets of Apple's
|
|
19
|
+
// app extensions it adds, and of an Android app the folder of its Java with the class in it the app calls, the
|
|
20
|
+
// permissions it asks for, a manifest of its own and the resources the app takes.
|
|
21
|
+
const PLATFORM_PARTS = {
|
|
22
|
+
macos: [...COMPILED, 'files', 'frameworks', 'entitlements', 'plist'],
|
|
23
|
+
ios: [...COMPILED, 'files', 'frameworks', 'entitlements', 'plist', 'privacy', 'targets'],
|
|
24
|
+
linux: [...COMPILED, 'files', 'libraries', 'packages', 'depends'],
|
|
25
|
+
windows: [...COMPILED, 'files', 'libraries'],
|
|
26
|
+
android: [...COMPILED, 'files', 'libraries', 'java', 'class', 'permissions', 'manifest', 'resources', 'maven', 'verification'],
|
|
27
|
+
};
|
|
28
|
+
// An Android permission, as its manifest names it.
|
|
29
|
+
const PERMISSION = /^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)+$/;
|
|
30
|
+
// The part of an extension a build's target takes, whose platform goes by Node's name.
|
|
31
|
+
const partOf = (platform) => ({ darwin: 'macos', win32: 'windows' })[platform] ?? platform;
|
|
32
|
+
// The host compiles C and C++ on every platform, and Objective-C, Objective-C++ and Swift on Apple's.
|
|
33
|
+
const SOURCE = /\.(c|cpp|cc|cxx)$/;
|
|
34
|
+
const APPLE_SOURCE = /\.(c|cpp|cc|cxx|m|mm|swift)$/;
|
|
35
|
+
// An iOS app extension's sources, which Xcode compiles, with the headers they import from beside them.
|
|
36
|
+
const TARGET_SOURCE = /\.(c|cpp|cc|cxx|m|mm|swift|h|hpp)$/;
|
|
37
|
+
const isSwift = (file) => file.endsWith('.swift');
|
|
38
|
+
// A macro, NAME or NAME=value, of a value a compiler's command line takes as it is.
|
|
39
|
+
const DEFINE = /^[A-Za-z_][A-Za-z0-9_]*(=[A-Za-z0-9_.+-]*)?$/;
|
|
40
|
+
const LINK_NAME = /^[A-Za-z0-9][A-Za-z0-9_.+-]*$/;
|
|
41
|
+
// A class in a package, which the activity's package can name.
|
|
42
|
+
const JAVA_CLASS = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/;
|
|
43
|
+
|
|
44
|
+
export { PUBLISH_PLATFORMS, checkedInstaller, checkedPublisher } from './extensionSteps.js';
|
|
45
|
+
|
|
46
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
47
|
+
const isEntitlement = (value) => typeof value === 'boolean' || typeof value === 'string'
|
|
48
|
+
|| (Array.isArray(value) && value.every((item) => typeof item === 'string'));
|
|
49
|
+
// The deepest the host reads a JSON value nested, MAX_DEPTH in its extension.cpp.
|
|
50
|
+
const MAX_JSON_DEPTH = 64;
|
|
51
|
+
// A value JSON writes as it is, which the extension's native code reads back the same: no deeper than the host reads,
|
|
52
|
+
// its strings and keys without a lone surrogate, which the host would read as U+FFFD.
|
|
53
|
+
export const isJsonValue = (value, depth = 0) => depth <= MAX_JSON_DEPTH && (value === null || typeof value === 'boolean'
|
|
54
|
+
|| (typeof value === 'string' && value.isWellFormed())
|
|
55
|
+
|| (typeof value === 'number' && Number.isFinite(value))
|
|
56
|
+
|| (Array.isArray(value) && value.every((item) => isJsonValue(item, depth + 1)))
|
|
57
|
+
|| (isObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
58
|
+
&& Object.entries(value).every(([key, item]) => key.isWellFormed() && isJsonValue(item, depth + 1))));
|
|
59
|
+
|
|
60
|
+
// The full path of a path of the extension's own, inside its folder.
|
|
61
|
+
function insideOwn(where, root, file) {
|
|
62
|
+
const full = path.resolve(root, String(file));
|
|
63
|
+
const relative = path.relative(root, full);
|
|
64
|
+
if (typeof file !== 'string' || path.isAbsolute(file) || isOutside(relative) || relative === '') {
|
|
65
|
+
throw new Error(`crossbind: ${where} names ${JSON.stringify(file)}, which is no path inside the extension's folder.`);
|
|
66
|
+
}
|
|
67
|
+
return full;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A file or folder of the extension's own, inside its folder.
|
|
71
|
+
function ownPath(where, root, file, isFolder = false) {
|
|
72
|
+
const full = insideOwn(where, root, file);
|
|
73
|
+
if (!fs.existsSync(full) || fs.statSync(full).isDirectory() !== isFolder) {
|
|
74
|
+
throw new Error(`crossbind: ${where} names ${file}, which is no ${isFolder ? 'folder' : 'file'} of the extension's.`);
|
|
75
|
+
}
|
|
76
|
+
return file;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The files and folders an extension carries, as app.files and a platform's files list them, inside its folder.
|
|
80
|
+
function checkedFiles(where, root, files) {
|
|
81
|
+
if (!Array.isArray(files)) throw new Error(`crossbind: ${where} is no list of the files and folders the extension carries.`);
|
|
82
|
+
return files.map((file) => carriedPath(where, root, file, "extension's"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function checkedSources(where, root, sources, pattern) {
|
|
86
|
+
if (!Array.isArray(sources)) throw new Error(`crossbind: ${where} is no list of source files.`);
|
|
87
|
+
return sources.map((file) => {
|
|
88
|
+
const kinds = { [SOURCE]: 'C or C++ source', [APPLE_SOURCE]: 'C, C++, Objective-C, Objective-C++ or Swift source' }[pattern] ?? 'C, C++, Objective-C, Objective-C++ or Swift source, nor a header';
|
|
89
|
+
if (!pattern.test(String(file))) throw new Error(`crossbind: ${where} names ${file}, which is no ${kinds}.`);
|
|
90
|
+
return ownPath(where, root, file);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The extension's folders, its own folder as '.' among them.
|
|
95
|
+
function checkedIncludes(where, root, includes) {
|
|
96
|
+
if (!Array.isArray(includes)) throw new Error(`crossbind: ${where} is no list of the extension's folders.`);
|
|
97
|
+
return includes.map((folder) => (folder === '.' ? folder : ownPath(where, root, folder, true)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkedDefines(where, defines) {
|
|
101
|
+
if (!Array.isArray(defines) || !defines.every((define) => typeof define === 'string' && DEFINE.test(define))) {
|
|
102
|
+
throw new Error(`crossbind: ${where} is no list of macros, each NAME or NAME=value, a value of letters, digits and _.+- alone.`);
|
|
103
|
+
}
|
|
104
|
+
return defines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What an extension compiles with, on every platform or on one.
|
|
108
|
+
function checkedCompiled(at, root, key, value, pattern) {
|
|
109
|
+
if (key === 'sources') return checkedSources(at, root, value, pattern);
|
|
110
|
+
if (key === 'includes') return checkedIncludes(at, root, value);
|
|
111
|
+
return checkedDefines(at, value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// An Ubuntu 22.04 package a Linux extension compiles and links against beside crossbind's sysroot, as the sysroot's own
|
|
115
|
+
// list names one: its Debian architecture, its file, which Launchpad serves, and the SHA-256 the Packages index gives.
|
|
116
|
+
const PACKAGE = /^(amd64|arm64) ([a-z0-9][a-z0-9+.-]*_[A-Za-z0-9.+~-]+_(amd64|arm64|all)\.deb) ([0-9a-f]{64})$/;
|
|
117
|
+
// A relation of the app's Debian package, as its Depends takes one: a package, maybe of some versions, or alternatives.
|
|
118
|
+
const RELATION = /^[a-z0-9][a-z0-9+.-]+( \((<<|<=|=|>=|>>) [A-Za-z0-9.+~:-]+\))?$/;
|
|
119
|
+
|
|
120
|
+
function checkedPackages(where, packages) {
|
|
121
|
+
if (!Array.isArray(packages)) throw new Error(`crossbind: ${where} is no list of packages.`);
|
|
122
|
+
return packages.map((line) => {
|
|
123
|
+
const match = typeof line === 'string' ? line.match(PACKAGE) : null;
|
|
124
|
+
if (!match || !['all', match[1]].includes(match[3])) {
|
|
125
|
+
throw new Error(`crossbind: ${where} names ${JSON.stringify(line)}, which is no "<arch> <file> <sha256>" of an Ubuntu 22.04 package of amd64 or arm64, as "amd64 libzstd-dev_1.4.8+dfsg-3build1_amd64.deb 9712…".`);
|
|
126
|
+
}
|
|
127
|
+
return line;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A library of Maven's an Android extension's Java uses, as Gradle names one, of one version: no range, which a later
|
|
132
|
+
// release would change under the checksums the extension lists.
|
|
133
|
+
const MAVEN = /^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[A-Za-z0-9][A-Za-z0-9_.-]*(@[a-z]+)?$/;
|
|
134
|
+
|
|
135
|
+
function checkedMaven(where, maven) {
|
|
136
|
+
if (!Array.isArray(maven) || !maven.every((coordinate) => typeof coordinate === 'string' && MAVEN.test(coordinate))) {
|
|
137
|
+
throw new Error(`crossbind: ${where} is no list of Maven libraries of one version each, as ['javax.inject:javax.inject:1'].`);
|
|
138
|
+
}
|
|
139
|
+
return maven;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkedDepends(where, depends) {
|
|
143
|
+
if (!Array.isArray(depends) || !depends.every((relation) => typeof relation === 'string' && relation.split(' | ').every((one) => RELATION.test(one)))) {
|
|
144
|
+
throw new Error(`crossbind: ${where} is no list of the Debian packages the app needs, as ['libzstd1', 'libsecret-1-0 (>= 0.20.5)'].`);
|
|
145
|
+
}
|
|
146
|
+
return depends;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkedNames(where, names) {
|
|
150
|
+
if (!Array.isArray(names) || !names.every((name) => typeof name === 'string' && LINK_NAME.test(name))) {
|
|
151
|
+
throw new Error(`crossbind: ${where} is no list of names.`);
|
|
152
|
+
}
|
|
153
|
+
return names;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The entitlements crossbind joins the app's own with, which are lists.
|
|
157
|
+
const LISTED_ENTITLEMENTS = ['com.apple.security.application-groups', 'com.apple.developer.associated-domains'];
|
|
158
|
+
|
|
159
|
+
function checkedEntitlements(where, entitlements) {
|
|
160
|
+
if (!isObject(entitlements) || !Object.values(entitlements).every(isEntitlement)) {
|
|
161
|
+
throw new Error(`crossbind: ${where} is no object of entitlements, each a boolean, a string or a list of strings.`);
|
|
162
|
+
}
|
|
163
|
+
const single = LISTED_ENTITLEMENTS.find((key) => Object.hasOwn(entitlements, key) && !Array.isArray(entitlements[key]));
|
|
164
|
+
if (single) throw new Error(`crossbind: ${where}.${single} is no list, which crossbind joins with the app's.`);
|
|
165
|
+
return entitlements;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Text an XML property list holds: well formed, without the control characters XML takes none of.
|
|
169
|
+
// eslint-disable-next-line no-control-regex
|
|
170
|
+
const isPlistText = (text) => text.isWellFormed() && !/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text);
|
|
171
|
+
// A value an Info.plist holds: text, a number, a boolean, and lists and dictionaries of them.
|
|
172
|
+
const isPlistValue = (value, depth = 0) => depth <= MAX_JSON_DEPTH && (typeof value === 'boolean'
|
|
173
|
+
|| (typeof value === 'string' && isPlistText(value))
|
|
174
|
+
|| (typeof value === 'number' && Number.isFinite(value))
|
|
175
|
+
|| (Array.isArray(value) && value.every((item) => isPlistValue(item, depth + 1)))
|
|
176
|
+
|| (isObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
177
|
+
&& Object.entries(value).every(([key, item]) => key !== '' && isPlistText(key) && isPlistValue(item, depth + 1))));
|
|
178
|
+
|
|
179
|
+
function checkedPlist(where, plist) {
|
|
180
|
+
if (!isObject(plist) || !isPlistValue(plist)) {
|
|
181
|
+
throw new Error(`crossbind: ${where} is no object of Info.plist keys, each with text, a number, a boolean, or a list or an object of them.`);
|
|
182
|
+
}
|
|
183
|
+
return plist;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkedPermissions(where, permissions) {
|
|
187
|
+
if (!Array.isArray(permissions) || !permissions.every((permission) => typeof permission === 'string' && PERMISSION.test(permission))) {
|
|
188
|
+
throw new Error(`crossbind: ${where} is no list of Android permissions, each by its full name, as android.permission.READ_CONTACTS.`);
|
|
189
|
+
}
|
|
190
|
+
return permissions;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const LICENSE_KEYS = ['name', 'version', 'license', 'file', 'source', 'sha256', 'notes'];
|
|
194
|
+
|
|
195
|
+
// The code of others an extension carries, which the app's license report lists: its name and version, its license as
|
|
196
|
+
// an SPDX expression, the file of its license's text in the extension's folder, and where its source comes from.
|
|
197
|
+
function checkedLicenses(where, root, licenses) {
|
|
198
|
+
if (!Array.isArray(licenses)) throw new Error(`crossbind: ${where} is no list of the code of others the extension carries.`);
|
|
199
|
+
return licenses.map((entry, index) => {
|
|
200
|
+
const at = `${where}[${index}]`;
|
|
201
|
+
if (!isObject(entry) || !['name', 'version', 'license'].every((key) => typeof entry[key] === 'string' && entry[key] !== '')) {
|
|
202
|
+
throw new Error(`crossbind: ${at} is no code the extension carries: { name, version, license, file }, with its license as an SPDX expression and its source and sha256 where it has them.`);
|
|
203
|
+
}
|
|
204
|
+
const unknown = Object.keys(entry).find((key) => !LICENSE_KEYS.includes(key));
|
|
205
|
+
if (unknown) throw new Error(`crossbind: ${at} takes ${LICENSE_KEYS.join(', ')}; ${unknown} is none of them.`);
|
|
206
|
+
if (entry.source !== undefined && typeof entry.source !== 'string') throw new Error(`crossbind: ${at}.source is no address of its source.`);
|
|
207
|
+
if (entry.notes !== undefined && typeof entry.notes !== 'string') throw new Error(`crossbind: ${at}.notes is no text.`);
|
|
208
|
+
if (entry.sha256 !== undefined && !/^[0-9a-f]{64}$/.test(entry.sha256)) throw new Error(`crossbind: ${at}.sha256 is no SHA-256, in 64 lowercase hex digits.`);
|
|
209
|
+
return { ...entry, file: ownPath(`${at}.file`, root, entry.file) };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The app reaches an extension's Java through one class, whose source, in Java or Kotlin, the folder of its Java holds by
|
|
214
|
+
// its package.
|
|
215
|
+
function checkedJava(where, root, { java, class: name }) {
|
|
216
|
+
if (java === undefined || name === undefined) {
|
|
217
|
+
throw new Error(`crossbind: ${where} takes java and class together: the folder of its Java and the Extension class in it the app calls.`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof name !== 'string' || !JAVA_CLASS.test(name)) throw new Error(`crossbind: ${where}.class names ${name}, which is no Java class in a package.`);
|
|
220
|
+
const source = path.join(java, ...name.split('.'));
|
|
221
|
+
if (!['.java', '.kt'].some((ending) => fs.existsSync(path.join(root, `${source}${ending}`)))) {
|
|
222
|
+
throw new Error(`crossbind: ${where}.class names ${name}, which has no source ${source}.java or ${source}.kt.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The targets of Apple's app extensions an extension adds to an iOS app, each compiling its own sources alone, with its
|
|
227
|
+
// resources, Info.plist keys and attributes, entitlements, frameworks and reasons for Apple's required reason API.
|
|
228
|
+
function checkedTargets(where, root, targets) {
|
|
229
|
+
if (!Array.isArray(targets)) throw new Error(`crossbind: ${where} is no list of targets, as [{ name: 'clock', kind: 'widget', sources: ['ios/Clock.swift'] }] is.`);
|
|
230
|
+
const names = new Set();
|
|
231
|
+
return targets.map((target, index) => {
|
|
232
|
+
const at = `${where}[${index}]`;
|
|
233
|
+
if (!isObject(target)) throw new Error(`crossbind: ${at} is no object of a target.`);
|
|
234
|
+
checkedTargetKind(at, target);
|
|
235
|
+
if (names.has(target.name)) throw new Error(`crossbind: ${where} names two targets ${target.name}.`);
|
|
236
|
+
names.add(target.name);
|
|
237
|
+
const sources = Array.isArray(target.sources) ? checkedSources(`${at}.sources`, root, target.sources, TARGET_SOURCE) : [];
|
|
238
|
+
if (!sources.some((file) => !isHeader(file))) throw new Error(`crossbind: ${at} has no sources, which a target compiles alone.`);
|
|
239
|
+
const given = (key, check) => (target[key] === undefined ? {} : { [key]: check(`${at}.${key}`, target[key]) });
|
|
240
|
+
const checked = {
|
|
241
|
+
...target,
|
|
242
|
+
sources,
|
|
243
|
+
// With slashes, as the Xcode project takes them, on Windows too.
|
|
244
|
+
...given('resources', (atKey, value) => checkedFiles(atKey, root, value).map((file) => file.split(path.sep).join('/'))),
|
|
245
|
+
...given('plist', checkedPlist),
|
|
246
|
+
...given('attributes', checkedPlist),
|
|
247
|
+
...given('entitlements', checkedEntitlements),
|
|
248
|
+
...given('frameworks', checkedNames),
|
|
249
|
+
...given('privacy', (atKey, value) => checkedPrivacy(value, atKey)),
|
|
250
|
+
};
|
|
251
|
+
checkedTargetOptions(at, checked, sources);
|
|
252
|
+
return checked;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function checkedPlatform(where, root, platform, part) {
|
|
257
|
+
if (!isObject(part)) throw new Error(`crossbind: ${where} is no object.`);
|
|
258
|
+
const allowed = PLATFORM_PARTS[platform];
|
|
259
|
+
const checked = Object.fromEntries(Object.entries(part).map(([key, value]) => {
|
|
260
|
+
const at = `${where}.${key}`;
|
|
261
|
+
if (!allowed.includes(key)) throw new Error(`crossbind: ${where} takes ${allowed.join(', ')}; ${key} is none of them.`);
|
|
262
|
+
if (COMPILED.includes(key)) return [key, checkedCompiled(at, root, key, value, ['macos', 'ios'].includes(platform) ? APPLE_SOURCE : SOURCE)];
|
|
263
|
+
if (key === 'files') return [key, checkedFiles(at, root, value)];
|
|
264
|
+
if (key === 'java' || key === 'resources') return [key, ownPath(at, root, value, true)];
|
|
265
|
+
if (key === 'manifest') return [key, ownPath(at, root, value)];
|
|
266
|
+
if (key === 'class') return [key, value];
|
|
267
|
+
if (key === 'entitlements') return [key, checkedEntitlements(at, value)];
|
|
268
|
+
if (key === 'plist') return [key, checkedPlist(at, value)];
|
|
269
|
+
if (key === 'privacy') return [key, checkedPrivacy(value, at)];
|
|
270
|
+
if (key === 'targets') return [key, checkedTargets(at, root, value)];
|
|
271
|
+
if (key === 'permissions') return [key, checkedPermissions(at, value)];
|
|
272
|
+
if (key === 'packages') return [key, checkedPackages(at, value)];
|
|
273
|
+
if (key === 'depends') return [key, checkedDepends(at, value)];
|
|
274
|
+
if (key === 'maven') return [key, checkedMaven(at, value)];
|
|
275
|
+
if (key === 'verification') return [key, ownPath(at, root, value)];
|
|
276
|
+
return [key, checkedNames(at, value)];
|
|
277
|
+
}));
|
|
278
|
+
if ('java' in checked || 'class' in checked) checkedJava(where, root, checked);
|
|
279
|
+
if (('maven' in checked) !== ('verification' in checked)) {
|
|
280
|
+
throw new Error(`crossbind: ${where} takes maven and verification together: the libraries Gradle resolves for it, and the checksums of each file of theirs, as Gradle's verification metadata lists them.`);
|
|
281
|
+
}
|
|
282
|
+
return checked;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// An option's path in the app's config, as a dotted name.
|
|
286
|
+
const OPTION_PATH = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
287
|
+
|
|
288
|
+
// A list of names, each once, none empty.
|
|
289
|
+
function checkedOwnNames(where, names, pattern) {
|
|
290
|
+
if (!Array.isArray(names) || names.length === 0 || !names.every((name) => typeof name === 'string' && pattern.test(name)) || new Set(names).size !== names.length) {
|
|
291
|
+
throw new Error(`crossbind: ${where} ${JSON.stringify(names)} is no list of names, each once.`);
|
|
292
|
+
}
|
|
293
|
+
return names;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// What an extension of crossbind's own says of itself beside what it adds: the roles of the host's it holds, where they
|
|
297
|
+
// are more than its name, and the app's options it reads, each by its path in app, which app.without would leave to no one.
|
|
298
|
+
function checkedOwnKeys({ holds, reads }, where) {
|
|
299
|
+
return {
|
|
300
|
+
...(holds === undefined ? {} : { holds: checkedOwnNames(`${where}'s holds`, holds, NAME) }),
|
|
301
|
+
...(reads === undefined ? {} : { reads: checkedOwnNames(`${where}'s reads`, reads, OPTION_PATH) }),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// An extension as crossbind takes it, its paths relative to root: its name, in app the sources every platform compiles,
|
|
306
|
+
// the schemes whose links its native code gets and what each platform adds, the options its native code reads or the
|
|
307
|
+
// fromApp that takes them from the app's config, and its publisher and its installer, where it has them.
|
|
308
|
+
export function checkedExtension(extension, root, where) {
|
|
309
|
+
if (!isObject(extension) || typeof extension.name !== 'string' || !NAME.test(extension.name)) {
|
|
310
|
+
throw new Error(`crossbind: ${where} has no name of lowercase letters and digits, which dashes may join.`);
|
|
311
|
+
}
|
|
312
|
+
const app = extension.app ?? {};
|
|
313
|
+
if (!isObject(app)) throw new Error(`crossbind: ${where}'s app is no object.`);
|
|
314
|
+
const checked = Object.fromEntries(Object.entries(app).map(([key, value]) => {
|
|
315
|
+
const at = `${where}'s app.${key}`;
|
|
316
|
+
if (COMPILED.includes(key)) return [key, checkedCompiled(at, root, key, value, SOURCE)];
|
|
317
|
+
if (key === 'files') return [key, checkedFiles(at, root, value)];
|
|
318
|
+
if (key === 'urlSchemes') return [key, checkedUrlSchemes(value, at)];
|
|
319
|
+
if (key === 'fileTypes') return [key, checkedFileTypes(value, at)];
|
|
320
|
+
if (key === 'links') return [key, checkedLinks(value, at)];
|
|
321
|
+
if (key === 'activityTypes') return [key, checkedActivityTypes(value, at)];
|
|
322
|
+
if (key === 'shortcutItems') return [key, checkedShortcutItems(value, at)];
|
|
323
|
+
if (key === 'licenses') return [key, checkedLicenses(at, root, value)];
|
|
324
|
+
if (!Object.hasOwn(PLATFORM_PARTS, key)) {
|
|
325
|
+
throw new Error(`crossbind: ${where}'s app takes ${COMPILED.join(', ')}, files, urlSchemes, fileTypes, links, activityTypes, shortcutItems, licenses and ${Object.keys(PLATFORM_PARTS).join(', ')}; ${key} is none of them.`);
|
|
326
|
+
}
|
|
327
|
+
return [key, checkedPlatform(at, root, key, value)];
|
|
328
|
+
}));
|
|
329
|
+
if (extension.fromApp !== undefined && typeof extension.fromApp !== 'function') throw new Error(`crossbind: ${where}'s fromApp is no function.`);
|
|
330
|
+
if (extension.fromApp !== undefined && extension.options !== undefined) throw new Error(`crossbind: ${where} gives options and a fromApp, which gives them too: one of them alone.`);
|
|
331
|
+
const installerAt = `${where}'s installer`;
|
|
332
|
+
const roles = {
|
|
333
|
+
...(extension.publisher === undefined ? {} : { publisher: checkedPublisher(extension.publisher, `${where}'s publisher`) }),
|
|
334
|
+
...(extension.installer === undefined ? {} : {
|
|
335
|
+
installer: {
|
|
336
|
+
...checkedInstaller(extension.installer, installerAt),
|
|
337
|
+
...(extension.installer.licenses === undefined ? {} : { licenses: checkedLicenses(`${installerAt}.licenses`, root, extension.installer.licenses) }),
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
...(extension.commands === undefined ? {} : { commands: checkedCommands(extension.commands, `${where}'s commands`) }),
|
|
341
|
+
...(extension.fromApp === undefined ? {} : { fromApp: extension.fromApp.bind(extension) }),
|
|
342
|
+
...checkedOwnKeys(extension, where),
|
|
343
|
+
};
|
|
344
|
+
if (extension.options === undefined) return { name: extension.name, root, app: checked, ...roles };
|
|
345
|
+
if (!isJsonValue(extension.options)) {
|
|
346
|
+
throw new Error(`crossbind: ${where}'s options are no JSON value the host reads as it is: plain objects, arrays, strings, finite numbers, booleans and null alone, 64 levels deep at most, without a lone surrogate.`);
|
|
347
|
+
}
|
|
348
|
+
return { name: extension.name, root, app: checked, options: extension.options, ...roles };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The versions of the contract between crossbind and its extensions this crossbind takes, the oldest to the newest: an
|
|
352
|
+
// extension written for any of them builds. A minor version that adds to the contract raises the newest, and a major
|
|
353
|
+
// version that breaks it the oldest.
|
|
354
|
+
export const OLDEST_EXTENSION_CONTRACT = 1;
|
|
355
|
+
export const EXTENSION_CONTRACT = 1;
|
|
356
|
+
|
|
357
|
+
// 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) {
|
|
360
|
+
if (contract === undefined) {
|
|
361
|
+
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
|
+
}
|
|
363
|
+
const versions = Array.isArray(contract) ? contract : [contract];
|
|
364
|
+
if (versions.length === 0 || versions.some((version) => !Number.isInteger(version) || version < 1) || new Set(versions).size !== versions.length) {
|
|
365
|
+
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
|
+
}
|
|
367
|
+
if (versions.some((version) => version >= OLDEST_EXTENSION_CONTRACT && version <= EXTENSION_CONTRACT)) return;
|
|
368
|
+
const named = versions.join(' and ');
|
|
369
|
+
if (versions.every((version) => version > EXTENSION_CONTRACT)) {
|
|
370
|
+
throw new Error(`crossbind: ${where} is written for crossbind's extension contract ${named}, newer than this crossbind's ${EXTENSION_CONTRACT}: update crossbind.`);
|
|
371
|
+
}
|
|
372
|
+
const taken = OLDEST_EXTENSION_CONTRACT === EXTENSION_CONTRACT ? `${EXTENSION_CONTRACT}` : `${OLDEST_EXTENSION_CONTRACT} to ${EXTENSION_CONTRACT}`;
|
|
373
|
+
throw new Error(`crossbind: ${where} is written for crossbind's extension contract ${named}, older than this crossbind's ${taken}: ${update}.`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The names crossbind keeps for itself, which no extension crossbind.config.js lists takes: those of the app's own
|
|
377
|
+
// functions and events, and every name of a page's request.
|
|
378
|
+
const KEPT_NAMES = new Set(['app', 'events', 'windows', 'crossbind']);
|
|
379
|
+
const isKeptName = (name) => KEPT_NAMES.has(name) || name.startsWith('page-');
|
|
380
|
+
|
|
381
|
+
// The roles of the host's a listed extension takes, each once: one, or a list of them.
|
|
382
|
+
function checkedReplaces(replaces, where) {
|
|
383
|
+
const roles = Array.isArray(replaces) ? replaces : [replaces];
|
|
384
|
+
if (roles.length === 0 || roles.some((role) => typeof role !== 'string') || new Set(roles).size !== roles.length) {
|
|
385
|
+
throw new Error(`crossbind: ${where}'s replaces is no role of the host's, or list of them.`);
|
|
386
|
+
}
|
|
387
|
+
return roles;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The extension package a listed extension's folder is in, under the project's base, which its package.json names as
|
|
391
|
+
// one: its folder and its manifest.
|
|
392
|
+
export function extensionPackage(root, base) {
|
|
393
|
+
for (let dir = root; isUnderBase(dir, base); dir = path.dirname(dir)) {
|
|
394
|
+
const file = path.join(dir, 'package.json');
|
|
395
|
+
if (fs.existsSync(file)) {
|
|
396
|
+
try {
|
|
397
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
398
|
+
return typeof manifest.crossbind?.extension === 'string' ? { dir, manifest } : null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (path.dirname(dir) === dir) return null;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// What an extension of crossbind's build once hooked into, which no build runs.
|
|
409
|
+
const OLD_HOOKS = ['loadConfig', 'buildWasm', 'createLib'];
|
|
410
|
+
const LISTED_KEYS = ['name', 'contract', 'root', 'app', 'options', 'fromApp', 'publisher', 'installer', 'commands', 'replaces'];
|
|
411
|
+
|
|
412
|
+
// An extension crossbind.config.js lists, as its package's crossbind.extension.js gives it: its name, the folder its
|
|
413
|
+
// files are in, which a build sees under the project's base alone, and what it adds to an app.
|
|
414
|
+
export function listedExtension(extension, where, base) {
|
|
415
|
+
if (!isObject(extension)) throw new Error(`crossbind: ${where} is no extension, as an extension package's crossbind.extension.js gives one.`);
|
|
416
|
+
const hooks = Object.keys(extension).filter((key) => OLD_HOOKS.includes(key));
|
|
417
|
+
if (hooks.length > 0) {
|
|
418
|
+
throw new Error(`crossbind: ${where} has the hooks ${hooks.join(', ')}, which crossbind no longer runs: an extension adds native code to an app. Set build flags in targetSpecs or crossbind.build.js.`);
|
|
419
|
+
}
|
|
420
|
+
const { root } = extension;
|
|
421
|
+
const isFolder = typeof root === 'string' && path.isAbsolute(root) && fs.existsSync(root) && fs.statSync(root).isDirectory();
|
|
422
|
+
// An extension of another contract may hold what this crossbind knows nothing of, so the contract comes first.
|
|
423
|
+
const owner = isFolder && isUnderBase(root, base) ? extensionPackage(root, base)?.manifest.name : null;
|
|
424
|
+
checkedContract(extension, where, `update ${owner ?? 'it'} to a version written for this crossbind`);
|
|
425
|
+
const unknown = Object.keys(extension).find((key) => !LISTED_KEYS.includes(key));
|
|
426
|
+
if (unknown) throw new Error(`crossbind: ${where} takes ${LISTED_KEYS.join(', ')}; ${unknown} is none of them.`);
|
|
427
|
+
if (!isFolder) throw new Error(`crossbind: ${where} has no root, the absolute path of the folder its files are in.`);
|
|
428
|
+
if (!isUnderBase(root, base)) throw new Error(`crossbind: ${where}'s root ${root} is outside ${base}, the project's base and all a build sees.`);
|
|
429
|
+
const checked = checkedExtension(extension, root, where);
|
|
430
|
+
if (isKeptName(checked.name)) throw new Error(`crossbind: ${where} is named ${checked.name}, which crossbind keeps for itself: name it as its package, as acme-${checked.name}.`);
|
|
431
|
+
return extension.replaces === undefined ? checked : { ...checked, replaces: checkedReplaces(extension.replaces, where) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const OWN_PACKAGE = /^@crossbind\/extension-(.+)$/;
|
|
435
|
+
|
|
436
|
+
// The names of the extension packages among the host's dependencies.
|
|
437
|
+
export const ownPackages = (appHostRoot) => Object.keys(JSON.parse(fs.readFileSync(path.join(appHostRoot, 'package.json'), 'utf8')).dependencies ?? {})
|
|
438
|
+
.filter((name) => OWN_PACKAGE.test(name)).sort();
|
|
439
|
+
|
|
440
|
+
// crossbind's own extensions: the extension packages among the host's dependencies, which an app has by having the host,
|
|
441
|
+
// each named as its package. crossbind finds them as it finds the embind runtime, through the host, as pnpm shows them to
|
|
442
|
+
// it alone.
|
|
443
|
+
export function ownExtensions(appHostRoot) {
|
|
444
|
+
const load = createRequire(path.join(appHostRoot, 'package.json'));
|
|
445
|
+
const packages = ownPackages(appHostRoot);
|
|
446
|
+
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
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Whether an option of the app's config asks for anything, as false, null and an empty list or object do not.
|
|
464
|
+
const asksForAnything = (value) => value !== undefined && value !== null && value !== false
|
|
465
|
+
&& (typeof value !== 'object' || Object.keys(value).length > 0);
|
|
466
|
+
|
|
467
|
+
// crossbind's own extensions but those app.without names: its own alone, each once, none whose option in the app's
|
|
468
|
+
// config would then reach no one.
|
|
469
|
+
export function withoutNamed(own, app = {}) {
|
|
470
|
+
const { without = [] } = app;
|
|
471
|
+
const names = own.map(({ name }) => name);
|
|
472
|
+
if (!Array.isArray(without) || !without.every((name) => typeof name === 'string') || new Set(without).size !== without.length) {
|
|
473
|
+
throw new Error(`crossbind: app.without ${JSON.stringify(without)} is no list of crossbind's own extensions, each once: ${names.join(', ')}.`);
|
|
474
|
+
}
|
|
475
|
+
const unknown = without.find((name) => !names.includes(name));
|
|
476
|
+
if (unknown) throw new Error(`crossbind: app.without names ${unknown}, which is none of crossbind's own extensions: ${names.join(', ')}.`);
|
|
477
|
+
const left = own.filter(({ name }) => without.includes(name));
|
|
478
|
+
for (const { name, reads = [] } of left) {
|
|
479
|
+
const read = reads.find((option) => asksForAnything(option.split('.').reduce((value, key) => value?.[key], app)));
|
|
480
|
+
if (read) throw new Error(`crossbind: app.without leaves out ${name}, which app.${read} in crossbind.config.js needs: remove one of them.`);
|
|
481
|
+
}
|
|
482
|
+
return own.filter((extension) => !left.includes(extension));
|
|
483
|
+
}
|
|
484
|
+
|
|
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 }));
|
|
488
|
+
|
|
489
|
+
// A step of an extension's publisher or installer, whose failure stops the run.
|
|
490
|
+
export async function extensionStep(name, which, run) {
|
|
491
|
+
try {
|
|
492
|
+
return await run();
|
|
493
|
+
} catch (error) {
|
|
494
|
+
throw stepFailure(name, which, error);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// What an extension's fromApp gives: the options of its native code, or null, which leaves it out of the app.
|
|
499
|
+
function fromAppOf({ name, fromApp }, api) {
|
|
500
|
+
let options;
|
|
501
|
+
try {
|
|
502
|
+
options = fromApp(api);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw stepFailure(name, 'fromApp', error);
|
|
505
|
+
}
|
|
506
|
+
if (!isJsonValue(options)) throw new Error(`crossbind: ${name}'s fromApp gives no JSON value the host reads as it is, or null, which leaves it out of the app.`);
|
|
507
|
+
return options;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The extensions of an app, each that gives a fromApp with the options it takes from the app's config, or left out where
|
|
511
|
+
// it gives null.
|
|
512
|
+
export const withFromApp = (extensions, apiOf) => extensions.flatMap((extension) => {
|
|
513
|
+
if (!extension.fromApp) return [extension];
|
|
514
|
+
const options = fromAppOf(extension, apiOf(extension.name));
|
|
515
|
+
return options === null ? [] : [{ ...extension, options }];
|
|
516
|
+
});
|
|
517
|
+
|
|
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.
|
|
520
|
+
export const appExtensionsOf = (listed) => listed
|
|
521
|
+
.filter((extension) => (!extension.publisher && !extension.installer) || Object.keys(extension.app).length > 0);
|
|
522
|
+
|
|
523
|
+
// The host's own extensions an app has, then those crossbind.config.js lists, each with the roles it holds, its own name
|
|
524
|
+
// where it says nothing else: a role a listed one replaces leaves the host's that held it, which the app leaves out once
|
|
525
|
+
// it holds none, and two that replace one stop the build, as one no extension of known holds. A listed one of the name of
|
|
526
|
+
// a host's own takes its place and its roles, saying so, as a later crossbind may add one of a name an app has already.
|
|
527
|
+
export function withRoles(own, listed, warn = () => {}, known = own) {
|
|
528
|
+
const holdersOf = new Map(known.flatMap(({ name, holds }) => (holds ?? [name]).map((role) => [role, name])));
|
|
529
|
+
const taken = new Map();
|
|
530
|
+
for (const extension of listed) {
|
|
531
|
+
const holder = holdersOf.get(extension.name);
|
|
532
|
+
if (holder !== undefined && holder !== extension.name) {
|
|
533
|
+
throw new Error(`crossbind: the extension ${extension.name} is named as the host's role ${extension.name}, which its ${holder} holds: name it as its package, as acme-${extension.name}, and give replaces: '${extension.name}' to take the host's place.`);
|
|
534
|
+
}
|
|
535
|
+
for (const role of extension.replaces ?? []) {
|
|
536
|
+
if (!holdersOf.has(role)) throw new Error(`crossbind: the extension ${extension.name} replaces ${role}, which is no role of the host's: it takes ${[...holdersOf.keys()].sort().join(', ')}.`);
|
|
537
|
+
if (taken.has(role)) throw new Error(`crossbind: the extensions ${taken.get(role)} and ${extension.name} both replace the host's ${role}, which one alone takes.`);
|
|
538
|
+
taken.set(role, extension.name);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const kept = own.flatMap((extension) => {
|
|
542
|
+
if (listed.some(({ name }) => name === extension.name)) {
|
|
543
|
+
warn(`the extension ${extension.name} that crossbind.config.js lists takes the place of the host's own ${extension.name}: name it as its package, as acme-${extension.name}, to keep both`);
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
const held = extension.holds ?? [extension.name];
|
|
547
|
+
const roles = held.filter((role) => !taken.has(role));
|
|
548
|
+
return roles.length > 0 ? [{ ...extension, roles, given: held.filter((role) => taken.has(role)) }] : [];
|
|
549
|
+
});
|
|
550
|
+
return [...kept, ...listed.map((extension) => {
|
|
551
|
+
const named = known.find(({ name }) => name === extension.name);
|
|
552
|
+
const held = named ? named.holds ?? [named.name] : [];
|
|
553
|
+
const isAnothers = (role) => taken.has(role) && taken.get(role) !== extension.name;
|
|
554
|
+
return { ...extension, roles: [...new Set([...(extension.replaces ?? []), ...held.filter((role) => !isAnothers(role))])], given: held.filter(isAnothers) };
|
|
555
|
+
})];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// crossbind's own publishers or installers, then those crossbind.config.js lists: one listed under the name of one of
|
|
559
|
+
// crossbind's takes its place.
|
|
560
|
+
export function withListed(own, listed) {
|
|
561
|
+
const kept = own.map((extension) => listed.find(({ name }) => name === extension.name) ?? extension);
|
|
562
|
+
return [...kept, ...listed.filter((extension) => !kept.includes(extension))];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// What two owners give one key: one value alike, or lists, which join; two that give it different values stop the
|
|
566
|
+
// build, naming both.
|
|
567
|
+
function joined(what, key, first, owner, value) {
|
|
568
|
+
if (Array.isArray(first.value) && Array.isArray(value)) {
|
|
569
|
+
return [...first.value, ...value.filter((item) => !first.value.some((had) => JSON.stringify(had) === JSON.stringify(item)))];
|
|
570
|
+
}
|
|
571
|
+
if (JSON.stringify(first.value) !== JSON.stringify(value)) throw new Error(`crossbind: ${first.owner} and ${owner} give the ${what} ${key} different values.`);
|
|
572
|
+
return first.value;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The keys of several owners, in the order they are claimed.
|
|
576
|
+
function mergedKeys(what, owned) {
|
|
577
|
+
const merged = new Map();
|
|
578
|
+
for (const [owner, keys] of owned) {
|
|
579
|
+
for (const [key, value] of Object.entries(keys)) {
|
|
580
|
+
const first = merged.get(key);
|
|
581
|
+
merged.set(key, first ? { owner: first.owner, value: joined(what, key, first, owner, value) } : { owner, value });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return Object.fromEntries([...merged].map(([key, { value }]) => [key, value]));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// The entitlements of several owners, as one claims each.
|
|
588
|
+
export const mergedEntitlements = (owned) => mergedKeys('entitlement', owned);
|
|
589
|
+
|
|
590
|
+
// An Apple app's Info.plist, crossbind's, with the keys its extensions give: one crossbind writes from the app's config
|
|
591
|
+
// an extension gives with the same value alone.
|
|
592
|
+
export function mergedPlist(base, owned) {
|
|
593
|
+
for (const [owner, plist] of owned) {
|
|
594
|
+
const written = Object.keys(plist).find((key) => Object.hasOwn(base, key) && JSON.stringify(base[key]) !== JSON.stringify(plist[key]));
|
|
595
|
+
if (written) throw new Error(`crossbind: ${owner} gives the Info.plist key ${written}, which crossbind writes from the app's config.`);
|
|
596
|
+
}
|
|
597
|
+
const added = owned.map(([owner, plist]) => [owner, Object.fromEntries(Object.entries(plist).filter(([key]) => !Object.hasOwn(base, key)))]);
|
|
598
|
+
return { ...base, ...mergedKeys('Info.plist key', added) };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// An iOS app's reasons for Apple's required reason API: app.privacy's, with those its extensions give.
|
|
602
|
+
export function mergedPrivacy(base, owned) {
|
|
603
|
+
const merged = Object.fromEntries(Object.entries(base).map(([category, reasons]) => [category, [...reasons]]));
|
|
604
|
+
owned.forEach(([, privacy]) => Object.entries(privacy).forEach(([category, reasons]) => {
|
|
605
|
+
merged[category] = [...new Set([...(merged[category] ?? []), ...reasons])];
|
|
606
|
+
}));
|
|
607
|
+
return merged;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const unique = (items) => [...new Set(items)];
|
|
611
|
+
|
|
612
|
+
// The Debian packages a Linux app needs beside WebKitGTK and GTK, as its extensions' Linux parts list them, each once.
|
|
613
|
+
export const linuxDepends = (extensions) => unique(extensions.flatMap((extension) => extension.app?.linux?.depends ?? []));
|
|
614
|
+
|
|
615
|
+
// What the extensions add on one platform: the names and folders of those with native code, the sources the host
|
|
616
|
+
// compiles, by their full paths, the frameworks and the libraries it links, each once, the Java folders of an Android
|
|
617
|
+
// app with the class of each, and the entitlements of an Apple one by their owners, for mergedEntitlements().
|
|
618
|
+
export function extensionPart(extensions, platform) {
|
|
619
|
+
const names = new Set();
|
|
620
|
+
for (const { name } of extensions) {
|
|
621
|
+
if (names.has(name)) throw new Error(`crossbind: the app has two extensions named ${name}.`);
|
|
622
|
+
names.add(name);
|
|
623
|
+
}
|
|
624
|
+
const own = (extension) => extension.app[partOf(platform)] ?? {};
|
|
625
|
+
// What every platform's and this one's part give of a kind, every platform's first.
|
|
626
|
+
const both = (extension, key) => [...(extension.app[key] ?? []), ...(own(extension)[key] ?? [])];
|
|
627
|
+
const sourcesOf = (extension) => both(extension, 'sources').map((file) => path.join(extension.root, file));
|
|
628
|
+
// What each extension that gives it gives of a kind, by its owner, for the merges.
|
|
629
|
+
const owned = (key) => extensions.filter((extension) => own(extension)[key]).map((extension) => [`the extension ${extension.name}`, own(extension)[key]]);
|
|
630
|
+
const native = extensions.filter((extension) => sourcesOf(extension).length > 0);
|
|
631
|
+
// The targets of Apple's app extensions an iOS app's extensions add, by their owners, no two of one name.
|
|
632
|
+
const owners = new Map();
|
|
633
|
+
const targets = extensions.flatMap((extension) => (own(extension).targets ?? []).map((target) => {
|
|
634
|
+
if (owners.has(target.name)) throw new Error(`crossbind: the extensions ${owners.get(target.name)} and ${extension.name} both name an iOS target ${target.name}.`);
|
|
635
|
+
owners.set(target.name, extension.name);
|
|
636
|
+
return { ...target, owner: extension.name, root: extension.root };
|
|
637
|
+
}));
|
|
638
|
+
return {
|
|
639
|
+
native: native.map(({ name, options, roles, given }) => ({ name, options: options ?? null, roles: roles ?? [], given: given ?? [] })),
|
|
640
|
+
roots: native.map(({ root }) => root),
|
|
641
|
+
sources: extensions.flatMap(sourcesOf),
|
|
642
|
+
// An Apple app's Swift compiles apart, before the host, whose own sources hold the extension's entry.
|
|
643
|
+
compiled: native.map((extension) => {
|
|
644
|
+
const sources = sourcesOf(extension).filter((file) => !isSwift(file));
|
|
645
|
+
if (sources.length === 0) {
|
|
646
|
+
throw new Error(`crossbind: the extension ${extension.name} has Swift alone on ${partOf(platform)}, whose entry, crossbind_extension_${extension.name.replace(/-/g, '_')}, a C++ or Objective-C++ source of its own defines.`);
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
name: extension.name,
|
|
650
|
+
sources,
|
|
651
|
+
swift: sourcesOf(extension).filter(isSwift),
|
|
652
|
+
// A Linux part's packages, which a sysroot of the extension's own holds for its sources.
|
|
653
|
+
packages: own(extension).packages ?? [],
|
|
654
|
+
includes: both(extension, 'includes').map((folder) => path.join(extension.root, folder)),
|
|
655
|
+
defines: both(extension, 'defines'),
|
|
656
|
+
};
|
|
657
|
+
}),
|
|
658
|
+
frameworks: unique(extensions.flatMap((extension) => own(extension).frameworks ?? [])),
|
|
659
|
+
libraries: unique(extensions.flatMap((extension) => own(extension).libraries ?? [])),
|
|
660
|
+
java: extensions.filter((extension) => own(extension).java).map((extension) => ({
|
|
661
|
+
name: extension.name, dir: path.join(extension.root, own(extension).java), className: own(extension).class, roles: extension.roles ?? [],
|
|
662
|
+
})),
|
|
663
|
+
// An Android app's libraries of Maven's, by the extension that uses them, with its verification metadata.
|
|
664
|
+
maven: extensions.filter((extension) => own(extension).maven).map((extension) => ({
|
|
665
|
+
name: extension.name, coordinates: own(extension).maven, verification: path.join(extension.root, own(extension).verification),
|
|
666
|
+
})),
|
|
667
|
+
entitlements: owned('entitlements'),
|
|
668
|
+
plist: owned('plist'),
|
|
669
|
+
privacy: owned('privacy'),
|
|
670
|
+
permissions: unique(extensions.flatMap((extension) => own(extension).permissions ?? [])),
|
|
671
|
+
manifests: extensions.filter((extension) => own(extension).manifest)
|
|
672
|
+
.map((extension) => ({ name: extension.name, file: path.join(extension.root, own(extension).manifest) })),
|
|
673
|
+
resources: extensions.filter((extension) => own(extension).resources)
|
|
674
|
+
.map((extension) => ({ name: extension.name, dir: path.join(extension.root, own(extension).resources) })),
|
|
675
|
+
// The files each carries, which the app keeps in ext/<name>/.
|
|
676
|
+
files: extensions.filter((extension) => both(extension, 'files').length > 0)
|
|
677
|
+
.map((extension) => ({ name: extension.name, root: extension.root, paths: both(extension, 'files') })),
|
|
678
|
+
targets,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const entryOf = (name) => `crossbind_extension_${name.replace(/-/g, '_')}`;
|
|
683
|
+
|
|
684
|
+
// What the host's CMake project gets of the extensions it compiles, each on its own: their names, and each one's
|
|
685
|
+
// sources, folders of includes and macros, as the cache's lists.
|
|
686
|
+
export function extensionCmakeArgs(compiled) {
|
|
687
|
+
const variable = (name) => `CROSSBIND_EXTENSION_${name.replace(/-/g, '_')}`;
|
|
688
|
+
return [
|
|
689
|
+
`-DCROSSBIND_EXTENSIONS=${compiled.map(({ name }) => name.replace(/-/g, '_')).join(';')}`,
|
|
690
|
+
...compiled.flatMap(({ name, sources, includes, defines }) => [
|
|
691
|
+
`-D${variable(name)}_SOURCES=${sources.join(';')}`,
|
|
692
|
+
`-D${variable(name)}_INCLUDES=${includes.join(';')}`,
|
|
693
|
+
`-D${variable(name)}_DEFINES=${defines.join(';')}`,
|
|
694
|
+
]),
|
|
695
|
+
];
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The C++ text of an extension's options, a raw string of their JSON, which the host reads back. A raw string's
|
|
699
|
+
// delimiter takes 16 characters at most.
|
|
700
|
+
function optionsText(name, options) {
|
|
701
|
+
const json = JSON.stringify(options);
|
|
702
|
+
if (json.includes(')crossbind_json"')) {
|
|
703
|
+
throw new Error(`crossbind: the extension ${name}'s options hold )crossbind_json", which ends the text the host reads them from.`);
|
|
704
|
+
}
|
|
705
|
+
return `R"crossbind_json(${json})crossbind_json"`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// The C++ that starts the extensions with native code, each through its entry, which its sources define, with its
|
|
709
|
+
// options and the roles of the host's it holds, and that names the owner of each of the app's URL schemes, of the
|
|
710
|
+
// extensions of the files it opens, of its sites' links, of the types of the user activities it continues and of its
|
|
711
|
+
// icon's quick actions: the extension that declared it, or for the app's own the holder of the links', files',
|
|
712
|
+
// activities' or shortcuts' role, which hands them to the page. An owner has native code to get what it owns.
|
|
713
|
+
export function registrationSource(native, urlSchemes, fileTypes = [], links = [], activityTypes = [], shortcutItems = []) {
|
|
714
|
+
const holderOf = (role) => native.find(({ roles = [] }) => roles.includes(role))?.name ?? role;
|
|
715
|
+
const schemes = urlSchemes.map(({ scheme, extension }) => ({ key: scheme, owner: extension ?? holderOf('links'), what: `the links of ${scheme}` }));
|
|
716
|
+
const files = fileTypes.flatMap(({ extensions, extension }) => extensions.map((ending) => ({ key: ending, owner: extension ?? holderOf('files'), what: `the files .${ending}` })));
|
|
717
|
+
// The MIME type an Android file comes with names its owner before its name does, where one owner's types alone list it.
|
|
718
|
+
const typed = fileTypes.flatMap(({ mimeTypes = [], extension }) => mimeTypes.map((mime) => ({ key: mime, owner: extension ?? holderOf('files') })));
|
|
719
|
+
const mimes = typed.filter(({ key, owner }, index) => typed.findIndex((other) => other.key === key) === index
|
|
720
|
+
&& typed.every((other) => other.key !== key || other.owner === owner));
|
|
721
|
+
const sites = links.map(({ link, extension }) => ({ key: link, owner: extension ?? holderOf('links'), what: `the links of ${link}` }));
|
|
722
|
+
const activities = activityTypes.map(({ type, extension }) => ({ key: type, owner: extension ?? holderOf('activities'), what: `the user activities ${type}` }));
|
|
723
|
+
const shortcuts = shortcutItems.map(({ type, extension }) => ({ key: type, owner: extension ?? holderOf('shortcuts'), what: `the quick action ${type}` }));
|
|
724
|
+
const unheard = [...schemes, ...files, ...sites, ...activities, ...shortcuts].find(({ owner }) => !native.some(({ name }) => name === owner));
|
|
725
|
+
if (unheard) throw new Error(`crossbind: the extension ${unheard.owner} gets ${unheard.what}, but has no native code on this platform.`);
|
|
726
|
+
const list = (roles = []) => `{${roles.map((role) => `"${role}"`).join(', ')}}`;
|
|
727
|
+
const start = ({ name, options, roles, given }) => ` startExtension("${name}", ${options === null ? 'nullptr' : optionsText(name, options)}, ${entryOf(name)}, ${list(roles)}, ${list(given)});`;
|
|
728
|
+
const owners = (fn, owned) => [
|
|
729
|
+
'',
|
|
730
|
+
`std::vector<std::pair<std::string, std::string>> ${fn}() {`,
|
|
731
|
+
` return {${owned.map(({ key, owner }) => `{"${key}", "${owner}"}`).join(', ')}};`,
|
|
732
|
+
'}',
|
|
733
|
+
];
|
|
734
|
+
return [
|
|
735
|
+
"// crossbind writes this file from the app's extensions: the entry of each one with native code, which starts it with",
|
|
736
|
+
"// its options, the roles of the host's it holds and those it gave an app's extension, and the owner of each of the",
|
|
737
|
+
"// app's URL schemes, file types, their MIME types, links, user activity types and quick actions, the extension that",
|
|
738
|
+
'// gets them.',
|
|
739
|
+
'#include "crossbind/extension.h"',
|
|
740
|
+
'',
|
|
741
|
+
...native.map(({ name }) => `void ${entryOf(name)}(crossbind::Registry ®istry);`),
|
|
742
|
+
...(native.length > 0 ? [''] : []),
|
|
743
|
+
'namespace crossbind::app {',
|
|
744
|
+
'',
|
|
745
|
+
'void startExtension(const char *name, const char *options, void (*entry)(Registry &), std::vector<std::string> roles,',
|
|
746
|
+
' std::vector<std::string> given);',
|
|
747
|
+
'',
|
|
748
|
+
'void extensionEntries() {',
|
|
749
|
+
...native.map(start),
|
|
750
|
+
'}',
|
|
751
|
+
...owners('urlSchemes', schemes),
|
|
752
|
+
...owners('fileTypes', files),
|
|
753
|
+
...owners('fileMimeTypes', mimes),
|
|
754
|
+
...owners('appLinks', sites),
|
|
755
|
+
...owners('userActivityTypes', activities),
|
|
756
|
+
...owners('shortcutItemTypes', shortcuts),
|
|
757
|
+
'',
|
|
758
|
+
'} // namespace crossbind::app',
|
|
759
|
+
'',
|
|
760
|
+
].join('\n');
|
|
761
|
+
}
|