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.
- package/README.md +1 -1
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +26 -17
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/buildApp.js +172 -74
- package/src/actions/buildNode.js +6 -1
- package/src/actions/buildWasm.js +0 -9
- package/src/actions/createInterface.js +17 -8
- package/src/actions/createLib.js +0 -4
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/gradleProject.js +196 -16
- package/src/actions/iosDevice.js +2 -2
- package/src/actions/iosSimulator.js +3 -3
- package/src/actions/licenses.js +83 -34
- package/src/actions/linuxPackages.js +48 -0
- package/src/actions/mavenVerification.js +37 -0
- package/src/actions/publish.js +220 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +49 -24
- package/src/actions/xcodeTargets.js +122 -0
- package/src/actions/xcodebuild.js +65 -0
- package/src/app/index.d.ts +126 -133
- package/src/app/index.js +126 -188
- package/src/assets/configure/install.sh +0 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +20 -28
- package/src/index.js +2 -1
- package/src/program.js +32 -3
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- package/src/utils/appExtensions.js +761 -0
- package/src/utils/appFiles.js +58 -0
- package/src/utils/appLaunches.js +249 -0
- package/src/utils/appOptions.js +109 -100
- package/src/utils/appTargets.js +168 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +143 -26
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/openUrl.js +2 -1
- package/src/utils/plist.js +5 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/systemConfig.js +16 -3
- package/vitest.config.js +6 -1
- package/src/actions/appSetup.js +0 -116
- package/src/actions/appStoreConnect.js +0 -188
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
- package/src/actions/googlePlay.js +0 -213
- package/src/actions/googleSignIn.js +0 -78
- package/src/actions/playSetup.js +0 -19
- package/src/actions/publishAndroid.js +0 -102
- 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
|
+
}
|
package/src/utils/appOptions.js
CHANGED
|
@@ -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
|
-
//
|
|
12
|
-
|
|
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
|
|
162
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
222
|
-
// characters at least, as one would take a Windows path's drive for a scheme.
|
|
223
|
-
export function
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
|
311
|
-
//
|
|
312
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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}`);
|