crossbind 2.0.0-beta.65 → 2.0.0-beta.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
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;
13
+
14
+ // The app's crossbind.js, which initNative() loads, hands the calls to the host.
15
+ function hostOf(member, name) {
16
+ const host = globalThis.Crossbind?.[member];
17
+ if (host) return host;
18
+ if (globalThis.Crossbind) throw new Error(`crossbind: this app's host is older than crossbind/app; update @crossbind/core-app-host to call ${name}`);
19
+ if (globalThis.__crossbind_token) throw new Error(`crossbind: ${name} works once the page has called initNative()`);
20
+ throw new Error(`crossbind: ${name} works in an app alone, whose crossbind.config.js sets target.runtimeEnv: 'app'`);
21
+ }
22
+
23
+ const call = (name, options) => hostOf('app', name)(name, options);
24
+
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) {
41
+ const keys = Object.keys(kinds).join(', ');
42
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
43
+ throw new TypeError(`crossbind: ${name} takes an object of ${keys}.`);
44
+ }
45
+ for (const [key, value] of Object.entries(options)) {
46
+ 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)}.`);
60
+ }
61
+ return options;
62
+ }
63
+
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',
77
+ };
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.`);
88
+ }
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}.`);
98
+ }
99
+ }
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');
120
+ }
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 };
128
+ }
129
+
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}`);
145
+ }
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}]`);
155
+ }
156
+ }
157
+
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;
168
+ }
169
+
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
+ export const app = {
196
+ // The app's own folders, made when first asked for: { data, cache, logs }.
197
+ paths: async () => call('app.paths', {}),
198
+ // Ends the app as closing its window does.
199
+ quit: async () => call('app.quit', {}),
200
+ // Ends the app as quit() does, then opens it again, with no link.
201
+ 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', {}),
205
+ };
206
+
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)),
226
+ };
227
+
228
+ export const events = {
229
+ // Calls listener with the value of each event of this name the app's native code emits from now on, until the
230
+ // function it returns is called.
231
+ on: (name, listener) => {
232
+ if (typeof name !== 'string' || !name) throw new TypeError('crossbind: events.on takes the name of an event.');
233
+ if (typeof listener !== 'function') throw new TypeError('crossbind: events.on takes a function to call.');
234
+ return hostOf('on', 'events.on')(name, listener);
235
+ },
236
+ };
237
+
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', {}),
245
+ };
@@ -1,55 +1,55 @@
1
1
  {
2
- "version": "1.0.15",
2
+ "version": "1.0.18",
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:720469aca3cff3f1abe69374882d2c48399864be3778225e7754e94036e3be7a",
9
+ "index": "sha256:31a213289c4dc1125b462ef006cc27b84ee7eca4e3ef0a93259bd698d56c7359",
10
10
  "platforms": {
11
- "linux/arm64": "sha256:3aa68ad3736491de26c6ca9b52a49c80e6291228cc17a05caba4c060ea68913c",
12
- "linux/amd64": "sha256:e77357a8c763244a5a5b4086fb771e81bec385526432ec6e77fb316f39158c53"
11
+ "linux/arm64": "sha256:e82b9a4b9661356238b264a9d5b6921d364cbc81c54ec8426349527144b91bbc",
12
+ "linux/amd64": "sha256:33c63cb3ed60a6d771f079a60c97dfa1f183f215d4087bb655ff4deca2366d9a"
13
13
  }
14
14
  },
15
15
  "base": {
16
- "index": "sha256:a7c9342c50a493c990f8c26f1bec7e3919ad6010ce8a6cdfaa772487a1040989",
16
+ "index": "sha256:ca5b17d648e797193d7f3c67635e1e73e56b58d02699a55ea61a35eb5ca8d0c8",
17
17
  "platforms": {
18
- "linux/arm64": "sha256:c9152c3ff4854ba898df1b9fe89e8a94fd29aa5832e8cc5a4bd5fa2e06cee10f",
19
- "linux/amd64": "sha256:817fc177f488b975bbbd196d9ec9d1d3effbf2c59d1ed82e979fcf5124c4c1aa"
18
+ "linux/arm64": "sha256:48666c55a2f943d8be029080fab985839bc50231b8556a00100bc3702a7bcc51",
19
+ "linux/amd64": "sha256:847f3c686dc8a846b05ee76d38a79b9ac68fb293ca014d56967ee12859849f1f"
20
20
  }
21
21
  },
22
22
  "web": {
23
- "index": "sha256:143c2e79a950f732913726f32559f7c70323accf2727b34a12f332df91b765ef",
23
+ "index": "sha256:72cdc6f5b41d946edc7ca8cd5e1bcf7314f03dfc71be9067994b38130f965711",
24
24
  "platforms": {
25
- "linux/arm64": "sha256:ebd0dbeee77a5fde4659b5f129713e5006cc205c629f837b56478e3691e85b9c",
26
- "linux/amd64": "sha256:14429be7ee52afa0bd9c94248db3a84f90e74c29700a3f5f34da2989d7161431"
25
+ "linux/arm64": "sha256:6d1756fa994e5d939ac5696ebe32012f56c95d0428a8bc7b3775d53acbff8735",
26
+ "linux/amd64": "sha256:14aa215c2157e485ce6d664e0077a3cf6938733855b8146e97830e48fda13875"
27
27
  }
28
28
  },
29
29
  "android": {
30
- "index": "sha256:249bdfda25dcd394c1e4955f3cd3e63140264697e9a543fbae1dce2ec3d80c38",
30
+ "index": "sha256:7cbd98594a1fb1f48fe156a21c6c5256cd535d2ffd152c1f80f2860d4dc5b6f0",
31
31
  "platforms": {
32
- "linux/amd64": "sha256:725375a79e64c93a7cc7e35f4708e36511f6a36212e0a3aaf44f6b5ec1057724"
32
+ "linux/amd64": "sha256:a6e99a0a66e038fd0e6d1154df6c7c58d68acd9def896b63f542d810d923bd87"
33
33
  }
34
34
  },
35
35
  "android-app": {
36
- "index": "sha256:d739c6b72e1545b086f38fe5613b58f52877a36e14c2ffd763acdd0296802802",
36
+ "index": "sha256:5cfd1df40ae28730d592f76ade7e08e628f0002d73979015308d50727f021f3e",
37
37
  "platforms": {
38
- "linux/amd64": "sha256:a7124034f1ba3d888b3cd32c18051f87498c47ec515eac809b7440394c54ecb7"
38
+ "linux/amd64": "sha256:98d035dfaeb27095d0a4edce83299ebbb3ffa8e360a4cb925db7cd0b0b8a4666"
39
39
  }
40
40
  },
41
41
  "linux": {
42
- "index": "sha256:433af720cf95a99c4e007c0cfc98ab358c6ddc61fecca75a0b844f598d750b1b",
42
+ "index": "sha256:9114bdc0e6b2c1b753d2ba154181566c320e9a901030a14c707f174b7e4849f5",
43
43
  "platforms": {
44
- "linux/arm64": "sha256:0cf32948302c1a23294d28d20dd3b784790c7789b9e99bc8be56888bddb6aeb7",
45
- "linux/amd64": "sha256:6da084ff433e792381aadd280b769aa1b09ba593e3e21ea4580f4736776e3cb9"
44
+ "linux/arm64": "sha256:47e104db909dc78a995733d3d27db94e135f1044e1f0860bb201930a65273af0",
45
+ "linux/amd64": "sha256:cd13e6b3f5c5303a583da5267531a83839fe8cf7979890463fc45a914a6d5d3f"
46
46
  }
47
47
  },
48
48
  "windows": {
49
- "index": "sha256:c1fb594cb245f6b0414f04cb7413881532438b204c7ce06f78f6deaea2d3cb24",
49
+ "index": "sha256:46e6da18313f4fd3b599fb2505383aa34f32ffe7ceb659426fa3fb71e6f1e8e7",
50
50
  "platforms": {
51
- "linux/amd64": "sha256:3ee3a4f9c565b0d2adedc3c699871bf8de4c2d7034b2b7a24309f4a6c4d0e64c",
52
- "linux/arm64": "sha256:44702d741848f96d71dfb0a5322c37c9c248f7a38fe9e7c09b7d307ce000cc3f"
51
+ "linux/arm64": "sha256:b6457367be5c6863f8d336ce426b9dba7fdfa192308876e4bc390d82a6e18474",
52
+ "linux/amd64": "sha256:fd5bdc1f34d64bb9aebae1e1d44fc380c1dd815bd6686349e428d4551732f6e0"
53
53
  }
54
54
  }
55
55
  }
package/src/bin.js CHANGED
@@ -16,6 +16,7 @@ import buildNode, { addonsInPackages, bundleNodeLoader, publishNodeData } from '
16
16
  import buildNativeCommand from './actions/buildNativeCommand.js';
17
17
  import buildApp, { appOutputPath, assertAppRunner, publishApp } from './actions/buildApp.js';
18
18
  import { releaseSigning } from './actions/androidGradle.js';
19
+ import { UPDATER_KEY, newUpdaterKey } from './actions/appUpdate.js';
19
20
  import buildExternal from './actions/buildExternal.js';
20
21
  import buildPackageTypes from './actions/buildTypes.js';
21
22
  import buildLib from './actions/buildLib.js';
@@ -140,6 +141,14 @@ const commandApp = program.command('app').description('build the project as an a
140
141
  vite.on('exit', (code, signal) => process.exit(code ?? 128 + os.constants.signals[signal]));
141
142
  }));
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
+
143
152
  program.command('licenses')
144
153
  .description('list bundled native dependencies with SPDX license, versions and source URL')
145
154
  .option('--notices [file]', 'write a THIRD-PARTY-NOTICES markdown file (default: THIRD-PARTY-NOTICES.md)')
package/src/index.js CHANGED
@@ -6,8 +6,9 @@ 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
10
  export { default as openInSimulator, buildForSimulator } from './actions/iosSimulator.js';
10
- export { default as openOnDevice } from './actions/androidDevice.js';
11
+ export { default as openOnDevice, adbPath } from './actions/androidDevice.js';
11
12
  export { buildApk } from './actions/androidGradle.js';
12
13
  export { default as isSourceNewer } from './actions/isSourceNewer.js';
13
14
  export { default as getData } from './actions/getData.js';
@@ -133,7 +133,7 @@ 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, and on iOS the Apple team that signs it.
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
137
  app: config.app || {},
138
138
  extensions: config.extensions || [],
139
139
  package: null,
@@ -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
+ }
@@ -0,0 +1,174 @@
1
+ // An app's icon: app.icon in crossbind.config.js, one square PNG the build makes each platform's icons from. Every size
2
+ // is resampled with a three-lobed Lanczos filter on premultiplied colors, as Tauri resizes icons.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { PNG } from 'pngjs';
6
+ import { getContentHash } from './hash.js';
7
+
8
+ // A macOS .icns: each kind of entry and the size of the PNG it holds.
9
+ const ICNS_ENTRIES = [['icp4', 16], ['icp5', 32], ['ic11', 32], ['ic12', 64], ['ic07', 128], ['ic13', 256], ['ic08', 256],
10
+ ['ic14', 512], ['ic09', 512], ['ic10', 1024]];
11
+ // A Windows .ico's sizes, each a PNG.
12
+ const ICO_SIZES = [16, 24, 32, 48, 64, 256];
13
+ const LINUX_SIZE = 512;
14
+ // Xcode makes an iOS app's every size from this one.
15
+ const IOS_SIZE = 1024;
16
+ // An Android launcher icon's foreground at each density is 108 dp, of which the launcher's mask shows the middle 72.
17
+ const ANDROID_FOREGROUNDS = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
18
+ const ANDROID_SHOWN = 72 / 108;
19
+ const WHITE = { red: 255, green: 255, blue: 255 };
20
+
21
+ // The app's icon, read and checked; none without app.icon.
22
+ export function readAppIcon(config) {
23
+ const { icon } = config.app;
24
+ if (icon === undefined) return null;
25
+ if (typeof icon !== 'string' || !icon) {
26
+ throw new Error(`crossbind: app.icon ${JSON.stringify(icon)} is no path to a PNG file, as 'icon.png' is.`);
27
+ }
28
+ const file = path.resolve(config.paths.project, icon);
29
+ if (!fs.existsSync(file)) throw new Error(`crossbind: app.icon names ${file}, which does not exist.`);
30
+ let bytes;
31
+ let image;
32
+ try {
33
+ bytes = fs.readFileSync(file);
34
+ image = PNG.sync.read(bytes);
35
+ } catch (error) {
36
+ throw new Error(`crossbind: app.icon ${file} is no PNG image: ${error.message}.`, { cause: error });
37
+ }
38
+ if (image.width !== image.height) {
39
+ throw new Error(`crossbind: app.icon ${file} is ${image.width} × ${image.height} pixels; an app's icon is square, at best 1024 × 1024.`);
40
+ }
41
+ return { file, hash: getContentHash(bytes), image: { width: image.width, height: image.height, data: image.data } };
42
+ }
43
+
44
+ function lanczos3(x) {
45
+ if (x === 0) return 1;
46
+ if (x <= -3 || x >= 3) return 0;
47
+ const px = Math.PI * x;
48
+ return (3 * Math.sin(px) * Math.sin(px / 3)) / (px * px);
49
+ }
50
+
51
+ // For each pixel along one axis of the resized image, the first pixel of the original it takes and their weights.
52
+ function contributions(from, to) {
53
+ const scale = from / to;
54
+ // Shrinking widens the filter, so every pixel of the original counts.
55
+ const stretch = Math.max(scale, 1);
56
+ return Array.from({ length: to }, (_, index) => {
57
+ const center = (index + 0.5) * scale;
58
+ const first = Math.max(0, Math.ceil(center - 3 * stretch - 0.5));
59
+ const last = Math.min(from - 1, Math.floor(center + 3 * stretch - 0.5));
60
+ const weights = Array.from({ length: last - first + 1 }, (__, k) => lanczos3((first + k + 0.5 - center) / stretch));
61
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
62
+ return { first, weights: weights.map((weight) => weight / total) };
63
+ });
64
+ }
65
+
66
+ // One pass along x: each row of `width` pixels becomes `size` pixels, written to the output transposed, so the second
67
+ // pass along y is the same pass again.
68
+ function pass(source, width, height, size) {
69
+ const out = new Float32Array(size * height * 4);
70
+ contributions(width, size).forEach(({ first, weights }, x) => {
71
+ for (let y = 0; y < height; y++) {
72
+ const row = y * width;
73
+ const at = (x * height + y) * 4;
74
+ for (let k = 0; k < weights.length; k++) {
75
+ const from = (row + first + k) * 4;
76
+ out[at] += weights[k] * source[from];
77
+ out[at + 1] += weights[k] * source[from + 1];
78
+ out[at + 2] += weights[k] * source[from + 2];
79
+ out[at + 3] += weights[k] * source[from + 3];
80
+ }
81
+ }
82
+ });
83
+ return out;
84
+ }
85
+
86
+ export function resize(image, size) {
87
+ if (image.width === size) return image;
88
+ const { width, height, data } = image;
89
+ // Premultiplied, so the color of a transparent pixel does not bleed into its neighbors.
90
+ const source = new Float32Array(width * height * 4);
91
+ for (let i = 0; i < data.length; i += 4) {
92
+ const alpha = data[i + 3] / 255;
93
+ source[i] = data[i] * alpha;
94
+ source[i + 1] = data[i + 1] * alpha;
95
+ source[i + 2] = data[i + 2] * alpha;
96
+ source[i + 3] = data[i + 3];
97
+ }
98
+ const resized = pass(pass(source, width, height, size), height, size, size);
99
+ const out = Buffer.alloc(size * size * 4);
100
+ const clamp = (value) => Math.min(255, Math.max(0, Math.round(value)));
101
+ for (let i = 0; i < out.length; i += 4) {
102
+ // The filter overshoots now and then; the colors divide by what it gave, the alpha is clamped.
103
+ const alpha = resized[i + 3];
104
+ out[i + 3] = clamp(alpha);
105
+ if (out[i + 3] === 0) continue;
106
+ out[i] = clamp((resized[i] * 255) / alpha);
107
+ out[i + 1] = clamp((resized[i + 1] * 255) / alpha);
108
+ out[i + 2] = clamp((resized[i + 2] * 255) / alpha);
109
+ }
110
+ return { width: size, height: size, data: out };
111
+ }
112
+
113
+ // The image at `size` in the middle of a transparent square of `canvas`.
114
+ function centered(image, canvas, size) {
115
+ const inner = resize(image, size);
116
+ const out = Buffer.alloc(canvas * canvas * 4);
117
+ const offset = Math.floor((canvas - size) / 2);
118
+ for (let y = 0; y < size; y++) inner.data.copy(out, ((y + offset) * canvas + offset) * 4, y * size * 4, (y + 1) * size * 4);
119
+ return { width: canvas, height: canvas, data: out };
120
+ }
121
+
122
+ const png = (image, options) => PNG.sync.write(image, options);
123
+
124
+ // A macOS app's icon file, which its Info.plist names.
125
+ export function macIcon(icon) {
126
+ const pngs = new Map();
127
+ const entries = ICNS_ENTRIES.map(([type, size]) => {
128
+ if (!pngs.has(size)) pngs.set(size, png(resize(icon.image, size)));
129
+ return chunk(type, pngs.get(size));
130
+ });
131
+ return chunk('icns', Buffer.concat(entries));
132
+ }
133
+
134
+ // An .icns chunk: its type, its length with these eight bytes, and what it holds.
135
+ function chunk(type, data) {
136
+ const header = Buffer.alloc(8);
137
+ header.write(type, 0, 'latin1');
138
+ header.writeUInt32BE(data.length + 8, 4);
139
+ return Buffer.concat([header, data]);
140
+ }
141
+
142
+ // A Windows app's icon, which its executable carries.
143
+ export function windowsIcon(icon) {
144
+ const images = ICO_SIZES.map((size) => png(resize(icon.image, size)));
145
+ const header = Buffer.alloc(6 + 16 * images.length);
146
+ header.writeUInt16LE(1, 2);
147
+ header.writeUInt16LE(images.length, 4);
148
+ let offset = header.length;
149
+ images.forEach((image, index) => {
150
+ const at = 6 + 16 * index;
151
+ // A width and height of 0 stand for 256.
152
+ header.writeUInt8(ICO_SIZES[index] % 256, at);
153
+ header.writeUInt8(ICO_SIZES[index] % 256, at + 1);
154
+ header.writeUInt16LE(1, at + 4);
155
+ header.writeUInt16LE(32, at + 6);
156
+ header.writeUInt32LE(image.length, at + 8);
157
+ header.writeUInt32LE(offset, at + 12);
158
+ offset += image.length;
159
+ });
160
+ return Buffer.concat([header, ...images]);
161
+ }
162
+
163
+ // A Linux app's window icon.
164
+ export const linuxIcon = (icon) => png(resize(icon.image, LINUX_SIZE));
165
+
166
+ // The one image an iOS app's asset catalog holds, on white and without an alpha channel, which the App Store refuses
167
+ // even when every pixel is opaque.
168
+ export const iosIcon = (icon) => png(resize(icon.image, IOS_SIZE), { colorType: 2, bgColor: WHITE });
169
+
170
+ // An Android app's adaptive icon: its foreground at each density, with the whole icon in what the mask shows; its
171
+ // background is white.
172
+ export const androidIcons = (icon) => Object.fromEntries(Object.entries(ANDROID_FOREGROUNDS).map(([density, size]) => [
173
+ `mipmap-${density}/ic_launcher_foreground.png`, png(centered(icon.image, size, Math.round(size * ANDROID_SHOWN))),
174
+ ]));