crossbind 2.0.0-beta.67 → 2.0.0-beta.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +33 -24
  2. package/package.json +69 -59
  3. package/src/actions/addExtension.js +191 -0
  4. package/src/actions/androidGradle.js +69 -25
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/appleSigning.js +32 -14
  8. package/src/actions/buildApp.js +229 -67
  9. package/src/actions/buildNode.js +6 -1
  10. package/src/actions/buildWasm.js +0 -9
  11. package/src/actions/createInterface.js +17 -8
  12. package/src/actions/createLib.js +5 -7
  13. package/src/actions/extensionApi.js +298 -0
  14. package/src/actions/extensionCommands.js +41 -0
  15. package/src/actions/extensionsInApp.js +19 -0
  16. package/src/actions/getCmakeParameters.js +2 -3
  17. package/src/actions/gradleProject.js +199 -16
  18. package/src/actions/iosDevice.js +200 -0
  19. package/src/actions/iosSimulator.js +3 -3
  20. package/src/actions/licenses.js +83 -34
  21. package/src/actions/linuxPackages.js +48 -0
  22. package/src/actions/mavenVerification.js +37 -0
  23. package/src/actions/publish.js +220 -0
  24. package/src/actions/uploadKey.js +134 -0
  25. package/src/actions/windowsSigning.js +1 -1
  26. package/src/actions/xcodeProject.js +63 -17
  27. package/src/actions/xcodeTargets.js +122 -0
  28. package/src/actions/xcodebuild.js +65 -0
  29. package/src/app/index.d.ts +126 -133
  30. package/src/app/index.js +126 -188
  31. package/src/assets/configure/install.sh +0 -0
  32. package/src/assets/toolchain-digests.json +20 -20
  33. package/src/bin.js +79 -26
  34. package/src/index.js +5 -1
  35. package/src/program.js +35 -4
  36. package/src/state/index.js +2 -1
  37. package/src/state/loadConfig.js +16 -5
  38. package/src/utils/appApiHeader.js +4 -0
  39. package/src/utils/appExtensions.js +761 -0
  40. package/src/utils/appFiles.js +58 -0
  41. package/src/utils/appLaunches.js +249 -0
  42. package/src/utils/appOptions.js +144 -95
  43. package/src/utils/appTargets.js +168 -0
  44. package/src/utils/ask.js +38 -0
  45. package/src/utils/bridgeTools.js +66 -0
  46. package/src/utils/configChanges.js +30 -0
  47. package/src/utils/configWriter.js +181 -0
  48. package/src/utils/extensionSteps.js +135 -0
  49. package/src/utils/hermes.js +24 -0
  50. package/src/utils/keychain.js +54 -0
  51. package/src/utils/openUrl.js +12 -0
  52. package/src/utils/password.js +34 -0
  53. package/src/utils/plist.js +5 -0
  54. package/src/utils/requiredReasonApis.js +77 -0
  55. package/src/utils/resolveAppHost.js +7 -0
  56. package/src/utils/secretFile.js +23 -0
  57. package/src/utils/systemConfig.js +19 -0
  58. package/src/utils/systemKeys.js +16 -0
  59. package/vitest.config.js +6 -1
  60. package/src/actions/appUpdate.js +0 -68
  61. package/src/actions/extensions.js +0 -7
package/src/app/index.js CHANGED
@@ -1,15 +1,7 @@
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, its tray icon and notifications, the events of the app's
3
- // native code and a desktop app's updates. Each call goes to the host of an app (target.runtimeEnv: 'app'); in any
4
- // other build it rejects, or events.on throws. The app's native code calls the same through <crossbind/app.h>, but for
5
- // the menus, the tray, notifications and events.on.
6
-
7
- const OPEN = { title: 'string', defaultPath: 'string', filters: 'filters', directory: 'boolean', multiple: 'boolean' };
8
- const SAVE = { title: 'string', defaultPath: 'string', defaultName: 'string', filters: 'filters' };
9
- const MESSAGE = { message: 'string', detail: 'string', kind: 'string', buttons: 'buttons' };
10
- const MESSAGE_KINDS = ['info', 'warning', 'error'];
11
- // Android's dialogs take three buttons at most.
12
- const MAX_BUTTONS = 3;
1
+ // What an app's host does for its page: the app's folders, quitting, its windows, the events of the app's native code and
2
+ // the functions of its extensions, by their names. Each call goes to the host of an app (target.runtimeEnv: 'app'); in any
3
+ // other build it rejects, or events.on throws. A page imports these from crossbind/app, which an app's bundler plugin
4
+ // takes from the app host, with what crossbind's own extensions give the page, as crossbind/app/core alone holds these.
13
5
 
14
6
  // The app's crossbind.js, which initNative() loads, hands the calls to the host.
15
7
  function hostOf(member, name) {
@@ -22,207 +14,124 @@ function hostOf(member, name) {
22
14
 
23
15
  const call = (name, options) => hostOf('app', name)(name, options);
24
16
 
25
- const isFilters = (value) => Array.isArray(value) && value.every((filter) => typeof filter?.name === 'string'
26
- && Array.isArray(filter.extensions) && filter.extensions.every((extension) => typeof extension === 'string'));
27
-
28
- // Spread reads a hole as undefined, which every() skips.
29
- const isButtons = (value) => Array.isArray(value) && value.length >= 1 && value.length <= MAX_BUTTONS
30
- && [...value].every((label) => typeof label === 'string' && label !== '');
31
-
32
- // The lists an option may be, beside what typeof tells: how each is checked, and what it is called.
33
- const LISTS = {
34
- filters: { isFitting: isFilters, name: 'list of { name, extensions }' },
35
- buttons: { isFitting: isButtons, name: 'list of one to three labels' },
36
- list: { isFitting: Array.isArray, name: 'list' },
37
- };
38
-
39
- // The options as the host takes them, so a misspelt or mistyped one fails here, saying which.
40
- function checked(name, options, kinds) {
17
+ // The options as the host takes them, so a misspelt or mistyped one fails here, saying which: each key's kind is what
18
+ // typeof gives, or { isFitting, name } for a list or an object, how it is checked and what it is called.
19
+ export function checkedOptions(name, options, kinds) {
41
20
  const keys = Object.keys(kinds).join(', ');
42
21
  if (options === null || typeof options !== 'object' || Array.isArray(options)) {
43
22
  throw new TypeError(`crossbind: ${name} takes an object of ${keys}.`);
44
23
  }
45
24
  for (const [key, value] of Object.entries(options)) {
46
25
  if (!Object.hasOwn(kinds, key)) throw new TypeError(`crossbind: ${name} takes ${keys}; ${key} is none of them.`);
47
- const list = LISTS[kinds[key]];
48
- if (value !== undefined && !(list ? list.isFitting(value) : typeof value === kinds[key])) {
49
- throw new TypeError(`crossbind: ${name}'s ${key} is no ${list ? list.name : kinds[key]}.`);
50
- }
51
- }
52
- return options;
53
- }
54
-
55
- function checkedMessage(options) {
56
- checked('dialog.message', options, MESSAGE);
57
- if (!options.message) throw new TypeError('crossbind: dialog.message takes a message.');
58
- if (options.kind !== undefined && !MESSAGE_KINDS.includes(options.kind)) {
59
- throw new TypeError(`crossbind: dialog.message's kind is info, warning or error, not ${JSON.stringify(options.kind)}.`);
26
+ const kind = kinds[key];
27
+ const isOfKind = typeof kind === 'string' ? typeof value === kind : kind.isFitting(value);
28
+ if (value !== undefined && !isOfKind) throw new TypeError(`crossbind: ${name}'s ${key} is no ${typeof kind === 'string' ? kind : kind.name}.`);
60
29
  }
61
30
  return options;
62
31
  }
63
32
 
64
- function checkedPath(name, path) {
65
- if (typeof path !== 'string' || !path) throw new TypeError(`crossbind: ${name} takes a path.`);
66
- return { path };
67
- }
68
-
69
- // A menu of the bar is { label, items } or a standard one by its role. An item is { label, id, accelerator, enabled,
70
- // checked }, a submenu { label, items }, { type: 'separator' }, or a standard item by its role, which a label may
71
- // rename and, but for an editing one, which keeps the platform's keys, an accelerator change.
72
- const MENU_ROLES = ['editMenu', 'windowMenu'];
73
- const EDITING_ROLES = ['undo', 'redo', 'cut', 'copy', 'paste', 'selectAll'];
74
- const ITEM_ROLES = [...EDITING_ROLES, 'minimize', 'close', 'quit'];
75
- const ITEM_KINDS = {
76
- label: 'string', id: 'string', accelerator: 'string', enabled: 'boolean', checked: 'boolean', items: 'list', role: 'string', type: 'string',
33
+ // A window's options, as windows.open() takes them, and those windows.set() changes: sizes and places in logical pixels.
34
+ // A window opens in the middle of the screen without x and y, and set() moves one there with center.
35
+ const WINDOW_SIZES = ['width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight'];
36
+ const WINDOW_SHARED = {
37
+ title: 'string', width: 'number', height: 'number', x: 'number', y: 'number', minWidth: 'number', minHeight: 'number',
38
+ maxWidth: 'number', maxHeight: 'number', resizable: 'boolean', alwaysOnTop: 'boolean', fullscreen: 'boolean',
77
39
  };
78
- const MODIFIERS = ['CmdOrCtrl', 'Ctrl', 'Alt', 'Shift'];
79
- 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)$/;
80
-
81
- // Typing uses neither a key with CmdOrCtrl, Ctrl or Alt nor a function key.
82
- function checkAccelerator(accelerator, where) {
83
- const modifiers = accelerator.split('+');
84
- const key = modifiers.pop();
85
- const isKnown = KEY.test(key) && modifiers.every((modifier) => MODIFIERS.includes(modifier)) && new Set(modifiers).size === modifiers.length;
86
- if (!isKnown || !(/^F\d+$/.test(key) || modifiers.some((modifier) => modifier !== 'Shift'))) {
87
- 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.`);
40
+ const WINDOW_SET = { ...WINDOW_SHARED, center: 'boolean' };
41
+ const WINDOW_OPEN = {
42
+ ...WINDOW_SHARED, path: 'string', name: 'string', backgroundColor: 'string', frame: 'boolean', parent: 'number', modal: 'boolean',
43
+ };
44
+ const MAX_WINDOW_SIZE = 16384;
45
+ const MAX_WINDOW_PLACE = 1000000;
46
+ // The size a window opens at without one.
47
+ const WINDOW_SIZE = { width: 1024, height: 768 };
48
+ const COLOR = /^#[0-9a-f]{6}$/i;
49
+
50
+ const isWindowId = (id) => Number.isSafeInteger(id) && id >= 1;
51
+
52
+ function checkedWindow(name, options, kinds) {
53
+ checkedOptions(name, options, kinds);
54
+ const least = name === 'windows.set' ? 0 : 1;
55
+ for (const key of WINDOW_SIZES) {
56
+ const size = options[key];
57
+ // A limit of 0 that windows.set() gives takes it away.
58
+ const isFitting = Number.isInteger(size) && size >= ((key === 'width' || key === 'height') ? 1 : least) && size <= MAX_WINDOW_SIZE;
59
+ if (size !== undefined && !isFitting) throw new TypeError(`crossbind: ${name}'s ${key} is no whole number of pixels up to ${MAX_WINDOW_SIZE}.`);
88
60
  }
89
- }
90
-
91
- function checkMenuItem(item, where, isMenu) {
92
- if (item === null || typeof item !== 'object' || Array.isArray(item)) throw new TypeError(`crossbind: menu.set's ${where} is no object.`);
93
- for (const [key, value] of Object.entries(item)) {
94
- 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.`);
95
- const kind = ITEM_KINDS[key];
96
- if (value !== undefined && !(kind === 'list' ? Array.isArray(value) : typeof value === kind)) {
97
- throw new TypeError(`crossbind: menu.set's ${where}.${key} is no ${kind}.`);
61
+ for (const key of ['x', 'y']) {
62
+ const place = options[key];
63
+ if (place !== undefined && !(Number.isInteger(place) && Math.abs(place) <= MAX_WINDOW_PLACE)) {
64
+ throw new TypeError(`crossbind: ${name}'s ${key} is no whole number of pixels from -${MAX_WINDOW_PLACE} to ${MAX_WINDOW_PLACE}.`);
98
65
  }
99
66
  }
100
- const has = (...keys) => keys.filter((key) => item[key] !== undefined);
101
- const refuse = (what) => {
102
- throw new TypeError(`crossbind: menu.set's ${where} ${what}.`);
103
- };
104
- if (item.type !== undefined) {
105
- if (item.type !== 'separator' || isMenu || has(...Object.keys(ITEM_KINDS)).length > 1) refuse("is no separator, which is { type: 'separator' } alone, inside a menu");
106
- } else if (item.role !== undefined) {
107
- const roles = isMenu ? MENU_ROLES : [...ITEM_ROLES, ...MENU_ROLES];
108
- if (!roles.includes(item.role)) refuse(`has no role ${JSON.stringify(item.role)}, which is one of ${roles.join(', ')}`);
109
- if (MENU_ROLES.includes(item.role) && has('id', 'accelerator', 'enabled', 'checked', 'items').length > 0) refuse('is a standard menu, which takes a label alone');
110
- 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");
111
- if (has('id', 'enabled', 'checked', 'items').length > 0) refuse('has a role, which takes a label and an accelerator alone');
112
- } else if (!item.label) {
113
- refuse('has no label');
114
- } else if (item.items !== undefined) {
115
- if (has('id', 'accelerator', 'enabled', 'checked').length > 0) refuse('is a menu, which takes a label and items alone');
116
- // entries() reads a hole as undefined, which forEach skips.
117
- for (const [index, child] of item.items.entries()) checkMenuItem(child, `${where}.items[${index}]`, false);
118
- } else if (isMenu) {
119
- refuse('is a menu, which takes a label and items, or the role editMenu or windowMenu');
67
+ if ((options.x === undefined) !== (options.y === undefined)) throw new TypeError(`crossbind: ${name} takes x and y together.`);
68
+ const { path, backgroundColor, parent } = options;
69
+ if (path !== undefined && (!path.startsWith('/') || path.startsWith('//') || path.split(/[?#]/, 1)[0].split('/').some((part) => DOT_PART.test(part) || part.includes('\\')))) {
70
+ throw new TypeError(`crossbind: ${name}'s path ${JSON.stringify(path)} is no path of the app's own pages, as /settings.html is.`);
120
71
  }
121
- if (item.accelerator !== undefined) checkAccelerator(item.accelerator, where);
122
- }
123
-
124
- function checkedMenus(menus) {
125
- if (!Array.isArray(menus)) throw new TypeError('crossbind: menu.set takes a list of menus.');
126
- for (const [index, item] of menus.entries()) checkMenuItem(item, `menus[${index}]`, true);
127
- return { menus };
72
+ if (backgroundColor !== undefined && !COLOR.test(backgroundColor)) throw new TypeError(`crossbind: ${name}'s backgroundColor is no color of the form #rrggbb.`);
73
+ if (parent !== undefined && !isWindowId(parent)) throw new TypeError(`crossbind: ${name}'s parent is no window's id.`);
74
+ if (options.modal && parent === undefined) throw new TypeError(`crossbind: ${name} opens a modal window above a parent: give it parent, the id of a window.`);
75
+ if (name === 'windows.open') checkedLimits(options);
76
+ return options;
128
77
  }
129
78
 
130
- // A tray item is { label, id, enabled, checked }, a submenu { label, items } or { type: 'separator' }: a tray's menu has
131
- // no keys, and no standard item.
132
- const TRAY = { tooltip: 'string', menu: 'list' };
133
- const TRAY_ITEM = { label: 'string', id: 'string', enabled: 'boolean', checked: 'boolean', items: 'list', type: 'string' };
134
- const NOTIFICATION = { title: 'string', body: 'string' };
135
-
136
- function checkTrayItem(item, where) {
137
- const refuse = (what) => {
138
- throw new TypeError(`crossbind: tray.set's ${where} ${what}.`);
139
- };
140
- if (item === null || typeof item !== 'object' || Array.isArray(item)) refuse('is no object');
141
- for (const [key, value] of Object.entries(item)) {
142
- if (!Object.hasOwn(TRAY_ITEM, key)) refuse(`takes ${Object.keys(TRAY_ITEM).join(', ')}; ${key} is none of them`);
143
- const kind = TRAY_ITEM[key];
144
- if (value !== undefined && !(kind === 'list' ? Array.isArray(value) : typeof value === kind)) refuse(`.${key} is no ${kind}`);
79
+ // The limits of a window that opens, as app.window takes them: those of one that resizes, around the size it opens at.
80
+ function checkedLimits(options) {
81
+ const limits = ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'].filter((key) => options[key] !== undefined);
82
+ if (options.resizable === false && limits.length > 0) {
83
+ throw new TypeError(`crossbind: windows.open's ${limits.join(', ')} limit a window that resizes: drop them, or leave resizable true.`);
145
84
  }
146
- const others = Object.keys(item).filter((key) => key !== 'type' && item[key] !== undefined);
147
- if (item.type !== undefined) {
148
- if (item.type !== 'separator' || others.length > 0) refuse("is no separator, which is { type: 'separator' } alone");
149
- } else if (!item.label) {
150
- refuse('has no label');
151
- } else if (item.items !== undefined) {
152
- if (others.some((key) => key !== 'label' && key !== 'items')) refuse('is a menu, which takes a label and items alone');
153
- // entries() reads a hole as undefined, which forEach skips.
154
- for (const [index, child] of item.items.entries()) checkTrayItem(child, `${where}.items[${index}]`);
85
+ for (const [size, side] of [['width', 'Width'], ['height', 'Height']]) {
86
+ const opensAt = options[size] ?? WINDOW_SIZE[size];
87
+ if (options[`min${side}`] > opensAt) throw new TypeError(`crossbind: windows.open's min${side} is above the ${size} the window opens at, ${opensAt}.`);
88
+ if (options[`max${side}`] < opensAt) throw new TypeError(`crossbind: windows.open's max${side} is below the ${size} the window opens at, ${opensAt}.`);
155
89
  }
156
90
  }
157
91
 
158
- function checkedTray(options) {
159
- checked('tray.set', options, TRAY);
160
- for (const [index, item] of (options.menu ?? []).entries()) checkTrayItem(item, `menu[${index}]`);
161
- return options;
162
- }
163
-
164
- function checkedNotification(options) {
165
- checked('notification.show', options, NOTIFICATION);
166
- if (!options.title) throw new TypeError('crossbind: notification.show takes a title.');
167
- return options;
92
+ // The window a call names by its id, or with none the calling page's own.
93
+ function windowOf(name, id) {
94
+ if (id === undefined) return {};
95
+ if (!isWindowId(id)) throw new TypeError(`crossbind: ${name} takes the id of a window, as windows.open() gives it, or none for the page's own.`);
96
+ return { id };
168
97
  }
169
98
 
170
- export const dialog = {
171
- // The paths of the files or folders the user picked, or null when they cancelled.
172
- open: async (options = {}) => call('dialog.open', checked('dialog.open', options, OPEN)),
173
- // The path the user chose to save to, or null when they cancelled.
174
- save: async (options = {}) => call('dialog.save', checked('dialog.save', options, SAVE)),
175
- // The index of the button the user clicked: Return gives the first, and Escape or closing the dialog the last.
176
- message: async (options) => call('dialog.message', checkedMessage(options)),
177
- };
178
-
179
- export const shell = {
180
- // An http, https or mailto address in the default browser or mail app.
181
- openExternal: async (url) => {
182
- if (typeof url !== 'string' && !(url instanceof URL)) throw new TypeError('crossbind: shell.openExternal takes an address.');
183
- return call('shell.openExternal', { url: String(url) });
184
- },
185
- // 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
186
- // any path, so the host opens files where app.openPath in crossbind.config.js lets it alone; native code opens any.
187
- openPath: async (path) => call('shell.openPath', checkedPath('shell.openPath', path)),
188
- // A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run.
189
- revealInFolder: async (path) => call('shell.revealInFolder', checkedPath('shell.revealInFolder', path)),
190
- // A file or folder moved to the system's trash, where its user can take it back: one in the app's folders, not one of
191
- // them itself, or one a dialog picked during this run. Where the system has no trash, nothing moves.
192
- trash: async (path) => call('shell.trash', checkedPath('shell.trash', path)),
193
- };
194
-
195
99
  export const app = {
196
100
  // The app's own folders, made when first asked for: { data, cache, logs }.
197
101
  paths: async () => call('app.paths', {}),
198
102
  // Ends the app as closing its window does.
199
103
  quit: async () => call('app.quit', {}),
104
+ // Asks the app's extensions, as its user's Quit does, then ends the app as quit() does, unless one keeps it running.
105
+ requestQuit: async () => call('app.requestQuit', {}),
200
106
  // Ends the app as quit() does, then opens it again, with no link.
201
107
  relaunch: async () => call('app.relaunch', {}),
202
- // The links of app.urlSchemes that opened the app and have not reached the page yet, the one it was launched with
203
- // among them; later ones come as the event open-url. Listening first, then asking, misses none.
204
- launchUrls: async () => call('app.launchUrls', {}),
108
+ // The launch that started a desktop app: { args, cwd }, its arguments without the program's own and without the links
109
+ // of the app's schemes, and the folder it was launched in. A phone's has neither.
110
+ launchArgs: async () => call('app.launchArgs', {}),
205
111
  };
206
112
 
207
- export const menu = {
208
- // 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
209
- // window. A click of an item with an id is an event, menu, of { id }, and of { id, checked } for an item it checked
210
- // or unchecked; a standard item does what its role says.
211
- set: async (menus) => call('menu.set', checkedMenus(menus)),
212
- };
213
-
214
- export const tray = {
215
- // The app's icon in the macOS menu bar, the Windows notification area or a Linux desktop's status tray, with its
216
- // tooltip and the menu a click opens. A click of an item with an id is the event menu, as one of menu.set's is, and a
217
- // click of the icon the event tray where it opens no menu. Set again, it changes the icon that shows.
218
- set: async (options = {}) => call('tray.set', checkedTray(options)),
219
- // Takes the icon away.
220
- remove: async () => call('tray.remove', {}),
221
- };
222
-
223
- export const notification = {
224
- // A notification of the system's, with a title and a body; a click on it is the event notification.
225
- show: async (options) => call('notification.show', checkedNotification(options)),
113
+ export const windows = {
114
+ // Opens a window of the app's own page at path, '/' by default, in front: { id, ignored }, its id and the options the
115
+ // platform could not honour, which it opens without. A name already open brings that window to the front instead.
116
+ // The other functions name a window by its id, and with none the calling page's own.
117
+ open: async (options = {}) => call('windows.open', checkedWindow('windows.open', options, WINDOW_OPEN)),
118
+ // Changes an open window: { ignored }, as open() gives it.
119
+ set: async (id, options) => {
120
+ const [named, changes] = typeof id === 'object' && options === undefined ? [undefined, id] : [id, options];
121
+ return call('windows.set', { ...checkedWindow('windows.set', changes, WINDOW_SET), ...windowOf('windows.set', named) });
122
+ },
123
+ // Closes a window as its user does, which an extension may keep open.
124
+ close: async (id) => call('windows.close', windowOf('windows.close', id)),
125
+ // Brings a window to the front, restored if it was minimized.
126
+ focus: async (id) => call('windows.focus', windowOf('windows.focus', id)),
127
+ minimize: async (id) => call('windows.minimize', windowOf('windows.minimize', id)),
128
+ toggleMaximize: async (id) => call('windows.toggleMaximize', windowOf('windows.toggleMaximize', id)),
129
+ // Moves the page's window with the pointer, as its title bar does, from a mousedown of the primary button.
130
+ startDrag: async () => call('windows.startDrag', {}),
131
+ // The open windows, the oldest first, and the page's own: { id, name, title, x, y, width, height, isMaximized,
132
+ // isMinimized, isFullscreen, isFocused }.
133
+ list: async () => call('windows.list', {}),
134
+ current: async () => call('windows.current', {}),
226
135
  };
227
136
 
228
137
  export const events = {
@@ -233,13 +142,42 @@ export const events = {
233
142
  if (typeof listener !== 'function') throw new TypeError('crossbind: events.on takes a function to call.');
234
143
  return hostOf('on', 'events.on')(name, listener);
235
144
  },
145
+ // The values of the events of this name the app's native code held for the page with holdEvent(), which came before
146
+ // the page asked for them since it loaded; later ones come as the events while it listens. Listening first, then
147
+ // asking, misses none.
148
+ held: async (name) => {
149
+ if (typeof name !== 'string' || !name) throw new TypeError('crossbind: events.held takes the name of an event.');
150
+ return call('events.held', { name });
151
+ },
152
+ };
153
+
154
+ // A function of one of the app's extensions, by its name, as clipboard.readText, with options of JSON values and bytes,
155
+ // a typed array, a DataView or an ArrayBuffer, whose result's bytes come back as a Uint8Array; an extension's own
156
+ // package calls it so. The host checks the options. onMessage gets each message the function sends before it answers,
157
+ // in order, all of them before invoke resolves; signal cancels the call, which then rejects with the signal's reason.
158
+ export const invoke = async (name, options = {}, { onMessage, signal } = {}) => {
159
+ if (typeof name !== 'string' || !name) throw new TypeError("crossbind: invoke takes the name of one of the app's functions.");
160
+ if (onMessage !== undefined && typeof onMessage !== 'function') throw new TypeError('crossbind: invoke takes onMessage as a function.');
161
+ if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('crossbind: invoke takes signal as an AbortSignal.');
162
+ const host = hostOf('app', name);
163
+ // An older host would leave them out without a word.
164
+ if (onMessage !== undefined || signal !== undefined) hostOf('takesMessages', 'invoke with onMessage or signal');
165
+ return host(name, options, { onMessage, signal });
236
166
  };
237
167
 
238
- // One check or install runs at a time, and another rejects meanwhile.
239
- export const updater = {
240
- // The version app.updater.url in crossbind.config.js offers when it is newer than the app's, as { version }, or null.
241
- check: async () => call('updater.check', {}),
242
- // Downloads that version, checks that the key of app.updater.publicKey signed it and installs it in place of the app,
243
- // which then quits and opens the new version. It rejects when that fails.
244
- install: async () => call('updater.install', {}),
168
+ const EXTENSION_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
169
+ // A part of a path the URL would resolve away, escaped or not, to another extension's or the page's own.
170
+ const DOT_PART = /^(\.|%2e){1,2}$/i;
171
+ const ANDROID_ORIGIN = 'https://appassets.androidplatform.net';
172
+
173
+ // The URL of a path an extension's route serves, as "/clips/intro.mp4" or "/thumb/42?size=256", for the page's <img>,
174
+ // <video> or fetch(), as the URL holds it: what a path takes escaped, the caller escapes. The route gets it decoded.
175
+ // The app's own origin is an https one on Android, whose WebView serves no scheme of an app's.
176
+ export const routeUrl = (extension, path) => {
177
+ if (typeof extension !== 'string' || !EXTENSION_NAME.test(extension)) throw new TypeError("crossbind: routeUrl takes the name of one of the app's extensions.");
178
+ if (typeof path !== 'string' || !path.startsWith('/')) throw new TypeError('crossbind: routeUrl takes a path that starts with "/".');
179
+ if (path.split(/[?#]/, 1)[0].split('/').some((part) => DOT_PART.test(part))) throw new TypeError('crossbind: routeUrl takes a path without a part "." or "..".');
180
+ const isAndroid = Boolean(globalThis.__crossbind_port) || globalThis.location?.origin === ANDROID_ORIGIN;
181
+ return `${isAndroid ? ANDROID_ORIGIN : 'crossbind://app'}/ext/${extension}${path}`;
245
182
  };
183
+
File without changes
@@ -1,55 +1,55 @@
1
1
  {
2
- "version": "1.0.18",
2
+ "version": "1.0.20",
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:31a213289c4dc1125b462ef006cc27b84ee7eca4e3ef0a93259bd698d56c7359",
9
+ "index": "sha256:3f62871ccbebbdca53b798fa1b21173bee848ada7744f5108df66a290d3cca78",
10
10
  "platforms": {
11
- "linux/arm64": "sha256:e82b9a4b9661356238b264a9d5b6921d364cbc81c54ec8426349527144b91bbc",
12
- "linux/amd64": "sha256:33c63cb3ed60a6d771f079a60c97dfa1f183f215d4087bb655ff4deca2366d9a"
11
+ "linux/arm64": "sha256:ec7c2bba3add2385099b71520c5d68540e8dba691f57a42c0e9143ea5b8981df",
12
+ "linux/amd64": "sha256:dfad6e04d3168f5e7375aba271d0e615cc6658793527503ce3070efbd4ac5f91"
13
13
  }
14
14
  },
15
15
  "base": {
16
- "index": "sha256:ca5b17d648e797193d7f3c67635e1e73e56b58d02699a55ea61a35eb5ca8d0c8",
16
+ "index": "sha256:a5c7ea17f3e13c13d3c9f53307cce95d037bbb6b58b08034cfa2cb8112a63407",
17
17
  "platforms": {
18
- "linux/arm64": "sha256:48666c55a2f943d8be029080fab985839bc50231b8556a00100bc3702a7bcc51",
19
- "linux/amd64": "sha256:847f3c686dc8a846b05ee76d38a79b9ac68fb293ca014d56967ee12859849f1f"
18
+ "linux/amd64": "sha256:a0934c868ccd5a09de14d193ae33b091ecfddf3c13548595e918c0b053e41dd6",
19
+ "linux/arm64": "sha256:14d4bcea7537972052cd6cee314a5c08f1cd68eebd3a7461ff7ca594994c49eb"
20
20
  }
21
21
  },
22
22
  "web": {
23
- "index": "sha256:72cdc6f5b41d946edc7ca8cd5e1bcf7314f03dfc71be9067994b38130f965711",
23
+ "index": "sha256:00a19f61fa46315175be11f20cea051311c218ab6e99bd1bca70236415a69af9",
24
24
  "platforms": {
25
- "linux/arm64": "sha256:6d1756fa994e5d939ac5696ebe32012f56c95d0428a8bc7b3775d53acbff8735",
26
- "linux/amd64": "sha256:14aa215c2157e485ce6d664e0077a3cf6938733855b8146e97830e48fda13875"
25
+ "linux/arm64": "sha256:6e75223e8c696d93f5ab57e286425b4b94c4b1dcb3cf291e63c2820636dfa2a6",
26
+ "linux/amd64": "sha256:fe4e84f435838a35f2d60774be0050c39f2ecf1b8ed6cca23530b8c32f5c6211"
27
27
  }
28
28
  },
29
29
  "android": {
30
- "index": "sha256:7cbd98594a1fb1f48fe156a21c6c5256cd535d2ffd152c1f80f2860d4dc5b6f0",
30
+ "index": "sha256:df5bf439ab81afbe28a166dcd3c0ec281aea11d4265b1fc24225cc686f643ce1",
31
31
  "platforms": {
32
- "linux/amd64": "sha256:a6e99a0a66e038fd0e6d1154df6c7c58d68acd9def896b63f542d810d923bd87"
32
+ "linux/amd64": "sha256:d2f5460a998aa1695b04d0d1bc231af4d77dd78931ca0f2b02adec51a61f0c02"
33
33
  }
34
34
  },
35
35
  "android-app": {
36
- "index": "sha256:5cfd1df40ae28730d592f76ade7e08e628f0002d73979015308d50727f021f3e",
36
+ "index": "sha256:d72766f41a7d0343fdf36a425a9c5e0ea7dda8ac6f3b2697fe49d79a21e5018b",
37
37
  "platforms": {
38
- "linux/amd64": "sha256:98d035dfaeb27095d0a4edce83299ebbb3ffa8e360a4cb925db7cd0b0b8a4666"
38
+ "linux/amd64": "sha256:8e62eea8685cd28276dfdd28b34aa75b3a0a677b40a93ea3f28f7f3ef1565425"
39
39
  }
40
40
  },
41
41
  "linux": {
42
- "index": "sha256:9114bdc0e6b2c1b753d2ba154181566c320e9a901030a14c707f174b7e4849f5",
42
+ "index": "sha256:a4828cf2821cc2313f5ddf5bc761073c90616fbef09daa8b8d93e7fa3050497a",
43
43
  "platforms": {
44
- "linux/arm64": "sha256:47e104db909dc78a995733d3d27db94e135f1044e1f0860bb201930a65273af0",
45
- "linux/amd64": "sha256:cd13e6b3f5c5303a583da5267531a83839fe8cf7979890463fc45a914a6d5d3f"
44
+ "linux/arm64": "sha256:501a878b581295d3946945dfa9bc98a0aa3de5d4c6bc006c7224756cccbf1e35",
45
+ "linux/amd64": "sha256:ee5a4f7ee23fdda7e50d4130d783a76d7c44d079ae34b4a326565510eea761dc"
46
46
  }
47
47
  },
48
48
  "windows": {
49
- "index": "sha256:46e6da18313f4fd3b599fb2505383aa34f32ffe7ceb659426fa3fb71e6f1e8e7",
49
+ "index": "sha256:13fc02ab67d1465cbe75715d7de6a87c5319477eba2c0459c914bb5b698df766",
50
50
  "platforms": {
51
- "linux/arm64": "sha256:b6457367be5c6863f8d336ce426b9dba7fdfa192308876e4bc390d82a6e18474",
52
- "linux/amd64": "sha256:fd5bdc1f34d64bb9aebae1e1d44fc380c1dd815bd6686349e428d4551732f6e0"
51
+ "linux/amd64": "sha256:78883c59167af69c5b9433ef6cdfa35aab3833501bb85d830d95f24079a6c8d0",
52
+ "linux/arm64": "sha256:f27f09f8e661261c770976580d9861d4ce290eb1ac3030e6198f3c0a4987bd8a"
53
53
  }
54
54
  }
55
55
  }
package/src/bin.js CHANGED
@@ -15,8 +15,13 @@ import buildWasiCommand from './actions/buildWasiCommand.js';
15
15
  import buildNode, { addonsInPackages, bundleNodeLoader, publishNodeData } from './actions/buildNode.js';
16
16
  import buildNativeCommand from './actions/buildNativeCommand.js';
17
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';
18
+ import {
19
+ makeUploadKey, releaseSigningEnv, showUploadKeyPassword, useUploadKey,
20
+ } from './actions/uploadKey.js';
21
+ import publish, { publishHelp, publishersOf } from './actions/publish.js';
22
+ import { iosDeviceOption } from './actions/iosDevice.js';
23
+ import addExtension from './actions/addExtension.js';
24
+ import addExtensionCommands from './actions/extensionCommands.js';
20
25
  import buildExternal from './actions/buildExternal.js';
21
26
  import buildPackageTypes from './actions/buildTypes.js';
22
27
  import buildLib from './actions/buildLib.js';
@@ -116,37 +121,85 @@ function projectVite() {
116
121
  }
117
122
  }
118
123
 
124
+ // The project's Vite, as `crossbind app dev`, `build` and `publish` run it, and its exit code once it ends. A signal sent
125
+ // to crossbind alone reaches Vite too, which ends through its exit hooks; Ctrl-C reaches both. Once Vite has ended,
126
+ // signals stop crossbind again, as at the questions a publish asks after its build.
127
+ function runVite(args, env) {
128
+ const vite = spawn(process.execPath, [projectVite(), ...args], { stdio: 'inherit', cwd: state.config.paths.project, env });
129
+ const forwards = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => [signal, () => vite.kill(signal)]);
130
+ forwards.forEach(([signal, forward]) => process.prependListener(signal, forward));
131
+ return new Promise((resolve) => {
132
+ vite.on('exit', (code, signal) => {
133
+ forwards.forEach(([name, forward]) => process.off(name, forward));
134
+ resolve(code ?? 128 + os.constants.signals[signal]);
135
+ });
136
+ });
137
+ }
138
+
139
+ function assertAppProject() {
140
+ if (state.config.target.runtimeEnv !== 'app') {
141
+ 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.");
142
+ }
143
+ }
144
+
119
145
  const commandApp = program.command('app').description('build the project as an app, or open it on the dev server');
120
146
  [
121
147
  ['dev', 'open the app on the dev server, and open it anew as its native code changes'],
122
148
  ['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.
149
+ ].forEach(([name, description]) => {
150
+ const appCommand = commandApp.command(name).description(description)
151
+ .addOption(new Option('-p, --platform <platform>', "the app's platform (default: CROSSBIND_APP_PLATFORM, else this machine's)").choices(APP_PLATFORMS));
152
+ if (name === 'dev') {
153
+ appCommand.option('--device [name]', 'open an iOS app on a connected iPhone or iPad, the one named or else the only one, rather than in the Simulator');
154
+ }
155
+ appCommand
156
+ // Vite's own options, as --port or --mode, go on to Vite.
157
+ .allowUnknownOption()
158
+ .allowExcessArguments()
159
+ .action(async ({ platform, device }, command) => {
160
+ assertAppProject();
161
+ // An Android build checks its upload key before the native build, not after it.
162
+ const isAndroidBuild = name === 'build' && (platform ?? process.env.CROSSBIND_APP_PLATFORM) === 'android';
163
+ const { env: signing } = isAndroidBuild ? await releaseSigningEnv() : { env: {} };
164
+ const onDevice = device === undefined ? {} : { CROSSBIND_APP_DEVICE: iosDeviceOption(device, platform ?? process.env.CROSSBIND_APP_PLATFORM) };
165
+ const env = { ...process.env, ...signing, ...(platform ? { CROSSBIND_APP_PLATFORM: platform } : {}), ...onDevice };
166
+ process.exit(await runVite([...(name === 'build' ? ['build'] : []), ...command.args], env));
167
+ });
168
+ });
169
+
170
+ commandApp.command('upload-key')
171
+ .description("make this machine's upload key, which signs an Android app's bundle for Google Play")
172
+ .option('--use <keystore>', 'make a key you have the upload key instead, once it opens with its password')
173
+ .option('--alias <alias>', 'the alias of the key in the keystore --use names', 'upload')
174
+ .option('--no-keychain', 'keep the password in no keychain, so builds ask for it')
175
+ .option('--show-password', 'print the password the keychain keeps, for another machine or CI')
176
+ .action(async ({ use, alias, keychain, showPassword }) => {
177
+ if (showPassword) showUploadKeyPassword();
178
+ else if (use) await useUploadKey(use, { alias, isKeychain: keychain });
179
+ else await makeUploadKey({ isKeychain: keychain });
180
+ });
181
+
182
+ commandApp.command('publish')
183
+ .usage('[store] [options]')
184
+ .description("build the app and send it to a store, as its publisher does, one of crossbind's own or of an extension's")
185
+ .addOption(new Option('-p, --platform <platform>', "the app's platform: without a store, the one that goes first for it (default: CROSSBIND_APP_PLATFORM, else for a store of desktop apps this machine's)").choices(APP_PLATFORMS))
186
+ .addHelpText('after', () => publishHelp(publishersOf(state.config?.extensions ?? [])))
187
+ // The store's own options, and Vite's, as --mode, which go on to Vite.
126
188
  .allowUnknownOption()
127
189
  .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
- }));
190
+ .action(async ({ platform }, command) => {
191
+ assertAppProject();
192
+ await publish(command.args, { platform, envPlatform: process.env.CROSSBIND_APP_PLATFORM, runVite });
193
+ });
194
+
195
+ // crossbind app alone reads them, so that an extension's package that cannot be read stops no other command.
196
+ if (process.argv[2] === 'app') addExtensionCommands(commandApp);
143
197
 
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}`);
198
+ program.command('add')
199
+ .description("install an app extension's package with the project's package manager and list it in crossbind.config.js's extensions")
200
+ .argument('<package>', "the package as a package manager takes it: its name, name@version or a path to it")
201
+ .action((spec) => {
202
+ addExtension(spec, { config: state.config });
150
203
  });
151
204
 
152
205
  program.command('licenses')
package/src/index.js CHANGED
@@ -6,8 +6,12 @@ export { default as buildWasm } from './actions/buildWasm.js';
6
6
  export {
7
7
  default as buildApp, buildProjectApp, publishApp, appLayout, appOutputName, appTargetParams,
8
8
  } from './actions/buildApp.js';
9
- export { checkInstaller } from './actions/appInstaller.js';
9
+ export { checkInstaller, shipApp } from './actions/appInstaller.js';
10
+ export { resolveAppPageApi } from './utils/resolveAppHost.js';
10
11
  export { default as openInSimulator, buildForSimulator } from './actions/iosSimulator.js';
12
+ export {
13
+ default as openOnIosDevice, buildForIosDevice, connectedIosDevice, iosDeviceAddress, iosDevServer,
14
+ } from './actions/iosDevice.js';
11
15
  export { default as openOnDevice, adbPath } from './actions/androidDevice.js';
12
16
  export { buildApk } from './actions/androidGradle.js';
13
17
  export { default as isSourceNewer } from './actions/isSourceNewer.js';