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,298 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import state from '../state/index.js';
5
+ import { exportOf } from '../state/loadConfig.js';
6
+ import loadSystemConfig, { extensionSettingsOf, updateSystemConfig } from '../utils/systemConfig.js';
7
+ import systemKeys from '../utils/systemKeys.js';
8
+ import { askLine, askYesNo, hasTerminal } from '../utils/ask.js';
9
+ import openUrl from '../utils/openUrl.js';
10
+ import { readSecret, storeSecret } from '../utils/keychain.js';
11
+ import { configChangesOf } from '../utils/configChanges.js';
12
+ import { entry, writeOption } from '../utils/configWriter.js';
13
+ import { readAppIcon, windowsIcon } from '../utils/appIcon.js';
14
+ import {
15
+ PRIVACY_CATEGORIES, androidAppId, appCategory, appDisplayName, appEncryption, appId, appPrivacy, appUrlSchemes,
16
+ } from '../utils/appOptions.js';
17
+ import { isJsonValue, linuxDepends } from '../utils/appExtensions.js';
18
+ import { fileTypeOwners } from '../utils/appLaunches.js';
19
+ import { unreasonedImports } from '../utils/requiredReasonApis.js';
20
+ import { resolveHermesDsym } from '../utils/hermes.js';
21
+ import { imageRoleFor } from '../utils/pullDockerImage.js';
22
+ import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
23
+ import {
24
+ androidVersion, appBuildNumber, appTeam, appVersion,
25
+ } from './buildApp.js';
26
+ import runTool from './run.js';
27
+ import { makeUploadKey, releaseSigningEnv } from './uploadKey.js';
28
+ import { appStoreKey } from './appleSigning.js';
29
+ import { inWindowsImage, jsignScript, windowsSigning } from './windowsSigning.js';
30
+ import { xcodebuild, xcodeTeams } from './xcodebuild.js';
31
+
32
+ // The api an extension's build-side code gets in place of crossbind's own modules, which it never imports: what the run
33
+ // is, the project and its app as the build reads them, the terminal, this machine's settings and keychain, and the
34
+ // signing and tools crossbind builds and packages each platform's apps with.
35
+
36
+ const PATH_PART = /^[A-Za-z_$][\w$]*$/;
37
+ const SETTING = /^[A-Z][A-Z0-9_]*$/;
38
+ const ACCOUNT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
39
+
40
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
41
+
42
+ function refuse() {
43
+ throw new TypeError('crossbind: api.config is read-only: api.config.write writes an option into crossbind.config.js.');
44
+ }
45
+
46
+ const views = new WeakMap();
47
+ const READ_ONLY = {
48
+ get(target, key, receiver) {
49
+ const value = Reflect.get(target, key, receiver);
50
+ const own = Reflect.getOwnPropertyDescriptor(target, key);
51
+ // A proxy gives a property that never changes as it is.
52
+ if (own && !own.configurable && !own.writable) return value;
53
+ return readOnly(value);
54
+ },
55
+ // A configurable property's value is a view too; one that never changes is given as it is, as a proxy must.
56
+ getOwnPropertyDescriptor(target, key) {
57
+ const own = Reflect.getOwnPropertyDescriptor(target, key);
58
+ return own?.configurable && 'value' in own ? { ...own, value: readOnly(own.value) } : own;
59
+ },
60
+ set: refuse,
61
+ defineProperty: refuse,
62
+ deleteProperty: refuse,
63
+ setPrototypeOf: refuse,
64
+ preventExtensions: refuse,
65
+ };
66
+
67
+ function readOnly(value) {
68
+ if (value === null || typeof value !== 'object') return value;
69
+ if (!views.has(value)) views.set(value, new Proxy(value, READ_ONLY));
70
+ return views.get(value);
71
+ }
72
+
73
+ // crossbind.config.js's export as the run's config read it, with the app's options the api wrote since, which the run's
74
+ // config holds: each read sees them, however long ago the extension took the view.
75
+ function configView(write) {
76
+ const current = () => ({ ...exportOf(state.config), app: state.config.app });
77
+ return new Proxy({}, {
78
+ get: (target, key) => (key === 'write' ? write : readOnly(current()[key])),
79
+ has: (target, key) => key in current(),
80
+ ownKeys: () => Reflect.ownKeys(current()),
81
+ getOwnPropertyDescriptor(target, key) {
82
+ const own = Reflect.getOwnPropertyDescriptor(current(), key);
83
+ return own && { ...own, value: readOnly(own.value) };
84
+ },
85
+ set: refuse,
86
+ defineProperty: refuse,
87
+ deleteProperty: refuse,
88
+ setPrototypeOf: refuse,
89
+ preventExtensions: refuse,
90
+ });
91
+ }
92
+
93
+ const deepFrozen = (value) => {
94
+ if (value !== null && typeof value === 'object') Object.values(value).forEach(deepFrozen);
95
+ return Object.freeze(value);
96
+ };
97
+
98
+ const valueAt = (object, keys) => keys.reduce((value, key) => (isObject(value) ? value[key] : undefined), object);
99
+ const withValue = (object, [key, ...rest], value) => ({ ...object, [key]: rest.length === 0 ? value : withValue(isObject(object?.[key]) ? object[key] : {}, rest, value) });
100
+
101
+ // What crossbind checks of an option written into crossbind.config.js, as its builds read it.
102
+ const OPTION_CHECKS = {
103
+ 'app.id': (config, platform) => (platform === 'android' ? androidAppId(config) : appId(config)),
104
+ 'app.name': appDisplayName,
105
+ 'app.icon': readAppIcon,
106
+ 'app.encryption': appEncryption,
107
+ 'app.privacy': appPrivacy,
108
+ };
109
+
110
+ // Writes an option into crossbind.config.js once the user says yes on a terminal, and into this run's config: one of
111
+ // those crossbind checks as the builds would, alone. One the config sets already is the user's to change, and one the
112
+ // builds would refuse fails as invalid, for the caller to ask again.
113
+ async function writeConfig(name, platform, key, value) {
114
+ const keys = typeof key === 'string' ? key.split('.') : [];
115
+ if (keys.length === 0 || !keys.every((part) => PATH_PART.test(part))) throw new Error(`crossbind: ${name} names ${JSON.stringify(key)}, which is no option of crossbind.config.js, as 'app.id' is.`);
116
+ if (!Object.hasOwn(OPTION_CHECKS, key)) {
117
+ const writable = Object.keys(OPTION_CHECKS);
118
+ throw new Error(`crossbind: ${name} writes ${key}, which api.config.write leaves to the app's developer: it writes ${writable.slice(0, -1).join(', ')} and ${writable.at(-1)} alone.`);
119
+ }
120
+ if (!isJsonValue(value)) throw new Error(`crossbind: ${name} gives ${key} a value that is no JSON value, which is all crossbind writes into crossbind.config.js.`);
121
+ // A copy of its own, as the value may hold the api's views of the config.
122
+ const plain = JSON.parse(JSON.stringify(value));
123
+ const text = `${keys.slice(0, -1).map((part) => `${part}.`).join('')}${entry(keys.at(-1), plain)}`;
124
+ if (valueAt(state.config, keys) !== undefined) throw new Error(`crossbind: set ${text} in crossbind.config.js in place of the ${key} there.`);
125
+ const changed = withValue(state.config, keys, plain);
126
+ try {
127
+ OPTION_CHECKS[key]?.(changed, platform);
128
+ } catch (error) {
129
+ throw Object.assign(error, { isInvalid: true });
130
+ }
131
+ if (!hasTerminal()) throw new Error(`crossbind: ${name} would write ${text} into crossbind.config.js, which takes a yes on a terminal: set it there yourself.`);
132
+ if (!await askYesNo(`Write ${text} into crossbind.config.js? [Y/n] `)) throw new Error(`crossbind: set ${text} in crossbind.config.js.`);
133
+ if (!writeOption(state.config.paths.project, keys, plain)) {
134
+ throw new Error(`crossbind: crossbind.config.js is no config crossbind can write ${key} into safely: set ${text} in it yourself.`);
135
+ }
136
+ state.config[keys[0]] = changed[keys[0]];
137
+ }
138
+
139
+ // This machine's settings: crossbind's own by their names, as `crossbind config set` takes them, and by any other name
140
+ // the extension's own, which ~/.crossbind.json keeps apart for each extension.
141
+ function settingsOf(name) {
142
+ const isCrossbinds = (key) => Object.hasOwn(systemKeys, key);
143
+ return Object.freeze({
144
+ get: (key) => (isCrossbinds(key) ? state.config.system[key] : extensionSettingsOf(state.config.system, name)[key]),
145
+ set(values) {
146
+ if (!isObject(values) || !Object.entries(values).every(([key, value]) => SETTING.test(key) && typeof value === 'string')) {
147
+ throw new Error(`crossbind: ${name} sets settings that are no object of this machine's settings, each an uppercase name with a string.`);
148
+ }
149
+ const entries = Object.entries(values);
150
+ entries.filter(([key]) => systemKeys[key]?.options).forEach(([key, value]) => {
151
+ if (!systemKeys[key].options.includes(value)) {
152
+ throw new Error(`crossbind: ${name} sets ${key} to ${JSON.stringify(value)}, which takes ${systemKeys[key].options.join(', ')} alone.`);
153
+ }
154
+ });
155
+ const own = entries.filter(([key]) => !isCrossbinds(key));
156
+ updateSystemConfig(Object.fromEntries(entries.filter(([key]) => isCrossbinds(key))), own.length > 0 ? { name, values: Object.fromEntries(own) } : null);
157
+ state.config.system = loadSystemConfig();
158
+ },
159
+ });
160
+ }
161
+
162
+ // The keychain keeps an extension's secrets under its own service, each by a path the account names, as Windows keeps
163
+ // one in a file beside that path.
164
+ function secretsOf(name) {
165
+ const service = `crossbind ${name}`;
166
+ const keyOf = (account) => {
167
+ if (typeof account !== 'string' || !ACCOUNT.test(account)) throw new Error(`crossbind: ${name} names the secret ${JSON.stringify(account)}, which is no name of letters, digits, dots, dashes and underscores.`);
168
+ return path.join(os.homedir(), '.crossbind', 'secrets', name, account);
169
+ };
170
+ return Object.freeze({
171
+ get: (account) => readSecret(keyOf(account), { service }),
172
+ set(account, secret) {
173
+ const key = keyOf(account);
174
+ if (typeof secret !== 'string' || !secret) throw new Error(`crossbind: ${name} keeps no secret of ${account}, which is no text.`);
175
+ fs.mkdirSync(path.dirname(key), { recursive: true, mode: 0o700 });
176
+ return storeSecret(key, secret, { service });
177
+ },
178
+ });
179
+ }
180
+
181
+ function terminalOf(name) {
182
+ const assertTerminal = (question) => {
183
+ if (!hasTerminal()) throw new Error(`crossbind: ${name} asks ${JSON.stringify(question.trim())}, with no terminal to answer it.`);
184
+ };
185
+ return {
186
+ ask(question) {
187
+ assertTerminal(question);
188
+ return askLine(question);
189
+ },
190
+ askYesNo(question) {
191
+ assertTerminal(question);
192
+ return askYesNo(question);
193
+ },
194
+ };
195
+ }
196
+
197
+ // Runs a tool to its end where crossbind packages an app of the target's platform: on this Mac, with Apple's tools on
198
+ // the PATH, for a macOS app, and for a Linux or Windows app in crossbind's image of its platform, which sees the
199
+ // project's base alone, the windows one with the key that signs the app for a tool that signs. An installer's api alone
200
+ // has the target of the app it makes.
201
+ function runOf(name, target) {
202
+ return (command, options) => {
203
+ if (!target) throw new Error(`crossbind: ${name} runs a tool with api.run, which an installer's api alone has.`);
204
+ if (!Array.isArray(command) || command.length === 0 || !command.every((part) => typeof part === 'string')) {
205
+ throw new Error(`crossbind: ${name} runs no command with api.run, which takes a list of strings: the program and its arguments.`);
206
+ }
207
+ const { cwd = state.config.paths.project, isSigning = false } = options ?? {};
208
+ if (typeof cwd !== 'string') throw new Error(`crossbind: ${name} runs ${command[0]} in ${JSON.stringify(cwd)}, which is no path of a folder.`);
209
+ const dir = path.resolve(state.config.paths.project, cwd);
210
+ if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`crossbind: ${name} runs ${command[0]} in ${dir}, which is no folder.`);
211
+ if (target.platform !== 'darwin' && !isUnderBase(dir, state.config.paths.base)) {
212
+ throw new Error(`crossbind: ${name} runs ${command[0]} in ${dir}, outside ${state.config.paths.base}, the project's base and all the ${imageRoleFor(target)} container sees. Build the app into a folder under it.`);
213
+ }
214
+ if (target.platform === 'win32') inWindowsImage(command, dir, isSigning ? windowsSigning() : null);
215
+ else runTool(null, command, null, target, { workdir: dir });
216
+ };
217
+ }
218
+
219
+ // The api of an extension's run on a platform: the options the command line gave it, the run's build number once the
220
+ // publish picked it, which the app's facts then show, and an installer's target, the app it makes.
221
+ export default function extensionApi(name, {
222
+ platform, options = {}, isGuided, run = {}, target = null,
223
+ }) {
224
+ const buildEnv = () => (run.buildNumber === undefined || run.buildNumber === null
225
+ ? process.env : { ...process.env, CROSSBIND_APP_BUILD_NUMBER: String(run.buildNumber) });
226
+ const config = configView((key, value) => writeConfig(name, platform, key, value));
227
+ return Object.freeze({
228
+ name,
229
+ platform,
230
+ isGuided,
231
+ options: Object.freeze({ ...options }),
232
+ project: Object.freeze({
233
+ get root() { return state.config.paths.project; },
234
+ get name() { return state.config.general.name; },
235
+ get package() { return readOnly(state.config.package); },
236
+ }),
237
+ get workDir() { return path.join(state.config.paths.build, name); },
238
+ config,
239
+ get publisherChanges() { return readOnly(configChangesOf()); },
240
+ app: Object.freeze({
241
+ get id() { return platform === 'android' ? androidAppId(state.config) : appId(state.config); },
242
+ get name() { return appDisplayName(state.config); },
243
+ get version() { return state.config.package?.version ?? '1.0.0'; },
244
+ get appleVersion() { return appVersion(); },
245
+ get versionCode() { return androidVersion(buildEnv()).code; },
246
+ get buildNumber() { return appBuildNumber(buildEnv()); },
247
+ get team() { return appTeam(); },
248
+ get encryption() { return appEncryption(state.config); },
249
+ get urlSchemes() { return Object.freeze(appUrlSchemes(state.config)); },
250
+ get fileTypes() { return Object.freeze(fileTypeOwners(state.config).map(({ extension, ...type }) => Object.freeze(type))); },
251
+ // The Debian packages a Linux app needs beside WebKitGTK and GTK, as its extensions' Linux parts list them.
252
+ get depends() { return Object.freeze(linuxDepends(state.config.extensions ?? [])); },
253
+ get category() {
254
+ const category = appCategory(state.config);
255
+ return category && Object.freeze({ macos: category.darwin, linux: category.linux });
256
+ },
257
+ }),
258
+ log: (text) => console.log(text),
259
+ warn: (text) => console.warn(text),
260
+ ...terminalOf(name),
261
+ openUrl(url) {
262
+ let address = null;
263
+ try {
264
+ address = new URL(url);
265
+ } catch {
266
+ // What is no URL is refused below.
267
+ }
268
+ if (!['http:', 'https:'].includes(address?.protocol)) throw new Error(`crossbind: ${name} opens ${JSON.stringify(url)}, which is no http or https address.`);
269
+ openUrl(address.href);
270
+ },
271
+ run: runOf(name, target),
272
+ settings: settingsOf(name),
273
+ secrets: secretsOf(name),
274
+ android: Object.freeze({
275
+ signing: () => releaseSigningEnv(),
276
+ async makeUploadKey() {
277
+ await makeUploadKey();
278
+ state.config.system = loadSystemConfig();
279
+ },
280
+ }),
281
+ apple: Object.freeze({
282
+ key: () => appStoreKey(),
283
+ teams: () => xcodeTeams(),
284
+ xcodebuild: (what, args, xcodebuildOptions) => xcodebuild(what, args, xcodebuildOptions),
285
+ unreasonedApis: (binary, privacy) => unreasonedImports(binary, privacy),
286
+ privacyCategories: deepFrozen(structuredClone(PRIVACY_CATEGORIES)),
287
+ dsyms: async () => [await resolveHermesDsym()],
288
+ }),
289
+ windows: Object.freeze({
290
+ icon() {
291
+ const icon = readAppIcon(state.config);
292
+ return icon && windowsIcon(icon);
293
+ },
294
+ // The script that signs a file in the windows image with the key, which a tool that signs as it builds runs.
295
+ get signer() { return windowsSigning() ? jsignScript() : null; },
296
+ }),
297
+ });
298
+ }
@@ -0,0 +1,41 @@
1
+ import { Option } from 'commander';
2
+ import extensionApi from './extensionApi.js';
3
+ import state from '../state/index.js';
4
+ import { hasTerminal } from '../utils/ask.js';
5
+ import { extensionStep, ownExtensions, withoutNamed } from '../utils/appExtensions.js';
6
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
7
+
8
+ const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
9
+
10
+ // crossbind's own extensions a project has, none where its host is not installed, which each command that needs it says:
11
+ // the commands of those app.without leaves out, and of those whose role or name another takes, go with them.
12
+ function ownOf(listed, app) {
13
+ let root;
14
+ try {
15
+ root = resolveAppHostRoot();
16
+ } catch {
17
+ return [];
18
+ }
19
+ const taken = new Set(listed.flatMap(({ name, replaces }) => [name, ...(replaces ?? [])]));
20
+ return withoutNamed(ownExtensions(root), app).filter(({ name }) => !taken.has(name));
21
+ }
22
+
23
+ // Adds to crossbind app the commands of an app's extensions, crossbind's own and those crossbind.config.js lists: a name
24
+ // another has stops the run, naming both.
25
+ export default function addExtensionCommands(parent, config = state.config) {
26
+ if (config?.target?.runtimeEnv !== 'app') return;
27
+ const listed = config.extensions ?? [];
28
+ const owners = new Map(parent.commands.map((command) => [command.name(), 'crossbind']));
29
+ for (const { name: extension, commands = {} } of [...ownOf(listed, config.app), ...listed]) {
30
+ for (const [name, command] of Object.entries(commands)) {
31
+ if (owners.has(name)) throw new Error(`crossbind: ${extension} adds the command crossbind app ${name}, which ${owners.get(name)} has.`);
32
+ owners.set(name, extension);
33
+ const added = parent.command(name).description(command.help);
34
+ Object.entries(command.options).forEach(([option, { help, value, default: fallback }]) => {
35
+ const flag = new Option(`--${kebab(option)}${value ? ` ${value}` : ''}`, help);
36
+ added.addOption(fallback === null ? flag : flag.default(fallback));
37
+ });
38
+ added.action((options) => extensionStep(extension, name, () => command.run(extensionApi(extension, { platform: null, options, isGuided: hasTerminal() }))));
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,19 @@
1
+ import extensionApi from './extensionApi.js';
2
+ import state from '../state/index.js';
3
+ import {
4
+ appExtensionsOf, ownExtensions, withFromApp, withRoles, withoutNamed,
5
+ } from '../utils/appExtensions.js';
6
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
7
+
8
+ // The platforms as an extension's api names them.
9
+ const API_PLATFORMS = { darwin: 'macos', win32: 'windows' };
10
+
11
+ // The extensions an app of this platform has: crossbind's own but those app.without leaves out, then those
12
+ // crossbind.config.js lists, each with the roles it holds, which a listed one's replaces takes from the host's, and the
13
+ // options it takes from the app's config, where it does.
14
+ export default function extensionsInApp(platform, warn) {
15
+ const own = ownExtensions(resolveAppHostRoot());
16
+ const { app, extensions = [] } = state.config;
17
+ const held = withRoles(appExtensionsOf(withoutNamed(own, app)), appExtensionsOf(extensions), warn, appExtensionsOf(own));
18
+ return withFromApp(held, (name) => extensionApi(name, { platform: API_PLATFORMS[platform] ?? platform, isGuided: false }));
19
+ }
@@ -5,6 +5,8 @@ import { ANDROID_API_LEVEL } from '../utils/androidToolchain.js';
5
5
  import { HERMES } from '../utils/hermes.js';
6
6
  import resolveAppHostRoot from '../utils/resolveAppHost.js';
7
7
  import { SIGNING } from './androidGradle.js';
8
+ import { appLinkParts } from '../utils/appLaunches.js';
9
+ import { mergedVerification } from './mavenVerification.js';
8
10
 
9
11
  // The ABIs an Android app builds for: phones and Apple silicon's emulators, and the emulators of x86_64 machines.
10
12
  export const ANDROID_ABIS = ['arm64-v8a', 'x86_64'];
@@ -34,6 +36,14 @@ const backgroundValues = (color) => `<?xml version="1.0" encoding="utf-8"?>
34
36
  </resources>
35
37
  `;
36
38
 
39
+ // The devices of app.permissions, which alone the page may ask for, whatever an extension's Java asks Android for.
40
+ const permissionValues = (permissions) => `<?xml version="1.0" encoding="utf-8"?>
41
+ <resources>
42
+ <string-array name="crossbind_permissions">${permissions.map((name) => `\n <item>${name}</item>`).join('')}
43
+ </string-array>
44
+ </resources>
45
+ `;
46
+
37
47
  // The Android permissions behind each of app.permissions, after the one every app asks for; the activity asks its user
38
48
  // for them when the page first uses the device.
39
49
  const INTERNET = ' <uses-permission android:name="android.permission.INTERNET" />';
@@ -42,22 +52,138 @@ const ANDROID_PERMISSIONS = {
42
52
  microphone: ['RECORD_AUDIO', 'MODIFY_AUDIO_SETTINGS'],
43
53
  location: ['ACCESS_COARSE_LOCATION', 'ACCESS_FINE_LOCATION'],
44
54
  };
45
- const usesPermissions = (permissions) => permissions
46
- .flatMap((name) => ANDROID_PERMISSIONS[name])
47
- .map((permission) => ` <uses-permission android:name="android.permission.${permission}" />`);
55
+ // The permissions the app asks for beside INTERNET, each once: app.permissions' and its extensions'.
56
+ const usesPermissions = (permissions, extensionPermissions) => [...new Set([
57
+ ...permissions.flatMap((name) => ANDROID_PERMISSIONS[name]).map((permission) => `android.permission.${permission}`),
58
+ ...extensionPermissions,
59
+ ])].filter((permission) => permission !== 'android.permission.INTERNET')
60
+ .map((permission) => ` <uses-permission android:name="${permission}" />`);
61
+
62
+ // What an extension's own manifest adds to the app's: the elements of its manifest, and those of its application, which
63
+ // go inside the app's, whose attributes are the app's alone. The app's manifest declares the android and tools prefixes.
64
+ const APPLICATION_END = ' </application>';
65
+ const PREFIXES = ['android', 'tools'];
48
66
 
49
- // The links of app.urlSchemes the activity opens, from a browser as well, before the end of the activity's element.
67
+ // A manifest's text without its comments, removed until none is left, as removing one may join others into one.
68
+ function withoutComments(text) {
69
+ let current = text;
70
+ let previous;
71
+ do {
72
+ previous = current;
73
+ current = current.replace(/<!--[\s\S]*?-->/g, '');
74
+ } while (current !== previous);
75
+ return current;
76
+ }
77
+
78
+ function manifestParts(name, file) {
79
+ const text = withoutComments(fs.readFileSync(file, 'utf8').replace(/<\?xml[\s\S]*?\?>/, ''));
80
+ const root = /^\s*<manifest\b([^>]*)>([\s\S]*)<\/manifest>\s*$/.exec(text);
81
+ if (!root) throw new Error(`crossbind: the extension ${name}'s manifest ${file} is no <manifest> of elements, as an AndroidManifest.xml is.`);
82
+ const other = [...root[1].matchAll(/xmlns:([\w-]+)/g)].map(([, prefix]) => prefix).find((prefix) => !PREFIXES.includes(prefix));
83
+ if (other) throw new Error(`crossbind: the extension ${name}'s manifest ${file} declares the prefix ${other}, which the app's has not: it takes android and tools.`);
84
+ const attribute = [...root[1].matchAll(/([\w:.-]+)\s*=/g)].map(([, key]) => key).find((key) => !key.startsWith('xmlns'));
85
+ if (attribute) throw new Error(`crossbind: the extension ${name}'s manifest ${file} gives its <manifest> ${attribute}, which is the app's own.`);
86
+ // The permissions it asks for are those its permissions list, which the app asks for once each.
87
+ if (/<uses-permission[\s/>]/.test(root[2])) {
88
+ throw new Error(`crossbind: the extension ${name}'s manifest ${file} asks for a permission with <uses-permission>, which its app.android.permissions lists, as the app asks for each once.`);
89
+ }
90
+ const applications = [...root[2].matchAll(/<application\b([^>]*?)(?:\/>|>([\s\S]*?)<\/application>)/g)];
91
+ if (applications.length > 1) throw new Error(`crossbind: the extension ${name}'s manifest ${file} holds ${applications.length} <application> elements, of which it takes one.`);
92
+ const [application] = applications;
93
+ if (application && application[1].trim() !== '') {
94
+ throw new Error(`crossbind: the extension ${name}'s manifest ${file} gives its <application> attributes, which are the app's own: it holds what the extension adds to the app's alone.`);
95
+ }
96
+ return { manifest: application ? root[2].replace(application[0], '') : root[2], application: application?.[2] ?? '' };
97
+ }
98
+
99
+ // An extension's elements, as the app's manifest holds them, at the indentation of their place there.
100
+ function indented(text, indent) {
101
+ const lines = text.split('\n').filter((line, index, all) => line.trim() !== '' || (index > all.findIndex((one) => one.trim() !== '')
102
+ && index < all.findLastIndex((one) => one.trim() !== '')));
103
+ const margin = Math.min(...lines.filter((line) => line.trim() !== '').map((line) => /^[ \t]*/.exec(line)[0].length));
104
+ return lines.map((line) => (line.trim() === '' ? '' : `${indent}${line.slice(margin).trimEnd()}`)).join('\n');
105
+ }
106
+
107
+ // What the activity opens, from a browser or a file manager as well, before the end of the activity's element.
50
108
  const ACTIVITY_END = ' </activity>';
51
- const urlFilter = (schemes) => [
52
- ' <intent-filter>',
109
+ const viewFilter = (data, isVerified = false) => [
110
+ ` <intent-filter${isVerified ? ' android:autoVerify="true"' : ''}>`,
53
111
  ' <action android:name="android.intent.action.VIEW" />',
54
112
  ' <category android:name="android.intent.category.DEFAULT" />',
55
113
  ' <category android:name="android.intent.category.BROWSABLE" />',
56
- ...schemes.map((scheme) => ` <data android:scheme="${scheme}" />`),
114
+ ...data.map((attributes) => ` <data ${attributes} />`),
57
115
  ' </intent-filter>',
58
116
  ].join('\n');
117
+ // The links of the app's schemes.
118
+ const urlFilter = (schemes) => viewFilter(schemes.map((scheme) => `android:scheme="${scheme}"`));
119
+ // The files of the app's types, which other apps hand it as content: addresses alone, as a file: one would have the app
120
+ // read its own: by their MIME types, as Android matches what a content provider gives, else by their names' extensions,
121
+ // which a provider's path shows or not; .*\\. takes one dot, and Android's pattern a single match.
122
+ const fileFilters = (fileTypes) => fileTypes.map(({ extensions, mimeTypes }) => viewFilter(['android:scheme="content"', ...(mimeTypes.length > 0
123
+ ? mimeTypes.map((mime) => `android:mimeType="${mime}"`)
124
+ : ['android:host="*"', 'android:mimeType="*/*"', ...extensions.flatMap((ending) => [`android:pathPattern=".*\\\\.${ending}"`, `android:pathPattern=".*\\\\..*\\\\.${ending}"`])])]));
125
+ // The https links of app.links, which Android verifies against each site's assetlinks.json.
126
+ const linkFilters = (links) => links.map((link) => {
127
+ const { host, path: prefix } = appLinkParts(link);
128
+ return viewFilter(['android:scheme="https"', `android:host="${host}"`, ...(prefix ? [`android:pathPrefix="${prefix}"`] : [])], true);
129
+ });
59
130
 
60
- const buildGradle = ({ id, label, version, abis, icon, launchMode, theme }) => `// crossbind writes this project anew on every build: the module comes prebuilt in src/main/jniLibs/, and the app's
131
+ // The app's quick actions, Android's static shortcuts of its icon, which shows the app's icon beside each, or the
132
+ // launcher's own without one: Android empties a static shortcut's task first, so a tap starts the host's trampoline,
133
+ // which hands it to the activity that runs, with its page. Each label is a resource, as Android takes none other.
134
+ const SHORTCUTS_META = ' <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />';
135
+ const shortcutsXml = (items, id, icon) => `<?xml version="1.0" encoding="utf-8"?>
136
+ <shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
137
+ ${items.map(({ type }, index) => [
138
+ ` <shortcut android:shortcutId="${type}"${icon ? ` android:icon="${icon}"` : ''} android:shortcutShortLabel="@string/crossbind_shortcut_${index}">`,
139
+ ` <intent android:action="dev.crossbind.app.SHORTCUT" android:targetPackage="${id}" android:targetClass="dev.crossbind.app.CrossbindShortcutActivity">`,
140
+ // Android reads an extra's value of digits, or true or false, as a number or a boolean: after a prefix, each is text.
141
+ ` <extra android:name="dev.crossbind.app.shortcut" android:value="type:${type}" />`,
142
+ ' </intent>',
143
+ ' </shortcut>',
144
+ ].join('\n')).join('\n')}
145
+ </shortcuts>
146
+ `;
147
+ // Text as a string resource holds it as it is: XML's escapes, and a backslash before what aapt reads otherwise, a quote,
148
+ // an apostrophe, a backslash and an @ or a ? that starts it after any space; formatted="false" keeps a % as it is.
149
+ const androidText = (text) => text.replace(/[\\'"]/g, '\\$&').replace(/^(\s*)([@?])/, '$1\\$2').replace(/&/g, '&amp;').replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;');
151
+ const shortcutLabels = (items) => `<?xml version="1.0" encoding="utf-8"?>
152
+ <resources>
153
+ ${items.map(({ title }, index) => ` <string name="crossbind_shortcut_${index}" formatted="false">${androidText(title)}</string>`).join('\n')}
154
+ </resources>
155
+ `;
156
+
157
+ // The class of each extension's Java, which the activity calls by the extension's name, in the extensions' order.
158
+ const extensionsJava = (java) => `// crossbind writes this file from the app's extensions: the class of each one's Java, under its name, and the holder of
159
+ // each of the host's roles it holds.
160
+ package dev.crossbind.app;
161
+
162
+ import java.util.Collections;
163
+ import java.util.HashMap;
164
+ import java.util.LinkedHashMap;
165
+ import java.util.Map;
166
+
167
+ final class Extensions {
168
+ // In the order of the app's extensions, in which each hears what the activity does.
169
+ static final Map<String, Extension> ALL;
170
+ // By the role, as page-dialogs, whose requests the activity hands its holder's Java.
171
+ static final Map<String, Extension> ROLES;
172
+
173
+ static {
174
+ Map<String, Extension> all = new LinkedHashMap<>();${java.map(({ name, className }) => `
175
+ all.put(${JSON.stringify(name)}, new ${className}());`).join('')}
176
+ ALL = Collections.unmodifiableMap(all);
177
+ Map<String, Extension> roles = new HashMap<>();${java.flatMap(({ name, roles = [] }) => roles.map((role) => `
178
+ roles.put(${JSON.stringify(role)}, all.get(${JSON.stringify(name)}));`)).join('')}
179
+ ROLES = Collections.unmodifiableMap(roles);
180
+ }
181
+
182
+ private Extensions() {}
183
+ }
184
+ `;
185
+
186
+ const buildGradle = ({ id, label, version, abis, icon, launchMode, theme, libraries = [] }) => `// crossbind writes this project anew on every build: the module comes prebuilt in src/main/jniLibs/, and the app's
61
187
  // files are in src/main/assets/app/.
62
188
  plugins {
63
189
  id 'com.android.application'
@@ -133,26 +259,62 @@ dependencies {
133
259
  }
134
260
  }
135
261
  implementation 'androidx.webkit:webkit:${ANDROIDX_WEBKIT}'
136
- }
262
+ ${libraries.map((library) => ` implementation '${library}'\n`).join('')}}
137
263
  `;
138
264
 
139
265
  // An Android app as a Gradle project, written anew from the parts its ABI targets built: each brings its libraries,
140
- // the first also the app's files. Gradle adds Hermes and androidx.webkit from Maven, and makes the APK or the bundle.
141
- // The icons given are the files of the app's launcher icon under res/; without them it has the system's.
142
- export default function writeGradleProject(to, { parts, id, label, version, icons = null, urlSchemes = [], background = null, permissions = [] }) {
266
+ // the first also the app's files. Gradle adds Hermes, androidx.webkit and its extensions' libraries from Maven, and makes
267
+ // the APK or the bundle.
268
+ // The icons given are the files of the app's launcher icon under res/; without them it has the system's. Each of java
269
+ // is an extension's folder of Java sources, by package, which the app's sources take beside the activity, and the
270
+ // class in it the activity calls.
271
+ export default function writeGradleProject(to, {
272
+ parts, id, label, version, icons = null, urlSchemes = [], fileTypes = [], links = [], shortcutItems = [], background = null, permissions = [],
273
+ java = [], extensionPermissions = [], manifests = [], resources = [], maven = [],
274
+ }) {
143
275
  const { name } = state.config.general;
144
276
  if (parts.length === 0) throw new Error(`crossbind: no part of the Android app ${name} is built, so there is no Gradle project to write.`);
277
+ // What the extensions add is read before the project goes, which a manifest crossbind refuses then leaves as it was.
278
+ const added = manifests.map(({ name: extension, file }) => manifestParts(extension, file));
279
+ // An app whose extensions ask Maven for libraries checks every file Gradle resolves, which it then resolves online.
280
+ const verification = maven.length > 0 ? mergedVerification(
281
+ fs.readFileSync(path.join(resolveAppHostRoot(), 'android-verification-metadata.xml'), 'utf8'),
282
+ maven.map(({ name: extension, verification: file }) => ({ name: extension, xml: fs.readFileSync(file, 'utf8') })),
283
+ ) : null;
145
284
  fs.rmSync(to, { recursive: true, force: true });
146
285
  fs.cpSync(path.join(resolveAppHostRoot(), 'android'), to, { recursive: true });
286
+ if (verification) fs.writeFileSync(path.join(to, 'gradle', 'verification-metadata.xml'), verification);
147
287
  const main = path.join(to, 'app', 'src', 'main');
148
288
  const manifest = path.join(main, 'AndroidManifest.xml');
149
289
  const text = fs.readFileSync(manifest, 'utf8');
150
- if (!text.includes(ACTIVITY_END) || !text.includes(INTERNET)) {
290
+ if (!text.includes(ACTIVITY_END) || !text.includes(INTERNET) || !text.includes(APPLICATION_END)) {
151
291
  throw new Error(`crossbind: ${manifest}, from the app host, has no activity or permissions to give app.urlSchemes and app.permissions: update @crossbind/core-app-host to match crossbind.`);
152
292
  }
293
+ const own = (key, indent) => added.map((part) => part[key]).filter((part) => part.trim() !== '').map((part) => indented(part, indent));
294
+ // A replacer, as the extensions' text may hold what a replacement string reads as a pattern, as $&.
153
295
  fs.writeFileSync(manifest, text
154
- .replace(INTERNET, [INTERNET, ...usesPermissions(permissions)].join('\n'))
155
- .replace(ACTIVITY_END, urlSchemes.length > 0 ? `${urlFilter(urlSchemes)}\n${ACTIVITY_END}` : ACTIVITY_END));
296
+ .replace(INTERNET, () => [INTERNET, ...usesPermissions(permissions, extensionPermissions), ...own('manifest', ' ')].join('\n'))
297
+ .replace(ACTIVITY_END, () => [
298
+ ...(urlSchemes.length > 0 ? [urlFilter(urlSchemes)] : []), ...fileFilters(fileTypes), ...linkFilters(links),
299
+ ...(shortcutItems.length > 0 ? [SHORTCUTS_META] : []), ACTIVITY_END,
300
+ ].join('\n'))
301
+ .replace(APPLICATION_END, () => [...own('application', ' '), APPLICATION_END].join('\n')));
302
+ fs.writeFileSync(path.join(main, 'java', 'dev', 'crossbind', 'app', 'Extensions.java'), extensionsJava(java));
303
+ // The Java and Kotlin sources of an extension's folder alone, which may hold its manifest and resources as well: the
304
+ // Android Gradle plugin compiles Kotlin beside the Java.
305
+ java.forEach(({ name: extension, dir }) => {
306
+ const sources = path.join(main, 'java');
307
+ const files = fs.readdirSync(dir, { recursive: true }).filter((file) => /\.(java|kt)$/.test(file) && fs.statSync(path.join(dir, file), { throwIfNoEntry: false })?.isFile());
308
+ files.forEach((file) => {
309
+ if (fs.existsSync(path.join(sources, file))) {
310
+ throw new Error(`crossbind: the extension ${extension} has ${file} in its Java, which the app's Gradle project has already.`);
311
+ }
312
+ });
313
+ files.forEach((file) => {
314
+ fs.mkdirSync(path.dirname(path.join(sources, file)), { recursive: true });
315
+ fs.copyFileSync(path.join(dir, file), path.join(sources, file));
316
+ });
317
+ });
156
318
  parts.forEach(({ arch, dir }) => fs.cpSync(path.join(dir, 'lib'), path.join(main, 'jniLibs', arch), { recursive: true }));
157
319
  // A release keeps its libraries as linked beside the stripped ones it ships, for Google Play's crash reports.
158
320
  parts.filter(({ dir }) => fs.existsSync(path.join(dir, 'symbols')))
@@ -166,9 +328,27 @@ export default function writeGradleProject(to, { parts, id, label, version, icon
166
328
  }
167
329
  fs.mkdirSync(path.join(main, 'res', 'values'), { recursive: true });
168
330
  fs.writeFileSync(path.join(main, 'res', 'values', 'background.xml'), backgroundValues(background));
331
+ fs.writeFileSync(path.join(main, 'res', 'values', 'permissions.xml'), permissionValues(permissions));
332
+ if (shortcutItems.length > 0) {
333
+ fs.mkdirSync(path.join(main, 'res', 'xml'), { recursive: true });
334
+ fs.writeFileSync(path.join(main, 'res', 'xml', 'shortcuts.xml'), shortcutsXml(shortcutItems, id, icons ? '@mipmap/ic_launcher' : null));
335
+ fs.writeFileSync(path.join(main, 'res', 'values', 'shortcuts.xml'), shortcutLabels(shortcutItems));
336
+ }
337
+ // An extension's resources join the app's, of which they replace none.
338
+ resources.forEach(({ name: extension, dir }) => {
339
+ fs.readdirSync(dir, { recursive: true }).forEach((file) => {
340
+ const into = path.join(main, 'res', file);
341
+ if (fs.existsSync(into) && !fs.statSync(into).isDirectory()) {
342
+ throw new Error(`crossbind: the extension ${extension} has ${file} in its resources, which the app's Gradle project has already.`);
343
+ }
344
+ });
345
+ fs.cpSync(dir, path.join(main, 'res'), { recursive: true });
346
+ });
169
347
  fs.writeFileSync(path.join(to, 'app', 'build.gradle'), buildGradle({
170
348
  id, label, version, abis: parts.map(({ arch }) => arch), icon: icons ? '@mipmap/ic_launcher' : '@android:drawable/sym_def_app_icon',
171
- launchMode: urlSchemes.length > 0 ? 'singleTask' : 'standard', theme: background ? '@style/CrossbindBackground' : DAY_NIGHT,
349
+ launchMode: urlSchemes.length + fileTypes.length + links.length + shortcutItems.length > 0 ? 'singleTask' : 'standard',
350
+ theme: background ? '@style/CrossbindBackground' : DAY_NIGHT,
351
+ libraries: [...new Set(maven.flatMap(({ coordinates }) => coordinates))],
172
352
  }));
173
353
  return to;
174
354
  }