crossbind 2.0.0-beta.68 → 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 (57) hide show
  1. package/README.md +1 -1
  2. package/package.json +69 -59
  3. package/src/actions/addExtension.js +191 -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 +172 -74
  8. package/src/actions/buildNode.js +6 -1
  9. package/src/actions/buildWasm.js +0 -9
  10. package/src/actions/createInterface.js +17 -8
  11. package/src/actions/createLib.js +0 -4
  12. package/src/actions/extensionApi.js +298 -0
  13. package/src/actions/extensionCommands.js +41 -0
  14. package/src/actions/extensionsInApp.js +19 -0
  15. package/src/actions/gradleProject.js +196 -16
  16. package/src/actions/iosDevice.js +2 -2
  17. package/src/actions/iosSimulator.js +3 -3
  18. package/src/actions/licenses.js +83 -34
  19. package/src/actions/linuxPackages.js +48 -0
  20. package/src/actions/mavenVerification.js +37 -0
  21. package/src/actions/publish.js +220 -0
  22. package/src/actions/windowsSigning.js +1 -1
  23. package/src/actions/xcodeProject.js +49 -24
  24. package/src/actions/xcodeTargets.js +122 -0
  25. package/src/actions/xcodebuild.js +65 -0
  26. package/src/app/index.d.ts +126 -133
  27. package/src/app/index.js +126 -188
  28. package/src/assets/configure/install.sh +0 -0
  29. package/src/assets/toolchain-digests.json +20 -20
  30. package/src/bin.js +20 -28
  31. package/src/index.js +2 -1
  32. package/src/program.js +32 -3
  33. package/src/state/index.js +2 -1
  34. package/src/state/loadConfig.js +16 -5
  35. package/src/utils/appExtensions.js +761 -0
  36. package/src/utils/appFiles.js +58 -0
  37. package/src/utils/appLaunches.js +249 -0
  38. package/src/utils/appOptions.js +109 -100
  39. package/src/utils/appTargets.js +168 -0
  40. package/src/utils/bridgeTools.js +66 -0
  41. package/src/utils/configChanges.js +30 -0
  42. package/src/utils/configWriter.js +143 -26
  43. package/src/utils/extensionSteps.js +135 -0
  44. package/src/utils/openUrl.js +2 -1
  45. package/src/utils/plist.js +5 -0
  46. package/src/utils/resolveAppHost.js +7 -0
  47. package/src/utils/systemConfig.js +16 -3
  48. package/vitest.config.js +6 -1
  49. package/src/actions/appSetup.js +0 -116
  50. package/src/actions/appStoreConnect.js +0 -188
  51. package/src/actions/appUpdate.js +0 -68
  52. package/src/actions/extensions.js +0 -7
  53. package/src/actions/googlePlay.js +0 -213
  54. package/src/actions/googleSignIn.js +0 -78
  55. package/src/actions/playSetup.js +0 -19
  56. package/src/actions/publishAndroid.js +0 -102
  57. package/src/actions/publishIos.js +0 -146
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // The first link a file or folder holds, by its path inside root, or none: an app carries no link, which could reach any
5
+ // file of the machine it is built on.
6
+ export function linkIn(full, root) {
7
+ const stat = fs.lstatSync(full);
8
+ if (stat.isSymbolicLink()) return path.relative(root, full);
9
+ if (!stat.isDirectory()) return null;
10
+ for (const entry of fs.readdirSync(full)) {
11
+ const found = linkIn(path.join(full, entry), root);
12
+ if (found) return found;
13
+ }
14
+ return null;
15
+ }
16
+
17
+ // Whether a path leads out of root, as a relative one of .. does.
18
+ export const isOutside = (relative) => relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
19
+
20
+ const LINKED = 'an app carries no link, which could reach any file of the machine it is built on';
21
+
22
+ // A file or folder a list of the files an app carries names, relative to root as it is written there, which whose names:
23
+ // inside root, as the system reaches it through any link on the way, and holding no link.
24
+ export function carriedPath(where, root, file, whose) {
25
+ const full = path.resolve(root, String(file));
26
+ const relative = path.relative(root, full);
27
+ if (typeof file !== 'string' || path.isAbsolute(file) || isOutside(relative) || relative === '') {
28
+ throw new Error(`crossbind: ${where} names ${JSON.stringify(file)}, which is no path inside the ${whose} folder.`);
29
+ }
30
+ if (!fs.existsSync(full)) throw new Error(`crossbind: ${where} names ${file}, which is no file or folder of the ${whose}.`);
31
+ const real = path.relative(fs.realpathSync(root), fs.realpathSync(full));
32
+ if (isOutside(real) || real === '') throw new Error(`crossbind: ${where} names ${file}, which a link leads out of the ${whose} folder: ${LINKED}.`);
33
+ const link = linkIn(full, root);
34
+ if (link) throw new Error(`crossbind: ${where} names ${file}, which holds the link ${link}: ${LINKED}.`);
35
+ return relative;
36
+ }
37
+
38
+ // Each file the lists name, those in their folders too, by its full path: what a build fingerprints by its size and
39
+ // time, as a large model would take long to read at each build.
40
+ export function carriedFilesOf(extensions, app) {
41
+ const filesOf = (full) => (fs.statSync(full).isDirectory()
42
+ ? fs.readdirSync(full).flatMap((entry) => filesOf(path.join(full, entry)))
43
+ : [full]);
44
+ return [...extensions.flatMap(({ root, paths }) => paths.map((file) => path.join(root, file))), ...app.paths.map((file) => path.join(app.root, file))]
45
+ .flatMap(filesOf);
46
+ }
47
+
48
+ // What an app carries beside its page: each extension's files in ext/<name>/ and the app's own in files/, each in the
49
+ // place it had in its folder, as clones where the file system makes them, as each part of a phone's app copies them.
50
+ export function copyCarriedFiles(resources, extensions, app) {
51
+ const copy = (root, file, to) => {
52
+ const target = path.join(to, file);
53
+ fs.mkdirSync(path.dirname(target), { recursive: true });
54
+ fs.cpSync(path.join(root, file), target, { recursive: true, mode: fs.constants.COPYFILE_FICLONE });
55
+ };
56
+ extensions.forEach(({ name, root, paths }) => paths.forEach((file) => copy(root, file, path.join(resources, 'ext', name))));
57
+ app.paths.forEach((file) => copy(app.root, file, path.join(resources, 'files')));
58
+ }
@@ -0,0 +1,249 @@
1
+ // What opens an app beside its schemes' links: the files of its types, on iOS and Android the https links of its sites
2
+ // and the quick actions of its icon, and on iOS and macOS the user activities of its types, each with the extension that
3
+ // declared it, whose native code gets them, or null for the app's own, which the host hands the page.
4
+
5
+ const FILE_TYPE_KEYS = ['name', 'extensions', 'mimeTypes', 'role', 'contentTypes'];
6
+ const ROLES = ['editor', 'viewer'];
7
+ const EXTENSION = /^[a-z0-9][a-z0-9_+-]*$/;
8
+ // Of what an Android manifest's attribute and a C++ string literal hold as they are.
9
+ const MIME_TYPE = /^[a-z0-9][a-z0-9!#$^_.+-]*\/[a-z0-9][a-z0-9!#$^_.+-]*$/;
10
+ const CONTENT_TYPE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
11
+ // A domain of two labels at least, the last of letters or punycode, maybe with *. for its subdomains, then maybe the
12
+ // path its links start with, of the characters a URL's path takes as they are, but those a manifest's attribute or the
13
+ // host's C++ string literal escapes, and %, as Android and the host match a link's decoded path.
14
+ const LINK = /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+([a-z]{2,}|xn--[a-z0-9-]*[a-z0-9])(\/[A-Za-z0-9\-._~!$()+,;=:@/]*)?$/;
15
+
16
+ const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ const ownerName = (extension, key) => (extension === null ? `app.${key}` : `the extension ${extension}`);
18
+
19
+ function checkedList(at, values, pattern, what, isRequired) {
20
+ if (!Array.isArray(values)) throw new Error(`crossbind: ${at} ${JSON.stringify(values)} is no list of ${what}.`);
21
+ if (isRequired && values.length === 0) throw new Error(`crossbind: ${at} lists none, where it takes ${what}.`);
22
+ for (const value of values) {
23
+ if (typeof value !== 'string' || !pattern.test(value)) throw new Error(`crossbind: ${at} takes ${what}; ${JSON.stringify(value)} is none.`);
24
+ }
25
+ return values;
26
+ }
27
+
28
+ // The types of files an app opens: each with its name, which the system shows its files' kind by, the extensions of its
29
+ // files, the MIME types Android and Linux match them by, its role, an editor's unless a viewer's, and the uniform type
30
+ // identifiers Apple's platforms know a type by that they have of their own, as CSV's.
31
+ export function checkedFileTypes(types, where) {
32
+ if (!Array.isArray(types)) {
33
+ throw new Error(`crossbind: ${where} ${JSON.stringify(types)} is no list of file types, as [{ name: 'GeoJSON', extensions: ['geojson'] }] is.`);
34
+ }
35
+ return types.map((type, index) => {
36
+ const at = `${where}[${index}]`;
37
+ if (!isPlainObject(type)) throw new Error(`crossbind: ${at} is no file type, as { name: 'GeoJSON', extensions: ['geojson'] } is.`);
38
+ const unknown = Object.keys(type).filter((key) => !FILE_TYPE_KEYS.includes(key));
39
+ if (unknown.length > 0) throw new Error(`crossbind: ${at} takes ${FILE_TYPE_KEYS.join(', ')}; ${unknown.join(', ')} is none of them.`);
40
+ if (typeof type.name !== 'string' || type.name.trim() === '') throw new Error(`crossbind: ${at} has no name, which the system shows its files' kind by.`);
41
+ // A Linux desktop entry and a Windows setup program hold it on one line.
42
+ if ([...type.name].some((character) => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) throw new Error(`crossbind: ${at}.name ${JSON.stringify(type.name)} holds a control character, as a line break; name it on one line.`);
43
+ const role = type.role ?? 'editor';
44
+ if (!ROLES.includes(role)) throw new Error(`crossbind: ${at}.role is 'editor' or 'viewer'; ${JSON.stringify(type.role)} is neither.`);
45
+ return {
46
+ name: type.name,
47
+ extensions: checkedList(`${at}.extensions`, type.extensions, EXTENSION, "lowercase letters and digits without the dot, as 'geojson' is", true),
48
+ mimeTypes: checkedList(`${at}.mimeTypes`, type.mimeTypes ?? [], MIME_TYPE, "MIME types, as 'application/geo+json' is", false),
49
+ role,
50
+ contentTypes: checkedList(`${at}.contentTypes`, type.contentTypes ?? [], CONTENT_TYPE, "uniform type identifiers, as 'public.comma-separated-values-text' is", false),
51
+ };
52
+ });
53
+ }
54
+
55
+ // The types of files that open the app, app.fileTypes's, then its extensions', each with the extension that declared it,
56
+ // or null. One owner of an extension's files alone: two stop the build, naming both.
57
+ export function fileTypeOwners(config) {
58
+ const owned = [
59
+ ...checkedFileTypes(config.app.fileTypes ?? [], 'app.fileTypes').map((type) => ({ ...type, extension: null })),
60
+ ...(config.extensions ?? []).flatMap(({ name, app }) => (app.fileTypes ?? []).map((type) => ({ ...type, extension: name }))),
61
+ ];
62
+ const owners = new Map();
63
+ for (const { extensions, extension } of owned) {
64
+ for (const ending of extensions) {
65
+ if (!owners.has(ending)) {
66
+ owners.set(ending, extension);
67
+ } else if (owners.get(ending) === extension) {
68
+ throw new Error(`crossbind: ${ownerName(extension, 'fileTypes')} lists the files .${ending} twice.`);
69
+ } else {
70
+ throw new Error(`crossbind: ${ownerName(owners.get(ending), 'fileTypes')} and ${ownerName(extension, 'fileTypes')} both claim the files .${ending}, which one alone opens.`);
71
+ }
72
+ }
73
+ }
74
+ return owned;
75
+ }
76
+
77
+ const ROLE_NAMES = { editor: 'Editor', viewer: 'Viewer' };
78
+
79
+ // A macOS app's document types: each by its extensions, unless its content types name it.
80
+ export function macDocumentTypes(fileTypes) {
81
+ return fileTypes.map(({ name, extensions, mimeTypes, role, contentTypes }) => ({
82
+ CFBundleTypeName: name,
83
+ CFBundleTypeRole: ROLE_NAMES[role],
84
+ ...(contentTypes.length > 0 ? { LSItemContentTypes: contentTypes } : { CFBundleTypeExtensions: extensions }),
85
+ ...(mimeTypes.length > 0 ? { CFBundleTypeMIMETypes: mimeTypes } : {}),
86
+ }));
87
+ }
88
+
89
+ // An iOS app's document types, which iOS knows by type identifiers alone: Apple's that a type's content types name, else
90
+ // one the app declares from the type's extensions and MIME types, named after its bundle id. The app opens a document
91
+ // where it is.
92
+ export function iosDocumentTypes(fileTypes, bundleId) {
93
+ if (fileTypes.length === 0) return {};
94
+ // A type identifier takes letters, digits, - and . alone.
95
+ const ownIdentifier = (type) => `${bundleId}.${type.extensions[0].replace(/[^A-Za-z0-9-]/g, '-')}`;
96
+ return {
97
+ CFBundleDocumentTypes: fileTypes.map((type) => ({
98
+ CFBundleTypeName: type.name,
99
+ CFBundleTypeRole: ROLE_NAMES[type.role],
100
+ LSItemContentTypes: type.contentTypes.length > 0 ? type.contentTypes : [ownIdentifier(type)],
101
+ })),
102
+ UTImportedTypeDeclarations: fileTypes.filter((type) => type.contentTypes.length === 0).map((type) => ({
103
+ UTTypeIdentifier: ownIdentifier(type),
104
+ UTTypeDescription: type.name,
105
+ UTTypeConformsTo: ['public.data'],
106
+ UTTypeTagSpecification: {
107
+ 'public.filename-extension': type.extensions,
108
+ ...(type.mimeTypes.length > 0 ? { 'public.mime-type': type.mimeTypes } : {}),
109
+ },
110
+ })),
111
+ LSSupportsOpeningDocumentsInPlace: true,
112
+ };
113
+ }
114
+
115
+ // The https links of a list's sites, which open the app on iOS and Android: each a domain, lowercase, maybe with *. for
116
+ // its subdomains, then maybe the path its links start with, as example.com/invite is.
117
+ export function checkedLinks(links, where) {
118
+ if (!Array.isArray(links)) throw new Error(`crossbind: ${where} ${JSON.stringify(links)} is no list of links, as ['example.com/invite'] is.`);
119
+ for (const link of links) {
120
+ if (typeof link !== 'string' || !LINK.test(link)) {
121
+ const scheme = typeof link === 'string' ? /^(https?):\/\//.exec(link)?.[1] : undefined;
122
+ throw new Error(`crossbind: ${where} takes a site's domain, maybe with *. for its subdomains, then maybe the path its links start with, as example.com/invite is${scheme ? `, without its ${scheme}://` : ''}; ${JSON.stringify(link)} is none.`);
123
+ }
124
+ }
125
+ const twice = links.find((link, index) => links.indexOf(link) !== index);
126
+ if (twice) throw new Error(`crossbind: ${where} lists ${twice} twice.`);
127
+ return links;
128
+ }
129
+
130
+ // The type of a user activity or a quick action: a reverse-DNS name or an intent's class, of what an Info.plist's
131
+ // string, an Android resource's attribute and the host's C++ string literal hold as they are.
132
+ const TYPE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
133
+ // A web page's activity is a site's link.
134
+ const BROWSING_WEB = 'NSUserActivityTypeBrowsingWeb';
135
+
136
+ // The types of the user activities an iOS or macOS app continues, as Handoff from another of its user's devices does,
137
+ // each once.
138
+ export function checkedActivityTypes(types, where) {
139
+ checkedList(where, types, TYPE_NAME, "user activity types of letters, digits, ., _ and -, as 'com.example.app.edit' is", false);
140
+ if (types.includes(BROWSING_WEB)) throw new Error(`crossbind: ${where} cannot take ${BROWSING_WEB}, a web page's, which app.links takes by its site.`);
141
+ const twice = types.find((type, index) => types.indexOf(type) !== index);
142
+ if (twice) throw new Error(`crossbind: ${where} lists ${twice} twice.`);
143
+ return types;
144
+ }
145
+
146
+ // The types of the user activities the app continues, app.activityTypes's, then its extensions', each with the extension
147
+ // that declared it, or null, whose activities the host's activities hands the page. Two owners of one stop the build,
148
+ // naming both.
149
+ export function activityTypeOwners(config) {
150
+ const owned = [
151
+ ...checkedActivityTypes(config.app.activityTypes ?? [], 'app.activityTypes').map((type) => ({ type, extension: null })),
152
+ ...(config.extensions ?? []).flatMap(({ name, app }) => (app.activityTypes ?? []).map((type) => ({ type, extension: name }))),
153
+ ];
154
+ owned.forEach(({ type, extension }, index) => {
155
+ const first = owned.findIndex((other) => other.type === type);
156
+ if (first !== index) {
157
+ throw new Error(`crossbind: ${ownerName(owned[first].extension, 'activityTypes')} and ${ownerName(extension, 'activityTypes')} both claim the user activities ${type}, which one alone continues.`);
158
+ }
159
+ });
160
+ return owned;
161
+ }
162
+
163
+ const SHORTCUT_KEYS = ['type', 'title', 'subtitle', 'icon'];
164
+ // iOS's own icons of a quick action, UIApplicationShortcutIconType's names without its prefix.
165
+ const SHORTCUT_ICONS = [
166
+ 'add', 'alarm', 'audio', 'bookmark', 'capturePhoto', 'captureVideo', 'cloud', 'compose', 'confirmation', 'contact', 'date', 'favorite', 'home',
167
+ 'invitation', 'location', 'love', 'mail', 'markLocation', 'message', 'pause', 'play', 'prohibit', 'search', 'share', 'shuffle', 'task',
168
+ 'taskCompleted', 'time', 'update',
169
+ ];
170
+
171
+ // Text the system shows on one line, or null for none where it may have none.
172
+ function checkedLine(at, value, isRequired) {
173
+ if (value === undefined && !isRequired) return null;
174
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`crossbind: ${at} is no text, which the system shows.`);
175
+ // XML holds neither \uFFFE nor \uFFFF.
176
+ if (/[\p{Cc}\uFFFE\uFFFF]/u.test(value)) {
177
+ throw new Error(`crossbind: ${at} ${JSON.stringify(value)} holds a control character, as a line break; give it on one line.`);
178
+ }
179
+ return value;
180
+ }
181
+
182
+ // The quick actions of an iOS or Android app's icon: each by its type, with its title, and on iOS a subtitle and one of
183
+ // its own icons, as compose is.
184
+ export function checkedShortcutItems(items, where) {
185
+ if (!Array.isArray(items)) throw new Error(`crossbind: ${where} ${JSON.stringify(items)} is no list of quick actions, as [{ type: 'new-note', title: 'New note' }] is.`);
186
+ const checked = items.map((item, index) => {
187
+ const at = `${where}[${index}]`;
188
+ if (!isPlainObject(item)) throw new Error(`crossbind: ${at} is no quick action, as { type: 'new-note', title: 'New note' } is.`);
189
+ const unknown = Object.keys(item).filter((key) => !SHORTCUT_KEYS.includes(key));
190
+ if (unknown.length > 0) throw new Error(`crossbind: ${at} takes ${SHORTCUT_KEYS.join(', ')}; ${unknown.join(', ')} is none of them.`);
191
+ if (typeof item.type !== 'string' || !TYPE_NAME.test(item.type)) {
192
+ throw new Error(`crossbind: ${at}.type takes letters, digits, ., _ and -, as 'new-note' does; ${JSON.stringify(item.type)} is none.`);
193
+ }
194
+ if (item.icon !== undefined && item.icon !== null && !SHORTCUT_ICONS.includes(item.icon)) {
195
+ throw new Error(`crossbind: ${at}.icon is one of iOS's own icons, ${SHORTCUT_ICONS.join(', ')}; ${JSON.stringify(item.icon)} is none.`);
196
+ }
197
+ const subtitle = item.subtitle === null ? null : checkedLine(`${at}.subtitle`, item.subtitle, false);
198
+ return { type: item.type, title: checkedLine(`${at}.title`, item.title, true), subtitle, icon: item.icon ?? null };
199
+ });
200
+ const twice = checked.find(({ type }, index) => checked.findIndex((other) => other.type === type) !== index);
201
+ if (twice) throw new Error(`crossbind: ${where} lists the quick action ${twice.type} twice.`);
202
+ return checked;
203
+ }
204
+
205
+ // The quick actions of the app's icon, app.shortcutItems's, then its extensions', each with the extension that declared
206
+ // it, or null, whose taps the host's shortcuts hands the page. Two owners of one type stop the build, naming both.
207
+ export function shortcutItemOwners(config) {
208
+ const owned = [
209
+ ...checkedShortcutItems(config.app.shortcutItems ?? [], 'app.shortcutItems').map((item) => ({ ...item, extension: null })),
210
+ ...(config.extensions ?? []).flatMap(({ name, app }) => (app.shortcutItems ?? []).map((item) => ({ ...item, extension: name }))),
211
+ ];
212
+ owned.forEach(({ type, extension }, index) => {
213
+ const first = owned.findIndex((other) => other.type === type);
214
+ if (first !== index) {
215
+ throw new Error(`crossbind: ${ownerName(owned[first].extension, 'shortcutItems')} and ${ownerName(extension, 'shortcutItems')} both claim the quick action ${type}, whose taps one alone gets.`);
216
+ }
217
+ });
218
+ return owned;
219
+ }
220
+
221
+ // An iOS app's quick actions, as its Info.plist lists them.
222
+ export const iosShortcutItems = (items) => items.map(({ type, title, subtitle, icon }) => ({
223
+ UIApplicationShortcutItemType: type,
224
+ UIApplicationShortcutItemTitle: title,
225
+ ...(subtitle ? { UIApplicationShortcutItemSubtitle: subtitle } : {}),
226
+ ...(icon ? { UIApplicationShortcutItemIconType: `UIApplicationShortcutIconType${icon[0].toUpperCase()}${icon.slice(1)}` } : {}),
227
+ }));
228
+
229
+ // A link's domain and the path its links start with, empty for all of the site's.
230
+ export function appLinkParts(link) {
231
+ const slash = link.indexOf('/');
232
+ return slash < 0 ? { host: link, path: '' } : { host: link.slice(0, slash), path: link.slice(slash) };
233
+ }
234
+
235
+ // The links that open the app, app.links's, then its extensions', each with the extension that declared it, or null,
236
+ // whose links the host's links hands the page. Two owners of one stop the build, naming both.
237
+ export function linkOwners(config) {
238
+ const owned = [
239
+ ...checkedLinks(config.app.links ?? [], 'app.links').map((link) => ({ link, extension: null })),
240
+ ...(config.extensions ?? []).flatMap(({ name, app }) => (app.links ?? []).map((link) => ({ link, extension: name }))),
241
+ ];
242
+ owned.forEach(({ link, extension }, index) => {
243
+ const first = owned.findIndex((other) => other.link === link);
244
+ if (first !== index) {
245
+ throw new Error(`crossbind: ${ownerName(owned[first].extension, 'links')} and ${ownerName(extension, 'links')} both claim the links of ${link}, which one alone gets.`);
246
+ }
247
+ });
248
+ return owned;
249
+ }
@@ -1,5 +1,6 @@
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
5
  const WINDOW_DEFAULTS = { width: 1024, height: 768, minWidth: 0, minHeight: 0, maxWidth: 0, maxHeight: 0, resizable: true, fullscreen: false, backgroundColor: null };
5
6
  // Larger than any display, and far within the int a host takes a size as.
@@ -8,9 +9,8 @@ const MAX_WINDOW_SIZE = 16384;
8
9
  const MAX_VERSION_NUMBER = 65535;
9
10
  // Up to four numbers, then a semver prerelease or build part; none of its characters needs escaping in C or RC.
10
11
  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'];
12
+ // The platforms whose apps have a window of their own, and updates.
13
+ const DESKTOP = ['darwin', 'linux', 'win32'];
14
14
  // Apple's app categories, which macOS shows an app under, each with the main categories of the freedesktop menu
15
15
  // specification a Linux menu shows it under. An audio or a video app is in AudioVideo as well, as the specification asks.
16
16
  const GAMES = ['', 'action-', 'adventure-', 'arcade-', 'board-', 'card-', 'casino-', 'dice-', 'educational-', 'family-', 'kids-', 'music-', 'puzzle-',
@@ -32,6 +32,18 @@ export function appId(config) {
32
32
  return id;
33
33
  }
34
34
 
35
+ // An Android application id: dot-separated parts of letters, digits and underscores that each start with a letter.
36
+ const ANDROID_APP_ID = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
37
+
38
+ // An Android app's id: the project's app.id, else one made from the project's name.
39
+ export function androidAppId(config) {
40
+ const id = config.app.id ?? `dev.crossbind.${config.general.name.replace(/[^A-Za-z0-9.]/g, '_')}`;
41
+ if (!ANDROID_APP_ID.test(id)) {
42
+ 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.`);
43
+ }
44
+ return id;
45
+ }
46
+
35
47
  // The name the app shows: its window's title, its macOS menus, its Windows file description and the label under its
36
48
  // icon. The project's name, without app.name.
37
49
  export function appDisplayName(config) {
@@ -158,18 +170,20 @@ export const PRIVACY_CATEGORIES = {
158
170
 
159
171
  // The reasons an iOS app's privacy manifest gives for the required reason API it calls: app.privacy, each category with
160
172
  // Apple's reasons that fit, as { fileTimestamp: ['C617.1'] }.
161
- export function appPrivacy(config) {
162
- const privacy = config.app.privacy ?? {};
173
+ export const appPrivacy = (config) => checkedPrivacy(config.app.privacy ?? {}, 'app.privacy');
174
+
175
+ // Reasons for Apple's required reason API, as app.privacy gives them and an extension's ios.privacy does.
176
+ export function checkedPrivacy(privacy, where) {
163
177
  const names = Object.keys(PRIVACY_CATEGORIES).join(', ');
164
178
  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.`);
179
+ 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
180
  }
167
181
  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.`);
182
+ if (unknown.length > 0) throw new Error(`crossbind: ${where} takes ${names}; ${unknown.join(', ')} is none of them.`);
169
183
  Object.entries(privacy).forEach(([name, reasons]) => {
170
184
  const allowed = PRIVACY_CATEGORIES[name].reasons;
171
185
  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.`);
186
+ 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
187
  }
174
188
  });
175
189
  return privacy;
@@ -186,6 +200,40 @@ export function appEncryption(config) {
186
200
  return encryption;
187
201
  }
188
202
 
203
+ const IOS_OPTIONS = ['multipleWindows', 'appGroup'];
204
+ // An App Group of iOS's: group. and a bundle id's dot-separated parts.
205
+ const APP_GROUP = /^group(\.[A-Za-z0-9-]+)+$/;
206
+
207
+ function iosOptions(config) {
208
+ const ios = config.app.ios ?? {};
209
+ if (typeof ios !== 'object' || Array.isArray(ios)) {
210
+ throw new Error(`crossbind: app.ios ${JSON.stringify(ios)} is no object of multipleWindows and appGroup, as { multipleWindows: true } is.`);
211
+ }
212
+ const unknown = Object.keys(ios).filter((key) => !IOS_OPTIONS.includes(key));
213
+ if (unknown.length > 0) throw new Error(`crossbind: app.ios takes multipleWindows and appGroup; ${unknown.join(', ')} is none of them.`);
214
+ return ios;
215
+ }
216
+
217
+ // Whether an iPad app's user, and its page, open more windows of it, as app.ios.multipleWindows says: iOS shows an
218
+ // iPhone's one window alone.
219
+ export function iosMultipleWindows(config) {
220
+ const { multipleWindows = false } = iosOptions(config);
221
+ if (typeof multipleWindows !== 'boolean') {
222
+ 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.`);
223
+ }
224
+ return multipleWindows;
225
+ }
226
+
227
+ // The App Group an iOS app shares with the targets its extensions add, as app.ios.appGroup names it, else group. and the
228
+ // app's id.
229
+ export function iosAppGroup(config) {
230
+ const { appGroup = `group.${appId(config)}` } = iosOptions(config);
231
+ if (typeof appGroup !== 'string' || !APP_GROUP.test(appGroup)) {
232
+ 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.`);
233
+ }
234
+ return appGroup;
235
+ }
236
+
189
237
  // The version a Windows app's executable carries: package.json's, whose numbers are its file version.
190
238
  function windowsVersion(config) {
191
239
  const version = config.package?.version ?? '1.0.0';
@@ -197,17 +245,13 @@ function windowsVersion(config) {
197
245
  return { text: version, numbers: [...numbers, 0, 0, 0].slice(0, 4).join(',') };
198
246
  }
199
247
 
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;
248
+ // The files and folders of the project the app carries for its own native code, as app.files lists them: inside the
249
+ // project's folder, which keep their places in the app's files/.
250
+ export function appFiles(config) {
251
+ const files = config.app.files ?? [];
252
+ 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.`);
253
+ const root = config.paths.project;
254
+ return files.map((file) => carriedPath('app.files', root, file, "project's"));
211
255
  }
212
256
 
213
257
  // Schemes the web, the system or crossbind owns, which no app takes, and the keys of Windows' own classes, whose name a
@@ -218,75 +262,55 @@ const RESERVED_SCHEMES = [
218
262
  'wow6432node',
219
263
  ];
220
264
 
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 ?? [];
265
+ // The schemes of a list whose links open the app, as myapp://… does: lowercase, which is how Android matches them, and
266
+ // of two characters at least, as one would take a Windows path's drive for a scheme.
267
+ export function checkedUrlSchemes(schemes, where) {
225
268
  if (!Array.isArray(schemes)) {
226
- throw new Error(`crossbind: app.urlSchemes ${JSON.stringify(schemes)} is no list of schemes, as ['myapp'] is.`);
269
+ throw new Error(`crossbind: ${where} ${JSON.stringify(schemes)} is no list of schemes, as ['myapp'] is.`);
227
270
  }
228
271
  for (const scheme of schemes) {
229
272
  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.`);
273
+ 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
274
  }
232
275
  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.`);
276
+ throw new Error(`crossbind: ${where} cannot take ${scheme}, which the web or the system owns: pick a scheme of the app's own.`);
234
277
  }
235
278
  }
236
- if (new Set(schemes).size !== schemes.length) throw new Error(`crossbind: app.urlSchemes lists a scheme twice: ${JSON.stringify(schemes)}.`);
279
+ if (new Set(schemes).size !== schemes.length) throw new Error(`crossbind: ${where} lists a scheme twice: ${JSON.stringify(schemes)}.`);
237
280
  return schemes;
238
281
  }
239
282
 
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'));
283
+ const ownerName = (extension) => (extension === null ? 'app.urlSchemes' : `the extension ${extension}`);
255
284
 
256
- const isSmallOrder = (key) => SMALL_ORDER_KEYS.some((point) => key.subarray(0, 31).equals(point.subarray(0, 31)) && (key[31] & 0x7f) === point[31]);
285
+ // The schemes whose links open the app, each with the extension that declared it, whose native code gets its links, or
286
+ // null for those of app.urlSchemes, whose links the host's links hands the page. Two owners of one scheme stop the build,
287
+ // naming both.
288
+ export function urlSchemeOwners(config) {
289
+ const owned = [
290
+ ...checkedUrlSchemes(config.app.urlSchemes ?? [], 'app.urlSchemes').map((scheme) => ({ scheme, extension: null })),
291
+ ...(config.extensions ?? []).flatMap(({ name, app }) => (app.urlSchemes ?? []).map((scheme) => ({ scheme, extension: name }))),
292
+ ];
293
+ owned.forEach(({ scheme, extension }, index) => {
294
+ const first = owned.findIndex((other) => other.scheme === scheme);
295
+ if (first !== index) {
296
+ throw new Error(`crossbind: ${ownerName(owned[first].extension)} and ${ownerName(extension)} both claim the scheme ${scheme}, whose links one alone gets.`);
297
+ }
298
+ });
299
+ return owned;
300
+ }
257
301
 
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.`);
302
+ // The schemes whose links open the app: app.urlSchemes's, then its extensions'.
303
+ export const appUrlSchemes = (config) => urlSchemeOwners(config).map(({ scheme }) => scheme);
304
+
305
+ // The installers app.installers names, which alone of each desktop platform's make what installs its app; null without
306
+ // it, when every one does.
307
+ export function appInstallers(config) {
308
+ const { installers } = config.app;
309
+ if (installers === undefined) return null;
310
+ if (!Array.isArray(installers) || !installers.every((name) => typeof name === 'string') || new Set(installers).size !== installers.length) {
311
+ throw new Error(`crossbind: app.installers ${JSON.stringify(installers)} is no list of installers' names, each once.`);
288
312
  }
289
- return { url: updater.url, publicKey, version };
313
+ return installers;
290
314
  }
291
315
 
292
316
  // A desktop host's window: its page's size and limits, whether it resizes and whether it opens full screen. A maximum of
@@ -307,12 +331,13 @@ function windowDefines(window) {
307
331
  ];
308
332
  }
309
333
 
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) {
334
+ // 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
335
+ // and the devices the page may use, an iOS host the App Group of its extensions' targets, a desktop host its window, and a
336
+ // Windows host its version resource.
337
+ export function appHeader(config, platform, appGroup = null) {
314
338
  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(', ')}}`;
339
+ const id = `#define CROSSBIND_APP_ID "${platform === 'android' ? androidAppId(config) : appId(config)}"`;
340
+ const name = `#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`;
316
341
  const color = appBackgroundColor(config);
317
342
  const permissions = appPermissions(config);
318
343
  // An Android app's activity takes the color and the permissions from its Gradle project.
@@ -320,36 +345,20 @@ export function appHeader(config, platform) {
320
345
  ...(color ? [`#define CROSSBIND_APP_BACKGROUND_COLOR 0x${color.slice(1).toLowerCase()}`] : []),
321
346
  ...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
322
347
  ];
323
- if (!['darwin', 'linux', 'win32'].includes(platform)) return [comment, openPath, ...page, ''].join('\n');
348
+ const windows = platform === 'ios' && iosMultipleWindows(config) ? ['#define CROSSBIND_APP_IOS_MULTIPLE_WINDOWS 1'] : [];
349
+ const group = platform === 'ios' && appGroup ? [`#define CROSSBIND_APP_GROUP "${appGroup}"`] : [];
350
+ if (!DESKTOP.includes(platform)) return [comment, ...page, ...windows, ...group, id, name, ''].join('\n');
324
351
  // The category goes to the Info.plist and the desktop entry, which the build writes later; any desktop refuses a
325
352
  // wrong one here.
326
353
  const copyright = appCopyright(config);
327
354
  appCategory(config);
328
355
  const lines = [
329
356
  comment,
330
- openPath,
331
357
  ...page,
332
- `#define CROSSBIND_APP_ID "${appId(config)}"`,
333
- `#define CROSSBIND_APP_NAME "${appDisplayName(config)}"`,
358
+ id,
359
+ name,
334
360
  ...windowDefines(appWindow(config)),
335
361
  ];
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
362
  if (platform === 'win32') {
354
363
  const version = windowsVersion(config);
355
364
  lines.push(`#define CROSSBIND_APP_VERSION "${version.text}"`, `#define CROSSBIND_APP_VERSION_NUMBERS ${version.numbers}`);