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.
Files changed (61) hide show
  1. package/README.md +33 -24
  2. package/package.json +69 -59
  3. package/src/actions/addExtension.js +191 -0
  4. package/src/actions/androidGradle.js +69 -25
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/appleSigning.js +32 -14
  8. package/src/actions/buildApp.js +229 -67
  9. package/src/actions/buildNode.js +6 -1
  10. package/src/actions/buildWasm.js +0 -9
  11. package/src/actions/createInterface.js +17 -8
  12. package/src/actions/createLib.js +5 -7
  13. package/src/actions/extensionApi.js +298 -0
  14. package/src/actions/extensionCommands.js +41 -0
  15. package/src/actions/extensionsInApp.js +19 -0
  16. package/src/actions/getCmakeParameters.js +2 -3
  17. package/src/actions/gradleProject.js +199 -16
  18. package/src/actions/iosDevice.js +200 -0
  19. package/src/actions/iosSimulator.js +3 -3
  20. package/src/actions/licenses.js +83 -34
  21. package/src/actions/linuxPackages.js +48 -0
  22. package/src/actions/mavenVerification.js +37 -0
  23. package/src/actions/publish.js +220 -0
  24. package/src/actions/uploadKey.js +134 -0
  25. package/src/actions/windowsSigning.js +1 -1
  26. package/src/actions/xcodeProject.js +63 -17
  27. package/src/actions/xcodeTargets.js +122 -0
  28. package/src/actions/xcodebuild.js +65 -0
  29. package/src/app/index.d.ts +126 -133
  30. package/src/app/index.js +126 -188
  31. package/src/assets/configure/install.sh +0 -0
  32. package/src/assets/toolchain-digests.json +20 -20
  33. package/src/bin.js +79 -26
  34. package/src/index.js +5 -1
  35. package/src/program.js +35 -4
  36. package/src/state/index.js +2 -1
  37. package/src/state/loadConfig.js +16 -5
  38. package/src/utils/appApiHeader.js +4 -0
  39. package/src/utils/appExtensions.js +761 -0
  40. package/src/utils/appFiles.js +58 -0
  41. package/src/utils/appLaunches.js +249 -0
  42. package/src/utils/appOptions.js +144 -95
  43. package/src/utils/appTargets.js +168 -0
  44. package/src/utils/ask.js +38 -0
  45. package/src/utils/bridgeTools.js +66 -0
  46. package/src/utils/configChanges.js +30 -0
  47. package/src/utils/configWriter.js +181 -0
  48. package/src/utils/extensionSteps.js +135 -0
  49. package/src/utils/hermes.js +24 -0
  50. package/src/utils/keychain.js +54 -0
  51. package/src/utils/openUrl.js +12 -0
  52. package/src/utils/password.js +34 -0
  53. package/src/utils/plist.js +5 -0
  54. package/src/utils/requiredReasonApis.js +77 -0
  55. package/src/utils/resolveAppHost.js +7 -0
  56. package/src/utils/secretFile.js +23 -0
  57. package/src/utils/systemConfig.js +19 -0
  58. package/src/utils/systemKeys.js +16 -0
  59. package/vitest.config.js +6 -1
  60. package/src/actions/appUpdate.js +0 -68
  61. 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
- 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,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 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 }));
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', 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))],
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. The process returned carries the app's output
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
  }