crossbind 2.0.0-beta.65 → 2.0.0-beta.67
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/package.json +6 -1
- package/src/actions/androidDevice.js +1 -1
- package/src/actions/appInstaller.js +177 -0
- package/src/actions/appUpdate.js +68 -0
- package/src/actions/appleSigning.js +96 -0
- package/src/actions/buildApp.js +110 -32
- package/src/actions/buildBinTools.js +0 -0
- package/src/actions/buildExternal.js +8 -1
- package/src/actions/createLib.js +10 -5
- package/src/actions/getCmakeParameters.js +4 -1
- package/src/actions/gradleProject.js +65 -4
- package/src/actions/licenses.js +31 -2
- package/src/actions/windowsSigning.js +96 -0
- package/src/actions/xcodeProject.js +44 -10
- package/src/app/index.d.ts +171 -0
- package/src/app/index.js +245 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +9 -0
- package/src/index.js +2 -1
- package/src/state/loadConfig.js +1 -1
- package/src/utils/appApiHeader.js +29 -0
- package/src/utils/appIcon.js +174 -0
- package/src/utils/appOptions.js +321 -0
- package/src/utils/embindArgumentGuards.js +8 -5
- package/src/utils/embindCallArguments.js +6 -6
- package/src/utils/overrideDependency.js +3 -0
- package/src/utils/recipePatches.js +61 -0
- package/src/utils/sourceFingerprint.js +8 -1
- package/src/utils/writeIfChanged.js +1 -1
- package/vitest.config.js +14 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// The app section of crossbind.config.js: what a user sees of an app, its name and, on the desktop, its window, and where
|
|
2
|
+
// its page opens files.
|
|
3
|
+
|
|
4
|
+
const WINDOW_DEFAULTS = { width: 1024, height: 768, minWidth: 0, minHeight: 0, maxWidth: 0, maxHeight: 0, resizable: true, fullscreen: false, backgroundColor: null };
|
|
5
|
+
// Larger than any display, and far within the int a host takes a size as.
|
|
6
|
+
const MAX_WINDOW_SIZE = 16384;
|
|
7
|
+
// Windows keeps each of a file version's four numbers in 16 bits.
|
|
8
|
+
const MAX_VERSION_NUMBER = 65535;
|
|
9
|
+
// Up to four numbers, then a semver prerelease or build part; none of its characters needs escaping in C or RC.
|
|
10
|
+
const WINDOWS_VERSION = /^(\d+(?:\.\d+){0,3})(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
11
|
+
// What app.openPath can let the page's shell.openPath open: the app's folders as app.paths() names them, and what a
|
|
12
|
+
// dialog picked during this run.
|
|
13
|
+
const OPEN_PATH_PLACES = ['data', 'cache', 'logs', 'picked'];
|
|
14
|
+
// Apple's app categories, which macOS shows an app under, each with the main categories of the freedesktop menu
|
|
15
|
+
// specification a Linux menu shows it under. An audio or a video app is in AudioVideo as well, as the specification asks.
|
|
16
|
+
const GAMES = ['', 'action-', 'adventure-', 'arcade-', 'board-', 'card-', 'casino-', 'dice-', 'educational-', 'family-', 'kids-', 'music-', 'puzzle-',
|
|
17
|
+
'racing-', 'role-playing-', 'simulation-', 'sports-', 'strategy-', 'trivia-', 'word-'].map((kind) => `${kind}games`);
|
|
18
|
+
const APP_CATEGORIES = {
|
|
19
|
+
business: 'Office', 'developer-tools': 'Development', education: 'Education', entertainment: 'AudioVideo', finance: 'Office',
|
|
20
|
+
'graphics-design': 'Graphics', 'healthcare-fitness': 'Utility', lifestyle: 'Utility', medical: 'Science', music: 'Audio;AudioVideo', news: 'Network',
|
|
21
|
+
photography: 'Graphics', productivity: 'Office', reference: 'Education', 'social-networking': 'Network', sports: 'Utility', travel: 'Utility',
|
|
22
|
+
utilities: 'Utility', video: 'Video;AudioVideo', weather: 'Utility', ...Object.fromEntries(GAMES.map((category) => [category, 'Game'])),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// An app's bundle id, which also names its folders on the desktop: app.id, else one made from the project's name.
|
|
26
|
+
export function appId(config) {
|
|
27
|
+
const id = config.app.id ?? `dev.crossbind.${config.general.name.replace(/[^A-Za-z0-9.-]/g, '-')}`;
|
|
28
|
+
// An empty part would let "." or ".." name the folder above the app's own.
|
|
29
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/.test(id)) {
|
|
30
|
+
throw new Error(`crossbind: app.id ${JSON.stringify(id)} is no bundle id, which takes dot-separated parts of letters, digits and hyphens, as com.example.app does.`);
|
|
31
|
+
}
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The name the app shows: its window's title, its macOS menus, its Windows file description and the label under its
|
|
36
|
+
// icon. The project's name, without app.name.
|
|
37
|
+
export function appDisplayName(config) {
|
|
38
|
+
const isOwn = config.app.name !== undefined;
|
|
39
|
+
const name = isOwn ? config.app.name : config.general.name;
|
|
40
|
+
// The host takes it as a C string and a Windows resource string, which hold none of these as they are, and Android
|
|
41
|
+
// reads a label that starts with @ or ? as a resource.
|
|
42
|
+
if (typeof name !== 'string' || !name.trim() || /["\\\p{Cc}]/u.test(name) || /^[@?]/.test(name)) {
|
|
43
|
+
const rule = 'which is text without a quotation mark, a backslash or a control character, starting with neither @ nor ?';
|
|
44
|
+
throw new Error(isOwn
|
|
45
|
+
? `crossbind: app.name ${JSON.stringify(name)} is no name an app can show, ${rule}. Change it, or drop app.name to show general.name.`
|
|
46
|
+
: `crossbind: general.name ${JSON.stringify(name)}, which an app shows without app.name, is no name it can show, ${rule}. Set app.name in crossbind.config.js.`);
|
|
47
|
+
}
|
|
48
|
+
return name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The copyright line a macOS app's About panel and a Windows executable's details show: app.copyright, else none. The
|
|
52
|
+
// host takes it as a C string and a Windows resource string, as it takes the name.
|
|
53
|
+
export function appCopyright(config) {
|
|
54
|
+
const copyright = config.app.copyright;
|
|
55
|
+
if (copyright === undefined) return null;
|
|
56
|
+
if (typeof copyright !== 'string' || !copyright.trim() || /["\\\p{Cc}]/u.test(copyright)) {
|
|
57
|
+
throw new Error(`crossbind: app.copyright ${JSON.stringify(copyright)} is no copyright line, which is text without a quotation mark, a backslash or a control character, as '© 2026 Example Inc.' is.`);
|
|
58
|
+
}
|
|
59
|
+
return copyright;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The category app.category names, under which macOS shows the app, and the ones a Linux menu shows it under; none
|
|
63
|
+
// without app.category.
|
|
64
|
+
export function appCategory(config) {
|
|
65
|
+
const category = config.app.category;
|
|
66
|
+
if (category === undefined) return null;
|
|
67
|
+
if (!Object.hasOwn(APP_CATEGORIES, category)) {
|
|
68
|
+
throw new Error(`crossbind: app.category ${JSON.stringify(category)} is none of Apple's app categories, which are ${Object.keys(APP_CATEGORIES).join(', ')}.`);
|
|
69
|
+
}
|
|
70
|
+
return { darwin: `public.app-category.${category}`, linux: APP_CATEGORIES[category] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes and
|
|
74
|
+
// whether it opens full screen. Its backgroundColor, which a phone takes as well, is appBackgroundColor's.
|
|
75
|
+
export function appWindow(config) {
|
|
76
|
+
const window = config.app.window ?? {};
|
|
77
|
+
const keys = Object.keys(WINDOW_DEFAULTS).join(', ');
|
|
78
|
+
if (typeof window !== 'object' || Array.isArray(window)) {
|
|
79
|
+
throw new Error(`crossbind: app.window ${JSON.stringify(window)} is no object of ${keys}, as { width: 1024, height: 768 } is.`);
|
|
80
|
+
}
|
|
81
|
+
const unknown = Object.keys(window).filter((key) => !(key in WINDOW_DEFAULTS));
|
|
82
|
+
if (unknown.length > 0) throw new Error(`crossbind: app.window takes ${keys}; ${unknown.join(', ')} is none of them.`);
|
|
83
|
+
const options = { ...WINDOW_DEFAULTS, ...window };
|
|
84
|
+
['width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight'].forEach((key) => {
|
|
85
|
+
const least = /^(min|max)/.test(key) ? 0 : 1;
|
|
86
|
+
if (!Number.isInteger(options[key]) || options[key] < least || options[key] > MAX_WINDOW_SIZE) {
|
|
87
|
+
throw new Error(`crossbind: app.window.${key} ${JSON.stringify(options[key])} is no size, which is a whole number of pixels from ${least} to ${MAX_WINDOW_SIZE}.`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
['resizable', 'fullscreen'].forEach((key) => {
|
|
91
|
+
if (typeof options[key] !== 'boolean') throw new Error(`crossbind: app.window.${key} ${JSON.stringify(options[key])} is no boolean: set it to true or false.`);
|
|
92
|
+
});
|
|
93
|
+
if (!options.resizable && (options.minWidth || options.minHeight || options.maxWidth || options.maxHeight)) {
|
|
94
|
+
throw new Error('crossbind: app.window.minWidth, minHeight, maxWidth and maxHeight limit a window that resizes: drop them, or set app.window.resizable to true.');
|
|
95
|
+
}
|
|
96
|
+
[['width', 'Width'], ['height', 'Height']].forEach(([size, side]) => {
|
|
97
|
+
const [least, most] = [options[`min${side}`], options[`max${side}`]];
|
|
98
|
+
if (least > options[size]) {
|
|
99
|
+
throw new Error(`crossbind: app.window.min${side} ${least} is above the ${size} the window opens with, ${options[size]}: lower it, or raise app.window.${size}.`);
|
|
100
|
+
}
|
|
101
|
+
if (most && most < options[size]) {
|
|
102
|
+
throw new Error(`crossbind: app.window.max${side} ${most} is below the ${size} the window opens with, ${options[size]}: raise it, or lower app.window.${size}.`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The color an app shows until its page paints, and behind a page without a background of its own, on iOS and Android as
|
|
109
|
+
// well: app.window.backgroundColor, else null, which leaves the system's.
|
|
110
|
+
export function appBackgroundColor(config) {
|
|
111
|
+
const color = config.app.window?.backgroundColor ?? null;
|
|
112
|
+
if (color !== null && (typeof color !== 'string' || !/^#[0-9A-Fa-f]{6}$/.test(color))) {
|
|
113
|
+
throw new Error(`crossbind: app.window.backgroundColor ${JSON.stringify(color)} is no color of the form #rrggbb, as #1e1e1e is.`);
|
|
114
|
+
}
|
|
115
|
+
return color;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PERMISSIONS = ['camera', 'microphone', 'location'];
|
|
119
|
+
|
|
120
|
+
// The devices the page may use, each with the reason for it, which macOS and iOS read from the Info.plist:
|
|
121
|
+
// app.permissions.
|
|
122
|
+
export function appPermissions(config) {
|
|
123
|
+
const permissions = config.app.permissions ?? {};
|
|
124
|
+
const names = PERMISSIONS.join(', ');
|
|
125
|
+
if (typeof permissions !== 'object' || Array.isArray(permissions)) {
|
|
126
|
+
throw new Error(`crossbind: app.permissions ${JSON.stringify(permissions)} is no object of ${names}, each with the reason the app uses it, as { camera: 'To scan barcodes' } is.`);
|
|
127
|
+
}
|
|
128
|
+
const unknown = Object.keys(permissions).filter((name) => !PERMISSIONS.includes(name));
|
|
129
|
+
if (unknown.length > 0) throw new Error(`crossbind: app.permissions takes ${names}; ${unknown.join(', ')} is none of them.`);
|
|
130
|
+
Object.entries(permissions).forEach(([name, reason]) => {
|
|
131
|
+
if (typeof reason !== 'string' || !reason.trim() || /\p{Cc}/u.test(reason)) {
|
|
132
|
+
throw new Error(`crossbind: app.permissions.${name} ${JSON.stringify(reason)} is no reason, which is text without control characters that the app's user reads when it asks for the ${name}, as 'To scan barcodes' is.`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return permissions;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The reasons of app.permissions under the Info.plist keys macOS or iOS reads them from; macOS reads location's older
|
|
139
|
+
// key as well.
|
|
140
|
+
export function usageDescriptions(config, platform) {
|
|
141
|
+
const keys = {
|
|
142
|
+
camera: ['NSCameraUsageDescription'],
|
|
143
|
+
microphone: ['NSMicrophoneUsageDescription'],
|
|
144
|
+
location: ['NSLocationWhenInUseUsageDescription', ...(platform === 'darwin' ? ['NSLocationUsageDescription'] : [])],
|
|
145
|
+
};
|
|
146
|
+
return Object.fromEntries(Object.entries(appPermissions(config)).flatMap(([name, reason]) => keys[name].map((key) => [key, reason])));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The version a Windows app's executable carries: package.json's, whose numbers are its file version.
|
|
150
|
+
function windowsVersion(config) {
|
|
151
|
+
const version = config.package?.version ?? '1.0.0';
|
|
152
|
+
const match = typeof version === 'string' ? WINDOWS_VERSION.exec(version) : null;
|
|
153
|
+
const numbers = match ? match[1].split('.').map(Number) : [];
|
|
154
|
+
if (!match || numbers.some((number) => number > MAX_VERSION_NUMBER)) {
|
|
155
|
+
throw new Error(`crossbind: package.json's version ${JSON.stringify(version)} is no version a Windows app can carry, which is up to four numbers from 0 to ${MAX_VERSION_NUMBER}, as 1.2.3 and 1.2.3-beta.1 are.`);
|
|
156
|
+
}
|
|
157
|
+
return { text: version, numbers: [...numbers, 0, 0, 0].slice(0, 4).join(',') };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Where the page's shell.openPath opens a file: nowhere unless app.openPath lists it, as opening a program runs it.
|
|
161
|
+
function appOpenPath(config) {
|
|
162
|
+
const places = config.app.openPath ?? [];
|
|
163
|
+
if (!Array.isArray(places)) {
|
|
164
|
+
throw new Error(`crossbind: app.openPath ${JSON.stringify(places)} is no list of ${OPEN_PATH_PLACES.join(', ')}, as ['logs', 'picked'] is.`);
|
|
165
|
+
}
|
|
166
|
+
const unknown = places.filter((place) => !OPEN_PATH_PLACES.includes(place));
|
|
167
|
+
if (unknown.length > 0) {
|
|
168
|
+
throw new Error(`crossbind: app.openPath takes ${OPEN_PATH_PLACES.join(', ')}; ${unknown.map((place) => JSON.stringify(place)).join(', ')} is none of them.`);
|
|
169
|
+
}
|
|
170
|
+
return places;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Schemes the web, the system or crossbind owns, which no app takes, and the keys of Windows' own classes, whose name a
|
|
174
|
+
// scheme's registry key would take.
|
|
175
|
+
const RESERVED_SCHEMES = [
|
|
176
|
+
'about', 'blob', 'content', 'crossbind', 'data', 'file', 'ftp', 'http', 'https', 'intent', 'javascript', 'mailto', 'sms', 'tel', 'ws', 'wss',
|
|
177
|
+
'appid', 'applications', 'clsid', 'directory', 'drive', 'folder', 'interface', 'mime', 'protocols', 'systemfileassociations', 'typelib', 'unknown',
|
|
178
|
+
'wow6432node',
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
// The schemes whose links open the app, as myapp://… does: lowercase, which is how Android matches them, and of two
|
|
182
|
+
// characters at least, as one would take a Windows path's drive for a scheme.
|
|
183
|
+
export function appUrlSchemes(config) {
|
|
184
|
+
const schemes = config.app.urlSchemes ?? [];
|
|
185
|
+
if (!Array.isArray(schemes)) {
|
|
186
|
+
throw new Error(`crossbind: app.urlSchemes ${JSON.stringify(schemes)} is no list of schemes, as ['myapp'] is.`);
|
|
187
|
+
}
|
|
188
|
+
for (const scheme of schemes) {
|
|
189
|
+
if (typeof scheme !== 'string' || !/^[a-z][a-z0-9+.-]+$/.test(scheme)) {
|
|
190
|
+
throw new Error(`crossbind: app.urlSchemes takes schemes of a lowercase letter, then one or more lowercase letters, digits, +, - and ., as myapp is; ${JSON.stringify(scheme)} is none.`);
|
|
191
|
+
}
|
|
192
|
+
if (RESERVED_SCHEMES.includes(scheme)) {
|
|
193
|
+
throw new Error(`crossbind: app.urlSchemes cannot take ${scheme}, which the web or the system owns: pick a scheme of the app's own.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (new Set(schemes).size !== schemes.length) throw new Error(`crossbind: app.urlSchemes lists a scheme twice: ${JSON.stringify(schemes)}.`);
|
|
197
|
+
return schemes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Semver's version: three numbers, then a prerelease and build metadata of dot-separated identifiers.
|
|
201
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
|
202
|
+
// What an update's files are named after: characters an address carries as they are, and no leading dot.
|
|
203
|
+
const UPDATE_NAME = /^[A-Za-z0-9_~-][A-Za-z0-9._~-]*$/;
|
|
204
|
+
// The Ed25519 public keys of the points of small order, whatever the sign of x, whose signatures anyone can forge: the
|
|
205
|
+
// list libsodium's ge25519_has_small_order checks.
|
|
206
|
+
const SMALL_ORDER_KEYS = [
|
|
207
|
+
'0000000000000000000000000000000000000000000000000000000000000000',
|
|
208
|
+
'0100000000000000000000000000000000000000000000000000000000000000',
|
|
209
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
|
|
210
|
+
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
211
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
212
|
+
'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
213
|
+
'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
214
|
+
].map((hex) => Buffer.from(hex, 'hex'));
|
|
215
|
+
|
|
216
|
+
const isSmallOrder = (key) => SMALL_ORDER_KEYS.some((point) => key.subarray(0, 31).equals(point.subarray(0, 31)) && (key[31] & 0x7f) === point[31]);
|
|
217
|
+
|
|
218
|
+
// Where a desktop app finds its updates, the key their signatures are checked with, and the version they are newer than:
|
|
219
|
+
// package.json's, which semver orders. Null without app.updater.
|
|
220
|
+
export function appUpdater(config) {
|
|
221
|
+
const updater = config.app.updater;
|
|
222
|
+
if (updater === undefined) return null;
|
|
223
|
+
const keys = 'url and publicKey';
|
|
224
|
+
if (updater === null || typeof updater !== 'object' || Array.isArray(updater)) {
|
|
225
|
+
throw new Error(`crossbind: app.updater ${JSON.stringify(updater)} is no object of ${keys}, as { url: 'https://example.com/updates/<name>-{platform}-{arch}.update.json', publicKey: '…' } is.`);
|
|
226
|
+
}
|
|
227
|
+
const unknown = Object.keys(updater).filter((key) => !['url', 'publicKey'].includes(key));
|
|
228
|
+
if (unknown.length > 0) throw new Error(`crossbind: app.updater takes ${keys}; ${unknown.join(', ')} is none of them.`);
|
|
229
|
+
const url = URL.canParse(updater.url) ? new URL(updater.url) : null;
|
|
230
|
+
const isLocal = url?.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
231
|
+
// The host takes the address as a C string, as it is written, and the update's file from beside the manifest.
|
|
232
|
+
if (!url || !(url.protocol === 'https:' || isLocal) || /[^\x21-\x7e]|["\\]/.test(updater.url) || url.pathname.endsWith('/')) {
|
|
233
|
+
throw new Error(`crossbind: app.updater.url ${JSON.stringify(updater.url)} is no https address of a manifest, as https://example.com/updates/<name>-{platform}-{arch}.update.json is; http serves localhost alone.`);
|
|
234
|
+
}
|
|
235
|
+
const publicKey = Buffer.from(typeof updater.publicKey === 'string' ? updater.publicKey : '', 'base64');
|
|
236
|
+
if (publicKey.length !== 32 || publicKey.toString('base64') !== updater.publicKey) {
|
|
237
|
+
throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is no Ed25519 public key, which \`crossbind app updater-key\` prints.`);
|
|
238
|
+
}
|
|
239
|
+
if (isSmallOrder(publicKey)) {
|
|
240
|
+
throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is a key anyone can forge signatures for, which no key pair has. Use the one \`crossbind app updater-key\` prints.`);
|
|
241
|
+
}
|
|
242
|
+
const version = config.package?.version ?? '1.0.0';
|
|
243
|
+
if (typeof version !== 'string' || !SEMVER.test(version)) {
|
|
244
|
+
throw new Error(`crossbind: package.json's version ${JSON.stringify(version)} is no semver version, as 1.2.3 and 1.2.3-beta.1 are, which an app that updates needs to tell a newer one.`);
|
|
245
|
+
}
|
|
246
|
+
if (!UPDATE_NAME.test(config.general.name)) {
|
|
247
|
+
throw new Error(`crossbind: general.name ${JSON.stringify(config.general.name)} names an app's update files, which an app fetches by name: letters, digits, and . _ ~ - alone, not starting with a dot. Change general.name in crossbind.config.js.`);
|
|
248
|
+
}
|
|
249
|
+
return { url: updater.url, publicKey, version };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A desktop host's window: its page's size and limits, whether it resizes and whether it opens full screen. A maximum of
|
|
253
|
+
// one side alone leaves the other as large as any display.
|
|
254
|
+
function windowDefines(window) {
|
|
255
|
+
const most = window.maxWidth || window.maxHeight ? [
|
|
256
|
+
`#define CROSSBIND_APP_WINDOW_MAX_WIDTH ${window.maxWidth || MAX_WINDOW_SIZE}`,
|
|
257
|
+
`#define CROSSBIND_APP_WINDOW_MAX_HEIGHT ${window.maxHeight || MAX_WINDOW_SIZE}`,
|
|
258
|
+
] : [];
|
|
259
|
+
return [
|
|
260
|
+
`#define CROSSBIND_APP_WINDOW_WIDTH ${window.width}`,
|
|
261
|
+
`#define CROSSBIND_APP_WINDOW_HEIGHT ${window.height}`,
|
|
262
|
+
`#define CROSSBIND_APP_WINDOW_MIN_WIDTH ${window.minWidth}`,
|
|
263
|
+
`#define CROSSBIND_APP_WINDOW_MIN_HEIGHT ${window.minHeight}`,
|
|
264
|
+
`#define CROSSBIND_APP_WINDOW_RESIZABLE ${window.resizable ? 1 : 0}`,
|
|
265
|
+
...most,
|
|
266
|
+
...(window.fullscreen ? ['#define CROSSBIND_APP_WINDOW_FULLSCREEN 1'] : []),
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The header an app's host compiles where its page opens files from, an iOS or desktop host the color it shows until
|
|
271
|
+
// its page paints and the devices the page may use, a desktop host its title, window, whether it keeps one instance and
|
|
272
|
+
// its updates, and a Windows host its version resource.
|
|
273
|
+
export function appHeader(config, platform) {
|
|
274
|
+
const comment = '// crossbind writes this file from crossbind.config.js and package.json.';
|
|
275
|
+
const openPath = `#define CROSSBIND_APP_OPEN_PATH {${appOpenPath(config).map((place) => `"${place}"`).join(', ')}}`;
|
|
276
|
+
const color = appBackgroundColor(config);
|
|
277
|
+
const permissions = appPermissions(config);
|
|
278
|
+
// An Android app's activity takes the color and the permissions from its Gradle project.
|
|
279
|
+
const page = platform === 'android' ? [] : [
|
|
280
|
+
...(color ? [`#define CROSSBIND_APP_BACKGROUND_COLOR 0x${color.slice(1).toLowerCase()}`] : []),
|
|
281
|
+
...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
|
|
282
|
+
];
|
|
283
|
+
if (!['darwin', 'linux', 'win32'].includes(platform)) return [comment, openPath, ...page, ''].join('\n');
|
|
284
|
+
// The category goes to the Info.plist and the desktop entry, which the build writes later; any desktop refuses a
|
|
285
|
+
// wrong one here.
|
|
286
|
+
const copyright = appCopyright(config);
|
|
287
|
+
appCategory(config);
|
|
288
|
+
const lines = [
|
|
289
|
+
comment,
|
|
290
|
+
openPath,
|
|
291
|
+
...page,
|
|
292
|
+
`#define CROSSBIND_APP_ID "${appId(config)}"`,
|
|
293
|
+
`#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`,
|
|
294
|
+
...windowDefines(appWindow(config)),
|
|
295
|
+
];
|
|
296
|
+
const urlSchemes = appUrlSchemes(config);
|
|
297
|
+
if (urlSchemes.length > 0) lines.push(`#define CROSSBIND_APP_URL_SCHEMES {${urlSchemes.map((scheme) => `"${scheme}"`).join(', ')}}`);
|
|
298
|
+
// A link the system opens a Windows or Linux app with starts it again, which reaches the app that runs this way; macOS
|
|
299
|
+
// hands the link to the app that runs.
|
|
300
|
+
const singleInstance = config.app.singleInstance ?? (urlSchemes.length > 0 && platform !== 'darwin');
|
|
301
|
+
if (typeof singleInstance !== 'boolean') {
|
|
302
|
+
throw new Error(`crossbind: app.singleInstance ${JSON.stringify(singleInstance)} is no boolean: set it to true to keep a desktop app to one instance, or to false to let each launch run.`);
|
|
303
|
+
}
|
|
304
|
+
if (singleInstance) lines.push('#define CROSSBIND_APP_SINGLE_INSTANCE 1');
|
|
305
|
+
const updater = appUpdater(config);
|
|
306
|
+
if (updater) {
|
|
307
|
+
lines.push(
|
|
308
|
+
`#define CROSSBIND_APP_UPDATER_URL "${updater.url}"`,
|
|
309
|
+
`#define CROSSBIND_APP_UPDATER_KEY {${[...updater.publicKey].map((byte) => `0x${byte.toString(16).padStart(2, '0')}`).join(', ')}}`,
|
|
310
|
+
`#define CROSSBIND_APP_UPDATER_VERSION "${updater.version}"`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (platform === 'win32') {
|
|
314
|
+
const version = windowsVersion(config);
|
|
315
|
+
lines.push(`#define CROSSBIND_APP_VERSION "${version.text}"`, `#define CROSSBIND_APP_VERSION_NUMBERS ${version.numbers}`);
|
|
316
|
+
if (copyright) lines.push(`#define CROSSBIND_APP_COPYRIGHT "${copyright}"`);
|
|
317
|
+
// The build writes the icon beside this header.
|
|
318
|
+
if (config.app.icon !== undefined) lines.push('#define CROSSBIND_APP_ICON 1');
|
|
319
|
+
}
|
|
320
|
+
return [...lines, ''].join('\n');
|
|
321
|
+
}
|
|
@@ -26,8 +26,9 @@ const GUARDS = [
|
|
|
26
26
|
},
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
// The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'
|
|
30
|
-
|
|
29
|
+
// The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'`;
|
|
30
|
+
// emscripten 6.0.10 and later write `===`, and their release glue drops the braces.
|
|
31
|
+
const BIGINT_FROM_NUMBER = /if\s*\(\s*typeof value\s*===?\s*["']number["']\s*\)\s*(?:\{\s*value\s*=\s*BigInt\(value\)\s*;?\s*\}|value\s*=\s*BigInt\(value\)\s*;?)/g;
|
|
31
32
|
const SAFE_BIGINT_FROM_NUMBER = 'if(typeof value=="number"){if(!Number.isSafeInteger(value))throw new TypeError("a 64-bit integer parameter takes a BigInt or a safe integer Number, got "+value);value=BigInt(value)}';
|
|
32
33
|
|
|
33
34
|
// Returns the rewritten glue and whether a bigint registration kept a conversion this rewrite does not recognise.
|
|
@@ -43,7 +44,7 @@ const DEBUG_GUARDS = [
|
|
|
43
44
|
{
|
|
44
45
|
registration: '__embind_register_integer',
|
|
45
46
|
end: 'readValueFromPointer: integerReadValueFromPointer(',
|
|
46
|
-
from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value
|
|
47
|
+
from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value !==? ["']number["'])/,
|
|
47
48
|
to: 'toWireType: (destructors, value) => {$1'
|
|
48
49
|
+ 'if (typeof value == "string" && size == 1 && value.length == 1) value = value.charCodeAt(0);$1'
|
|
49
50
|
+ 'if (value !== null && typeof value == "object" && typeof value.value == "number") value = value.value;$1',
|
|
@@ -72,9 +73,11 @@ function guardDebugRegistration(glue, { registration, end, from, to, applied })
|
|
|
72
73
|
return { text: glue.slice(0, start) + rewritten + glue.slice(stop), missed: rewritten === code };
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
// Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect.
|
|
76
|
+
// Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect. Emscripten
|
|
77
|
+
// 6.0.10 and later write a registration the glue uses once into the wasm imports, as `name:(` rather than `name=(`; a
|
|
78
|
+
// debug glue's wasm imports only name it (`name: __name`).
|
|
76
79
|
export function guardEmbindArguments(glue) {
|
|
77
|
-
const missed = GUARDS.filter(({ registration, from, to }) =>
|
|
80
|
+
const missed = GUARDS.filter(({ registration, from, to }) => new RegExp(`${registration}[=:]\\(`).test(glue) && !glue.includes(from) && !glue.includes(to))
|
|
78
81
|
.map(({ registration }) => registration);
|
|
79
82
|
const released = GUARDS.reduce((current, { from, to }) => current.split(from).join(to), glue);
|
|
80
83
|
return DEBUG_GUARDS.reduce(({ text, missed: names }, guard) => {
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// once it returns, or once its promise settles for an async (_JSPI) function, so a call started before
|
|
4
4
|
// then overwrites them and the earlier call frees the later call's arguments instead of its own, which
|
|
5
5
|
// leak: two pending calls of one _JSPI method, or a method called again from a JavaScript callback it
|
|
6
|
-
// runs. Emscripten 6.0.9
|
|
7
|
-
// the three inside the invoker, so each call gets its own.
|
|
6
|
+
// runs. Emscripten 6.0.9 and 6.0.13 build it this way, as did upstream main on 2026-09-30; this rewrite
|
|
7
|
+
// declares the three inside the invoker, so each call gets its own.
|
|
8
8
|
|
|
9
9
|
// The release glue spells it on one line, the debug glue one statement per line with an argument
|
|
10
|
-
// count check first.
|
|
11
|
-
const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
|
|
12
|
-
const PER_CALL = '
|
|
13
|
-
const SEPARATED = /var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
|
|
10
|
+
// count check first. Emscripten 6.0.10 and later hand the release invoker straight to createNamedFunction.
|
|
11
|
+
const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*((?:var invokerFn\s*=\s*|return createNamedFunction\(humanName,\s*)function\s*\(\.\.\.args\)\s*\{)(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
|
|
12
|
+
const PER_CALL = '$1$2var argsWired=new Array(expectedArgCount);var invokerFuncArgs=[];var destructors=[];';
|
|
13
|
+
const SEPARATED = /(?:var invokerFn\s*=\s*|return createNamedFunction\(humanName,\s*)function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
|
|
14
14
|
|
|
15
15
|
// Returns the rewritten glue and whether it still holds an invoker this rewrite does not recognise.
|
|
16
16
|
export function separateCallArguments(glue) {
|
|
@@ -90,9 +90,12 @@ export function getOverrideKey(dep) {
|
|
|
90
90
|
override,
|
|
91
91
|
(key, value) => (typeof value === 'function' ? `fn:${value.toString()}` : value),
|
|
92
92
|
);
|
|
93
|
+
// The package version too: a new release of the package can build the same upstream release differently, as one
|
|
94
|
+
// that adds a patch does, and the rebuild cached under the old one would keep linking the old build.
|
|
93
95
|
const parts = [
|
|
94
96
|
dep.general?.name || '',
|
|
95
97
|
override.nativeVersion || dep.package?.nativeVersion || '',
|
|
98
|
+
dep.package?.version || '',
|
|
96
99
|
serial,
|
|
97
100
|
];
|
|
98
101
|
return getContentHash(parts.join(' '));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getContentHash } from './hash.js';
|
|
6
|
+
|
|
7
|
+
const patchPath = (file) => (file instanceof URL ? fileURLToPath(file) : file);
|
|
8
|
+
|
|
9
|
+
// Inside the tree, which every extraction replaces whole: a tree says it is patched only once its own patches went in,
|
|
10
|
+
// so neither one a patch failed on nor one a stopped run left behind passes, whatever stamp an earlier tree had.
|
|
11
|
+
const stampOf = (buildPath) => path.join(buildPath, 'source', '.crossbind-patches');
|
|
12
|
+
|
|
13
|
+
export function recipePatchesFingerprint(patches) {
|
|
14
|
+
if (!patches?.length) return null;
|
|
15
|
+
return getContentHash(JSON.stringify(patches.map(({ file }) => fs.readFileSync(patchPath(file), 'utf8'))));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// downloadAndExtractFile keeps a tree whose archive still matches, so a tree patched under another set
|
|
19
|
+
// would keep its old patches or miss the new ones: it is extracted again instead.
|
|
20
|
+
export function refreshPatchedSource(buildPath, patches) {
|
|
21
|
+
const stamp = stampOf(buildPath);
|
|
22
|
+
const recorded = fs.existsSync(stamp) ? fs.readFileSync(stamp, 'utf8') : '';
|
|
23
|
+
if (recorded !== (recipePatchesFingerprint(patches) ?? '')) {
|
|
24
|
+
fs.rmSync(path.join(buildPath, 'source'), { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writePatchedSourceStamp(buildPath, patches) {
|
|
29
|
+
const fingerprint = recipePatchesFingerprint(patches);
|
|
30
|
+
if (fingerprint) fs.writeFileSync(stampOf(buildPath), fingerprint);
|
|
31
|
+
else fs.rmSync(stampOf(buildPath), { force: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// git apply is atomic and refuses a hunk whose context does not match, so a source the patch no longer
|
|
35
|
+
// fits stops the build instead of shipping unpatched. It works on the files alone: a repository named in the
|
|
36
|
+
// environment, as a git hook passes on, would make it skip every path, and one the downloaded tree carries could run
|
|
37
|
+
// its filters on the host, so GIT_DIR names none. autocrlf, which Git for Windows sets, would give the patched files
|
|
38
|
+
// CRLF endings.
|
|
39
|
+
export function applyRecipePatches(sourcePath, patches) {
|
|
40
|
+
const env = {
|
|
41
|
+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
|
|
42
|
+
GIT_DIR: `${sourcePath}.no-repository`,
|
|
43
|
+
};
|
|
44
|
+
for (const { file } of patches ?? []) {
|
|
45
|
+
const patch = patchPath(file);
|
|
46
|
+
try {
|
|
47
|
+
execFileSync('git', ['-c', 'core.autocrlf=false', 'apply', '--whitespace=nowarn', '--', patch], {
|
|
48
|
+
cwd: sourcePath,
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
env,
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT') throw new Error(`crossbind: applying ${path.basename(patch)} needs git on the PATH.`, { cause: error });
|
|
54
|
+
throw new Error(
|
|
55
|
+
`crossbind: ${path.basename(patch)} does not apply to the extracted source. A recipe's patches are diffs against the release it pins: `
|
|
56
|
+
+ `drop the patch once a release includes it.\n${String(error.stderr ?? error.message).trim()}`,
|
|
57
|
+
{ cause: error },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { getContentHash } from './hash.js';
|
|
3
|
+
import { recipePatchesFingerprint } from './recipePatches.js';
|
|
3
4
|
|
|
4
5
|
// The prebuilt-lib cache in buildLib is existence-only, so a nativeVersion or recipe-sha256 bump
|
|
5
6
|
// would otherwise be served from the previous build - shipping the old upstream under the new
|
|
@@ -12,7 +13,13 @@ export function getSourceFingerprint(config, target) {
|
|
|
12
13
|
// Local-source packages pin neither; buildLib checks their native source inventory instead.
|
|
13
14
|
if (!nativeVersion && !sha256) return null;
|
|
14
15
|
const sourceReplacements = target ? config?.build?.sourceReplaceList?.(target, config.allDependencyPaths?.[target.path] ?? {}) : undefined;
|
|
15
|
-
|
|
16
|
+
const patches = recipePatchesFingerprint(config?.build?.patches);
|
|
17
|
+
return getContentHash(JSON.stringify({
|
|
18
|
+
nativeVersion,
|
|
19
|
+
sha256,
|
|
20
|
+
...(sourceReplacements?.length ? { sourceReplacements } : {}),
|
|
21
|
+
...(patches ? { patches } : {}),
|
|
22
|
+
}));
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function isSourceFingerprintStale(libdir, fingerprint) {
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
export default function writeIfChanged(file, content) {
|
|
5
|
-
if (fs.existsSync(file) && fs.readFileSync(file
|
|
5
|
+
if (fs.existsSync(file) && fs.readFileSync(file).equals(Buffer.from(content))) return;
|
|
6
6
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
7
7
|
fs.writeFileSync(file, content);
|
|
8
8
|
}
|
package/vitest.config.js
CHANGED
|
@@ -27,6 +27,20 @@ export default defineConfig({
|
|
|
27
27
|
CROSSBIND_REMOTE_URL_LINUX: '',
|
|
28
28
|
CROSSBIND_REMOTE_URL_WINDOWS: '',
|
|
29
29
|
CROSSBIND_CLOUD_URL: 'http://127.0.0.1:9',
|
|
30
|
+
// Nor their signing keys: a macOS app here is signed ad hoc, and a Windows app not at all, unless a test names
|
|
31
|
+
// a key.
|
|
32
|
+
CROSSBIND_APPLE_SIGNING_IDENTITY: '',
|
|
33
|
+
CROSSBIND_APPLE_API_KEY: '',
|
|
34
|
+
CROSSBIND_APPLE_API_KEY_ID: '',
|
|
35
|
+
CROSSBIND_APPLE_API_ISSUER: '',
|
|
36
|
+
CROSSBIND_WINDOWS_SIGNING_STORETYPE: '',
|
|
37
|
+
CROSSBIND_WINDOWS_SIGNING_KEYSTORE: '',
|
|
38
|
+
CROSSBIND_WINDOWS_SIGNING_ALIAS: '',
|
|
39
|
+
CROSSBIND_WINDOWS_SIGNING_STOREPASS: '',
|
|
40
|
+
CROSSBIND_WINDOWS_SIGNING_KEYPASS: '',
|
|
41
|
+
CROSSBIND_WINDOWS_SIGNING_CERTFILE: '',
|
|
42
|
+
CROSSBIND_WINDOWS_SIGNING_TSAURL: '',
|
|
43
|
+
CROSSBIND_UPDATER_PRIVATE_KEY: '',
|
|
30
44
|
},
|
|
31
45
|
coverage: {
|
|
32
46
|
provider: 'v8',
|