crossbind 2.0.0-beta.67 → 2.0.0-beta.68

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.
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process';
3
3
  import state from '../state/index.js';
4
4
  import { IOS_DEPLOYMENT_TARGET } from '../utils/iosToolchain.js';
5
5
  import { textPlist, xmlPlist } from '../utils/plist.js';
6
+ import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
6
7
 
7
8
  // The SDKs an iOS app builds for, one target each, and the slice of Hermes' release each takes.
8
9
  export const IOS_SDKS = ['iphoneos', 'iphonesimulator'];
@@ -18,7 +19,7 @@ int main(int argc, char *argv[]) { return crossbind_app_main(argc, argv); }
18
19
 
19
20
  const OBJECT_NAMES = ['project', 'mainGroup', 'products', 'target', 'product', 'main', 'info', 'app', 'hermes', 'mainBuild',
20
21
  'appBuild', 'hermesLink', 'hermesEmbed', 'sources', 'frameworks', 'resources', 'embed', 'projectDebug', 'projectRelease',
21
- 'targetDebug', 'targetRelease', 'projectConfigs', 'targetConfigs', 'assets', 'assetsBuild'];
22
+ 'targetDebug', 'targetRelease', 'projectConfigs', 'targetConfigs', 'assets', 'assetsBuild', 'privacy', 'privacyBuild'];
22
23
  // Xcode names its objects by 24 hex digits. Fixed ones leave the project unchanged while what it holds is.
23
24
  const ID = Object.fromEntries(OBJECT_NAMES.map((name, index) => [name, `CB${(index + 1).toString(16).toUpperCase().padStart(22, '0')}`]));
24
25
 
@@ -39,7 +40,7 @@ const ORIENTATIONS = ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationL
39
40
  // The color set of app.window.backgroundColor, which the launch screen shows.
40
41
  const LAUNCH_COLOR = 'LaunchBackground';
41
42
 
42
- const infoPlist = (name, urlSchemes, hasBackground, usage) => xmlPlist({
43
+ const infoPlist = (name, urlSchemes, hasBackground, usage, encryption) => xmlPlist({
43
44
  CFBundleDevelopmentRegion: 'en',
44
45
  CFBundleDisplayName: name,
45
46
  CFBundleExecutable: '$(EXECUTABLE_NAME)',
@@ -50,6 +51,7 @@ const infoPlist = (name, urlSchemes, hasBackground, usage) => xmlPlist({
50
51
  CFBundleShortVersionString: '$(MARKETING_VERSION)',
51
52
  ...(urlSchemes.length > 0 ? { CFBundleURLTypes: [{ CFBundleURLName: '$(PRODUCT_BUNDLE_IDENTIFIER)', CFBundleURLSchemes: urlSchemes }] } : {}),
52
53
  CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
54
+ ...(encryption === null ? {} : { ITSAppUsesNonExemptEncryption: encryption }),
53
55
  LSRequiresIPhoneOS: true,
54
56
  UIApplicationSceneManifest: { UIApplicationSupportsMultipleScenes: false },
55
57
  UILaunchScreen: hasBackground ? { UIColorName: LAUNCH_COLOR } : {},
@@ -59,7 +61,7 @@ const infoPlist = (name, urlSchemes, hasBackground, usage) => xmlPlist({
59
61
  ...usage,
60
62
  });
61
63
 
62
- function objects(name, targetSettings, hasAssets) {
64
+ function objects(name, targetSettings, hasAssets, hasPrivacy) {
63
65
  const file = (path, type) => ({ isa: 'PBXFileReference', lastKnownFileType: type, path, sourceTree: '<group>' });
64
66
  const phase = (isa, files, extra = {}) => ({ isa, buildActionMask: 2147483647, files, runOnlyForDeploymentPostprocessing: 0, ...extra });
65
67
  const configuration = (configurationName, buildSettings) => ({ isa: 'XCBuildConfiguration', buildSettings, name: configurationName });
@@ -71,8 +73,13 @@ function objects(name, targetSettings, hasAssets) {
71
73
  [ID.assets]: file('Assets.xcassets', 'folder.assetcatalog'),
72
74
  [ID.assetsBuild]: { isa: 'PBXBuildFile', fileRef: ID.assets },
73
75
  } : {};
76
+ const privacy = hasPrivacy ? {
77
+ [ID.privacy]: file('PrivacyInfo.xcprivacy', 'text.xml'),
78
+ [ID.privacyBuild]: { isa: 'PBXBuildFile', fileRef: ID.privacy },
79
+ } : {};
74
80
  return {
75
81
  ...assets,
82
+ ...privacy,
76
83
  [ID.main]: file('main.m', 'sourcecode.c.objc'),
77
84
  [ID.info]: file('Info.plist', 'text.plist.xml'),
78
85
  [ID.app]: file('app', 'folder'),
@@ -86,10 +93,12 @@ function objects(name, targetSettings, hasAssets) {
86
93
  [ID.hermesEmbed]: { isa: 'PBXBuildFile', fileRef: ID.hermes, settings: { ATTRIBUTES: ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] } },
87
94
  [ID.sources]: phase('PBXSourcesBuildPhase', [ID.mainBuild]),
88
95
  [ID.frameworks]: phase('PBXFrameworksBuildPhase', [ID.hermesLink]),
89
- [ID.resources]: phase('PBXResourcesBuildPhase', [ID.appBuild, ...(hasAssets ? [ID.assetsBuild] : [])]),
96
+ [ID.resources]: phase('PBXResourcesBuildPhase', [ID.appBuild, ...(hasAssets ? [ID.assetsBuild] : []), ...(hasPrivacy ? [ID.privacyBuild] : [])]),
90
97
  [ID.embed]: phase('PBXCopyFilesBuildPhase', [ID.hermesEmbed], { dstPath: '', dstSubfolderSpec: 10, name: 'Embed Frameworks' }),
91
98
  [ID.mainGroup]: {
92
- isa: 'PBXGroup', children: [ID.main, ID.info, ID.app, ...(hasAssets ? [ID.assets] : []), ID.hermes, ID.products], sourceTree: '<group>',
99
+ isa: 'PBXGroup',
100
+ children: [ID.main, ID.info, ID.app, ...(hasAssets ? [ID.assets] : []), ...(hasPrivacy ? [ID.privacy] : []), ID.hermes, ID.products],
101
+ sourceTree: '<group>',
93
102
  },
94
103
  [ID.products]: { isa: 'PBXGroup', children: [ID.product], name: 'Products', sourceTree: '<group>' },
95
104
  [ID.target]: {
@@ -143,10 +152,20 @@ function writeAssets(catalog, icon, background) {
143
152
  }
144
153
  }
145
154
 
155
+ // The privacy manifest of app.privacy, without which App Store Connect refuses an app that calls required reason API.
156
+ const privacyManifest = (privacy) => xmlPlist({
157
+ NSPrivacyAccessedAPITypes: Object.entries(privacy).map(([name, reasons]) => ({
158
+ NSPrivacyAccessedAPIType: PRIVACY_CATEGORIES[name].type,
159
+ NSPrivacyAccessedAPITypeReasons: reasons,
160
+ })),
161
+ });
162
+
146
163
  // An iOS app as an Xcode project, written anew from the parts its SDK targets built: each brings its archives and
147
164
  // their link line, and its slice of Hermes; the first also the app's files. Xcode signs the app and runs it, in the
148
165
  // Simulator or on a device, and makes every size of its icon from the one PNG given.
149
- export default function writeXcodeProject(to, { parts, identity, label, version, icon = null, urlSchemes = [], background = null, usage = {} }) {
166
+ export default function writeXcodeProject(to, {
167
+ parts, identity, label, version, build = version, icon = null, urlSchemes = [], background = null, usage = {}, encryption = null, privacy = {},
168
+ }) {
150
169
  const { name } = state.config.general;
151
170
  if (parts.length === 0) throw new Error(`crossbind: no part of the iOS app ${name} is built, so there is no Xcode project to write.`);
152
171
  fs.rmSync(to, { recursive: true, force: true });
@@ -165,8 +184,10 @@ export default function writeXcodeProject(to, { parts, identity, label, version,
165
184
  if (result.status !== 0) throw new Error(`crossbind: cannot make Hermes' xcframework for ${to}: ${result.stderr || result.error?.message}`);
166
185
 
167
186
  fs.writeFileSync(`${to}/main.m`, MAIN);
168
- fs.writeFileSync(`${to}/Info.plist`, infoPlist(label, urlSchemes, Boolean(background), usage));
187
+ fs.writeFileSync(`${to}/Info.plist`, infoPlist(label, urlSchemes, Boolean(background), usage, encryption));
169
188
  writeAssets(`${to}/Assets.xcassets`, icon, background);
189
+ const hasPrivacy = Object.keys(privacy).length > 0;
190
+ if (hasPrivacy) fs.writeFileSync(`${to}/PrivacyInfo.xcprivacy`, privacyManifest(privacy));
170
191
  const targetSettings = {
171
192
  ...(icon ? { ASSETCATALOG_COMPILER_APPICON_NAME: 'AppIcon' } : {}),
172
193
  PRODUCT_NAME: name,
@@ -175,12 +196,12 @@ export default function writeXcodeProject(to, { parts, identity, label, version,
175
196
  CODE_SIGN_STYLE: 'Automatic',
176
197
  INFOPLIST_FILE: 'Info.plist',
177
198
  MARKETING_VERSION: version,
178
- CURRENT_PROJECT_VERSION: version,
199
+ CURRENT_PROJECT_VERSION: build,
179
200
  LD_RUNPATH_SEARCH_PATHS: ['$(inherited)', '@executable_path/Frameworks'],
180
201
  ...linkFlags,
181
202
  };
182
203
  fs.writeFileSync(`${to}/${name}.xcodeproj/project.pbxproj`, textPlist({
183
- archiveVersion: 1, classes: {}, objectVersion: 56, objects: objects(name, targetSettings, Boolean(icon || background)), rootObject: ID.project,
204
+ archiveVersion: 1, classes: {}, objectVersion: 56, objects: objects(name, targetSettings, Boolean(icon || background), hasPrivacy), rootObject: ID.project,
184
205
  }));
185
206
  return to;
186
207
  }
package/src/bin.js CHANGED
@@ -15,8 +15,13 @@ import buildWasiCommand from './actions/buildWasiCommand.js';
15
15
  import buildNode, { addonsInPackages, bundleNodeLoader, publishNodeData } from './actions/buildNode.js';
16
16
  import buildNativeCommand from './actions/buildNativeCommand.js';
17
17
  import buildApp, { appOutputPath, assertAppRunner, publishApp } from './actions/buildApp.js';
18
- import { releaseSigning } from './actions/androidGradle.js';
19
18
  import { UPDATER_KEY, newUpdaterKey } from './actions/appUpdate.js';
19
+ import {
20
+ makeUploadKey, releaseSigningEnv, showUploadKeyPassword, useUploadKey,
21
+ } from './actions/uploadKey.js';
22
+ import { publishAndroid } from './actions/publishAndroid.js';
23
+ import { publishIos } from './actions/publishIos.js';
24
+ import { iosDeviceOption } from './actions/iosDevice.js';
20
25
  import buildExternal from './actions/buildExternal.js';
21
26
  import buildPackageTypes from './actions/buildTypes.js';
22
27
  import buildLib from './actions/buildLib.js';
@@ -116,30 +121,51 @@ function projectVite() {
116
121
  }
117
122
  }
118
123
 
124
+ // The project's Vite, as `crossbind app dev`, `build` and `publish` run it, and its exit code once it ends. A signal sent
125
+ // to crossbind alone reaches Vite too, which ends through its exit hooks; Ctrl-C reaches both. Once Vite has ended,
126
+ // signals stop crossbind again, as at the questions a publish asks after its build.
127
+ function runVite(args, env) {
128
+ const vite = spawn(process.execPath, [projectVite(), ...args], { stdio: 'inherit', cwd: state.config.paths.project, env });
129
+ const forwards = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => [signal, () => vite.kill(signal)]);
130
+ forwards.forEach(([signal, forward]) => process.prependListener(signal, forward));
131
+ return new Promise((resolve) => {
132
+ vite.on('exit', (code, signal) => {
133
+ forwards.forEach(([name, forward]) => process.off(name, forward));
134
+ resolve(code ?? 128 + os.constants.signals[signal]);
135
+ });
136
+ });
137
+ }
138
+
139
+ function assertAppProject() {
140
+ if (state.config.target.runtimeEnv !== 'app') {
141
+ throw new Error("crossbind: crossbind app builds an app, which this project is not: set target: { runtimeEnv: 'app' } in crossbind.config.js, as the desktop-app template does.");
142
+ }
143
+ }
144
+
119
145
  const commandApp = program.command('app').description('build the project as an app, or open it on the dev server');
120
146
  [
121
147
  ['dev', 'open the app on the dev server, and open it anew as its native code changes'],
122
148
  ['build', 'build the app with its page: for Android also its APK, and with an upload key its bundle'],
123
- ].forEach(([name, description]) => commandApp.command(name).description(description)
124
- .addOption(new Option('-p, --platform <platform>', "the app's platform (default: CROSSBIND_APP_PLATFORM, else this machine's)").choices(APP_PLATFORMS))
125
- // Vite's own options, as --port or --mode, go on to Vite.
126
- .allowUnknownOption()
127
- .allowExcessArguments()
128
- .action(({ platform }, command) => {
129
- if (state.config.target.runtimeEnv !== 'app') {
130
- throw new Error("crossbind: crossbind app builds an app, which this project is not: set target: { runtimeEnv: 'app' } in crossbind.config.js, as the desktop-app template does.");
131
- }
132
- // An Android build checks its upload key before the native build, not after it.
133
- if (name === 'build' && (platform ?? process.env.CROSSBIND_APP_PLATFORM) === 'android') releaseSigning();
134
- const vite = spawn(process.execPath, [projectVite(), ...(name === 'build' ? ['build'] : []), ...command.args], {
135
- stdio: 'inherit',
136
- cwd: state.config.paths.project,
137
- env: platform ? { ...process.env, CROSSBIND_APP_PLATFORM: platform } : process.env,
149
+ ].forEach(([name, description]) => {
150
+ const appCommand = commandApp.command(name).description(description)
151
+ .addOption(new Option('-p, --platform <platform>', "the app's platform (default: CROSSBIND_APP_PLATFORM, else this machine's)").choices(APP_PLATFORMS));
152
+ if (name === 'dev') {
153
+ appCommand.option('--device [name]', 'open an iOS app on a connected iPhone or iPad, the one named or else the only one, rather than in the Simulator');
154
+ }
155
+ appCommand
156
+ // Vite's own options, as --port or --mode, go on to Vite.
157
+ .allowUnknownOption()
158
+ .allowExcessArguments()
159
+ .action(async ({ platform, device }, command) => {
160
+ assertAppProject();
161
+ // An Android build checks its upload key before the native build, not after it.
162
+ const isAndroidBuild = name === 'build' && (platform ?? process.env.CROSSBIND_APP_PLATFORM) === 'android';
163
+ const { env: signing } = isAndroidBuild ? await releaseSigningEnv() : { env: {} };
164
+ const onDevice = device === undefined ? {} : { CROSSBIND_APP_DEVICE: iosDeviceOption(device, platform ?? process.env.CROSSBIND_APP_PLATFORM) };
165
+ const env = { ...process.env, ...signing, ...(platform ? { CROSSBIND_APP_PLATFORM: platform } : {}), ...onDevice };
166
+ process.exit(await runVite([...(name === 'build' ? ['build'] : []), ...command.args], env));
138
167
  });
139
- // A signal sent to crossbind alone reaches Vite too, which ends through its exit hooks; Ctrl-C reaches both.
140
- ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => process.prependListener(signal, () => vite.kill(signal)));
141
- vite.on('exit', (code, signal) => process.exit(code ?? 128 + os.constants.signals[signal]));
142
- }));
168
+ });
143
169
 
144
170
  commandApp.command('updater-key')
145
171
  .description("make the key pair that signs a desktop app's updates")
@@ -149,6 +175,41 @@ commandApp.command('updater-key')
149
175
  console.log(`crossbind: ${UPDATER_KEY}, kept secret where the app is built: ${privateKey}`);
150
176
  });
151
177
 
178
+ commandApp.command('upload-key')
179
+ .description("make this machine's upload key, which signs an Android app's bundle for Google Play")
180
+ .option('--use <keystore>', 'make a key you have the upload key instead, once it opens with its password')
181
+ .option('--alias <alias>', 'the alias of the key in the keystore --use names', 'upload')
182
+ .option('--no-keychain', 'keep the password in no keychain, so builds ask for it')
183
+ .option('--show-password', 'print the password the keychain keeps, for another machine or CI')
184
+ .action(async ({ use, alias, keychain, showPassword }) => {
185
+ if (showPassword) showUploadKeyPassword();
186
+ else if (use) await useUploadKey(use, { alias, isKeychain: keychain });
187
+ else await makeUploadKey({ isKeychain: keychain });
188
+ });
189
+
190
+ commandApp.command('publish')
191
+ .description('build the app and send it to its store: an Android bundle to a Google Play track, an iOS app to App Store Connect for TestFlight')
192
+ .addOption(new Option('-p, --platform <platform>', "the store's platform (default: CROSSBIND_APP_PLATFORM)").choices(['android', 'ios']))
193
+ .option('--track <track>', 'the Google Play track the release goes on: internal, alpha, beta, production or one of your own', 'internal')
194
+ .option('--draft', 'leave the Google Play release a draft, which Play Console rolls out')
195
+ // Vite's own options, as --mode, go on to Vite.
196
+ .allowUnknownOption()
197
+ .allowExcessArguments()
198
+ .action(async ({ platform, track, draft }, command) => {
199
+ assertAppProject();
200
+ const store = platform ?? process.env.CROSSBIND_APP_PLATFORM;
201
+ if (store === 'android') {
202
+ await publishAndroid({ track, status: draft ? 'draft' : 'completed', viteArgs: command.args }, { runVite });
203
+ return;
204
+ }
205
+ if (store !== 'ios') throw new Error('crossbind: crossbind app publish takes -p android or -p ios, the platform of the store it sends the app to.');
206
+ const playOptions = ['track', 'draft'].filter((option) => command.getOptionValueSource(option) === 'cli');
207
+ if (playOptions.length > 0) {
208
+ throw new Error(`crossbind: ${playOptions.map((option) => `--${option}`).join(' and ')} ${playOptions.length > 1 ? 'are' : 'is'} Google Play's; App Store Connect hands every iOS upload to TestFlight.`);
209
+ }
210
+ await publishIos({ viteArgs: command.args }, { runVite });
211
+ });
212
+
152
213
  program.command('licenses')
153
214
  .description('list bundled native dependencies with SPDX license, versions and source URL')
154
215
  .option('--notices [file]', 'write a THIRD-PARTY-NOTICES markdown file (default: THIRD-PARTY-NOTICES.md)')
package/src/index.js CHANGED
@@ -8,6 +8,9 @@ export {
8
8
  } from './actions/buildApp.js';
9
9
  export { checkInstaller } from './actions/appInstaller.js';
10
10
  export { default as openInSimulator, buildForSimulator } from './actions/iosSimulator.js';
11
+ export {
12
+ default as openOnIosDevice, buildForIosDevice, connectedIosDevice, iosDeviceAddress, iosDevServer,
13
+ } from './actions/iosDevice.js';
11
14
  export { default as openOnDevice, adbPath } from './actions/androidDevice.js';
12
15
  export { buildApk } from './actions/androidGradle.js';
13
16
  export { default as isSourceNewer } from './actions/isSourceNewer.js';
package/src/program.js CHANGED
@@ -22,7 +22,9 @@ export const handleFatal = (e) => {
22
22
  if (e?.cause?.message) message += `\n caused by: ${e.cause.message.split('\n')[0]}`;
23
23
  console.error(message);
24
24
  }
25
- process.exit(1);
25
+ // Writes to a pipe are asynchronous, so exiting at once would cut what is still queued, such as the failed
26
+ // command's output run.js printed: exit after both streams have written it.
27
+ process.stdout.write('', () => process.stderr.write('', () => process.exit(1)));
26
28
  };
27
29
 
28
30
  export const createProgram = () => new Command()
@@ -12,6 +12,10 @@ export function compilesAsApp(target) {
12
12
  return state.config.target?.runtimeEnv === 'app' && runtimeEnvsOf(target.platform).includes('app');
13
13
  }
14
14
 
15
+ // What an app's own code compiles with: CROSSBIND_APP, and on iOS its debug information, from which the archive's dSYM
16
+ // gives App Store Connect its files and lines; Android's NDK keeps that by default.
17
+ export const appCompileOptions = (target) => (compilesAsApp(target) ? ['-DCROSSBIND_APP', ...(target.platform === 'ios' ? ['-g'] : [])] : []);
18
+
15
19
  export function appApiHeader() {
16
20
  const header = `${resolveAppHostRoot()}/cpp/include/crossbind/app.h`;
17
21
  if (!fs.existsSync(header)) {
@@ -146,6 +146,46 @@ export function usageDescriptions(config, platform) {
146
146
  return Object.fromEntries(Object.entries(appPermissions(config)).flatMap(([name, reason]) => keys[name].map((key) => [key, reason])));
147
147
  }
148
148
 
149
+ // Apple's categories of required reason API, each with the type a privacy manifest names it by and the reasons Apple
150
+ // takes for it, from "Describing use of required reason API".
151
+ export const PRIVACY_CATEGORIES = {
152
+ fileTimestamp: { type: 'NSPrivacyAccessedAPICategoryFileTimestamp', reasons: ['DDA9.1', 'C617.1', '3B52.1', '0A2A.1'] },
153
+ systemBootTime: { type: 'NSPrivacyAccessedAPICategorySystemBootTime', reasons: ['35F9.1', '8FFB.1', '3D61.1'] },
154
+ diskSpace: { type: 'NSPrivacyAccessedAPICategoryDiskSpace', reasons: ['85F4.1', 'E174.1', '7D9E.1', 'B728.1'] },
155
+ activeKeyboards: { type: 'NSPrivacyAccessedAPICategoryActiveKeyboards', reasons: ['3EC4.1', '54BD.1'] },
156
+ userDefaults: { type: 'NSPrivacyAccessedAPICategoryUserDefaults', reasons: ['CA92.1', '1C8F.1', 'C56D.1', 'AC6B.1'] },
157
+ };
158
+
159
+ // The reasons an iOS app's privacy manifest gives for the required reason API it calls: app.privacy, each category with
160
+ // Apple's reasons that fit, as { fileTimestamp: ['C617.1'] }.
161
+ export function appPrivacy(config) {
162
+ const privacy = config.app.privacy ?? {};
163
+ const names = Object.keys(PRIVACY_CATEGORIES).join(', ');
164
+ if (typeof privacy !== 'object' || privacy === null || Array.isArray(privacy)) {
165
+ throw new Error(`crossbind: app.privacy ${JSON.stringify(privacy)} is no object of ${names}, each with Apple's reasons for it, as { fileTimestamp: ['C617.1'] } is.`);
166
+ }
167
+ const unknown = Object.keys(privacy).filter((name) => !Object.hasOwn(PRIVACY_CATEGORIES, name));
168
+ if (unknown.length > 0) throw new Error(`crossbind: app.privacy takes ${names}; ${unknown.join(', ')} is none of them.`);
169
+ Object.entries(privacy).forEach(([name, reasons]) => {
170
+ const allowed = PRIVACY_CATEGORIES[name].reasons;
171
+ if (!Array.isArray(reasons) || reasons.length === 0 || reasons.some((reason) => !allowed.includes(reason))) {
172
+ throw new Error(`crossbind: app.privacy.${name} ${JSON.stringify(reasons)} is no list of Apple's reasons for it, which are ${allowed.join(', ')}; Apple's "Describing use of required reason API" says what each covers.`);
173
+ }
174
+ });
175
+ return privacy;
176
+ }
177
+
178
+ // Whether an iOS app uses encryption that export rules do not exempt, which its Info.plist tells App Store Connect so that
179
+ // it stops asking for every build: app.encryption. It is a legal statement, so there is none without it.
180
+ export function appEncryption(config) {
181
+ const encryption = config.app.encryption;
182
+ if (encryption === undefined) return null;
183
+ if (typeof encryption !== 'boolean') {
184
+ throw new Error(`crossbind: app.encryption ${JSON.stringify(encryption)} is no boolean: set it to false when the app uses no encryption, or only what export rules exempt as the system's HTTPS, and to true when it uses any other.`);
185
+ }
186
+ return encryption;
187
+ }
188
+
149
189
  // The version a Windows app's executable carries: package.json's, whose numbers are its file version.
150
190
  function windowsVersion(config) {
151
191
  const version = config.package?.version ?? '1.0.0';
@@ -0,0 +1,38 @@
1
+ import readline from 'node:readline';
2
+ import { Writable } from 'node:stream';
3
+
4
+ // Whether someone is at a terminal to answer a question, as no one is in CI or behind a pipe.
5
+ export const hasTerminal = () => Boolean(process.stdin.isTTY && process.stderr.isTTY);
6
+
7
+ function ask(question, { input, output, isHidden }) {
8
+ output.write(question);
9
+ // A hidden answer is echoed into nothing, while the terminal, in raw mode, shows none of it either.
10
+ const echo = isHidden ? new Writable({ write: (chunk, encoding, done) => done() }) : output;
11
+ const reader = readline.createInterface({ input, output: echo, terminal: isHidden });
12
+ return new Promise((resolve, reject) => {
13
+ let isAnswered = false;
14
+ reader.once('line', (line) => {
15
+ isAnswered = true;
16
+ reader.close();
17
+ if (isHidden) output.write('\n');
18
+ resolve(line);
19
+ });
20
+ reader.once('SIGINT', () => {
21
+ reader.close();
22
+ output.write('\n');
23
+ reject(new Error('crossbind: stopped at the question.'));
24
+ });
25
+ reader.once('close', () => {
26
+ if (!isAnswered) reject(new Error('crossbind: the terminal closed before an answer.'));
27
+ });
28
+ });
29
+ }
30
+
31
+ // An answer typed on the terminal that the terminal does not show, as a password.
32
+ export const askHidden = (question, { input = process.stdin, output = process.stderr } = {}) => ask(question, { input, output, isHidden: true });
33
+
34
+ // An answer typed on the terminal.
35
+ export const askLine = (question, { input = process.stdin, output = process.stderr } = {}) => ask(question, { input, output, isHidden: false });
36
+
37
+ // A yes or no typed on the terminal: yes unless it starts with n.
38
+ export const askYesNo = async (question, streams) => !/^\s*n/i.test(await askLine(question, streams));
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import { parseAst } from 'rollup/parseAst';
3
+
4
+ // The kinds of crossbind.config whose default export can be an object literal to write into.
5
+ const CONFIG_EXTENSIONS = ['js', 'mjs', 'cjs'];
6
+ const DEFAULT_INDENT = ' ';
7
+
8
+ const indentOf = (source, at) => source.slice(source.lastIndexOf('\n', at - 1) + 1).match(/^[ \t]*/)[0];
9
+ const isOneLine = (source, from, to) => !source.slice(from, to).includes('\n');
10
+ const insert = (source, at, text) => `${source.slice(0, at)}${text}${source.slice(at)}`;
11
+ const propertyOf = (object, name) => object.properties.find((property) => property.type === 'Property' && !property.computed
12
+ && (property.key.name ?? property.key.value) === name);
13
+ // A spread may carry the key, and would win over one written before it.
14
+ const hasSpread = (object) => object.properties.some((property) => property.type === 'SpreadElement');
15
+ // A value as one writes it in a config by hand: { fileTimestamp: ['C617.1'] }.
16
+ function literal(value) {
17
+ if (typeof value === 'string') return `'${value.replace(/[\\']/g, '\\$&')}'`;
18
+ if (Array.isArray(value)) return `[${value.map(literal).join(', ')}]`;
19
+ if (value !== null && typeof value === 'object') return `{ ${Object.entries(value).map(([key, item]) => `${key}: ${literal(item)}`).join(', ')} }`;
20
+ return JSON.stringify(value);
21
+ }
22
+ export const entry = (key, value) => `${key}: ${literal(value)}`;
23
+
24
+ // How far an object's keys sit in from the line it opens on.
25
+ function indentUnit(source, object) {
26
+ const [first] = object.properties;
27
+ if (!first || isOneLine(source, object.start, first.start)) return DEFAULT_INDENT;
28
+ return indentOf(source, first.start).slice(indentOf(source, object.start).length) || DEFAULT_INDENT;
29
+ }
30
+
31
+ // The config's source with `key: value` first in its app section, which it makes where there is none, or null where it
32
+ // cannot place it safely: the default export must be an object literal, its app one too, without the key or a spread.
33
+ export function withAppOption(source, key, value) {
34
+ const exported = parseAst(source).body.find((node) => node.type === 'ExportDefaultDeclaration')?.declaration;
35
+ if (exported?.type !== 'ObjectExpression') return null;
36
+ const unit = indentUnit(source, exported);
37
+ const text = entry(key, value);
38
+ const app = propertyOf(exported, 'app');
39
+ if (!app) {
40
+ if (hasSpread(exported)) return null;
41
+ const [first] = exported.properties;
42
+ if (!first || isOneLine(source, exported.start, first.start)) return insert(source, exported.start + 1, ` app: { ${text} },`);
43
+ const indent = indentOf(source, first.start);
44
+ return insert(source, exported.start + 1, `\n${indent}app: {\n${indent}${unit}${text},\n${indent}},`);
45
+ }
46
+ if (app.value.type !== 'ObjectExpression' || propertyOf(app.value, key) || hasSpread(app.value)) return null;
47
+ const [first] = app.value.properties;
48
+ if (!first) {
49
+ const indent = indentOf(source, app.start);
50
+ return `${source.slice(0, app.value.start)}{\n${indent}${unit}${text},\n${indent}}${source.slice(app.value.end)}`;
51
+ }
52
+ if (isOneLine(source, app.value.start, first.start)) return insert(source, app.value.start + 1, ` ${text},`);
53
+ return insert(source, app.value.start + 1, `\n${indentOf(source, first.start)}${text},`);
54
+ }
55
+
56
+ // Writes `key: value` into the project's crossbind.config, and says whether it could.
57
+ export function writeAppOption(project, key, value) {
58
+ const file = CONFIG_EXTENSIONS.map((extension) => `${project}/crossbind.config.${extension}`).find((candidate) => fs.existsSync(candidate));
59
+ if (!file) return false;
60
+ const changed = withAppOption(fs.readFileSync(file, 'utf8'), key, value);
61
+ if (changed === null) return false;
62
+ fs.writeFileSync(file, changed);
63
+ return true;
64
+ }
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import withDirLock from './dirLock.js';
5
5
  import downloadAndExtractFile from './downloadAndExtractFile.js';
6
+ import logger from './logger.js';
6
7
 
7
8
  // The Hermes React Native publishes on Maven Central for Apple platforms: the macOS framework, the iOS xcframework,
8
9
  // hermesc and the headers. A new version also moves third_party/jsi of @crossbind/core-app-host to its tag, and the
@@ -25,6 +26,29 @@ export default async function resolveHermes() {
25
26
  return `${dir}/source/destroot`;
26
27
  }
27
28
 
29
+ // The dSYMs of the release's frameworks, with which App Store Connect shows Hermes' frames in an iOS app's crash reports
30
+ // as functions and lines.
31
+ export const HERMES_DSYM = {
32
+ url: `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${HERMES.version}/hermes-ios-${HERMES.version}-hermes-framework-dSYM-release.tar.gz`,
33
+ sha256: '5cea3dd57ecadb42c40d1cc8acf2e178dbfbe6a230410742d997ff3fe313a9c0',
34
+ };
35
+
36
+ // The dSYM of the device's framework, downloaded once and checked against the pinned sha256. The release's 360 MB hold
37
+ // every Apple platform's, of which only the device's 80 MB are kept.
38
+ export async function resolveHermesDsym() {
39
+ const dir = `${hermesDir()}-dsym`;
40
+ const dsym = `${dir}/source/iphoneos/hermesvm.framework.dSYM`;
41
+ if (!fs.existsSync(dsym)) logger.info(`crossbind: downloading the dSYM of Hermes ${HERMES.version} once, 360 MB of which 80 MB stay, for crash reports.`);
42
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
43
+ await withDirLock(`${dir}.lock`, async () => {
44
+ await downloadAndExtractFile(HERMES_DSYM.url, dir, HERMES_DSYM.sha256);
45
+ fs.rmSync(`${dir}/${path.basename(HERMES_DSYM.url)}`, { force: true });
46
+ fs.readdirSync(`${dir}/source`).filter((entry) => entry !== 'iphoneos')
47
+ .forEach((entry) => fs.rmSync(`${dir}/source/${entry}`, { recursive: true, force: true }));
48
+ });
49
+ return dsym;
50
+ }
51
+
28
52
  // Linux and Windows have no published Hermes, so crossbind's linux and windows images build the release's source, with
29
53
  // their static libc++: JSI hands std types across, and the host and the project's archives build there too.
30
54
  export const HERMES_SOURCE = {
@@ -0,0 +1,54 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ // The keychain names a secret by a service and an account: an upload key's password by this service, with the key's own
4
+ // path as the account.
5
+ const SERVICE = 'crossbind upload key';
6
+ // security -i reads a command line whose quoted words take a backslash before a quote or a backslash.
7
+ const quoted = (word) => `"${word.replace(/[\\"]/g, '\\$&')}"`;
8
+ // Windows PowerShell, which imports its core modules from its own home: a pwsh 7 parent's PSModulePath hides them.
9
+ const powershell = (script) => ['-NoProfile', '-NonInteractive', '-Command', `Import-Module (Join-Path $PSHOME 'Modules\\Microsoft.PowerShell.Security'); ${script}`];
10
+ const STORE_DPAPI = 'ConvertTo-SecureString -String $env:CROSSBIND_KEYCHAIN_SECRET -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -LiteralPath $env:CROSSBIND_KEYCHAIN_FILE -NoNewline';
11
+ const READ_DPAPI = "$secret = Get-Content -LiteralPath $env:CROSSBIND_KEYCHAIN_FILE -Raw | ConvertTo-SecureString; [Console]::Out.Write([System.Net.NetworkCredential]::new('', $secret).Password)";
12
+
13
+ const run = (program, args, options = {}) => spawnSync(program, args, { encoding: 'utf8', ...options });
14
+
15
+ // Each system's keychain, given the secret on stdin or in the environment, never on a command line. On Windows it is the
16
+ // user's DPAPI, in a file beside the key that only that user on that machine can read, so the path alone names it.
17
+ const KEYCHAINS = {
18
+ darwin: {
19
+ store: (service, key, secret) => run('security', ['-i'], { input: `add-generic-password -U -s ${quoted(service)} -a ${quoted(key)} -w ${quoted(secret)}\n` }),
20
+ read: (service, key) => run('security', ['find-generic-password', '-s', service, '-a', key, '-w']),
21
+ },
22
+ win32: {
23
+ store: (service, key, secret) => run('powershell.exe', powershell(STORE_DPAPI), {
24
+ env: { ...process.env, CROSSBIND_KEYCHAIN_SECRET: secret, CROSSBIND_KEYCHAIN_FILE: `${key}.dpapi` },
25
+ }),
26
+ read: (service, key) => run('powershell.exe', powershell(READ_DPAPI), { env: { ...process.env, CROSSBIND_KEYCHAIN_FILE: `${key}.dpapi` } }),
27
+ },
28
+ linux: {
29
+ store: (service, key, secret) => run('secret-tool', ['store', '--label', service, 'service', service, 'account', key], { input: secret }),
30
+ read: (service, key) => run('secret-tool', ['lookup', 'service', service, 'account', key]),
31
+ },
32
+ };
33
+
34
+ const keychainOf = (platform) => KEYCHAINS[platform] ?? KEYCHAINS.linux;
35
+
36
+ // Whether the system has a keychain to keep a password in: macOS and Windows always, Linux where secret-tool is.
37
+ export const hasKeychain = ({ platform = process.platform } = {}) => ['darwin', 'win32'].includes(platform) || !run('secret-tool', ['--help']).error;
38
+
39
+ // An upload key's password, or another service's secret, from the system's keychain, or null when it keeps none or there
40
+ // is no keychain.
41
+ export function readSecret(key, { platform = process.platform, service = SERVICE } = {}) {
42
+ const result = keychainOf(platform).read(service, key);
43
+ if (result.error || result.status !== 0) return null;
44
+ // security ends what it prints with a newline.
45
+ return result.stdout.replace(/\n$/, '') || null;
46
+ }
47
+
48
+ // Keeps an upload key's password, or another service's secret, in the system's keychain, and says whether it did: Linux
49
+ // without secret-tool and its service has none. A secret counts as kept once it reads back, as security -i fails without
50
+ // an exit code.
51
+ export function storeSecret(key, secret, { platform = process.platform, service = SERVICE } = {}) {
52
+ keychainOf(platform).store(service, key, secret);
53
+ return readSecret(key, { platform, service }) === secret;
54
+ }
@@ -0,0 +1,11 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', '""'] };
4
+
5
+ // Opens an address in the user's browser where the system can; whoever calls it prints the address too.
6
+ export default function openUrl(url) {
7
+ const [program, ...args] = OPENERS[process.platform] ?? ['xdg-open'];
8
+ const opener = spawn(program, [...args, url], { stdio: 'ignore', detached: true });
9
+ opener.on('error', () => {});
10
+ opener.unref();
11
+ }
@@ -0,0 +1,34 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ // NIST SP 800-63B-4's least length for a password that alone guards what it locks, as an upload key's does.
4
+ export const MIN_PASSWORD_LENGTH = 15;
5
+ // Rows a password can run along, either way and around: the alphabet, the digits, and a keyboard's.
6
+ const ROWS = ['abcdefghijklmnopqrstuvwxyz', '0123456789', 'qwertyuiopasdfghjklzxcvbnm', '1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p', 'q1w2e3r4t5y6u7i8o9p0'];
7
+ // Long passwords others try first, which the rules above let through.
8
+ const GUESSED = ['correcthorsebatterystaple', 'thequickbrownfoxjumpsoverthelazydog', 'crossbinduploadkey', 'androiduploadkey'];
9
+
10
+ // What the user reads before typing an upload key's password.
11
+ export const PASSWORD_RULES = [
12
+ `${MIN_PASSWORD_LENGTH} characters or more; spaces and any characters are welcome, so a few words make a good one`,
13
+ 'not one part repeated, not a run as 1234, abcd or qwerty, and not a well-known password',
14
+ ];
15
+
16
+ const runsAlong = (text, row) => {
17
+ const around = row.repeat(Math.ceil(text.length / row.length) + 1);
18
+ return around.includes(text) || [...around].reverse().join('').includes(text);
19
+ };
20
+
21
+ // Why a password is too weak to lock an upload key, or null when it is not. NIST asks for no mix of kinds of characters.
22
+ export function passwordProblem(password) {
23
+ const length = [...password].length;
24
+ if (length < MIN_PASSWORD_LENGTH) return `it has ${length} characters and needs at least ${MIN_PASSWORD_LENGTH}`;
25
+ if (/\p{Cc}/u.test(password)) return 'it has a control character, which no terminal types';
26
+ const plain = password.toLowerCase();
27
+ if (/^(.+?)\1+$/su.test(plain)) return 'it repeats one part';
28
+ if (ROWS.some((row) => runsAlong(plain, row))) return 'it runs along a row, as 1234, abcd and qwerty do';
29
+ if (GUESSED.includes(plain.replace(/\s+/g, ''))) return 'it is a password others guess first';
30
+ return null;
31
+ }
32
+
33
+ // A password crossbind makes: 18 random bytes, 144 bits, as 24 characters any terminal and keytool take.
34
+ export const makePassword = () => randomBytes(18).toString('base64url');