crossbind 2.0.0-beta.64 → 2.0.0-beta.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +6 -1
  2. package/src/actions/androidDevice.js +77 -0
  3. package/src/actions/androidGradle.js +121 -0
  4. package/src/actions/appInstaller.js +174 -0
  5. package/src/actions/appUpdate.js +68 -0
  6. package/src/actions/appleSigning.js +96 -0
  7. package/src/actions/buildApp.js +509 -0
  8. package/src/actions/buildDependencies.js +4 -3
  9. package/src/actions/buildExternal.js +3 -3
  10. package/src/actions/buildLib.js +1 -1
  11. package/src/actions/buildNode.js +15 -10
  12. package/src/actions/createInterface.js +1 -2
  13. package/src/actions/createLib.js +13 -5
  14. package/src/actions/getCmakeParameters.js +6 -2
  15. package/src/actions/getDependLibs.js +8 -2
  16. package/src/actions/getLinkInputs.js +10 -7
  17. package/src/actions/gradleProject.js +171 -0
  18. package/src/actions/iosSimulator.js +63 -0
  19. package/src/actions/licenses.js +189 -14
  20. package/src/actions/nativeBridges.js +35 -0
  21. package/src/actions/run.js +3 -3
  22. package/src/actions/target.js +2 -1
  23. package/src/actions/windowsSigning.js +96 -0
  24. package/src/actions/xcodeProject.js +186 -0
  25. package/src/app/index.d.ts +129 -0
  26. package/src/app/index.js +184 -0
  27. package/src/assets/js-runtime/adapters/worker-comlink.js +8 -1
  28. package/src/assets/toolchain-digests.json +24 -18
  29. package/src/bin.js +84 -32
  30. package/src/index.js +7 -0
  31. package/src/integration/getCrossbindScript.js +15 -1
  32. package/src/state/index.js +5 -1
  33. package/src/state/loadConfig.js +2 -0
  34. package/src/utils/appApiHeader.js +29 -0
  35. package/src/utils/appIcon.js +174 -0
  36. package/src/utils/appOptions.js +284 -0
  37. package/src/utils/appRustCrates.js +3 -1
  38. package/src/utils/downloadAndExtractFile.js +42 -14
  39. package/src/utils/embindRsFingerprint.js +10 -3
  40. package/src/utils/hermes.js +40 -0
  41. package/src/utils/plist.js +42 -0
  42. package/src/utils/pullDockerImage.js +9 -3
  43. package/src/utils/replaceBasePathForDocker.js +8 -0
  44. package/src/utils/resolveAppHost.js +21 -0
  45. package/src/utils/selectRunner.js +1 -1
  46. package/src/utils/systemKeys.js +4 -0
  47. package/src/utils/targets.js +19 -3
  48. package/src/utils/writeIfChanged.js +1 -1
  49. package/vitest.config.js +19 -0
@@ -0,0 +1,174 @@
1
+ // An app's icon: app.icon in crossbind.config.js, one square PNG the build makes each platform's icons from. Every size
2
+ // is resampled with a three-lobed Lanczos filter on premultiplied colors, as Tauri resizes icons.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { PNG } from 'pngjs';
6
+ import { getContentHash } from './hash.js';
7
+
8
+ // A macOS .icns: each kind of entry and the size of the PNG it holds.
9
+ const ICNS_ENTRIES = [['icp4', 16], ['icp5', 32], ['ic11', 32], ['ic12', 64], ['ic07', 128], ['ic13', 256], ['ic08', 256],
10
+ ['ic14', 512], ['ic09', 512], ['ic10', 1024]];
11
+ // A Windows .ico's sizes, each a PNG.
12
+ const ICO_SIZES = [16, 24, 32, 48, 64, 256];
13
+ const LINUX_SIZE = 512;
14
+ // Xcode makes an iOS app's every size from this one.
15
+ const IOS_SIZE = 1024;
16
+ // An Android launcher icon's foreground at each density is 108 dp, of which the launcher's mask shows the middle 72.
17
+ const ANDROID_FOREGROUNDS = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
18
+ const ANDROID_SHOWN = 72 / 108;
19
+ const WHITE = { red: 255, green: 255, blue: 255 };
20
+
21
+ // The app's icon, read and checked; none without app.icon.
22
+ export function readAppIcon(config) {
23
+ const { icon } = config.app;
24
+ if (icon === undefined) return null;
25
+ if (typeof icon !== 'string' || !icon) {
26
+ throw new Error(`crossbind: app.icon ${JSON.stringify(icon)} is no path to a PNG file, as 'icon.png' is.`);
27
+ }
28
+ const file = path.resolve(config.paths.project, icon);
29
+ if (!fs.existsSync(file)) throw new Error(`crossbind: app.icon names ${file}, which does not exist.`);
30
+ let bytes;
31
+ let image;
32
+ try {
33
+ bytes = fs.readFileSync(file);
34
+ image = PNG.sync.read(bytes);
35
+ } catch (error) {
36
+ throw new Error(`crossbind: app.icon ${file} is no PNG image: ${error.message}.`, { cause: error });
37
+ }
38
+ if (image.width !== image.height) {
39
+ throw new Error(`crossbind: app.icon ${file} is ${image.width} × ${image.height} pixels; an app's icon is square, at best 1024 × 1024.`);
40
+ }
41
+ return { file, hash: getContentHash(bytes), image: { width: image.width, height: image.height, data: image.data } };
42
+ }
43
+
44
+ function lanczos3(x) {
45
+ if (x === 0) return 1;
46
+ if (x <= -3 || x >= 3) return 0;
47
+ const px = Math.PI * x;
48
+ return (3 * Math.sin(px) * Math.sin(px / 3)) / (px * px);
49
+ }
50
+
51
+ // For each pixel along one axis of the resized image, the first pixel of the original it takes and their weights.
52
+ function contributions(from, to) {
53
+ const scale = from / to;
54
+ // Shrinking widens the filter, so every pixel of the original counts.
55
+ const stretch = Math.max(scale, 1);
56
+ return Array.from({ length: to }, (_, index) => {
57
+ const center = (index + 0.5) * scale;
58
+ const first = Math.max(0, Math.ceil(center - 3 * stretch - 0.5));
59
+ const last = Math.min(from - 1, Math.floor(center + 3 * stretch - 0.5));
60
+ const weights = Array.from({ length: last - first + 1 }, (__, k) => lanczos3((first + k + 0.5 - center) / stretch));
61
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
62
+ return { first, weights: weights.map((weight) => weight / total) };
63
+ });
64
+ }
65
+
66
+ // One pass along x: each row of `width` pixels becomes `size` pixels, written to the output transposed, so the second
67
+ // pass along y is the same pass again.
68
+ function pass(source, width, height, size) {
69
+ const out = new Float32Array(size * height * 4);
70
+ contributions(width, size).forEach(({ first, weights }, x) => {
71
+ for (let y = 0; y < height; y++) {
72
+ const row = y * width;
73
+ const at = (x * height + y) * 4;
74
+ for (let k = 0; k < weights.length; k++) {
75
+ const from = (row + first + k) * 4;
76
+ out[at] += weights[k] * source[from];
77
+ out[at + 1] += weights[k] * source[from + 1];
78
+ out[at + 2] += weights[k] * source[from + 2];
79
+ out[at + 3] += weights[k] * source[from + 3];
80
+ }
81
+ }
82
+ });
83
+ return out;
84
+ }
85
+
86
+ export function resize(image, size) {
87
+ if (image.width === size) return image;
88
+ const { width, height, data } = image;
89
+ // Premultiplied, so the color of a transparent pixel does not bleed into its neighbors.
90
+ const source = new Float32Array(width * height * 4);
91
+ for (let i = 0; i < data.length; i += 4) {
92
+ const alpha = data[i + 3] / 255;
93
+ source[i] = data[i] * alpha;
94
+ source[i + 1] = data[i + 1] * alpha;
95
+ source[i + 2] = data[i + 2] * alpha;
96
+ source[i + 3] = data[i + 3];
97
+ }
98
+ const resized = pass(pass(source, width, height, size), height, size, size);
99
+ const out = Buffer.alloc(size * size * 4);
100
+ const clamp = (value) => Math.min(255, Math.max(0, Math.round(value)));
101
+ for (let i = 0; i < out.length; i += 4) {
102
+ // The filter overshoots now and then; the colors divide by what it gave, the alpha is clamped.
103
+ const alpha = resized[i + 3];
104
+ out[i + 3] = clamp(alpha);
105
+ if (out[i + 3] === 0) continue;
106
+ out[i] = clamp((resized[i] * 255) / alpha);
107
+ out[i + 1] = clamp((resized[i + 1] * 255) / alpha);
108
+ out[i + 2] = clamp((resized[i + 2] * 255) / alpha);
109
+ }
110
+ return { width: size, height: size, data: out };
111
+ }
112
+
113
+ // The image at `size` in the middle of a transparent square of `canvas`.
114
+ function centered(image, canvas, size) {
115
+ const inner = resize(image, size);
116
+ const out = Buffer.alloc(canvas * canvas * 4);
117
+ const offset = Math.floor((canvas - size) / 2);
118
+ for (let y = 0; y < size; y++) inner.data.copy(out, ((y + offset) * canvas + offset) * 4, y * size * 4, (y + 1) * size * 4);
119
+ return { width: canvas, height: canvas, data: out };
120
+ }
121
+
122
+ const png = (image, options) => PNG.sync.write(image, options);
123
+
124
+ // A macOS app's icon file, which its Info.plist names.
125
+ export function macIcon(icon) {
126
+ const pngs = new Map();
127
+ const entries = ICNS_ENTRIES.map(([type, size]) => {
128
+ if (!pngs.has(size)) pngs.set(size, png(resize(icon.image, size)));
129
+ return chunk(type, pngs.get(size));
130
+ });
131
+ return chunk('icns', Buffer.concat(entries));
132
+ }
133
+
134
+ // An .icns chunk: its type, its length with these eight bytes, and what it holds.
135
+ function chunk(type, data) {
136
+ const header = Buffer.alloc(8);
137
+ header.write(type, 0, 'latin1');
138
+ header.writeUInt32BE(data.length + 8, 4);
139
+ return Buffer.concat([header, data]);
140
+ }
141
+
142
+ // A Windows app's icon, which its executable carries.
143
+ export function windowsIcon(icon) {
144
+ const images = ICO_SIZES.map((size) => png(resize(icon.image, size)));
145
+ const header = Buffer.alloc(6 + 16 * images.length);
146
+ header.writeUInt16LE(1, 2);
147
+ header.writeUInt16LE(images.length, 4);
148
+ let offset = header.length;
149
+ images.forEach((image, index) => {
150
+ const at = 6 + 16 * index;
151
+ // A width and height of 0 stand for 256.
152
+ header.writeUInt8(ICO_SIZES[index] % 256, at);
153
+ header.writeUInt8(ICO_SIZES[index] % 256, at + 1);
154
+ header.writeUInt16LE(1, at + 4);
155
+ header.writeUInt16LE(32, at + 6);
156
+ header.writeUInt32LE(image.length, at + 8);
157
+ header.writeUInt32LE(offset, at + 12);
158
+ offset += image.length;
159
+ });
160
+ return Buffer.concat([header, ...images]);
161
+ }
162
+
163
+ // A Linux app's window icon.
164
+ export const linuxIcon = (icon) => png(resize(icon.image, LINUX_SIZE));
165
+
166
+ // The one image an iOS app's asset catalog holds, on white and without an alpha channel, which the App Store refuses
167
+ // even when every pixel is opaque.
168
+ export const iosIcon = (icon) => png(resize(icon.image, IOS_SIZE), { colorType: 2, bgColor: WHITE });
169
+
170
+ // An Android app's adaptive icon: its foreground at each density, with the whole icon in what the mask shows; its
171
+ // background is white.
172
+ export const androidIcons = (icon) => Object.fromEntries(Object.entries(ANDROID_FOREGROUNDS).map(([density, size]) => [
173
+ `mipmap-${density}/ic_launcher_foreground.png`, png(centered(icon.image, size, Math.round(size * ANDROID_SHOWN))),
174
+ ]));
@@ -0,0 +1,284 @@
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
+
15
+ // An app's bundle id, which also names its folders on the desktop: app.id, else one made from the project's name.
16
+ export function appId(config) {
17
+ const id = config.app.id ?? `dev.crossbind.${config.general.name.replace(/[^A-Za-z0-9.-]/g, '-')}`;
18
+ // An empty part would let "." or ".." name the folder above the app's own.
19
+ if (typeof id !== 'string' || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/.test(id)) {
20
+ 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.`);
21
+ }
22
+ return id;
23
+ }
24
+
25
+ // The name the app shows: its window's title, its macOS menus, its Windows file description and the label under its
26
+ // icon. The project's name, without app.name.
27
+ export function appDisplayName(config) {
28
+ const isOwn = config.app.name !== undefined;
29
+ const name = isOwn ? config.app.name : config.general.name;
30
+ // The host takes it as a C string and a Windows resource string, which hold none of these as they are, and Android
31
+ // reads a label that starts with @ or ? as a resource.
32
+ if (typeof name !== 'string' || !name.trim() || /["\\\p{Cc}]/u.test(name) || /^[@?]/.test(name)) {
33
+ const rule = 'which is text without a quotation mark, a backslash or a control character, starting with neither @ nor ?';
34
+ throw new Error(isOwn
35
+ ? `crossbind: app.name ${JSON.stringify(name)} is no name an app can show, ${rule}. Change it, or drop app.name to show general.name.`
36
+ : `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.`);
37
+ }
38
+ return name;
39
+ }
40
+
41
+ // The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes and
42
+ // whether it opens full screen. Its backgroundColor, which a phone takes as well, is appBackgroundColor's.
43
+ export function appWindow(config) {
44
+ const window = config.app.window ?? {};
45
+ const keys = Object.keys(WINDOW_DEFAULTS).join(', ');
46
+ if (typeof window !== 'object' || Array.isArray(window)) {
47
+ throw new Error(`crossbind: app.window ${JSON.stringify(window)} is no object of ${keys}, as { width: 1024, height: 768 } is.`);
48
+ }
49
+ const unknown = Object.keys(window).filter((key) => !(key in WINDOW_DEFAULTS));
50
+ if (unknown.length > 0) throw new Error(`crossbind: app.window takes ${keys}; ${unknown.join(', ')} is none of them.`);
51
+ const options = { ...WINDOW_DEFAULTS, ...window };
52
+ ['width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight'].forEach((key) => {
53
+ const least = /^(min|max)/.test(key) ? 0 : 1;
54
+ if (!Number.isInteger(options[key]) || options[key] < least || options[key] > MAX_WINDOW_SIZE) {
55
+ 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}.`);
56
+ }
57
+ });
58
+ ['resizable', 'fullscreen'].forEach((key) => {
59
+ 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.`);
60
+ });
61
+ if (!options.resizable && (options.minWidth || options.minHeight || options.maxWidth || options.maxHeight)) {
62
+ 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.');
63
+ }
64
+ [['width', 'Width'], ['height', 'Height']].forEach(([size, side]) => {
65
+ const [least, most] = [options[`min${side}`], options[`max${side}`]];
66
+ if (least > options[size]) {
67
+ 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}.`);
68
+ }
69
+ if (most && most < options[size]) {
70
+ 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}.`);
71
+ }
72
+ });
73
+ return options;
74
+ }
75
+
76
+ // The color an app shows until its page paints, and behind a page without a background of its own, on iOS and Android as
77
+ // well: app.window.backgroundColor, else null, which leaves the system's.
78
+ export function appBackgroundColor(config) {
79
+ const color = config.app.window?.backgroundColor ?? null;
80
+ if (color !== null && (typeof color !== 'string' || !/^#[0-9A-Fa-f]{6}$/.test(color))) {
81
+ throw new Error(`crossbind: app.window.backgroundColor ${JSON.stringify(color)} is no color of the form #rrggbb, as #1e1e1e is.`);
82
+ }
83
+ return color;
84
+ }
85
+
86
+ const PERMISSIONS = ['camera', 'microphone', 'location'];
87
+
88
+ // The devices the page may use, each with the reason for it, which macOS and iOS read from the Info.plist:
89
+ // app.permissions.
90
+ export function appPermissions(config) {
91
+ const permissions = config.app.permissions ?? {};
92
+ const names = PERMISSIONS.join(', ');
93
+ if (typeof permissions !== 'object' || Array.isArray(permissions)) {
94
+ 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.`);
95
+ }
96
+ const unknown = Object.keys(permissions).filter((name) => !PERMISSIONS.includes(name));
97
+ if (unknown.length > 0) throw new Error(`crossbind: app.permissions takes ${names}; ${unknown.join(', ')} is none of them.`);
98
+ Object.entries(permissions).forEach(([name, reason]) => {
99
+ if (typeof reason !== 'string' || !reason.trim() || /\p{Cc}/u.test(reason)) {
100
+ 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.`);
101
+ }
102
+ });
103
+ return permissions;
104
+ }
105
+
106
+ // The reasons of app.permissions under the Info.plist keys macOS or iOS reads them from; macOS reads location's older
107
+ // key as well.
108
+ export function usageDescriptions(config, platform) {
109
+ const keys = {
110
+ camera: ['NSCameraUsageDescription'],
111
+ microphone: ['NSMicrophoneUsageDescription'],
112
+ location: ['NSLocationWhenInUseUsageDescription', ...(platform === 'darwin' ? ['NSLocationUsageDescription'] : [])],
113
+ };
114
+ return Object.fromEntries(Object.entries(appPermissions(config)).flatMap(([name, reason]) => keys[name].map((key) => [key, reason])));
115
+ }
116
+
117
+ // The version a Windows app's executable carries: package.json's, whose numbers are its file version.
118
+ function windowsVersion(config) {
119
+ const version = config.package?.version ?? '1.0.0';
120
+ const match = typeof version === 'string' ? WINDOWS_VERSION.exec(version) : null;
121
+ const numbers = match ? match[1].split('.').map(Number) : [];
122
+ if (!match || numbers.some((number) => number > MAX_VERSION_NUMBER)) {
123
+ 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.`);
124
+ }
125
+ return { text: version, numbers: [...numbers, 0, 0, 0].slice(0, 4).join(',') };
126
+ }
127
+
128
+ // Where the page's shell.openPath opens a file: nowhere unless app.openPath lists it, as opening a program runs it.
129
+ function appOpenPath(config) {
130
+ const places = config.app.openPath ?? [];
131
+ if (!Array.isArray(places)) {
132
+ throw new Error(`crossbind: app.openPath ${JSON.stringify(places)} is no list of ${OPEN_PATH_PLACES.join(', ')}, as ['logs', 'picked'] is.`);
133
+ }
134
+ const unknown = places.filter((place) => !OPEN_PATH_PLACES.includes(place));
135
+ if (unknown.length > 0) {
136
+ throw new Error(`crossbind: app.openPath takes ${OPEN_PATH_PLACES.join(', ')}; ${unknown.map((place) => JSON.stringify(place)).join(', ')} is none of them.`);
137
+ }
138
+ return places;
139
+ }
140
+
141
+ // Schemes the web, the system or crossbind owns, which no app takes, and the keys of Windows' own classes, whose name a
142
+ // scheme's registry key would take.
143
+ const RESERVED_SCHEMES = [
144
+ 'about', 'blob', 'content', 'crossbind', 'data', 'file', 'ftp', 'http', 'https', 'intent', 'javascript', 'mailto', 'sms', 'tel', 'ws', 'wss',
145
+ 'appid', 'applications', 'clsid', 'directory', 'drive', 'folder', 'interface', 'mime', 'protocols', 'systemfileassociations', 'typelib', 'unknown',
146
+ 'wow6432node',
147
+ ];
148
+
149
+ // The schemes whose links open the app, as myapp://… does: lowercase, which is how Android matches them, and of two
150
+ // characters at least, as one would take a Windows path's drive for a scheme.
151
+ export function appUrlSchemes(config) {
152
+ const schemes = config.app.urlSchemes ?? [];
153
+ if (!Array.isArray(schemes)) {
154
+ throw new Error(`crossbind: app.urlSchemes ${JSON.stringify(schemes)} is no list of schemes, as ['myapp'] is.`);
155
+ }
156
+ for (const scheme of schemes) {
157
+ if (typeof scheme !== 'string' || !/^[a-z][a-z0-9+.-]+$/.test(scheme)) {
158
+ 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.`);
159
+ }
160
+ if (RESERVED_SCHEMES.includes(scheme)) {
161
+ throw new Error(`crossbind: app.urlSchemes cannot take ${scheme}, which the web or the system owns: pick a scheme of the app's own.`);
162
+ }
163
+ }
164
+ if (new Set(schemes).size !== schemes.length) throw new Error(`crossbind: app.urlSchemes lists a scheme twice: ${JSON.stringify(schemes)}.`);
165
+ return schemes;
166
+ }
167
+
168
+ // Semver's version: three numbers, then a prerelease and build metadata of dot-separated identifiers.
169
+ const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
170
+ // What an update's files are named after: characters an address carries as they are, and no leading dot.
171
+ const UPDATE_NAME = /^[A-Za-z0-9_~-][A-Za-z0-9._~-]*$/;
172
+ // The Ed25519 public keys of the points of small order, whatever the sign of x, whose signatures anyone can forge: the
173
+ // list libsodium's ge25519_has_small_order checks.
174
+ const SMALL_ORDER_KEYS = [
175
+ '0000000000000000000000000000000000000000000000000000000000000000',
176
+ '0100000000000000000000000000000000000000000000000000000000000000',
177
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
178
+ 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
179
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
180
+ 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
181
+ 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
182
+ ].map((hex) => Buffer.from(hex, 'hex'));
183
+
184
+ const isSmallOrder = (key) => SMALL_ORDER_KEYS.some((point) => key.subarray(0, 31).equals(point.subarray(0, 31)) && (key[31] & 0x7f) === point[31]);
185
+
186
+ // Where a desktop app finds its updates, the key their signatures are checked with, and the version they are newer than:
187
+ // package.json's, which semver orders. Null without app.updater.
188
+ export function appUpdater(config) {
189
+ const updater = config.app.updater;
190
+ if (updater === undefined) return null;
191
+ const keys = 'url and publicKey';
192
+ if (updater === null || typeof updater !== 'object' || Array.isArray(updater)) {
193
+ 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.`);
194
+ }
195
+ const unknown = Object.keys(updater).filter((key) => !['url', 'publicKey'].includes(key));
196
+ if (unknown.length > 0) throw new Error(`crossbind: app.updater takes ${keys}; ${unknown.join(', ')} is none of them.`);
197
+ const url = URL.canParse(updater.url) ? new URL(updater.url) : null;
198
+ const isLocal = url?.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
199
+ // The host takes the address as a C string, as it is written, and the update's file from beside the manifest.
200
+ if (!url || !(url.protocol === 'https:' || isLocal) || /[^\x21-\x7e]|["\\]/.test(updater.url) || url.pathname.endsWith('/')) {
201
+ 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.`);
202
+ }
203
+ const publicKey = Buffer.from(typeof updater.publicKey === 'string' ? updater.publicKey : '', 'base64');
204
+ if (publicKey.length !== 32 || publicKey.toString('base64') !== updater.publicKey) {
205
+ throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is no Ed25519 public key, which \`crossbind app updater-key\` prints.`);
206
+ }
207
+ if (isSmallOrder(publicKey)) {
208
+ 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.`);
209
+ }
210
+ const version = config.package?.version ?? '1.0.0';
211
+ if (typeof version !== 'string' || !SEMVER.test(version)) {
212
+ 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.`);
213
+ }
214
+ if (!UPDATE_NAME.test(config.general.name)) {
215
+ 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.`);
216
+ }
217
+ return { url: updater.url, publicKey, version };
218
+ }
219
+
220
+ // A desktop host's window: its page's size and limits, whether it resizes and whether it opens full screen. A maximum of
221
+ // one side alone leaves the other as large as any display.
222
+ function windowDefines(window) {
223
+ const most = window.maxWidth || window.maxHeight ? [
224
+ `#define CROSSBIND_APP_WINDOW_MAX_WIDTH ${window.maxWidth || MAX_WINDOW_SIZE}`,
225
+ `#define CROSSBIND_APP_WINDOW_MAX_HEIGHT ${window.maxHeight || MAX_WINDOW_SIZE}`,
226
+ ] : [];
227
+ return [
228
+ `#define CROSSBIND_APP_WINDOW_WIDTH ${window.width}`,
229
+ `#define CROSSBIND_APP_WINDOW_HEIGHT ${window.height}`,
230
+ `#define CROSSBIND_APP_WINDOW_MIN_WIDTH ${window.minWidth}`,
231
+ `#define CROSSBIND_APP_WINDOW_MIN_HEIGHT ${window.minHeight}`,
232
+ `#define CROSSBIND_APP_WINDOW_RESIZABLE ${window.resizable ? 1 : 0}`,
233
+ ...most,
234
+ ...(window.fullscreen ? ['#define CROSSBIND_APP_WINDOW_FULLSCREEN 1'] : []),
235
+ ];
236
+ }
237
+
238
+ // The header an app's host compiles where its page opens files from, an iOS or desktop host the color it shows until
239
+ // its page paints and the devices the page may use, a desktop host its title, window, whether it keeps one instance and
240
+ // its updates, and a Windows host its version resource.
241
+ export function appHeader(config, platform) {
242
+ const comment = '// crossbind writes this file from crossbind.config.js and package.json.';
243
+ const openPath = `#define CROSSBIND_APP_OPEN_PATH {${appOpenPath(config).map((place) => `"${place}"`).join(', ')}}`;
244
+ const color = appBackgroundColor(config);
245
+ const permissions = appPermissions(config);
246
+ // An Android app's activity takes the color and the permissions from its Gradle project.
247
+ const page = platform === 'android' ? [] : [
248
+ ...(color ? [`#define CROSSBIND_APP_BACKGROUND_COLOR 0x${color.slice(1).toLowerCase()}`] : []),
249
+ ...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
250
+ ];
251
+ if (!['darwin', 'linux', 'win32'].includes(platform)) return [comment, openPath, ...page, ''].join('\n');
252
+ const lines = [
253
+ comment,
254
+ openPath,
255
+ ...page,
256
+ `#define CROSSBIND_APP_ID "${appId(config)}"`,
257
+ `#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`,
258
+ ...windowDefines(appWindow(config)),
259
+ ];
260
+ const urlSchemes = appUrlSchemes(config);
261
+ if (urlSchemes.length > 0) lines.push(`#define CROSSBIND_APP_URL_SCHEMES {${urlSchemes.map((scheme) => `"${scheme}"`).join(', ')}}`);
262
+ // A link the system opens a Windows or Linux app with starts it again, which reaches the app that runs this way; macOS
263
+ // hands the link to the app that runs.
264
+ const singleInstance = config.app.singleInstance ?? (urlSchemes.length > 0 && platform !== 'darwin');
265
+ if (typeof singleInstance !== 'boolean') {
266
+ 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.`);
267
+ }
268
+ if (singleInstance) lines.push('#define CROSSBIND_APP_SINGLE_INSTANCE 1');
269
+ const updater = appUpdater(config);
270
+ if (updater) {
271
+ lines.push(
272
+ `#define CROSSBIND_APP_UPDATER_URL "${updater.url}"`,
273
+ `#define CROSSBIND_APP_UPDATER_KEY {${[...updater.publicKey].map((byte) => `0x${byte.toString(16).padStart(2, '0')}`).join(', ')}}`,
274
+ `#define CROSSBIND_APP_UPDATER_VERSION "${updater.version}"`,
275
+ );
276
+ }
277
+ if (platform === 'win32') {
278
+ const version = windowsVersion(config);
279
+ lines.push(`#define CROSSBIND_APP_VERSION "${version.text}"`, `#define CROSSBIND_APP_VERSION_NUMBERS ${version.numbers}`);
280
+ // The build writes the icon beside this header.
281
+ if (config.app.icon !== undefined) lines.push('#define CROSSBIND_APP_ICON 1');
282
+ }
283
+ return [...lines, ''].join('\n');
284
+ }
@@ -80,7 +80,9 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
80
80
  ...crates.map((c) => `${c.replaceAll('_', '-')}-crossbind-app = { path = "${relPath(superDir, `${root}/${c}`)}" }`),
81
81
  '',
82
82
  '[profile.release]',
83
- 'panic = "abort"',
83
+ // Unwinding, as in a package's bridge, whose shims raise a panic as a JS exception: an abort runtime here could
84
+ // end the process on a panic in any crate. wasm still aborts, as its std does.
85
+ 'panic = "unwind"',
84
86
  '',
85
87
  ...crates.flatMap((c) => [`[profile.release.package.${c.replaceAll('_', '-')}-crossbind-app]`, 'codegen-units = 1', '']),
86
88
  '[workspace]',
@@ -14,19 +14,45 @@ const RETRYABLE_STATUS = new Set([408, 429]);
14
14
  const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
15
15
  const retryable = (error) => Object.assign(error, { retryable: true });
16
16
 
17
- export default async function downloadAndExtractFile(url, output, sha256, options = {}) {
18
- const archive = path.basename(url);
17
+ // A recipe names its source with getURL and may name mirrors that serve the same archive under the same pin.
18
+ export function sourcesOf({ getURL, getMirrors }, version) {
19
+ return [getURL(version), ...(getMirrors?.(version) ?? [])];
20
+ }
21
+
22
+ // `urls` is one URL or a list of them: the first names the archive, the others are mirrors of it.
23
+ export default async function downloadAndExtractFile(urls, output, sha256, options = {}) {
24
+ const sources = [urls].flat();
25
+ const archive = path.basename(sources[0]);
19
26
  if (fs.existsSync(`${output}/source`) && sourceCameFrom(output, archive)) {
20
27
  return false;
21
28
  }
29
+ sources.forEach((url) => assertHttps(url));
22
30
  fs.rmSync(`${output}/source`, { recursive: true, force: true });
23
- const filePath = await downloadFile(url, output, options);
24
- verifyIntegrity(filePath, url, sha256);
31
+ const { filePath, url } = await downloadFromAny(sources, output, sha256, { ...options, name: archive });
25
32
  extractArchive(filePath, url, output);
26
33
  fs.writeFileSync(`${output}/source.archive`, archive);
27
34
  return true;
28
35
  }
29
36
 
37
+ // A source that cannot serve the archive, or serves bytes the pin does not match, hands over to the next one.
38
+ async function downloadFromAny(sources, output, sha256, options) {
39
+ const failures = [];
40
+ for (const url of sources) {
41
+ try {
42
+ const filePath = await downloadFile(url, output, options);
43
+ verifyIntegrity(filePath, url, sha256);
44
+ return { filePath, url };
45
+ } catch (err) {
46
+ failures.push(err);
47
+ }
48
+ }
49
+ if (failures.length === 1) throw failures[0];
50
+ throw new Error(
51
+ `crossbind: no source of ${options.name} could be used:\n${failures.map((err) => ` - ${err.message}`).join('\n')}`,
52
+ { cause: failures.at(-1) },
53
+ );
54
+ }
55
+
30
56
  // A cached source tree only counts when it came from the archive the recipe names now, or a
31
57
  // nativeVersion bump would keep compiling the old tree. Trees from before the marker are judged
32
58
  // by the archive that was downloaded next to them.
@@ -37,20 +63,22 @@ function sourceCameFrom(output, archive) {
37
63
  }
38
64
 
39
65
  // Extraction runs through the system tar (GNU tar and bsdtar both detect gzip/bzip2/xz, and
40
- // every platform the engine builds on ships one). Extracting into a scratch directory keeps
41
- // the archive from choosing where its files land, and gives the upstream root folder - which
42
- // recipes address as build/source - one place to be renamed from.
66
+ // every platform the engine builds on ships one), and a zip archive, which GNU tar cannot read,
67
+ // through unzip. Extracting into a scratch directory keeps the archive from choosing where its
68
+ // files land, and gives the upstream root folder - which recipes address as build/source - one
69
+ // place to be renamed from.
43
70
  function extractArchive(filePath, url, output) {
44
71
  const work = `${output}/.extract`;
45
72
  fs.rmSync(work, { recursive: true, force: true });
46
73
  mkdirSync(work, { recursive: true });
47
74
  try {
48
- const tar = spawnSync('tar', ['-xf', filePath, '-C', work], { encoding: 'utf8' });
49
- if (tar.error) {
50
- throw new Error(`crossbind: cannot run tar to extract ${filePath}: ${tar.error.message}`);
75
+ const [tool, args] = /\.(zip|aar)$/.test(filePath) ? ['unzip', ['-q', filePath, '-d', work]] : ['tar', ['-xf', filePath, '-C', work]];
76
+ const extracted = spawnSync(tool, args, { encoding: 'utf8' });
77
+ if (extracted.error) {
78
+ throw new Error(`crossbind: cannot run ${tool} to extract ${filePath}: ${extracted.error.message}`);
51
79
  }
52
- if (tar.status !== 0) {
53
- throw new Error(`crossbind: extracting ${filePath} (from ${url}) failed: ${(tar.stderr || '').trim() || `tar exited ${tar.status}`}`);
80
+ if (extracted.status !== 0) {
81
+ throw new Error(`crossbind: extracting ${filePath} (from ${url}) failed: ${(extracted.stderr || '').trim() || `${tool} exited ${extracted.status}`}`);
54
82
  }
55
83
  const entries = fs.readdirSync(work, { withFileTypes: true });
56
84
  if (entries.length === 0) {
@@ -95,9 +123,9 @@ export function assertHttps(target, context = target) {
95
123
 
96
124
  // fetch follows redirects itself, which release downloads rely on (github and the osgeo
97
125
  // mirrors both bounce), so the download needs no redirect library of its own.
98
- export async function downloadFile(url, folder, { retryDelaysMs = RETRY_DELAYS_MS } = {}) {
126
+ export async function downloadFile(url, folder, { retryDelaysMs = RETRY_DELAYS_MS, name = path.basename(url) } = {}) {
99
127
  mkdirSync(folder, { recursive: true });
100
- const dest = `${folder}/${path.basename(url)}`;
128
+ const dest = `${folder}/${name}`;
101
129
  if (fs.existsSync(dest)) return dest;
102
130
 
103
131
  assertHttps(url);
@@ -1,18 +1,25 @@
1
1
  import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import { getContentHash, getFileHash } from './hash.js';
4
5
  import { collectRustSources } from './inputStamp.js';
5
6
  import resolveEmbindRustRoot from './resolveEmbindRust.js';
7
+ import { HOST_BUILT_PLATFORMS } from './targets.js';
8
+
9
+ const { toolchains } = createRequire(import.meta.url)('../assets/toolchain-digests.json');
6
10
 
7
11
  // Every cargo staticlib bundles the embind-rs it was built from. An archive from another one links stale glue, and next
8
12
  // to an archive built after it every embind_rs symbol is defined twice, so a prebuilt records the one it came from.
9
13
  export const EMBIND_RS_FINGERPRINT_FILE = 'crossbind-embind-rs.fingerprint';
10
14
 
11
- // Paths count relative to the crate, so an archive built from the same embind-rs elsewhere matches.
12
- export function getEmbindRsFingerprint() {
15
+ // Paths count relative to the crate, so an archive built from the same embind-rs elsewhere matches. An archive an image
16
+ // built carries that image's Rust std too, whose second copy from another rustc lld refuses, so the image's rustc
17
+ // counts as well; Apple's linker takes the mix, and the host's own rustc builds those.
18
+ export function getEmbindRsFingerprint(target) {
13
19
  const crate = `${resolveEmbindRustRoot()}/crate`;
14
20
  const files = [...collectRustSources([crate]), `${crate}/Cargo.toml`];
15
- return getContentHash(files.map((file) => `${path.relative(crate, file)}:${getFileHash(file)}`).sort().join('\n'));
21
+ const embindRs = getContentHash(files.map((file) => `${path.relative(crate, file)}:${getFileHash(file)}`).sort().join('\n'));
22
+ return !target || HOST_BUILT_PLATFORMS.includes(target.platform) ? embindRs : getContentHash(`${embindRs}\nrustc ${toolchains.rust}`);
16
23
  }
17
24
 
18
25
  export function isEmbindRsFingerprintStale(prebuiltDir, fingerprint) {
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import withDirLock from './dirLock.js';
5
+ import downloadAndExtractFile from './downloadAndExtractFile.js';
6
+
7
+ // The Hermes React Native publishes on Maven Central for Apple platforms: the macOS framework, the iOS xcframework,
8
+ // hermesc and the headers. A new version also moves third_party/jsi of @crossbind/core-app-host to its tag, and the
9
+ // Hermes of crossbind's android-app, linux and windows images.
10
+ export const HERMES = {
11
+ version: '250829098.0.17',
12
+ sha256: '2967ee51b41bd433b3027dda875e96194354e2b4d715f9f186fd0c04ccef309d',
13
+ license: 'MIT',
14
+ };
15
+
16
+ export const hermesUrl = (version) => `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-release.tar.gz`;
17
+
18
+ export const hermesDir = () => path.join(os.homedir(), '.crossbind', 'hermes', HERMES.version);
19
+
20
+ // The release's destroot, downloaded once and checked against the pinned sha256.
21
+ export default async function resolveHermes() {
22
+ const dir = hermesDir();
23
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
24
+ await withDirLock(`${dir}.lock`, () => downloadAndExtractFile(hermesUrl(HERMES.version), dir, HERMES.sha256));
25
+ return `${dir}/source/destroot`;
26
+ }
27
+
28
+ // Linux and Windows have no published Hermes, so crossbind's linux and windows images build the release's source, with
29
+ // their static libc++: JSI hands std types across, and the host and the project's archives build there too.
30
+ export const HERMES_SOURCE = {
31
+ url: `https://github.com/facebook/hermes/archive/refs/tags/hermes-v${HERMES.version}.tar.gz`,
32
+ sha256: '37e91915c67ce0c457f69b2de00fcaf28fcf755ab7de6e4f33f1d6ee9efcffed',
33
+ };
34
+
35
+ // Android's Hermes is React Native's AAR, which an app's Gradle build takes for the libraries it packages, and which
36
+ // crossbind's android-app image holds, with its headers and hermesc, for the app's host.
37
+ export const HERMES_ANDROID = {
38
+ url: `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-android/${HERMES.version}/hermes-android-${HERMES.version}-release.aar`,
39
+ sha256: '6fb440b29aadb5925bed1109338da61623c54c3508020bb2cecebcaaa80e53b8',
40
+ };