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,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.13",
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:c545ea396bd8919f7a671617dbbdf184bd0d6eb904d3170dd120d6671db3e0a9",
9
+ "index": "sha256:b0745e62b7bc2d07acebf8dce8edef2e24a5f82f55bf37f51bacdbb67c393339",
10
10
  "platforms": {
11
- "linux/arm64": "sha256:c53ee0a31c52fdc6eba175e479218ddbff0c071654b5e93a08ae768cdbbfe35a",
12
- "linux/amd64": "sha256:1427255e619c3f5dcd3fa7fd284d15afbe00eef1a0798e08a9b50ebbb86c5234"
11
+ "linux/amd64": "sha256:42e71c9f4b349e116fb25cf1fc357cb070ae9e294a6087bb111dad2346256594",
12
+ "linux/arm64": "sha256:7144a83fa33d2beaad105e88c0067fb6a44d9b9620e576f226758872962cca44"
13
13
  }
14
14
  },
15
15
  "base": {
16
- "index": "sha256:5f5d133504cb2c81ee67a93ca5795698423277522caac742478510d6158ed559",
16
+ "index": "sha256:d1f2fee78a0238aebb70a2aaed3753212f8151afe0430144b4dfb522f1ccf2ac",
17
17
  "platforms": {
18
- "linux/arm64": "sha256:df1c82f4b7e00a4ab33e2423f6d5bbaa66dd67bc45b96e5498e3391ec5f55fae",
19
- "linux/amd64": "sha256:ffc0a178bc821e56975d9348b7e44d8081b9c6fb9351bc168b38d5063d9864ea"
18
+ "linux/arm64": "sha256:7faf80e5692ae636f4bc25af0849711c442275b2405b3d52f9e1943ed8089aa2",
19
+ "linux/amd64": "sha256:7d31882ed442c0425746fa1cbadf4bf1f6a5f035b2222cb907ad5006ef1af24d"
20
20
  }
21
21
  },
22
22
  "web": {
23
- "index": "sha256:b5ee271886c316404cae103c5412bc02d811ad48fd36a919fdf3a82af14fe688",
23
+ "index": "sha256:97d873ae67dac443a88d0dede3fee2893177f167e08e8c9096c29aebd95a3d0f",
24
24
  "platforms": {
25
- "linux/arm64": "sha256:91d35c9eac65f479f5139ba476b3daa3b517aab6f21f5206070af610cec5b62c",
26
- "linux/amd64": "sha256:f308cb1702f308d69d9f21eb99aa109cdbf62e655549b483580476d8f01fb07b"
25
+ "linux/arm64": "sha256:a274755f6d2cc1702ff7681b75e44b5d1146b73b45ac2dab5e4b543554ad4b19",
26
+ "linux/amd64": "sha256:23414220cfd4839005d3a5c1e3b83cff4760a3e6eb23687c2a71a1187d0cd211"
27
27
  }
28
28
  },
29
29
  "android": {
30
- "index": "sha256:a01ddeaed550091f45e720d09bd559978d4c46e396c4553b3fc4544b90033fd1",
30
+ "index": "sha256:5cae71358e7619d5958dab6f38b5dad9582792dc3a49c07fdf8dbdbf157ab5bd",
31
31
  "platforms": {
32
- "linux/amd64": "sha256:3bafd0a3f80bf7370a19522a3b75cb58a977d1b1b28940ed02b87db1058a8fd6"
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:c1d7f9672edb6eeea86172d8ba8cc8ba824f6c53134587ea247ea5dc126d6c43",
42
+ "index": "sha256:6a642147d344f804f42241fc80c21b07bfe1eb40a9a364250959c3715a60700a",
37
43
  "platforms": {
38
- "linux/amd64": "sha256:0ff9ee1d5c139eb97934919f7b9535af0eb2c3bebd9c1974fa95cfb143e3ccf7",
39
- "linux/arm64": "sha256:f476e4a95635c04aca741937c2174a1915731c52c9d182b247e17d4ca4163b33"
44
+ "linux/arm64": "sha256:2582d7cf143f876ad4a324174ccb0a1097c4ca2ce775c0216756415ca797cb93",
45
+ "linux/amd64": "sha256:a10f0efc028e8ba63a7a45dda475eb669d8a078bf1e2830ac0a8c026277afe92"
40
46
  }
41
47
  },
42
48
  "windows": {
43
- "index": "sha256:53b08c8918a84023ea90c6420fff783564aced0ec711fa0e7b71a09578643a32",
49
+ "index": "sha256:c4c436763d559a5346f96369b444b79006d4298c5dfce78c15c06089cfdaaa33",
44
50
  "platforms": {
45
- "linux/arm64": "sha256:402f115c8eed9e6418cf9e47a6bcad2d456d15d032c9b2baaa54a5005f00c8ba",
46
- "linux/amd64": "sha256:50f359882015ca93c8b356a01bf03ce4cb098307f72c30122c7b6ad123eae339"
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 { execFileSync } from 'node:child_process';
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 getDependFilePath from './integration/getDependFilePath.js';
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
- build(targetParams, rebuildDeps, binaryRuntimeEnvs, { bindImports });
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>', 'also list the crossbind runtime an addon of --platform compiles in, or without --platform the one its loader bundles').choices(['node']))
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 = image === 'android' ? 'linux/amd64' : undefined;
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
- buildLib(targetParams);
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 boundHeaders = createBoundBridges(targets);
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
- const getPlatformScript = target.platform === 'wasm' ? getWebScript : getReactNativeScript;
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,
@@ -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();
@@ -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
+ }