crossbind 2.0.0-beta.67 → 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 +33 -24
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +69 -25
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +229 -67
- 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 +5 -7
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/getCmakeParameters.js +2 -3
- package/src/actions/gradleProject.js +199 -16
- package/src/actions/iosDevice.js +200 -0
- 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/uploadKey.js +134 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +63 -17
- 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 +79 -26
- package/src/index.js +5 -1
- package/src/program.js +35 -4
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- package/src/utils/appApiHeader.js +4 -0
- 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 +144 -95
- package/src/utils/appTargets.js +168 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +181 -0
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +12 -0
- package/src/utils/password.js +34 -0
- package/src/utils/plist.js +5 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/systemConfig.js +19 -0
- package/src/utils/systemKeys.js +16 -0
- package/vitest.config.js +6 -1
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
.map((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
|
-
//
|
|
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
|
|
52
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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, '&').replace(/</g, '<')
|
|
150
|
+
.replace(/>/g, '>');
|
|
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,27 +259,66 @@ 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
|
|
141
|
-
//
|
|
142
|
-
|
|
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,
|
|
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 }));
|
|
319
|
+
// A release keeps its libraries as linked beside the stripped ones it ships, for Google Play's crash reports.
|
|
320
|
+
parts.filter(({ dir }) => fs.existsSync(path.join(dir, 'symbols')))
|
|
321
|
+
.forEach(({ arch, dir }) => fs.cpSync(path.join(dir, 'symbols'), path.join(to, 'symbols', arch), { recursive: true }));
|
|
157
322
|
fs.cpSync(path.join(parts[0].dir, 'app'), path.join(main, 'assets', 'app'), { recursive: true });
|
|
158
323
|
if (icons) {
|
|
159
324
|
Object.entries({ ...icons, 'mipmap-anydpi/ic_launcher.xml': ADAPTIVE_ICON }).forEach(([file, content]) => {
|
|
@@ -163,9 +328,27 @@ export default function writeGradleProject(to, { parts, id, label, version, icon
|
|
|
163
328
|
}
|
|
164
329
|
fs.mkdirSync(path.join(main, 'res', 'values'), { recursive: true });
|
|
165
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
|
+
});
|
|
166
347
|
fs.writeFileSync(path.join(to, 'app', 'build.gradle'), buildGradle({
|
|
167
348
|
id, label, version, abis: parts.map(({ arch }) => arch), icon: icons ? '@mipmap/ic_launcher' : '@android:drawable/sym_def_app_icon',
|
|
168
|
-
launchMode: urlSchemes.length > 0 ? 'singleTask' : 'standard',
|
|
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))],
|
|
169
352
|
}));
|
|
170
353
|
return to;
|
|
171
354
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { EXEC_MAX_BUFFER } from './run.js';
|
|
5
|
+
import { appTeam } from './buildApp.js';
|
|
6
|
+
import { xcodebuild, xcodeTeams } from './xcodebuild.js';
|
|
7
|
+
import state from '../state/index.js';
|
|
8
|
+
import { appId } from '../utils/appOptions.js';
|
|
9
|
+
|
|
10
|
+
// How much of what devicectl says as it launches the app is read for a failure; the app's own output follows it.
|
|
11
|
+
const SAID_LIMIT = 64 * 1024;
|
|
12
|
+
const LAUNCHED = /Launched application with/;
|
|
13
|
+
// Addresses of this Mac that no device reaches.
|
|
14
|
+
const LOOPBACK = ['localhost', '127.0.0.1', '::1'];
|
|
15
|
+
// Vite's --host for every address of this Mac.
|
|
16
|
+
const EVERY_ADDRESS = [true, '0.0.0.0', '::'];
|
|
17
|
+
|
|
18
|
+
const developerModeFix = (name) => `Developer Mode is off on ${name}: turn it on in Settings › Privacy & Security › Developer Mode, which restarts ${name}, and run again.`;
|
|
19
|
+
|
|
20
|
+
// What a failed install or launch on the device means, in devicectl's words, and what the developer does about it.
|
|
21
|
+
const FIXES = [
|
|
22
|
+
{ pattern: /device was still locked|device is currently locked/i, fix: ({ name }) => `unlock ${name}, and run again.` },
|
|
23
|
+
{ pattern: /developer mode is not enabled/i, fix: ({ name }) => developerModeFix(name) },
|
|
24
|
+
{
|
|
25
|
+
pattern: /0xe8008012|provisioning profile cannot be installed on this device/i,
|
|
26
|
+
fix: ({ name }) => `the app's profile leaves ${name} out, which Xcode registers with the team as it builds for it: run again, and if it stays out, run the app on ${name} from Xcode once.`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pattern: /not been explicitly trusted by the user/i,
|
|
30
|
+
fix: ({ name }) => `${name} does not trust the app's developer yet, as with a free team's first app: on ${name}, open Settings › General › VPN & Device Management, trust the Apple Development entry, and run again.`,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function tool(program, args, options = {}) {
|
|
35
|
+
const result = spawnSync(program, args, { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, ...options });
|
|
36
|
+
if (result.status !== 0) {
|
|
37
|
+
throw new Error(`crossbind: ${program} ${args.join(' ')} failed: ${`${result.stdout ?? ''}${result.stderr ?? ''}`.trim() || result.error?.message}`);
|
|
38
|
+
}
|
|
39
|
+
return result.stdout;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// devicectl's output, or its failure with what it said and, where known, what to do on `device`.
|
|
43
|
+
function devicectl(args, device = null) {
|
|
44
|
+
const result = spawnSync('xcrun', ['devicectl', ...args], { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER });
|
|
45
|
+
if (result.status === 0) return result.stdout;
|
|
46
|
+
const said = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() || result.error?.message;
|
|
47
|
+
const fix = device && FIXES.find(({ pattern }) => pattern.test(said));
|
|
48
|
+
throw new Error([`crossbind: devicectl ${args.join(' ')} failed: ${said}`, ...(fix ? [`crossbind: ${fix.fix(device)}`] : [])].join('\n'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The iPhones and iPads this Mac reaches, on a cable or over the network; devicectl lists the Simulators too.
|
|
52
|
+
function physicalDevices() {
|
|
53
|
+
return JSON.parse(devicectl(['list', 'devices', '--json-output', '-'])).result.devices
|
|
54
|
+
.filter(({ hardwareProperties: hardware, connectionProperties: connection }) => hardware?.reality === 'physical'
|
|
55
|
+
&& hardware.platform === 'iOS' && connection?.transportType)
|
|
56
|
+
.map(({ deviceProperties: properties, hardwareProperties: hardware, connectionProperties: connection }) => ({
|
|
57
|
+
name: properties?.name ?? hardware.udid,
|
|
58
|
+
udid: hardware.udid,
|
|
59
|
+
isWired: connection.transportType === 'wired',
|
|
60
|
+
isPaired: connection.pairingState === 'paired',
|
|
61
|
+
developerMode: properties?.developerModeStatus,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// iOS names a device with ’, as Ada’s iPhone, where a terminal types '.
|
|
66
|
+
const plainName = (name) => name.replaceAll('’', "'");
|
|
67
|
+
|
|
68
|
+
function pick(devices, choice) {
|
|
69
|
+
const found = choice ? devices.filter(({ name, udid }) => plainName(name) === plainName(choice) || udid === choice) : devices;
|
|
70
|
+
if (found.length === 1) return found[0];
|
|
71
|
+
if (found.length > 1) {
|
|
72
|
+
const names = found.map(({ name }) => name);
|
|
73
|
+
// A name two devices share names neither; a UDID is one device's.
|
|
74
|
+
const example = new Set(names).size === names.length ? `"${found.at(-1).name}"` : found.at(-1).udid;
|
|
75
|
+
throw new Error(`crossbind: ${found.length} iPhones and iPads are connected (${names.join(', ')}): name one, as --device ${example}.`);
|
|
76
|
+
}
|
|
77
|
+
throw new Error(choice
|
|
78
|
+
? `crossbind: no iPhone or iPad named ${choice} is connected; connected: ${devices.map(({ name }) => name).join(', ') || 'none'}.`
|
|
79
|
+
: 'crossbind: no iPhone or iPad is connected. Connect one with a cable, unlock it, and tap Trust on it if it asks.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The iPhone or iPad an app runs on in development: the one `choice` names, by its name or UDID, else the only one
|
|
83
|
+
// connected. It gets paired with this Mac, which the phone's Trust allows, and runs development apps once its Developer
|
|
84
|
+
// Mode is on.
|
|
85
|
+
export function connectedIosDevice(choice) {
|
|
86
|
+
let device = pick(physicalDevices(), choice);
|
|
87
|
+
if (!device.isPaired) {
|
|
88
|
+
console.log(`crossbind: tap Trust on ${device.name}, then enter its passcode, to pair it with this Mac.`);
|
|
89
|
+
devicectl(['manage', 'pair', '--device', device.udid], device);
|
|
90
|
+
device = pick(physicalDevices(), device.udid);
|
|
91
|
+
}
|
|
92
|
+
// A device just paired may not say yet; its install then names the fix, if it needs one.
|
|
93
|
+
if (device.developerMode === 'disabled') throw new Error(`crossbind: ${developerModeFix(device.name)}`);
|
|
94
|
+
return { name: device.name, udid: device.udid, isWired: device.isWired };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// What `crossbind app dev --device` hands Vite as CROSSBIND_APP_DEVICE: the device it names, else the UDID of the only
|
|
98
|
+
// one connected.
|
|
99
|
+
export function iosDeviceOption(choice, platform) {
|
|
100
|
+
if (platform !== 'ios') {
|
|
101
|
+
throw new Error('crossbind: --device opens an iOS app on an iPhone or iPad: add -p ios. An Android app opens on the attached device already, which ANDROID_SERIAL picks among several.');
|
|
102
|
+
}
|
|
103
|
+
return choice === true || choice === '' ? connectedIosDevice().udid : choice;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The network interfaces the device's USB connection makes on this Mac, which ioreg holds under the USB device whose
|
|
107
|
+
// serial number is the device's UDID without its dash. plutil turns no binary, date or non-finite value into JSON,
|
|
108
|
+
// which ioreg's tree holds and the lookup needs none of.
|
|
109
|
+
function cableInterfaces({ name, udid }) {
|
|
110
|
+
const tree = spawnSync('ioreg', ['-a', '-r', '-c', 'IOUSBHostDevice', '-l'], { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER }).stdout;
|
|
111
|
+
if (!tree) return [];
|
|
112
|
+
const input = tree.replace(/<(data|date|real)>[^<]*<\/\1>/g, '<string></string>');
|
|
113
|
+
const json = spawnSync('plutil', ['-convert', 'json', '-o', '-', '-'], { input, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER });
|
|
114
|
+
if (json.status !== 0) {
|
|
115
|
+
throw new Error(`crossbind: cannot read the USB devices ioreg lists, among them the cable to ${name}: ${(json.stderr ?? '').trim() || json.error?.message}`);
|
|
116
|
+
}
|
|
117
|
+
const serial = udid.replaceAll('-', '');
|
|
118
|
+
const nodes = (node) => [node, ...(node.IORegistryEntryChildren ?? []).flatMap(nodes)];
|
|
119
|
+
const usb = [JSON.parse(json.stdout)].flat().flatMap(nodes).find((node) => node['USB Serial Number'] === serial);
|
|
120
|
+
return usb ? nodes(usb).map((node) => node['BSD Name']).filter(Boolean) : [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function defaultRouteInterface() {
|
|
124
|
+
return spawnSync('route', ['-n', 'get', 'default'], { encoding: 'utf8' }).stdout?.match(/interface:\s*(\S+)/)?.[1];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The address of this Mac that the device reaches the dev server at: on the device's own cable, which needs no network,
|
|
128
|
+
// else on the default route's network, which the device must share. A .local name or the CoreDevice tunnel reaches
|
|
129
|
+
// nothing from the app.
|
|
130
|
+
export function iosDeviceAddress(device) {
|
|
131
|
+
const route = device.isWired ? null : defaultRouteInterface();
|
|
132
|
+
if (/^(utun|ipsec|ppp)/.test(route ?? '')) {
|
|
133
|
+
throw new Error(`crossbind: this Mac's default route goes through ${route}, a VPN, which ${device.name} cannot reach: give this Mac's address on the phone's network with --host <address>, or connect the phone with a cable.`);
|
|
134
|
+
}
|
|
135
|
+
const interfaces = device.isWired ? cableInterfaces(device) : [route].filter(Boolean);
|
|
136
|
+
const address = interfaces.map((name) => os.networkInterfaces()[name]?.find(({ family }) => family === 'IPv4')?.address).find(Boolean);
|
|
137
|
+
if (address) return address;
|
|
138
|
+
throw new Error(device.isWired
|
|
139
|
+
? `crossbind: this Mac has no address on the cable to ${device.name} yet: plug it in again, or give the address the phone reaches this Mac at with --host <address>.`
|
|
140
|
+
: `crossbind: this Mac has no network address ${device.name} could reach it at: join the network the phone is on, or give an address with --host <address>.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Where the dev server is for the device: the address Vite's own --host names, else the one this Mac has for the
|
|
144
|
+
// device, and the host Vite listens on, that one address unless --host asks for every one.
|
|
145
|
+
export function iosDevServer(device, requested) {
|
|
146
|
+
if (LOOPBACK.includes(requested)) {
|
|
147
|
+
throw new Error(`crossbind: ${device.name} cannot reach this Mac at ${requested}: give --host an address the phone reaches, or leave --host out.`);
|
|
148
|
+
}
|
|
149
|
+
if (EVERY_ADDRESS.includes(requested)) return { address: iosDeviceAddress(device), host: requested };
|
|
150
|
+
const address = typeof requested === 'string' ? requested : iosDeviceAddress(device);
|
|
151
|
+
return { address, host: address };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function noTeam() {
|
|
155
|
+
const teams = xcodeTeams({ withPersonal: true }).map(({ id, name }) => `${id} (${name})`);
|
|
156
|
+
const which = teams.length > 0
|
|
157
|
+
? `one of the teams Xcode is signed in to: ${teams.join(', ')}`
|
|
158
|
+
: 'the team of an account you sign in to in Xcode, under Settings › Accounts; a free Apple Account has a Personal Team';
|
|
159
|
+
return `crossbind: an app on an iPhone or iPad is signed for an Apple team: set app.team in crossbind.config.js, or XCODE_DEVELOPMENT_TEAM in ~/.crossbind.json, to ${which}.`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Builds an iOS app's Xcode project for the device, signed for the app's team by Xcode, which registers the device and
|
|
163
|
+
// the bundle id with the team and makes the profile as it needs, and returns the app. xcodebuild takes a destination
|
|
164
|
+
// only with a scheme, which Xcode makes of the project's target.
|
|
165
|
+
export function buildForIosDevice(project, device) {
|
|
166
|
+
const { name } = state.config.general;
|
|
167
|
+
const team = appTeam();
|
|
168
|
+
if (!team) throw new Error(noTeam());
|
|
169
|
+
const products = `${state.config.paths.build}/xcode`;
|
|
170
|
+
fs.mkdirSync(products, { recursive: true });
|
|
171
|
+
xcodebuild(`the build for ${device.name}`, [
|
|
172
|
+
'-project', `${project}/${name}.xcodeproj`, '-scheme', name, '-configuration', 'Debug', '-sdk', 'iphoneos', '-destination', `id=${device.udid}`,
|
|
173
|
+
'-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', `SYMROOT=${products}`, 'build',
|
|
174
|
+
], { log: `${products}/device.log`, facts: { team, id: appId(state.config) } });
|
|
175
|
+
return `${products}/Debug-iphoneos/${name}.app`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Installs an iOS app on the device and opens it with `env` in its environment. The process returned is devicectl,
|
|
179
|
+
// which xcrun runs in its own place: it carries the app's output and ends with the app, and a signal to it ends the app
|
|
180
|
+
// too. A launch the phone refuses, as one that waits for the developer's trust, names its fix.
|
|
181
|
+
export default function openOnIosDevice(app, { env = {}, stdio = 'pipe', device } = {}) {
|
|
182
|
+
devicectl(['device', 'install', 'app', '--device', device.udid, app], device);
|
|
183
|
+
const bundleId = tool('plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', `${app}/Info.plist`]).trim();
|
|
184
|
+
const [input, output, errors] = typeof stdio === 'string' ? [stdio, stdio, stdio] : stdio;
|
|
185
|
+
const child = spawn('xcrun', [
|
|
186
|
+
'devicectl', 'device', 'process', 'launch', '--device', device.udid, '--console', '--terminate-existing',
|
|
187
|
+
'--environment-variables', JSON.stringify(env), bundleId,
|
|
188
|
+
], { stdio: [input, output, 'pipe'] });
|
|
189
|
+
child.stderr.setEncoding('utf8');
|
|
190
|
+
let said = '';
|
|
191
|
+
child.stderr.on('data', (text) => {
|
|
192
|
+
if (errors === 'inherit') process.stderr.write(text);
|
|
193
|
+
if (said === null) return;
|
|
194
|
+
said += text;
|
|
195
|
+
const fix = FIXES.find(({ pattern }) => pattern.test(said));
|
|
196
|
+
if (fix) process.stderr.write(`crossbind: ${fix.fix(device)}\n`);
|
|
197
|
+
if (fix || LAUNCHED.test(said) || said.length > SAID_LIMIT) said = null;
|
|
198
|
+
});
|
|
199
|
+
return { child, close: () => child.kill() };
|
|
200
|
+
}
|
|
@@ -43,8 +43,8 @@ export function buildForSimulator(project, configuration = 'Release') {
|
|
|
43
43
|
return `${products}/${configuration}-iphonesimulator/${name}.app`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Opens a Simulator build of an iOS app, with `env` in its environment
|
|
47
|
-
// and ends with the app; close() ends the app.
|
|
46
|
+
// Opens a Simulator build of an iOS app, with `env` in its environment, in the Simulator of the device returned. The
|
|
47
|
+
// process returned carries the app's output and ends with the app; close() ends the app.
|
|
48
48
|
export default function openInSimulator(app, { env = {}, show = false, stdio = 'pipe' } = {}) {
|
|
49
49
|
const device = simulator();
|
|
50
50
|
// Simulator.app only shows the device, which runs the app without it as well.
|
|
@@ -59,5 +59,5 @@ export default function openInSimulator(app, { env = {}, show = false, stdio = '
|
|
|
59
59
|
childEnv[`SIMCTL_CHILD_${key}`] = value;
|
|
60
60
|
});
|
|
61
61
|
const child = spawn('xcrun', ['simctl', 'launch', '--console-pty', '--terminate-running-process', device, bundleId], { env: childEnv, stdio });
|
|
62
|
-
return { child, close: () => spawnSync('xcrun', ['simctl', 'terminate', device, bundleId]) };
|
|
62
|
+
return { child, device, close: () => spawnSync('xcrun', ['simctl', 'terminate', device, bundleId]) };
|
|
63
63
|
}
|