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.
- package/package.json +6 -1
- package/src/actions/androidDevice.js +77 -0
- package/src/actions/androidGradle.js +121 -0
- package/src/actions/appInstaller.js +174 -0
- package/src/actions/appUpdate.js +68 -0
- package/src/actions/appleSigning.js +96 -0
- package/src/actions/buildApp.js +509 -0
- package/src/actions/buildDependencies.js +4 -3
- package/src/actions/buildExternal.js +3 -3
- package/src/actions/buildLib.js +1 -1
- package/src/actions/buildNode.js +15 -10
- package/src/actions/createInterface.js +1 -2
- package/src/actions/createLib.js +13 -5
- package/src/actions/getCmakeParameters.js +6 -2
- package/src/actions/getDependLibs.js +8 -2
- package/src/actions/getLinkInputs.js +10 -7
- package/src/actions/gradleProject.js +171 -0
- package/src/actions/iosSimulator.js +63 -0
- package/src/actions/licenses.js +189 -14
- package/src/actions/nativeBridges.js +35 -0
- package/src/actions/run.js +3 -3
- package/src/actions/target.js +2 -1
- package/src/actions/windowsSigning.js +96 -0
- package/src/actions/xcodeProject.js +186 -0
- package/src/app/index.d.ts +129 -0
- package/src/app/index.js +184 -0
- package/src/assets/js-runtime/adapters/worker-comlink.js +8 -1
- package/src/assets/toolchain-digests.json +24 -18
- package/src/bin.js +84 -32
- package/src/index.js +7 -0
- package/src/integration/getCrossbindScript.js +15 -1
- package/src/state/index.js +5 -1
- package/src/state/loadConfig.js +2 -0
- package/src/utils/appApiHeader.js +29 -0
- package/src/utils/appIcon.js +174 -0
- package/src/utils/appOptions.js +284 -0
- package/src/utils/appRustCrates.js +3 -1
- package/src/utils/downloadAndExtractFile.js +42 -14
- package/src/utils/embindRsFingerprint.js +10 -3
- package/src/utils/hermes.js +40 -0
- package/src/utils/plist.js +42 -0
- package/src/utils/pullDockerImage.js +9 -3
- package/src/utils/replaceBasePathForDocker.js +8 -0
- package/src/utils/resolveAppHost.js +21 -0
- package/src/utils/selectRunner.js +1 -1
- package/src/utils/systemKeys.js +4 -0
- package/src/utils/targets.js +19 -3
- package/src/utils/writeIfChanged.js +1 -1
- package/vitest.config.js +19 -0
package/src/app/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// What an app's host does for its page: the dialogs that pick files or show a message, opening an address or a file
|
|
2
|
+
// outside the app, the app's folders, quitting, its menus, the events of the app's native code and a desktop app's
|
|
3
|
+
// updates. Each call goes to the host of an app (target.runtimeEnv: 'app'); in any other build it rejects, or events.on
|
|
4
|
+
// throws. The app's native code calls the same through <crossbind/app.h>, but for the menus and events.on.
|
|
5
|
+
|
|
6
|
+
const OPEN = { title: 'string', defaultPath: 'string', filters: 'filters', directory: 'boolean', multiple: 'boolean' };
|
|
7
|
+
const SAVE = { title: 'string', defaultPath: 'string', defaultName: 'string', filters: 'filters' };
|
|
8
|
+
const MESSAGE = { message: 'string', detail: 'string', kind: 'string', buttons: 'buttons' };
|
|
9
|
+
const MESSAGE_KINDS = ['info', 'warning', 'error'];
|
|
10
|
+
// Android's dialogs take three buttons at most.
|
|
11
|
+
const MAX_BUTTONS = 3;
|
|
12
|
+
|
|
13
|
+
// The app's crossbind.js, which initNative() loads, hands the calls to the host.
|
|
14
|
+
function hostOf(member, name) {
|
|
15
|
+
const host = globalThis.Crossbind?.[member];
|
|
16
|
+
if (host) return host;
|
|
17
|
+
if (globalThis.Crossbind) throw new Error(`crossbind: this app's host is older than crossbind/app; update @crossbind/core-app-host to call ${name}`);
|
|
18
|
+
if (globalThis.__crossbind_token) throw new Error(`crossbind: ${name} works once the page has called initNative()`);
|
|
19
|
+
throw new Error(`crossbind: ${name} works in an app alone, whose crossbind.config.js sets target.runtimeEnv: 'app'`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const call = (name, options) => hostOf('app', name)(name, options);
|
|
23
|
+
|
|
24
|
+
const isFilters = (value) => Array.isArray(value) && value.every((filter) => typeof filter?.name === 'string'
|
|
25
|
+
&& Array.isArray(filter.extensions) && filter.extensions.every((extension) => typeof extension === 'string'));
|
|
26
|
+
|
|
27
|
+
// Spread reads a hole as undefined, which every() skips.
|
|
28
|
+
const isButtons = (value) => Array.isArray(value) && value.length >= 1 && value.length <= MAX_BUTTONS
|
|
29
|
+
&& [...value].every((label) => typeof label === 'string' && label !== '');
|
|
30
|
+
|
|
31
|
+
// The lists an option may be, beside what typeof tells: how each is checked, and what it is called.
|
|
32
|
+
const LISTS = {
|
|
33
|
+
filters: { isFitting: isFilters, name: 'list of { name, extensions }' },
|
|
34
|
+
buttons: { isFitting: isButtons, name: 'list of one to three labels' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The options as the host takes them, so a misspelt or mistyped one fails here, saying which.
|
|
38
|
+
function checked(name, options, kinds) {
|
|
39
|
+
const keys = Object.keys(kinds).join(', ');
|
|
40
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
41
|
+
throw new TypeError(`crossbind: ${name} takes an object of ${keys}.`);
|
|
42
|
+
}
|
|
43
|
+
for (const [key, value] of Object.entries(options)) {
|
|
44
|
+
if (!Object.hasOwn(kinds, key)) throw new TypeError(`crossbind: ${name} takes ${keys}; ${key} is none of them.`);
|
|
45
|
+
const list = LISTS[kinds[key]];
|
|
46
|
+
if (value !== undefined && !(list ? list.isFitting(value) : typeof value === kinds[key])) {
|
|
47
|
+
throw new TypeError(`crossbind: ${name}'s ${key} is no ${list ? list.name : kinds[key]}.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return options;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkedMessage(options) {
|
|
54
|
+
checked('dialog.message', options, MESSAGE);
|
|
55
|
+
if (!options.message) throw new TypeError('crossbind: dialog.message takes a message.');
|
|
56
|
+
if (options.kind !== undefined && !MESSAGE_KINDS.includes(options.kind)) {
|
|
57
|
+
throw new TypeError(`crossbind: dialog.message's kind is info, warning or error, not ${JSON.stringify(options.kind)}.`);
|
|
58
|
+
}
|
|
59
|
+
return options;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkedPath(name, path) {
|
|
63
|
+
if (typeof path !== 'string' || !path) throw new TypeError(`crossbind: ${name} takes a path.`);
|
|
64
|
+
return { path };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A menu of the bar is { label, items } or a standard one by its role. An item is { label, id, accelerator, enabled,
|
|
68
|
+
// checked }, a submenu { label, items }, { type: 'separator' }, or a standard item by its role, which a label may
|
|
69
|
+
// rename and, but for an editing one, which keeps the platform's keys, an accelerator change.
|
|
70
|
+
const MENU_ROLES = ['editMenu', 'windowMenu'];
|
|
71
|
+
const EDITING_ROLES = ['undo', 'redo', 'cut', 'copy', 'paste', 'selectAll'];
|
|
72
|
+
const ITEM_ROLES = [...EDITING_ROLES, 'minimize', 'close', 'quit'];
|
|
73
|
+
const ITEM_KINDS = {
|
|
74
|
+
label: 'string', id: 'string', accelerator: 'string', enabled: 'boolean', checked: 'boolean', items: 'list', role: 'string', type: 'string',
|
|
75
|
+
};
|
|
76
|
+
const MODIFIERS = ['CmdOrCtrl', 'Ctrl', 'Alt', 'Shift'];
|
|
77
|
+
const KEY = /^([A-Z0-9]|F([1-9]|1[0-2])|Enter|Escape|Tab|Space|Backspace|Delete|Up|Down|Left|Right|Home|End|PageUp|PageDown|Plus|Minus|Comma|Period)$/;
|
|
78
|
+
|
|
79
|
+
// Typing uses neither a key with CmdOrCtrl, Ctrl or Alt nor a function key.
|
|
80
|
+
function checkAccelerator(accelerator, where) {
|
|
81
|
+
const modifiers = accelerator.split('+');
|
|
82
|
+
const key = modifiers.pop();
|
|
83
|
+
const isKnown = KEY.test(key) && modifiers.every((modifier) => MODIFIERS.includes(modifier)) && new Set(modifiers).size === modifiers.length;
|
|
84
|
+
if (!isKnown || !(/^F\d+$/.test(key) || modifiers.some((modifier) => modifier !== 'Shift'))) {
|
|
85
|
+
throw new TypeError(`crossbind: menu.set's ${where}.accelerator ${JSON.stringify(accelerator)} is none, which is a key with CmdOrCtrl, Ctrl or Alt and maybe Shift, joined by +, as CmdOrCtrl+Shift+O is, or F1 to F12, alone or with any of them.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkMenuItem(item, where, isMenu) {
|
|
90
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) throw new TypeError(`crossbind: menu.set's ${where} is no object.`);
|
|
91
|
+
for (const [key, value] of Object.entries(item)) {
|
|
92
|
+
if (!Object.hasOwn(ITEM_KINDS, key)) throw new TypeError(`crossbind: menu.set's ${where} takes ${Object.keys(ITEM_KINDS).join(', ')}; ${key} is none of them.`);
|
|
93
|
+
const kind = ITEM_KINDS[key];
|
|
94
|
+
if (value !== undefined && !(kind === 'list' ? Array.isArray(value) : typeof value === kind)) {
|
|
95
|
+
throw new TypeError(`crossbind: menu.set's ${where}.${key} is no ${kind}.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const has = (...keys) => keys.filter((key) => item[key] !== undefined);
|
|
99
|
+
const refuse = (what) => {
|
|
100
|
+
throw new TypeError(`crossbind: menu.set's ${where} ${what}.`);
|
|
101
|
+
};
|
|
102
|
+
if (item.type !== undefined) {
|
|
103
|
+
if (item.type !== 'separator' || isMenu || has(...Object.keys(ITEM_KINDS)).length > 1) refuse("is no separator, which is { type: 'separator' } alone, inside a menu");
|
|
104
|
+
} else if (item.role !== undefined) {
|
|
105
|
+
const roles = isMenu ? MENU_ROLES : [...ITEM_ROLES, ...MENU_ROLES];
|
|
106
|
+
if (!roles.includes(item.role)) refuse(`has no role ${JSON.stringify(item.role)}, which is one of ${roles.join(', ')}`);
|
|
107
|
+
if (MENU_ROLES.includes(item.role) && has('id', 'accelerator', 'enabled', 'checked', 'items').length > 0) refuse('is a standard menu, which takes a label alone');
|
|
108
|
+
if (EDITING_ROLES.includes(item.role) && item.accelerator !== undefined) refuse("is an editing item, which keeps the platform's keys and takes a label alone");
|
|
109
|
+
if (has('id', 'enabled', 'checked', 'items').length > 0) refuse('has a role, which takes a label and an accelerator alone');
|
|
110
|
+
} else if (!item.label) {
|
|
111
|
+
refuse('has no label');
|
|
112
|
+
} else if (item.items !== undefined) {
|
|
113
|
+
if (has('id', 'accelerator', 'enabled', 'checked').length > 0) refuse('is a menu, which takes a label and items alone');
|
|
114
|
+
// entries() reads a hole as undefined, which forEach skips.
|
|
115
|
+
for (const [index, child] of item.items.entries()) checkMenuItem(child, `${where}.items[${index}]`, false);
|
|
116
|
+
} else if (isMenu) {
|
|
117
|
+
refuse('is a menu, which takes a label and items, or the role editMenu or windowMenu');
|
|
118
|
+
}
|
|
119
|
+
if (item.accelerator !== undefined) checkAccelerator(item.accelerator, where);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function checkedMenus(menus) {
|
|
123
|
+
if (!Array.isArray(menus)) throw new TypeError('crossbind: menu.set takes a list of menus.');
|
|
124
|
+
for (const [index, item] of menus.entries()) checkMenuItem(item, `menus[${index}]`, true);
|
|
125
|
+
return { menus };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const dialog = {
|
|
129
|
+
// The paths of the files or folders the user picked, or null when they cancelled.
|
|
130
|
+
open: async (options = {}) => call('dialog.open', checked('dialog.open', options, OPEN)),
|
|
131
|
+
// The path the user chose to save to, or null when they cancelled.
|
|
132
|
+
save: async (options = {}) => call('dialog.save', checked('dialog.save', options, SAVE)),
|
|
133
|
+
// The index of the button the user clicked: Return gives the first, and Escape or closing the dialog the last.
|
|
134
|
+
message: async (options) => call('dialog.message', checkedMessage(options)),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const shell = {
|
|
138
|
+
// An http, https or mailto address in the default browser or mail app.
|
|
139
|
+
openExternal: async (url) => {
|
|
140
|
+
if (typeof url !== 'string' && !(url instanceof URL)) throw new TypeError('crossbind: shell.openExternal takes an address.');
|
|
141
|
+
return call('shell.openExternal', { url: String(url) });
|
|
142
|
+
},
|
|
143
|
+
// A file in the app the system opens its kind with. Opening a program runs it, and a script in the page could ask for
|
|
144
|
+
// any path, so the host opens files where app.openPath in crossbind.config.js lets it alone; native code opens any.
|
|
145
|
+
openPath: async (path) => call('shell.openPath', checkedPath('shell.openPath', path)),
|
|
146
|
+
// A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run.
|
|
147
|
+
revealInFolder: async (path) => call('shell.revealInFolder', checkedPath('shell.revealInFolder', path)),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const app = {
|
|
151
|
+
// The app's own folders, made when first asked for: { data, cache, logs }.
|
|
152
|
+
paths: async () => call('app.paths', {}),
|
|
153
|
+
// Ends the app as closing its window does.
|
|
154
|
+
quit: async () => call('app.quit', {}),
|
|
155
|
+
// The links of app.urlSchemes that opened the app and have not reached the page yet, the one it was launched with
|
|
156
|
+
// among them; later ones come as the event open-url. Listening first, then asking, misses none.
|
|
157
|
+
launchUrls: async () => call('app.launchUrls', {}),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const menu = {
|
|
161
|
+
// Sets the app's menus: on macOS after its app menu, which the app keeps, and on Windows and Linux in a bar of its
|
|
162
|
+
// window. A click of an item with an id is an event, menu, of { id }, and of { id, checked } for an item it checked
|
|
163
|
+
// or unchecked; a standard item does what its role says.
|
|
164
|
+
set: async (menus) => call('menu.set', checkedMenus(menus)),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const events = {
|
|
168
|
+
// Calls listener with the value of each event of this name the app's native code emits from now on, until the
|
|
169
|
+
// function it returns is called.
|
|
170
|
+
on: (name, listener) => {
|
|
171
|
+
if (typeof name !== 'string' || !name) throw new TypeError('crossbind: events.on takes the name of an event.');
|
|
172
|
+
if (typeof listener !== 'function') throw new TypeError('crossbind: events.on takes a function to call.');
|
|
173
|
+
return hostOf('on', 'events.on')(name, listener);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// One check or install runs at a time, and another rejects meanwhile.
|
|
178
|
+
export const updater = {
|
|
179
|
+
// The version app.updater.url in crossbind.config.js offers when it is newer than the app's, as { version }, or null.
|
|
180
|
+
check: async () => call('updater.check', {}),
|
|
181
|
+
// Downloads that version, checks that the key of app.updater.publicKey signed it and installs it in place of the app,
|
|
182
|
+
// which then quits and opens the new version. It rejects when that fails.
|
|
183
|
+
install: async () => call('updater.install', {}),
|
|
184
|
+
};
|
|
@@ -41,8 +41,12 @@ const ownsObject = (obj) => {
|
|
|
41
41
|
return Boolean($$ && ($$.smartPtr || $$.count?.owned));
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
// The module's own objects, its globals above all, live as long as the module, so letting go of a handle to one
|
|
45
|
+
// frees nothing.
|
|
46
|
+
const moduleObjects = new WeakSet();
|
|
47
|
+
|
|
44
48
|
const freeIfOwned = (obj) => {
|
|
45
|
-
if (ownsObject(obj) && !obj.isDeleted()) obj.delete();
|
|
49
|
+
if (ownsObject(obj) && !moduleObjects.has(obj) && !obj.isDeleted()) obj.delete();
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
// The worker side of a handle the main thread no longer reaches: its id goes, and the object with the last id.
|
|
@@ -297,6 +301,9 @@ export function moduleRoot(m) {
|
|
|
297
301
|
configurable: true,
|
|
298
302
|
});
|
|
299
303
|
Object.defineProperty(m, RELEASE, { value: releaseEmbindObjects, configurable: true });
|
|
304
|
+
Object.values(m).forEach((value) => {
|
|
305
|
+
if (value !== null && typeof value === 'object') moduleObjects.add(value);
|
|
306
|
+
});
|
|
300
307
|
return wrapWithVectorCoercion(m);
|
|
301
308
|
}
|
|
302
309
|
|
|
@@ -1,49 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0.
|
|
2
|
+
"version": "1.0.16",
|
|
3
3
|
"registry": "ghcr.io/crossbind",
|
|
4
4
|
"toolchains": {
|
|
5
5
|
"rust": "1.99.1"
|
|
6
6
|
},
|
|
7
7
|
"images": {
|
|
8
8
|
"rust-sysroot": {
|
|
9
|
-
"index": "sha256:
|
|
9
|
+
"index": "sha256:b0745e62b7bc2d07acebf8dce8edef2e24a5f82f55bf37f51bacdbb67c393339",
|
|
10
10
|
"platforms": {
|
|
11
|
-
"linux/
|
|
12
|
-
"linux/
|
|
11
|
+
"linux/amd64": "sha256:42e71c9f4b349e116fb25cf1fc357cb070ae9e294a6087bb111dad2346256594",
|
|
12
|
+
"linux/arm64": "sha256:7144a83fa33d2beaad105e88c0067fb6a44d9b9620e576f226758872962cca44"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"base": {
|
|
16
|
-
"index": "sha256:
|
|
16
|
+
"index": "sha256:d1f2fee78a0238aebb70a2aaed3753212f8151afe0430144b4dfb522f1ccf2ac",
|
|
17
17
|
"platforms": {
|
|
18
|
-
"linux/arm64": "sha256:
|
|
19
|
-
"linux/amd64": "sha256:
|
|
18
|
+
"linux/arm64": "sha256:7faf80e5692ae636f4bc25af0849711c442275b2405b3d52f9e1943ed8089aa2",
|
|
19
|
+
"linux/amd64": "sha256:7d31882ed442c0425746fa1cbadf4bf1f6a5f035b2222cb907ad5006ef1af24d"
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"web": {
|
|
23
|
-
"index": "sha256:
|
|
23
|
+
"index": "sha256:97d873ae67dac443a88d0dede3fee2893177f167e08e8c9096c29aebd95a3d0f",
|
|
24
24
|
"platforms": {
|
|
25
|
-
"linux/arm64": "sha256:
|
|
26
|
-
"linux/amd64": "sha256:
|
|
25
|
+
"linux/arm64": "sha256:a274755f6d2cc1702ff7681b75e44b5d1146b73b45ac2dab5e4b543554ad4b19",
|
|
26
|
+
"linux/amd64": "sha256:23414220cfd4839005d3a5c1e3b83cff4760a3e6eb23687c2a71a1187d0cd211"
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"android": {
|
|
30
|
-
"index": "sha256:
|
|
30
|
+
"index": "sha256:5cae71358e7619d5958dab6f38b5dad9582792dc3a49c07fdf8dbdbf157ab5bd",
|
|
31
31
|
"platforms": {
|
|
32
|
-
"linux/amd64": "sha256:
|
|
32
|
+
"linux/amd64": "sha256:96ff7fa364b8fb109fd3f0257f591c3dc6315e2b1a810511bb129807940895bc"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"android-app": {
|
|
36
|
+
"index": "sha256:b225a3d04d771916c7a2cc0261990f09b71f6c8b5ae63bc0f3869c7cb5a00fb9",
|
|
37
|
+
"platforms": {
|
|
38
|
+
"linux/amd64": "sha256:e18361ed3cee022767312cd9f63471f4fe4bf41fccb9c7dd53dafec2da67adc0"
|
|
33
39
|
}
|
|
34
40
|
},
|
|
35
41
|
"linux": {
|
|
36
|
-
"index": "sha256:
|
|
42
|
+
"index": "sha256:6a642147d344f804f42241fc80c21b07bfe1eb40a9a364250959c3715a60700a",
|
|
37
43
|
"platforms": {
|
|
38
|
-
"linux/
|
|
39
|
-
"linux/
|
|
44
|
+
"linux/arm64": "sha256:2582d7cf143f876ad4a324174ccb0a1097c4ca2ce775c0216756415ca797cb93",
|
|
45
|
+
"linux/amd64": "sha256:a10f0efc028e8ba63a7a45dda475eb669d8a078bf1e2830ac0a8c026277afe92"
|
|
40
46
|
}
|
|
41
47
|
},
|
|
42
48
|
"windows": {
|
|
43
|
-
"index": "sha256:
|
|
49
|
+
"index": "sha256:c4c436763d559a5346f96369b444b79006d4298c5dfce78c15c06089cfdaaa33",
|
|
44
50
|
"platforms": {
|
|
45
|
-
"linux/arm64": "sha256:
|
|
46
|
-
"linux/amd64": "sha256:
|
|
51
|
+
"linux/arm64": "sha256:6030741b223fcaccf301e165fb6dace4053dafaba966b067d48c376b7aedb290",
|
|
52
|
+
"linux/amd64": "sha256:b314de43f2ad7dec0e97e4f0d5033a8c53795118034d13220eb05c9ab885c7a5"
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
}
|
package/src/bin.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
5
8
|
import { Option } from 'commander';
|
|
6
9
|
|
|
7
10
|
import { state } from './index.js';
|
|
8
11
|
import { addSystemCommands, createProgram, handleFatal } from './program.js';
|
|
9
|
-
import createBridgeFile from './actions/createInterface.js';
|
|
10
12
|
import createLib from './actions/createLib.js';
|
|
11
13
|
import buildWasm from './actions/buildWasm.js';
|
|
12
14
|
import buildWasiCommand from './actions/buildWasiCommand.js';
|
|
13
15
|
import buildNode, { addonsInPackages, bundleNodeLoader, publishNodeData } from './actions/buildNode.js';
|
|
14
16
|
import buildNativeCommand from './actions/buildNativeCommand.js';
|
|
17
|
+
import buildApp, { appOutputPath, assertAppRunner, publishApp } from './actions/buildApp.js';
|
|
18
|
+
import { releaseSigning } from './actions/androidGradle.js';
|
|
19
|
+
import { UPDATER_KEY, newUpdaterKey } from './actions/appUpdate.js';
|
|
15
20
|
import buildExternal from './actions/buildExternal.js';
|
|
16
21
|
import buildPackageTypes from './actions/buildTypes.js';
|
|
17
22
|
import buildLib from './actions/buildLib.js';
|
|
@@ -19,8 +24,7 @@ import buildDependencies from './actions/buildDependencies.js';
|
|
|
19
24
|
import writeRuntimeEntry, { removeImportHooks, writeImportHooks, writeImportManifest } from './actions/writeRuntimeEntry.js';
|
|
20
25
|
import { bindsAppImports, createImportedBridges, findAppNativeImports } from './actions/createImportedBridges.js';
|
|
21
26
|
import { nativeExtensions } from './utils/nativeImports.js';
|
|
22
|
-
import
|
|
23
|
-
import { boundHeaderSpecifiers, bridgeTargets, nodeBridgeTarget, resolveBoundHeaders } from './utils/boundHeaders.js';
|
|
27
|
+
import { createBridges, createNativeBridges } from './actions/nativeBridges.js';
|
|
24
28
|
import { ENTRY_RUNTIMES } from './utils/runtimeEntries.js';
|
|
25
29
|
import { publishNativeCommand, publishNodeAddon, publishWasiCommand } from './actions/publishBinary.js';
|
|
26
30
|
import runCrossbindApp from './actions/run.js';
|
|
@@ -84,7 +88,8 @@ program.command('build')
|
|
|
84
88
|
if (state.config.build.withBuildConfig) {
|
|
85
89
|
buildExternal(targetParams);
|
|
86
90
|
} else {
|
|
87
|
-
|
|
91
|
+
// An app builds only for the platforms -p names, so a plain build keeps making the archives alone.
|
|
92
|
+
build(targetParams, rebuildDeps, binaryRuntimeEnvs, { bindImports }, rest.platform ?? []);
|
|
88
93
|
}
|
|
89
94
|
});
|
|
90
95
|
|
|
@@ -99,13 +104,58 @@ function declaredSbomPath(projectDir) {
|
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
// An app's page is the project's Vite build, which @crossbind/plugin-vite makes into the app of the platform named.
|
|
108
|
+
const APP_PLATFORMS = ['android', 'ios', 'linux', 'macos', 'windows'];
|
|
109
|
+
|
|
110
|
+
function projectVite() {
|
|
111
|
+
try {
|
|
112
|
+
const manifest = createRequire(`${state.config.paths.project}/package.json`).resolve('vite/package.json');
|
|
113
|
+
return path.join(path.dirname(manifest), JSON.parse(fs.readFileSync(manifest, 'utf8')).bin.vite);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error("crossbind: crossbind app builds the app's page with the project's Vite, which the project does not have. Add vite and @crossbind/plugin-vite to it, as the desktop-app template has them.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const commandApp = program.command('app').description('build the project as an app, or open it on the dev server');
|
|
120
|
+
[
|
|
121
|
+
['dev', 'open the app on the dev server, and open it anew as its native code changes'],
|
|
122
|
+
['build', 'build the app with its page: for Android also its APK, and with an upload key its bundle'],
|
|
123
|
+
].forEach(([name, description]) => commandApp.command(name).description(description)
|
|
124
|
+
.addOption(new Option('-p, --platform <platform>', "the app's platform (default: CROSSBIND_APP_PLATFORM, else this machine's)").choices(APP_PLATFORMS))
|
|
125
|
+
// Vite's own options, as --port or --mode, go on to Vite.
|
|
126
|
+
.allowUnknownOption()
|
|
127
|
+
.allowExcessArguments()
|
|
128
|
+
.action(({ platform }, command) => {
|
|
129
|
+
if (state.config.target.runtimeEnv !== 'app') {
|
|
130
|
+
throw new Error("crossbind: crossbind app builds an app, which this project is not: set target: { runtimeEnv: 'app' } in crossbind.config.js, as the desktop-app template does.");
|
|
131
|
+
}
|
|
132
|
+
// An Android build checks its upload key before the native build, not after it.
|
|
133
|
+
if (name === 'build' && (platform ?? process.env.CROSSBIND_APP_PLATFORM) === 'android') releaseSigning();
|
|
134
|
+
const vite = spawn(process.execPath, [projectVite(), ...(name === 'build' ? ['build'] : []), ...command.args], {
|
|
135
|
+
stdio: 'inherit',
|
|
136
|
+
cwd: state.config.paths.project,
|
|
137
|
+
env: platform ? { ...process.env, CROSSBIND_APP_PLATFORM: platform } : process.env,
|
|
138
|
+
});
|
|
139
|
+
// A signal sent to crossbind alone reaches Vite too, which ends through its exit hooks; Ctrl-C reaches both.
|
|
140
|
+
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => process.prependListener(signal, () => vite.kill(signal)));
|
|
141
|
+
vite.on('exit', (code, signal) => process.exit(code ?? 128 + os.constants.signals[signal]));
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
commandApp.command('updater-key')
|
|
145
|
+
.description("make the key pair that signs a desktop app's updates")
|
|
146
|
+
.action(() => {
|
|
147
|
+
const { publicKey, privateKey } = newUpdaterKey();
|
|
148
|
+
console.log(`crossbind: app.updater.publicKey in crossbind.config.js: ${publicKey}`);
|
|
149
|
+
console.log(`crossbind: ${UPDATER_KEY}, kept secret where the app is built: ${privateKey}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
102
152
|
program.command('licenses')
|
|
103
153
|
.description('list bundled native dependencies with SPDX license, versions and source URL')
|
|
104
154
|
.option('--notices [file]', 'write a THIRD-PARTY-NOTICES markdown file (default: THIRD-PARTY-NOTICES.md)')
|
|
105
155
|
.option('--sbom [file]', 'write a CycloneDX SBOM json file (default: sbom.cdx.json)')
|
|
106
156
|
.option('--check', 'exit non-zero when a license field is missing or not valid SPDX')
|
|
107
157
|
.option('--platform <platform>', "also list what this platform's artifact statically links beyond the package graph (vendored copies, toolchain runtime)")
|
|
108
|
-
.addOption(new Option('-e, --runtime-env <runtimeEnv>',
|
|
158
|
+
.addOption(new Option('-e, --runtime-env <runtimeEnv>', "also list the crossbind runtime an addon of --platform compiles in (without --platform the one its loader bundles), or what an app's host carries").choices(['node', 'app']))
|
|
109
159
|
.option('--json [file]', 'write the rows, license texts included, as JSON (default: licenses.json)')
|
|
110
160
|
.option('--package', "write this Node-API package's LICENSE, sbom.cdx.json and package.json license field (with -e node)")
|
|
111
161
|
.action(async (options) => {
|
|
@@ -175,7 +225,7 @@ program.command('clean-deps')
|
|
|
175
225
|
});
|
|
176
226
|
|
|
177
227
|
// A container belongs to one image: the web container carries no NDK, and android runs a forced platform.
|
|
178
|
-
const IMAGE_ROLES = ['web', 'android', 'linux', 'windows'];
|
|
228
|
+
const IMAGE_ROLES = ['web', 'android', 'android-app', 'linux', 'windows'];
|
|
179
229
|
const dockerContainerName = (role) => {
|
|
180
230
|
if (!IMAGE_ROLES.includes(role)) {
|
|
181
231
|
console.error(`crossbind: unknown image "${role}" - expected one of ${IMAGE_ROLES.join(', ')}.`);
|
|
@@ -205,8 +255,8 @@ const imageArgument = (command) => command.argument('[image]', `image to act on
|
|
|
205
255
|
|
|
206
256
|
imageArgument(commandDocker.command('create').description('create docker container'))
|
|
207
257
|
.action((image) => {
|
|
208
|
-
// Google ships the linux NDK for x86_64 only, so an android container is always amd64.
|
|
209
|
-
const platform =
|
|
258
|
+
// Google ships the linux NDK for x86_64 only, so an android or android-app container is always amd64.
|
|
259
|
+
const platform = ['android', 'android-app'].includes(image) ? 'linux/amd64' : undefined;
|
|
210
260
|
// Docker on Linux makes a missing mount source owned by root, which the user's builds cannot write.
|
|
211
261
|
[cargoHome(), conanRoot()].forEach((dir) => fs.mkdirSync(dir, { recursive: true }));
|
|
212
262
|
dockerExec([
|
|
@@ -306,11 +356,12 @@ function onlyLoadsPackagedAddons(binaryParams) {
|
|
|
306
356
|
return targets.every((target) => target.runtimeEnv === 'node' && target.platform !== 'wasm') && addonsInPackages();
|
|
307
357
|
}
|
|
308
358
|
|
|
309
|
-
async function build(targetParams, rebuildOption, binaryRuntimeEnvs, wasmOptions = {}) {
|
|
359
|
+
async function build(targetParams, rebuildOption, binaryRuntimeEnvs, wasmOptions = {}, appPlatforms = []) {
|
|
310
360
|
await buildDependencies({ targetParams, rebuildOption });
|
|
311
361
|
const binaryParams = { ...targetParams, runtimeEnv: binaryRuntimeEnvs };
|
|
312
362
|
if (!onlyLoadsPackagedAddons(binaryParams)) {
|
|
313
|
-
|
|
363
|
+
// An app links its archives into itself, so it publishes no xcframework or podspec of them.
|
|
364
|
+
buildLib(targetParams, { skipXcframework: binaryRuntimeEnvs.includes('app') });
|
|
314
365
|
}
|
|
315
366
|
if (binaryRuntimeEnvs.length === 0) {
|
|
316
367
|
logger.info(`crossbind: no runtime environment was selected, so only the archives were built - pass -e (${runtimeEnvs.join(', ')}) for a binary.`);
|
|
@@ -318,6 +369,7 @@ async function build(targetParams, rebuildOption, binaryRuntimeEnvs, wasmOptions
|
|
|
318
369
|
await createWasmJs(binaryParams, wasmOptions);
|
|
319
370
|
await createNodeAddons(binaryParams);
|
|
320
371
|
await createNativeCommands(binaryParams);
|
|
372
|
+
await createApps({ ...binaryParams, platform: appPlatforms });
|
|
321
373
|
await createWasiCommands(binaryParams);
|
|
322
374
|
}
|
|
323
375
|
buildPackageTypes();
|
|
@@ -336,6 +388,26 @@ async function createNativeCommands(targetParams) {
|
|
|
336
388
|
}
|
|
337
389
|
}
|
|
338
390
|
|
|
391
|
+
// An app built without a bundler has no page; the Vite plugin puts one in.
|
|
392
|
+
async function createApps(targetParams) {
|
|
393
|
+
const targets = getBuildTargets(targetParams).filter((target) => target.runtimeEnv === 'app');
|
|
394
|
+
if (targets.length === 0) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
targets.forEach(assertAppRunner);
|
|
398
|
+
const bridges = createNativeBridges(targets).headers.flatMap((header) => header.bridge ?? []);
|
|
399
|
+
// An iOS or Android app is one project for all its SDKs or ABIs, written once they are built, and every time, as it
|
|
400
|
+
// also takes the app's identity and the host package's template.
|
|
401
|
+
const projects = new Map();
|
|
402
|
+
for (const target of targets) {
|
|
403
|
+
const isProject = ['ios', 'android'].includes(target.platform);
|
|
404
|
+
const built = await buildApp(target, { bridges, force: !isProject && !fs.existsSync(appOutputPath(target)) });
|
|
405
|
+
if (isProject) projects.set(appOutputPath(target), target);
|
|
406
|
+
else if (built) publishApp(target);
|
|
407
|
+
}
|
|
408
|
+
projects.forEach((target) => publishApp(target));
|
|
409
|
+
}
|
|
410
|
+
|
|
339
411
|
async function createWasiCommands(targetParams) {
|
|
340
412
|
const targets = getFilteredBuildTargets(targetParams, { platform: 'wasi' });
|
|
341
413
|
if (targets.length === 0) {
|
|
@@ -351,23 +423,6 @@ async function createWasiCommands(targetParams) {
|
|
|
351
423
|
}
|
|
352
424
|
}
|
|
353
425
|
|
|
354
|
-
function createBridges(target) {
|
|
355
|
-
let headers = [];
|
|
356
|
-
state.config.paths.header.forEach((headerPath) => {
|
|
357
|
-
headers.push(findFiles('**/*.h', { cwd: headerPath }));
|
|
358
|
-
});
|
|
359
|
-
headers = headers.filter((path) => !!path.toString()).flat();
|
|
360
|
-
|
|
361
|
-
return headers.map((header) => ({ file: header, bridge: createBridgeFile(header, target) }));
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// The dependency headers the config binds whole, each read where one of the targets' packages ships it.
|
|
365
|
-
function createBoundBridges(targets) {
|
|
366
|
-
const headers = resolveBoundHeaders(boundHeaderSpecifiers(state.config), bridgeTargets(targets, state.targets), getDependFilePath);
|
|
367
|
-
const wholeHeaders = headers.map((header) => header.file);
|
|
368
|
-
return headers.map((header) => ({ ...header, bridge: createBridgeFile(header.file, header.target, { wholeHeaders }) }));
|
|
369
|
-
}
|
|
370
|
-
|
|
371
426
|
// A package whose platform packages link its addons ships what loads them: the loader and the data.
|
|
372
427
|
async function publishLoaders(targets) {
|
|
373
428
|
fs.mkdirSync(state.config.paths.output, { recursive: true });
|
|
@@ -389,10 +444,7 @@ async function createNodeAddons(targetParams) {
|
|
|
389
444
|
`${resolveEmbindJsiRoot()}/cpp/src`,
|
|
390
445
|
`${resolveEmbindNapiRoot()}/third_party/node-api-jsi/jsi`,
|
|
391
446
|
];
|
|
392
|
-
const
|
|
393
|
-
const ownHeaders = [...createBridges(nodeBridgeTarget(targets)), ...boundHeaders];
|
|
394
|
-
const imports = findAppNativeImports(bridgeTargets(targets, state.targets));
|
|
395
|
-
const headers = [...ownHeaders, ...createImportedBridges(ownHeaders, imports)];
|
|
447
|
+
const { headers, imports } = createNativeBridges(targets);
|
|
396
448
|
const opt = {
|
|
397
449
|
buildSource: false,
|
|
398
450
|
nativeGlob: [
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,13 @@ export { default as getParentPath } from './utils/getParentPath.js';
|
|
|
3
3
|
export { default as createLib } from './actions/createLib.js';
|
|
4
4
|
export { default as createBridgeFile } from './actions/createInterface.js';
|
|
5
5
|
export { default as buildWasm } from './actions/buildWasm.js';
|
|
6
|
+
export {
|
|
7
|
+
default as buildApp, buildProjectApp, publishApp, appLayout, appOutputName, appTargetParams,
|
|
8
|
+
} from './actions/buildApp.js';
|
|
9
|
+
export { checkInstaller } from './actions/appInstaller.js';
|
|
10
|
+
export { default as openInSimulator, buildForSimulator } from './actions/iosSimulator.js';
|
|
11
|
+
export { default as openOnDevice, adbPath } from './actions/androidDevice.js';
|
|
12
|
+
export { buildApk } from './actions/androidGradle.js';
|
|
6
13
|
export { default as isSourceNewer } from './actions/isSourceNewer.js';
|
|
7
14
|
export { default as getData } from './actions/getData.js';
|
|
8
15
|
export { default as getCmakeParameters } from './actions/getCmakeParameters.js';
|
|
@@ -112,7 +112,9 @@ function buildScript(target, symbols, base) {
|
|
|
112
112
|
throw new Error('The target is not available!');
|
|
113
113
|
}
|
|
114
114
|
const env = JSON.stringify(getEnv(target));
|
|
115
|
-
|
|
115
|
+
let getPlatformScript = getReactNativeScript;
|
|
116
|
+
if (target.platform === 'wasm') getPlatformScript = getWebScript;
|
|
117
|
+
else if (target.runtimeEnv === 'app') getPlatformScript = getAppScript;
|
|
116
118
|
|
|
117
119
|
let symbolExportDefineString = '';
|
|
118
120
|
let symbolExportAssignString = '';
|
|
@@ -200,6 +202,18 @@ function getReactNativeScript(env) {
|
|
|
200
202
|
`;
|
|
201
203
|
}
|
|
202
204
|
|
|
205
|
+
// An app's page loads the runtime the app serves as crossbind.js, which asks the app's host for the module. The build's
|
|
206
|
+
// env is the host's own, so the page passes on only what initNative() is given.
|
|
207
|
+
function getAppScript(env, base) {
|
|
208
|
+
return `
|
|
209
|
+
function __crossbindBoot(config) {
|
|
210
|
+
const base = new URL((typeof __webpack_public_path__ === 'string' ? __webpack_public_path__ : ${JSON.stringify(base)}) || './', document.baseURI).href;
|
|
211
|
+
return import(/* webpackIgnore: true */ /* @vite-ignore */ base + 'crossbind.js')
|
|
212
|
+
.then(() => window.Crossbind.initNative(config));
|
|
213
|
+
}
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
function getWebScript(env, base) {
|
|
204
218
|
const params = `{
|
|
205
219
|
path: base,
|
package/src/state/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import loadJson from '../utils/loadJson.js';
|
|
3
3
|
import writeJson from '../utils/writeJson.js';
|
|
4
4
|
import {
|
|
5
|
-
TARGETS, targetPathOf, nodeAddonNamesOf, nativeCommandNamesOf, wasiNamesOf,
|
|
5
|
+
TARGETS, targetPathOf, nodeAddonNamesOf, nativeCommandNamesOf, wasiNamesOf, appNamesOf,
|
|
6
6
|
} from '../utils/targets.js';
|
|
7
7
|
import { findCargoModuleImportsIn, writeCargoMarker } from '../utils/cargoImport.js';
|
|
8
8
|
import { exportWasiSdkPath } from '../utils/wasiToolchain.js';
|
|
@@ -35,6 +35,7 @@ async function initProcessState() {
|
|
|
35
35
|
DOCKER_REGISTRY_MIRROR: 'CROSSBIND_REGISTRY_MIRROR',
|
|
36
36
|
DOCKER_IMAGE_WEB: 'CROSSBIND_IMAGE_WEB',
|
|
37
37
|
DOCKER_IMAGE_ANDROID: 'CROSSBIND_IMAGE_ANDROID',
|
|
38
|
+
DOCKER_IMAGE_ANDROID_APP: 'CROSSBIND_IMAGE_ANDROID_APP',
|
|
38
39
|
DOCKER_IMAGE_LINUX: 'CROSSBIND_IMAGE_LINUX',
|
|
39
40
|
DOCKER_IMAGE_WINDOWS: 'CROSSBIND_IMAGE_WINDOWS',
|
|
40
41
|
}).forEach(([key, envKey]) => {
|
|
@@ -62,6 +63,9 @@ async function initProcessState() {
|
|
|
62
63
|
if (target.runtimeEnv === 'native') {
|
|
63
64
|
Object.assign(target, nativeCommandNamesOf(target, state.config.general.name));
|
|
64
65
|
}
|
|
66
|
+
if (target.runtimeEnv === 'app') {
|
|
67
|
+
Object.assign(target, appNamesOf(target, state.config.general.name));
|
|
68
|
+
}
|
|
65
69
|
});
|
|
66
70
|
|
|
67
71
|
setAllDependecyPaths();
|
package/src/state/loadConfig.js
CHANGED
|
@@ -133,6 +133,8 @@ export function getFilledConfig(config, options = { isDepend: false }) {
|
|
|
133
133
|
targetSpecs: config.targetSpecs || [],
|
|
134
134
|
build: config.build || {},
|
|
135
135
|
target: config.target || {},
|
|
136
|
+
// An app's identity: its bundle id, on iOS the Apple team that signs it, the name it shows and its desktop window.
|
|
137
|
+
app: config.app || {},
|
|
136
138
|
extensions: config.extensions || [],
|
|
137
139
|
package: null,
|
|
138
140
|
functions: config.functions || {},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// <crossbind/app.h>, which an app's own code includes for the host's functions (docs/api/app.md).
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import state from '../state/index.js';
|
|
5
|
+
import resolveAppHostRoot from './resolveAppHost.js';
|
|
6
|
+
import { runtimeEnvsOf } from './targets.js';
|
|
7
|
+
|
|
8
|
+
// An app project's native code compiles against the header, with CROSSBIND_APP defined, on every platform an app runs
|
|
9
|
+
// on, and in every flavour of that platform's archives, which share one build. A library and an app's web build get
|
|
10
|
+
// neither.
|
|
11
|
+
export function compilesAsApp(target) {
|
|
12
|
+
return state.config.target?.runtimeEnv === 'app' && runtimeEnvsOf(target.platform).includes('app');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function appApiHeader() {
|
|
16
|
+
const header = `${resolveAppHostRoot()}/cpp/include/crossbind/app.h`;
|
|
17
|
+
if (!fs.existsSync(header)) {
|
|
18
|
+
throw new Error('crossbind: this @crossbind/core-app-host has no <crossbind/app.h>; update it to the version of crossbind.');
|
|
19
|
+
}
|
|
20
|
+
return header;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The app host can lie outside the docker mount, so the header is copied into the build tree first; returns the
|
|
24
|
+
// include dir.
|
|
25
|
+
export function stageAppApiInclude(dir) {
|
|
26
|
+
fs.mkdirSync(`${dir}/include/crossbind`, { recursive: true });
|
|
27
|
+
fs.copyFileSync(appApiHeader(), `${dir}/include/crossbind/app.h`);
|
|
28
|
+
return `${dir}/include`;
|
|
29
|
+
}
|