crossbind 2.0.0-beta.68 → 2.0.0-beta.72

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 (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +70 -59
  3. package/src/actions/addExtension.js +196 -0
  4. package/src/actions/androidGradle.js +26 -17
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/buildApp.js +177 -74
  8. package/src/actions/buildDependencies.js +3 -3
  9. package/src/actions/buildNode.js +6 -1
  10. package/src/actions/buildWasm.js +0 -9
  11. package/src/actions/createImportedBridges.js +10 -8
  12. package/src/actions/createInterface.js +40 -29
  13. package/src/actions/createLib.js +3 -7
  14. package/src/actions/extensionApi.js +298 -0
  15. package/src/actions/extensionCommands.js +41 -0
  16. package/src/actions/extensionsInApp.js +19 -0
  17. package/src/actions/getAllBridges.js +20 -5
  18. package/src/actions/getDependLibs.js +5 -0
  19. package/src/actions/getLinkInputs.js +2 -1
  20. package/src/actions/gradleProject.js +207 -16
  21. package/src/actions/iosDevice.js +2 -2
  22. package/src/actions/iosSimulator.js +3 -3
  23. package/src/actions/licenses.js +113 -58
  24. package/src/actions/linuxPackages.js +48 -0
  25. package/src/actions/mavenVerification.js +37 -0
  26. package/src/actions/{prepareConanDependencies.js → preparePackageSources.js} +13 -12
  27. package/src/actions/publish.js +220 -0
  28. package/src/actions/windowsSigning.js +1 -1
  29. package/src/actions/writeRuntimeEntry.js +2 -2
  30. package/src/actions/xcodeProject.js +60 -29
  31. package/src/actions/xcodeTargets.js +122 -0
  32. package/src/actions/xcodebuild.js +65 -0
  33. package/src/app/index.d.ts +144 -132
  34. package/src/app/index.js +147 -185
  35. package/src/assets/configure/install.sh +0 -0
  36. package/src/assets/node-import-hooks.mjs +7 -6
  37. package/src/assets/toolchain-digests.json +20 -20
  38. package/src/bin.js +37 -37
  39. package/src/index.js +4 -3
  40. package/src/integration/getCrossbindScript.js +3 -2
  41. package/src/integration/getDependFilePath.js +10 -10
  42. package/src/program.js +32 -3
  43. package/src/state/{attachConanDependencies.js → attachPackageSources.js} +20 -16
  44. package/src/state/index.js +8 -6
  45. package/src/state/loadConfig.js +26 -9
  46. package/src/state/refreshPackageSources.js +19 -0
  47. package/src/utils/appExtensions.js +777 -0
  48. package/src/utils/appFiles.js +58 -0
  49. package/src/utils/appLaunches.js +249 -0
  50. package/src/utils/appOptions.js +142 -112
  51. package/src/utils/appRustCrates.js +2 -2
  52. package/src/utils/appTargets.js +168 -0
  53. package/src/utils/bridgeTools.js +71 -0
  54. package/src/utils/configChanges.js +30 -0
  55. package/src/utils/configWriter.js +143 -26
  56. package/src/utils/cppDts.js +11 -11
  57. package/src/utils/extensionSteps.js +172 -0
  58. package/src/utils/nativeImports.js +9 -2
  59. package/src/utils/nodeDevBuild.js +2 -1
  60. package/src/utils/openUrl.js +2 -1
  61. package/src/utils/packageSourceApi.js +87 -0
  62. package/src/utils/packageSourceImport.js +69 -0
  63. package/src/utils/packageSourceInstall.js +162 -0
  64. package/src/utils/{conanStage.js → packageSourceStage.js} +64 -90
  65. package/src/utils/packageSources.js +108 -0
  66. package/src/utils/plist.js +5 -0
  67. package/src/utils/printableText.js +5 -0
  68. package/src/utils/resolveAppHost.js +7 -0
  69. package/src/utils/runPackageSource.js +251 -0
  70. package/src/utils/rustBridgeGen.js +3 -3
  71. package/src/utils/systemConfig.js +16 -3
  72. package/src/utils/targetToolchain.js +77 -0
  73. package/vitest.config.js +6 -1
  74. package/src/actions/appSetup.js +0 -116
  75. package/src/actions/appStoreConnect.js +0 -188
  76. package/src/actions/appUpdate.js +0 -68
  77. package/src/actions/extensions.js +0 -7
  78. package/src/actions/googlePlay.js +0 -213
  79. package/src/actions/googleSignIn.js +0 -78
  80. package/src/actions/playSetup.js +0 -19
  81. package/src/actions/publishAndroid.js +0 -102
  82. package/src/actions/publishIos.js +0 -146
  83. package/src/state/refreshConanDependencies.js +0 -17
  84. package/src/utils/conanDependencies.js +0 -66
  85. package/src/utils/conanImport.js +0 -58
  86. package/src/utils/conanInstall.js +0 -154
  87. package/src/utils/conanProfile.js +0 -203
  88. package/src/utils/runConan.js +0 -257
@@ -1,16 +1,17 @@
1
1
  // The app section of crossbind.config.js: what a user sees of an app, its name and, on the desktop, its window, and where
2
2
  // its page opens files.
3
+ import { carriedPath } from './appFiles.js';
3
4
 
4
- const WINDOW_DEFAULTS = { width: 1024, height: 768, minWidth: 0, minHeight: 0, maxWidth: 0, maxHeight: 0, resizable: true, fullscreen: false, backgroundColor: null };
5
+ const WINDOW_DEFAULTS = { width: 1024, height: 768, minWidth: 0, minHeight: 0, maxWidth: 0, maxHeight: 0, resizable: true, fullscreen: false, backgroundColor: null, titleBar: 'visible' };
6
+ const TITLE_BARS = ['visible', 'hidden'];
5
7
  // Larger than any display, and far within the int a host takes a size as.
6
8
  const MAX_WINDOW_SIZE = 16384;
7
9
  // Windows keeps each of a file version's four numbers in 16 bits.
8
10
  const MAX_VERSION_NUMBER = 65535;
9
11
  // Up to four numbers, then a semver prerelease or build part; none of its characters needs escaping in C or RC.
10
12
  const WINDOWS_VERSION = /^(\d+(?:\.\d+){0,3})(?:[-+][0-9A-Za-z.+-]*)?$/;
11
- // What app.openPath can let the page's shell.openPath open: the app's folders as app.paths() names them, and what a
12
- // dialog picked during this run.
13
- const OPEN_PATH_PLACES = ['data', 'cache', 'logs', 'picked'];
13
+ // The platforms whose apps have a window of their own, and updates.
14
+ const DESKTOP = ['darwin', 'linux', 'win32'];
14
15
  // Apple's app categories, which macOS shows an app under, each with the main categories of the freedesktop menu
15
16
  // specification a Linux menu shows it under. An audio or a video app is in AudioVideo as well, as the specification asks.
16
17
  const GAMES = ['', 'action-', 'adventure-', 'arcade-', 'board-', 'card-', 'casino-', 'dice-', 'educational-', 'family-', 'kids-', 'music-', 'puzzle-',
@@ -32,6 +33,18 @@ export function appId(config) {
32
33
  return id;
33
34
  }
34
35
 
36
+ // An Android application id: dot-separated parts of letters, digits and underscores that each start with a letter.
37
+ const ANDROID_APP_ID = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
38
+
39
+ // An Android app's id: the project's app.id, else one made from the project's name.
40
+ export function androidAppId(config) {
41
+ const id = config.app.id ?? `dev.crossbind.${config.general.name.replace(/[^A-Za-z0-9.]/g, '_')}`;
42
+ if (!ANDROID_APP_ID.test(id)) {
43
+ throw new Error(`crossbind: ${JSON.stringify(id)} (app.id, else made from the project's name) is no Android application id, which takes dot-separated parts of letters, digits and underscores that each start with a letter, as com.example.app does. Set app.id in crossbind.config.js.`);
44
+ }
45
+ return id;
46
+ }
47
+
35
48
  // The name the app shows: its window's title, its macOS menus, its Windows file description and the label under its
36
49
  // icon. The project's name, without app.name.
37
50
  export function appDisplayName(config) {
@@ -70,8 +83,9 @@ export function appCategory(config) {
70
83
  return { darwin: `public.app-category.${category}`, linux: APP_CATEGORIES[category] };
71
84
  }
72
85
 
73
- // The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes and
74
- // whether it opens full screen. Its backgroundColor, which a phone takes as well, is appBackgroundColor's.
86
+ // The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes,
87
+ // whether it opens full screen and whether a macOS page lies under its title bar. Its backgroundColor, which a phone
88
+ // takes as well, is appBackgroundColor's.
75
89
  export function appWindow(config) {
76
90
  const window = config.app.window ?? {};
77
91
  const keys = Object.keys(WINDOW_DEFAULTS).join(', ');
@@ -90,6 +104,9 @@ export function appWindow(config) {
90
104
  ['resizable', 'fullscreen'].forEach((key) => {
91
105
  if (typeof options[key] !== 'boolean') throw new Error(`crossbind: app.window.${key} ${JSON.stringify(options[key])} is no boolean: set it to true or false.`);
92
106
  });
107
+ if (!TITLE_BARS.includes(options.titleBar)) {
108
+ throw new Error(`crossbind: app.window.titleBar ${JSON.stringify(options.titleBar)} is neither 'visible' nor 'hidden': 'hidden' lays a macOS app's page under its title bar, whose buttons stay.`);
109
+ }
93
110
  if (!options.resizable && (options.minWidth || options.minHeight || options.maxWidth || options.maxHeight)) {
94
111
  throw new Error('crossbind: app.window.minWidth, minHeight, maxWidth and maxHeight limit a window that resizes: drop them, or set app.window.resizable to true.');
95
112
  }
@@ -105,16 +122,29 @@ export function appWindow(config) {
105
122
  return options;
106
123
  }
107
124
 
108
- // The color an app shows until its page paints, and behind a page without a background of its own, on iOS and Android as
109
- // well: app.window.backgroundColor, else null, which leaves the system's.
110
- export function appBackgroundColor(config) {
125
+ const COLOR = /^#[0-9A-Fa-f]{6}$/;
126
+
127
+ // The colors an app shows until its page paints, and behind a page without a background of its own, on iOS and Android as
128
+ // well: app.window.backgroundColor, one color, or { light, dark }, the one while the system is light and the one while
129
+ // it is dark, which a phone follows; else null, which leaves the system's.
130
+ function backgroundColors(config) {
111
131
  const color = config.app.window?.backgroundColor ?? null;
112
- if (color !== null && (typeof color !== 'string' || !/^#[0-9A-Fa-f]{6}$/.test(color))) {
113
- throw new Error(`crossbind: app.window.backgroundColor ${JSON.stringify(color)} is no color of the form #rrggbb, as #1e1e1e is.`);
132
+ if (color === null) return null;
133
+ if (typeof color === 'string' && COLOR.test(color)) return { light: color, dark: null };
134
+ const isPair = typeof color === 'object' && !Array.isArray(color) && Object.keys(color).sort().join() === 'dark,light'
135
+ && [color.light, color.dark].every((one) => typeof one === 'string' && COLOR.test(one));
136
+ if (!isPair) {
137
+ throw new Error(`crossbind: app.window.backgroundColor ${JSON.stringify(color)} is no color of the form #rrggbb, as #1e1e1e is, nor { light, dark } of two, as { light: '#f2f2f2', dark: '#1e1e1e' } is.`);
114
138
  }
115
139
  return color;
116
140
  }
117
141
 
142
+ // The color, or the light one of the two, which a desktop shows whatever its system's appearance.
143
+ export const appBackgroundColor = (config) => backgroundColors(config)?.light ?? null;
144
+
145
+ // The color an iOS or Android app shows while its system is dark, where app.window.backgroundColor gives one.
146
+ export const appDarkBackgroundColor = (config) => backgroundColors(config)?.dark ?? null;
147
+
118
148
  const PERMISSIONS = ['camera', 'microphone', 'location'];
119
149
 
120
150
  // The devices the page may use, each with the reason for it, which macOS and iOS read from the Info.plist:
@@ -158,18 +188,20 @@ export const PRIVACY_CATEGORIES = {
158
188
 
159
189
  // The reasons an iOS app's privacy manifest gives for the required reason API it calls: app.privacy, each category with
160
190
  // Apple's reasons that fit, as { fileTimestamp: ['C617.1'] }.
161
- export function appPrivacy(config) {
162
- const privacy = config.app.privacy ?? {};
191
+ export const appPrivacy = (config) => checkedPrivacy(config.app.privacy ?? {}, 'app.privacy');
192
+
193
+ // Reasons for Apple's required reason API, as app.privacy gives them and an extension's ios.privacy does.
194
+ export function checkedPrivacy(privacy, where) {
163
195
  const names = Object.keys(PRIVACY_CATEGORIES).join(', ');
164
196
  if (typeof privacy !== 'object' || privacy === null || Array.isArray(privacy)) {
165
- throw new Error(`crossbind: app.privacy ${JSON.stringify(privacy)} is no object of ${names}, each with Apple's reasons for it, as { fileTimestamp: ['C617.1'] } is.`);
197
+ throw new Error(`crossbind: ${where} ${JSON.stringify(privacy)} is no object of ${names}, each with Apple's reasons for it, as { fileTimestamp: ['C617.1'] } is.`);
166
198
  }
167
199
  const unknown = Object.keys(privacy).filter((name) => !Object.hasOwn(PRIVACY_CATEGORIES, name));
168
- if (unknown.length > 0) throw new Error(`crossbind: app.privacy takes ${names}; ${unknown.join(', ')} is none of them.`);
200
+ if (unknown.length > 0) throw new Error(`crossbind: ${where} takes ${names}; ${unknown.join(', ')} is none of them.`);
169
201
  Object.entries(privacy).forEach(([name, reasons]) => {
170
202
  const allowed = PRIVACY_CATEGORIES[name].reasons;
171
203
  if (!Array.isArray(reasons) || reasons.length === 0 || reasons.some((reason) => !allowed.includes(reason))) {
172
- throw new Error(`crossbind: app.privacy.${name} ${JSON.stringify(reasons)} is no list of Apple's reasons for it, which are ${allowed.join(', ')}; Apple's "Describing use of required reason API" says what each covers.`);
204
+ throw new Error(`crossbind: ${where}.${name} ${JSON.stringify(reasons)} is no list of Apple's reasons for it, which are ${allowed.join(', ')}; Apple's "Describing use of required reason API" says what each covers.`);
173
205
  }
174
206
  });
175
207
  return privacy;
@@ -186,6 +218,40 @@ export function appEncryption(config) {
186
218
  return encryption;
187
219
  }
188
220
 
221
+ const IOS_OPTIONS = ['multipleWindows', 'appGroup'];
222
+ // An App Group of iOS's: group. and a bundle id's dot-separated parts.
223
+ const APP_GROUP = /^group(\.[A-Za-z0-9-]+)+$/;
224
+
225
+ function iosOptions(config) {
226
+ const ios = config.app.ios ?? {};
227
+ if (typeof ios !== 'object' || Array.isArray(ios)) {
228
+ throw new Error(`crossbind: app.ios ${JSON.stringify(ios)} is no object of multipleWindows and appGroup, as { multipleWindows: true } is.`);
229
+ }
230
+ const unknown = Object.keys(ios).filter((key) => !IOS_OPTIONS.includes(key));
231
+ if (unknown.length > 0) throw new Error(`crossbind: app.ios takes multipleWindows and appGroup; ${unknown.join(', ')} is none of them.`);
232
+ return ios;
233
+ }
234
+
235
+ // Whether an iPad app's user, and its page, open more windows of it, as app.ios.multipleWindows says: iOS shows an
236
+ // iPhone's one window alone.
237
+ export function iosMultipleWindows(config) {
238
+ const { multipleWindows = false } = iosOptions(config);
239
+ if (typeof multipleWindows !== 'boolean') {
240
+ throw new Error(`crossbind: app.ios.multipleWindows ${JSON.stringify(multipleWindows)} is no boolean: set it to true for an iPad app's windows, or leave it out.`);
241
+ }
242
+ return multipleWindows;
243
+ }
244
+
245
+ // The App Group an iOS app shares with the targets its extensions add, as app.ios.appGroup names it, else group. and the
246
+ // app's id.
247
+ export function iosAppGroup(config) {
248
+ const { appGroup = `group.${appId(config)}` } = iosOptions(config);
249
+ if (typeof appGroup !== 'string' || !APP_GROUP.test(appGroup)) {
250
+ throw new Error(`crossbind: app.ios.appGroup ${JSON.stringify(appGroup)} is no App Group, which is group. and a bundle id's parts, as group.com.example.notes is.`);
251
+ }
252
+ return appGroup;
253
+ }
254
+
189
255
  // The version a Windows app's executable carries: package.json's, whose numbers are its file version.
190
256
  function windowsVersion(config) {
191
257
  const version = config.package?.version ?? '1.0.0';
@@ -197,17 +263,13 @@ function windowsVersion(config) {
197
263
  return { text: version, numbers: [...numbers, 0, 0, 0].slice(0, 4).join(',') };
198
264
  }
199
265
 
200
- // Where the page's shell.openPath opens a file: nowhere unless app.openPath lists it, as opening a program runs it.
201
- function appOpenPath(config) {
202
- const places = config.app.openPath ?? [];
203
- if (!Array.isArray(places)) {
204
- throw new Error(`crossbind: app.openPath ${JSON.stringify(places)} is no list of ${OPEN_PATH_PLACES.join(', ')}, as ['logs', 'picked'] is.`);
205
- }
206
- const unknown = places.filter((place) => !OPEN_PATH_PLACES.includes(place));
207
- if (unknown.length > 0) {
208
- throw new Error(`crossbind: app.openPath takes ${OPEN_PATH_PLACES.join(', ')}; ${unknown.map((place) => JSON.stringify(place)).join(', ')} is none of them.`);
209
- }
210
- return places;
266
+ // The files and folders of the project the app carries for its own native code, as app.files lists them: inside the
267
+ // project's folder, which keep their places in the app's files/.
268
+ export function appFiles(config) {
269
+ const files = config.app.files ?? [];
270
+ if (!Array.isArray(files)) throw new Error(`crossbind: app.files ${JSON.stringify(files)} is no list of the project's files and folders, as ['seed.sqlite'] is.`);
271
+ const root = config.paths.project;
272
+ return files.map((file) => carriedPath('app.files', root, file, "project's"));
211
273
  }
212
274
 
213
275
  // Schemes the web, the system or crossbind owns, which no app takes, and the keys of Windows' own classes, whose name a
@@ -218,79 +280,59 @@ const RESERVED_SCHEMES = [
218
280
  'wow6432node',
219
281
  ];
220
282
 
221
- // The schemes whose links open the app, as myapp://… does: lowercase, which is how Android matches them, and of two
222
- // characters at least, as one would take a Windows path's drive for a scheme.
223
- export function appUrlSchemes(config) {
224
- const schemes = config.app.urlSchemes ?? [];
283
+ // The schemes of a list whose links open the app, as myapp://… does: lowercase, which is how Android matches them, and
284
+ // of two characters at least, as one would take a Windows path's drive for a scheme.
285
+ export function checkedUrlSchemes(schemes, where) {
225
286
  if (!Array.isArray(schemes)) {
226
- throw new Error(`crossbind: app.urlSchemes ${JSON.stringify(schemes)} is no list of schemes, as ['myapp'] is.`);
287
+ throw new Error(`crossbind: ${where} ${JSON.stringify(schemes)} is no list of schemes, as ['myapp'] is.`);
227
288
  }
228
289
  for (const scheme of schemes) {
229
290
  if (typeof scheme !== 'string' || !/^[a-z][a-z0-9+.-]+$/.test(scheme)) {
230
- throw new Error(`crossbind: app.urlSchemes takes schemes of a lowercase letter, then one or more lowercase letters, digits, +, - and ., as myapp is; ${JSON.stringify(scheme)} is none.`);
291
+ throw new Error(`crossbind: ${where} takes schemes of a lowercase letter, then one or more lowercase letters, digits, +, - and ., as myapp is; ${JSON.stringify(scheme)} is none.`);
231
292
  }
232
293
  if (RESERVED_SCHEMES.includes(scheme)) {
233
- throw new Error(`crossbind: app.urlSchemes cannot take ${scheme}, which the web or the system owns: pick a scheme of the app's own.`);
294
+ throw new Error(`crossbind: ${where} cannot take ${scheme}, which the web or the system owns: pick a scheme of the app's own.`);
234
295
  }
235
296
  }
236
- if (new Set(schemes).size !== schemes.length) throw new Error(`crossbind: app.urlSchemes lists a scheme twice: ${JSON.stringify(schemes)}.`);
297
+ if (new Set(schemes).size !== schemes.length) throw new Error(`crossbind: ${where} lists a scheme twice: ${JSON.stringify(schemes)}.`);
237
298
  return schemes;
238
299
  }
239
300
 
240
- // Semver's version: three numbers, then a prerelease and build metadata of dot-separated identifiers.
241
- const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
242
- // What an update's files are named after: characters an address carries as they are, and no leading dot.
243
- const UPDATE_NAME = /^[A-Za-z0-9_~-][A-Za-z0-9._~-]*$/;
244
- // The Ed25519 public keys of the points of small order, whatever the sign of x, whose signatures anyone can forge: the
245
- // list libsodium's ge25519_has_small_order checks.
246
- const SMALL_ORDER_KEYS = [
247
- '0000000000000000000000000000000000000000000000000000000000000000',
248
- '0100000000000000000000000000000000000000000000000000000000000000',
249
- '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
250
- 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
251
- 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
252
- 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
253
- 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
254
- ].map((hex) => Buffer.from(hex, 'hex'));
255
-
256
- const isSmallOrder = (key) => SMALL_ORDER_KEYS.some((point) => key.subarray(0, 31).equals(point.subarray(0, 31)) && (key[31] & 0x7f) === point[31]);
257
-
258
- // Where a desktop app finds its updates, the key their signatures are checked with, and the version they are newer than:
259
- // package.json's, which semver orders. Null without app.updater.
260
- export function appUpdater(config) {
261
- const updater = config.app.updater;
262
- if (updater === undefined) return null;
263
- const keys = 'url and publicKey';
264
- if (updater === null || typeof updater !== 'object' || Array.isArray(updater)) {
265
- throw new Error(`crossbind: app.updater ${JSON.stringify(updater)} is no object of ${keys}, as { url: 'https://example.com/updates/<name>-{platform}-{arch}.update.json', publicKey: '…' } is.`);
266
- }
267
- const unknown = Object.keys(updater).filter((key) => !['url', 'publicKey'].includes(key));
268
- if (unknown.length > 0) throw new Error(`crossbind: app.updater takes ${keys}; ${unknown.join(', ')} is none of them.`);
269
- const url = URL.canParse(updater.url) ? new URL(updater.url) : null;
270
- const isLocal = url?.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
271
- // The host takes the address as a C string, as it is written, and the update's file from beside the manifest.
272
- if (!url || !(url.protocol === 'https:' || isLocal) || /[^\x21-\x7e]|["\\]/.test(updater.url) || url.pathname.endsWith('/')) {
273
- throw new Error(`crossbind: app.updater.url ${JSON.stringify(updater.url)} is no https address of a manifest, as https://example.com/updates/<name>-{platform}-{arch}.update.json is; http serves localhost alone.`);
274
- }
275
- const publicKey = Buffer.from(typeof updater.publicKey === 'string' ? updater.publicKey : '', 'base64');
276
- if (publicKey.length !== 32 || publicKey.toString('base64') !== updater.publicKey) {
277
- throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is no Ed25519 public key, which \`crossbind app updater-key\` prints.`);
278
- }
279
- if (isSmallOrder(publicKey)) {
280
- throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is a key anyone can forge signatures for, which no key pair has. Use the one \`crossbind app updater-key\` prints.`);
281
- }
282
- const version = config.package?.version ?? '1.0.0';
283
- if (typeof version !== 'string' || !SEMVER.test(version)) {
284
- throw new Error(`crossbind: package.json's version ${JSON.stringify(version)} is no semver version, as 1.2.3 and 1.2.3-beta.1 are, which an app that updates needs to tell a newer one.`);
285
- }
286
- if (!UPDATE_NAME.test(config.general.name)) {
287
- throw new Error(`crossbind: general.name ${JSON.stringify(config.general.name)} names an app's update files, which an app fetches by name: letters, digits, and . _ ~ - alone, not starting with a dot. Change general.name in crossbind.config.js.`);
301
+ const ownerName = (extension) => (extension === null ? 'app.urlSchemes' : `the extension ${extension}`);
302
+
303
+ // The schemes whose links open the app, each with the extension that declared it, whose native code gets its links, or
304
+ // null for those of app.urlSchemes, whose links the host's links hands the page. Two owners of one scheme stop the build,
305
+ // naming both.
306
+ export function urlSchemeOwners(config) {
307
+ const owned = [
308
+ ...checkedUrlSchemes(config.app.urlSchemes ?? [], 'app.urlSchemes').map((scheme) => ({ scheme, extension: null })),
309
+ ...(config.extensions ?? []).flatMap(({ name, app }) => (app.urlSchemes ?? []).map((scheme) => ({ scheme, extension: name }))),
310
+ ];
311
+ owned.forEach(({ scheme, extension }, index) => {
312
+ const first = owned.findIndex((other) => other.scheme === scheme);
313
+ if (first !== index) {
314
+ throw new Error(`crossbind: ${ownerName(owned[first].extension)} and ${ownerName(extension)} both claim the scheme ${scheme}, whose links one alone gets.`);
315
+ }
316
+ });
317
+ return owned;
318
+ }
319
+
320
+ // The schemes whose links open the app: app.urlSchemes's, then its extensions'.
321
+ export const appUrlSchemes = (config) => urlSchemeOwners(config).map(({ scheme }) => scheme);
322
+
323
+ // The installers app.installers names, which alone of each desktop platform's make what installs its app; null without
324
+ // it, when every one does.
325
+ export function appInstallers(config) {
326
+ const { installers } = config.app;
327
+ if (installers === undefined) return null;
328
+ if (!Array.isArray(installers) || !installers.every((name) => typeof name === 'string') || new Set(installers).size !== installers.length) {
329
+ throw new Error(`crossbind: app.installers ${JSON.stringify(installers)} is no list of installers' names, each once.`);
288
330
  }
289
- return { url: updater.url, publicKey, version };
331
+ return installers;
290
332
  }
291
333
 
292
- // A desktop host's window: its page's size and limits, whether it resizes and whether it opens full screen. A maximum of
293
- // one side alone leaves the other as large as any display.
334
+ // A desktop host's window: its page's size and limits, whether it resizes, whether it opens full screen and whether its
335
+ // page lies under its title bar. A maximum of one side alone leaves the other as large as any display.
294
336
  function windowDefines(window) {
295
337
  const most = window.maxWidth || window.maxHeight ? [
296
338
  `#define CROSSBIND_APP_WINDOW_MAX_WIDTH ${window.maxWidth || MAX_WINDOW_SIZE}`,
@@ -304,52 +346,40 @@ function windowDefines(window) {
304
346
  `#define CROSSBIND_APP_WINDOW_RESIZABLE ${window.resizable ? 1 : 0}`,
305
347
  ...most,
306
348
  ...(window.fullscreen ? ['#define CROSSBIND_APP_WINDOW_FULLSCREEN 1'] : []),
349
+ ...(window.titleBar === 'hidden' ? ['#define CROSSBIND_APP_WINDOW_TITLE_BAR_HIDDEN 1'] : []),
307
350
  ];
308
351
  }
309
352
 
310
- // The header an app's host compiles where its page opens files from, an iOS or desktop host the color it shows until
311
- // its page paints and the devices the page may use, a desktop host its title, window, whether it keeps one instance and
312
- // its updates, and a Windows host its version resource.
313
- export function appHeader(config, platform) {
353
+ // The header an app's host compiles its id and name with, an iOS or desktop host the color it shows until its page paints
354
+ // and the devices the page may use, an iOS host the App Group of its extensions' targets, a desktop host its window, and a
355
+ // Windows host its version resource.
356
+ export function appHeader(config, platform, appGroup = null) {
314
357
  const comment = '// crossbind writes this file from crossbind.config.js and package.json.';
315
- const openPath = `#define CROSSBIND_APP_OPEN_PATH {${appOpenPath(config).map((place) => `"${place}"`).join(', ')}}`;
358
+ const id = `#define CROSSBIND_APP_ID "${platform === 'android' ? androidAppId(config) : appId(config)}"`;
359
+ const name = `#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`;
316
360
  const color = appBackgroundColor(config);
361
+ const dark = platform === 'ios' ? appDarkBackgroundColor(config) : null;
317
362
  const permissions = appPermissions(config);
318
363
  // An Android app's activity takes the color and the permissions from its Gradle project.
319
364
  const page = platform === 'android' ? [] : [
320
365
  ...(color ? [`#define CROSSBIND_APP_BACKGROUND_COLOR 0x${color.slice(1).toLowerCase()}`] : []),
366
+ ...(dark ? [`#define CROSSBIND_APP_BACKGROUND_COLOR_DARK 0x${dark.slice(1).toLowerCase()}`] : []),
321
367
  ...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
322
368
  ];
323
- if (!['darwin', 'linux', 'win32'].includes(platform)) return [comment, openPath, ...page, ''].join('\n');
369
+ const windows = platform === 'ios' && iosMultipleWindows(config) ? ['#define CROSSBIND_APP_IOS_MULTIPLE_WINDOWS 1'] : [];
370
+ const group = platform === 'ios' && appGroup ? [`#define CROSSBIND_APP_GROUP "${appGroup}"`] : [];
371
+ if (!DESKTOP.includes(platform)) return [comment, ...page, ...windows, ...group, id, name, ''].join('\n');
324
372
  // The category goes to the Info.plist and the desktop entry, which the build writes later; any desktop refuses a
325
373
  // wrong one here.
326
374
  const copyright = appCopyright(config);
327
375
  appCategory(config);
328
376
  const lines = [
329
377
  comment,
330
- openPath,
331
378
  ...page,
332
- `#define CROSSBIND_APP_ID "${appId(config)}"`,
333
- `#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`,
379
+ id,
380
+ name,
334
381
  ...windowDefines(appWindow(config)),
335
382
  ];
336
- const urlSchemes = appUrlSchemes(config);
337
- if (urlSchemes.length > 0) lines.push(`#define CROSSBIND_APP_URL_SCHEMES {${urlSchemes.map((scheme) => `"${scheme}"`).join(', ')}}`);
338
- // A link the system opens a Windows or Linux app with starts it again, which reaches the app that runs this way; macOS
339
- // hands the link to the app that runs.
340
- const singleInstance = config.app.singleInstance ?? (urlSchemes.length > 0 && platform !== 'darwin');
341
- if (typeof singleInstance !== 'boolean') {
342
- throw new Error(`crossbind: app.singleInstance ${JSON.stringify(singleInstance)} is no boolean: set it to true to keep a desktop app to one instance, or to false to let each launch run.`);
343
- }
344
- if (singleInstance) lines.push('#define CROSSBIND_APP_SINGLE_INSTANCE 1');
345
- const updater = appUpdater(config);
346
- if (updater) {
347
- lines.push(
348
- `#define CROSSBIND_APP_UPDATER_URL "${updater.url}"`,
349
- `#define CROSSBIND_APP_UPDATER_KEY {${[...updater.publicKey].map((byte) => `0x${byte.toString(16).padStart(2, '0')}`).join(', ')}}`,
350
- `#define CROSSBIND_APP_UPDATER_VERSION "${updater.version}"`,
351
- );
352
- }
353
383
  if (platform === 'win32') {
354
384
  const version = windowsVersion(config);
355
385
  lines.push(`#define CROSSBIND_APP_VERSION "${version.text}"`, `#define CROSSBIND_APP_VERSION_NUMBERS ${version.numbers}`);
@@ -23,9 +23,9 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
23
23
  .map((e) => e.name);
24
24
 
25
25
  // Prune bridges whose source of truth is gone. An app-local surface whose .rs was deleted
26
- // breaks cargo on the missing #[path] target; a crate_ bridge whose cargoDependencies entry
26
+ // breaks cargo on the missing #[path] target; a crate_ bridge whose packages.cargo entry
27
27
  // was removed silently keeps dead registrations in every later binary. crate_ pruning only
28
- // runs when the caller passes the current cargoDependencies - null means "don't judge".
28
+ // runs when the caller passes the crates declared now - null means "don't judge".
29
29
  crates = crates.filter((name) => {
30
30
  const dir = `${root}/${name}`;
31
31
  const libRs = `${dir}/src/lib.rs`;
@@ -0,0 +1,168 @@
1
+ import { IOS_DEPLOYMENT_TARGET } from './iosToolchain.js';
2
+
3
+ // Apple's app extensions an iOS app's extensions add, each a target of the app's Xcode project of its own, by their
4
+ // kinds: the extension point each runs at, which an other target names itself, the options it needs or may give, and
5
+ // the frameworks iOS starts it with, which its code may name nothing of, so that the linker would leave them out.
6
+ const KINDS = {
7
+ widget: { point: 'com.apple.widgetkit-extension', needs: [], takes: [] },
8
+ share: { point: 'com.apple.share-services', needs: ['principalClass', 'activation'], takes: [] },
9
+ 'notification-service': { point: 'com.apple.usernotifications.service', needs: ['principalClass'], takes: [] },
10
+ 'notification-content': {
11
+ point: 'com.apple.usernotifications.content-extension', needs: ['principalClass', 'categories'], takes: [], frameworks: ['UserNotifications', 'UserNotificationsUI'],
12
+ },
13
+ other: { point: null, needs: ['extensionPoint'], takes: ['principalClass'] },
14
+ };
15
+
16
+ // The frameworks a target links: its kind's, and its own.
17
+ export const targetFrameworks = (target) => [...new Set([...(KINDS[target.kind].frameworks ?? []), ...(target.frameworks ?? [])])];
18
+
19
+ // The widget targets whose Swift names no containerBackground, through which iOS 17 asks a widget's view for its
20
+ // background: a debug build draws "Please adopt containerBackground API" in place of one without it. read(target, file)
21
+ // gives a source's text.
22
+ export const widgetsWithoutBackground = (targets, read) => targets
23
+ .filter((target) => target.kind === 'widget')
24
+ .filter((target) => !target.sources.some((file) => file.endsWith('.swift') && read(target, file).includes('containerBackground')))
25
+ .map((target) => target.name);
26
+ const KIND_KEYS = ['principalClass', 'activation', 'categories', 'extensionPoint'];
27
+ const TARGET_KEYS = ['name', 'kind', 'sources', 'resources', 'displayName', 'plist', 'entitlements', 'frameworks', 'privacy', 'appGroup',
28
+ 'deploymentTarget', ...KIND_KEYS, 'attributes'];
29
+ const NEEDED = {
30
+ principalClass: 'the class iOS makes as it starts the target',
31
+ activation: "what of the share sheet's items it takes, as { images: 10 }",
32
+ categories: 'the notification categories it shows',
33
+ extensionPoint: 'the extension point iOS runs it at, as com.apple.keyboard-service',
34
+ };
35
+ // What of the share sheet's items a share extension takes, by the keys of Apple's activation rule: a count of each kind,
36
+ // and text or none.
37
+ const ACTIVATION = {
38
+ attachments: 'NSExtensionActivationSupportsAttachmentsWithMaxCount',
39
+ files: 'NSExtensionActivationSupportsFileWithMaxCount',
40
+ images: 'NSExtensionActivationSupportsImageWithMaxCount',
41
+ movies: 'NSExtensionActivationSupportsMovieWithMaxCount',
42
+ text: 'NSExtensionActivationSupportsText',
43
+ urls: 'NSExtensionActivationSupportsWebURLWithMaxCount',
44
+ webPages: 'NSExtensionActivationSupportsWebPageWithMaxCount',
45
+ };
46
+ // The attributes crossbind writes from a target's options, and the Info.plist keys it writes for every target, the App
47
+ // Group, which the target's code reads, of one that asks for it.
48
+ const WRITTEN_ATTRIBUTES = { NSExtensionActivationRule: 'activation', UNNotificationExtensionCategory: 'categories' };
49
+ const APP_GROUP_KEY = 'CrossbindAppGroup';
50
+ const WRITTEN_KEYS = ['CFBundleDevelopmentRegion', 'CFBundleDisplayName', 'CFBundleExecutable', 'CFBundleIdentifier', 'CFBundleInfoDictionaryVersion',
51
+ 'CFBundleName', 'CFBundlePackageType', 'CFBundleShortVersionString', 'CFBundleVersion', 'NSExtension', APP_GROUP_KEY];
52
+
53
+ const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
54
+ const CLASS = /^[A-Za-z_][A-Za-z0-9_]*$/;
55
+ const EXTENSION_POINT = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
56
+ const VERSION = /^\d+\.\d+$/;
57
+ // ExtensionKit's points, whose extensions are of another product, in the app's Extensions, which crossbind makes none of.
58
+ const EXTENSION_KIT_POINTS = ['com.apple.appintents-extension'];
59
+ // What a target compiles apart from its headers, and the sources of Objective-C, beside which a class goes by its own name.
60
+ export const isHeader = (file) => /\.(h|hpp)$/.test(file);
61
+ const isObjectiveC = (file) => /\.(m|mm)$/.test(file);
62
+ const kindOf = (kind) => `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind} target`;
63
+
64
+ const isBelow = (version, floor) => {
65
+ const [major, minor] = version.split('.').map(Number);
66
+ const [floorMajor, floorMinor] = floor.split('.').map(Number);
67
+ return major < floorMajor || (major === floorMajor && minor < floorMinor);
68
+ };
69
+
70
+ // A target's name and kind, of the keys a target takes alone.
71
+ export function checkedTargetKind(at, target) {
72
+ const unknown = Object.keys(target).find((key) => !TARGET_KEYS.includes(key));
73
+ if (unknown) throw new Error(`crossbind: ${at} takes ${TARGET_KEYS.join(', ')}; ${unknown} is none of them.`);
74
+ if (typeof target.name !== 'string' || !NAME.test(target.name)) {
75
+ throw new Error(`crossbind: ${at}.name ${JSON.stringify(target.name)} is no name of lowercase letters and digits, which dashes may join.`);
76
+ }
77
+ if (!Object.hasOwn(KINDS, target.kind)) throw new Error(`crossbind: ${at}.kind ${JSON.stringify(target.kind)} is none of ${Object.keys(KINDS).join(', ')}.`);
78
+ }
79
+
80
+ function checkedActivation(at, activation) {
81
+ const isTaken = (key, value) => Object.hasOwn(ACTIVATION, key) && (key === 'text' ? value === true : Number.isInteger(value) && value >= 1);
82
+ if (activation === null || typeof activation !== 'object' || Array.isArray(activation) || Object.keys(activation).length === 0
83
+ || !Object.entries(activation).every(([key, value]) => isTaken(key, value))) {
84
+ throw new Error(`crossbind: ${at}.activation is no object of ${Object.keys(ACTIVATION).join(', ')}, each a count from 1 and text true, as { images: 10, urls: 1 } is.`);
85
+ }
86
+ }
87
+
88
+ // What a target's kind takes and the rest of its own keys, once its sources and the values every part takes are checked.
89
+ export function checkedTargetOptions(at, target, sources) {
90
+ const { kind } = target;
91
+ for (const key of KIND_KEYS) {
92
+ const isGiven = target[key] !== undefined;
93
+ if (isGiven && !KINDS[kind].needs.includes(key) && !KINDS[kind].takes.includes(key)) throw new Error(`crossbind: ${at}, ${kindOf(kind)}, takes no ${key}.`);
94
+ if (!isGiven && KINDS[kind].needs.includes(key)) throw new Error(`crossbind: ${at}, ${kindOf(kind)}, has no ${key}: ${NEEDED[key]}.`);
95
+ }
96
+ if (kind === 'widget' && !sources.some((file) => file.endsWith('.swift'))) {
97
+ throw new Error(`crossbind: ${at} is a widget, whose SwiftUI needs a Swift source among its sources.`);
98
+ }
99
+ if (target.principalClass !== undefined && (typeof target.principalClass !== 'string' || !CLASS.test(target.principalClass))) {
100
+ throw new Error(`crossbind: ${at}.principalClass ${JSON.stringify(target.principalClass)} is no class name.`);
101
+ }
102
+ if (target.activation !== undefined) checkedActivation(at, target.activation);
103
+ if (target.categories !== undefined
104
+ && (!Array.isArray(target.categories) || target.categories.length === 0 || !target.categories.every((id) => typeof id === 'string' && id !== ''))) {
105
+ throw new Error(`crossbind: ${at}.categories is no list of the notification categories the target shows.`);
106
+ }
107
+ if (target.extensionPoint !== undefined && (typeof target.extensionPoint !== 'string' || !EXTENSION_POINT.test(target.extensionPoint))) {
108
+ throw new Error(`crossbind: ${at}.extensionPoint ${JSON.stringify(target.extensionPoint)} is no extension point, as com.apple.keyboard-service is.`);
109
+ }
110
+ if (EXTENSION_KIT_POINTS.includes(target.extensionPoint)) {
111
+ throw new Error(`crossbind: ${at}.extensionPoint ${target.extensionPoint} is ExtensionKit's, whose extensions crossbind makes none of yet.`);
112
+ }
113
+ // The attributes of an option its kind takes, which an other target gives itself.
114
+ const written = Object.keys(target.attributes ?? {}).find((key) => Object.hasOwn(WRITTEN_ATTRIBUTES, key) && KINDS[kind].needs.includes(WRITTEN_ATTRIBUTES[key]));
115
+ if (written) throw new Error(`crossbind: ${at}.attributes gives ${written}, which crossbind writes from the target's ${WRITTEN_ATTRIBUTES[written]}.`);
116
+ const own = Object.keys(target.plist ?? {}).find((key) => WRITTEN_KEYS.includes(key));
117
+ if (own) throw new Error(`crossbind: ${at}.plist gives ${own}, which crossbind writes for each target.`);
118
+ const files = ['Info.plist', ...(target.privacy ? ['PrivacyInfo.xcprivacy'] : [])];
119
+ const resource = (target.resources ?? []).find((file) => files.includes(file.split('/').at(-1)));
120
+ if (resource) throw new Error(`crossbind: ${at}.resources names ${resource}, a file crossbind writes into the target's bundle.`);
121
+ const version = target.deploymentTarget;
122
+ if (version !== undefined && (typeof version !== 'string' || !VERSION.test(version))) {
123
+ throw new Error(`crossbind: ${at}.deploymentTarget ${JSON.stringify(version)} is no iOS version, as '17.0' is.`);
124
+ }
125
+ if (version !== undefined && isBelow(version, IOS_DEPLOYMENT_TARGET)) {
126
+ throw new Error(`crossbind: ${at}.deploymentTarget ${version} is below the app's iOS ${IOS_DEPLOYMENT_TARGET}, which each of its targets runs on as well.`);
127
+ }
128
+ if (target.appGroup !== undefined && typeof target.appGroup !== 'boolean') throw new Error(`crossbind: ${at}.appGroup is no boolean.`);
129
+ if (target.displayName !== undefined && (typeof target.displayName !== 'string' || target.displayName.trim() === '')) {
130
+ throw new Error(`crossbind: ${at}.displayName is no name the system shows.`);
131
+ }
132
+ }
133
+
134
+ // The Swift module a target's sources make, whose name takes no dash.
135
+ export const targetModule = (name) => name.replace(/-/g, '_');
136
+
137
+ // A target's Info.plist, as crossbind writes it, with the target's own keys: its bundle, by Xcode's settings, its
138
+ // extension point, principal class and attributes, and the App Group it asks for. The principal class of a target
139
+ // without Objective-C is Swift's, in its module, as Xcode's templates name it; one beside Objective-C goes by the name its
140
+ // runtime knows.
141
+ export function targetPlist(target, label, appGroup) {
142
+ const hasObjectiveC = target.sources.some(isObjectiveC);
143
+ const attributes = {
144
+ ...(target.kind === 'notification-content' ? { UNNotificationExtensionInitialContentSizeRatio: 1 } : {}),
145
+ ...target.attributes,
146
+ ...(target.activation ? { NSExtensionActivationRule: Object.fromEntries(Object.entries(target.activation).map(([key, value]) => [ACTIVATION[key], value])) } : {}),
147
+ ...(target.categories ? { UNNotificationExtensionCategory: target.categories.length === 1 ? target.categories[0] : target.categories } : {}),
148
+ };
149
+ const principal = target.principalClass && (hasObjectiveC ? target.principalClass : `$(PRODUCT_MODULE_NAME).${target.principalClass}`);
150
+ return {
151
+ CFBundleDevelopmentRegion: 'en',
152
+ CFBundleDisplayName: target.displayName ?? label,
153
+ CFBundleExecutable: '$(EXECUTABLE_NAME)',
154
+ CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
155
+ CFBundleInfoDictionaryVersion: '6.0',
156
+ CFBundleName: '$(PRODUCT_NAME)',
157
+ CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
158
+ CFBundleShortVersionString: '$(MARKETING_VERSION)',
159
+ CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
160
+ NSExtension: {
161
+ NSExtensionPointIdentifier: target.extensionPoint ?? KINDS[target.kind].point,
162
+ ...(principal ? { NSExtensionPrincipalClass: principal } : {}),
163
+ ...(Object.keys(attributes).length > 0 ? { NSExtensionAttributes: attributes } : {}),
164
+ },
165
+ ...(target.appGroup ? { [APP_GROUP_KEY]: appGroup } : {}),
166
+ ...target.plist,
167
+ };
168
+ }