crossbind 2.0.0-beta.66 → 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.
- package/README.md +32 -23
- package/package.json +2 -2
- package/src/actions/androidGradle.js +44 -9
- package/src/actions/appInstaller.js +8 -5
- package/src/actions/appSetup.js +116 -0
- package/src/actions/appStoreConnect.js +188 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +83 -16
- package/src/actions/buildBinTools.js +0 -0
- package/src/actions/buildExternal.js +8 -1
- package/src/actions/createLib.js +5 -3
- package/src/actions/getCmakeParameters.js +2 -3
- package/src/actions/googlePlay.js +213 -0
- package/src/actions/googleSignIn.js +78 -0
- package/src/actions/gradleProject.js +3 -0
- package/src/actions/iosDevice.js +200 -0
- package/src/actions/playSetup.js +19 -0
- package/src/actions/publishAndroid.js +102 -0
- package/src/actions/publishIos.js +146 -0
- package/src/actions/uploadKey.js +134 -0
- package/src/actions/xcodeProject.js +30 -9
- package/src/app/index.d.ts +43 -1
- package/src/app/index.js +64 -3
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +81 -20
- package/src/index.js +3 -0
- package/src/program.js +3 -1
- package/src/utils/appApiHeader.js +4 -0
- package/src/utils/appOptions.js +77 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/configWriter.js +64 -0
- package/src/utils/embindArgumentGuards.js +8 -5
- package/src/utils/embindCallArguments.js +6 -6
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +11 -0
- package/src/utils/overrideDependency.js +3 -0
- package/src/utils/password.js +34 -0
- package/src/utils/recipePatches.js +61 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/sourceFingerprint.js +8 -1
- package/src/utils/systemConfig.js +6 -0
- package/src/utils/systemKeys.js +16 -0
|
@@ -154,6 +154,9 @@ export default function writeGradleProject(to, { parts, id, label, version, icon
|
|
|
154
154
|
.replace(INTERNET, [INTERNET, ...usesPermissions(permissions)].join('\n'))
|
|
155
155
|
.replace(ACTIVITY_END, urlSchemes.length > 0 ? `${urlFilter(urlSchemes)}\n${ACTIVITY_END}` : ACTIVITY_END));
|
|
156
156
|
parts.forEach(({ arch, dir }) => fs.cpSync(path.join(dir, 'lib'), path.join(main, 'jniLibs', arch), { recursive: true }));
|
|
157
|
+
// A release keeps its libraries as linked beside the stripped ones it ships, for Google Play's crash reports.
|
|
158
|
+
parts.filter(({ dir }) => fs.existsSync(path.join(dir, 'symbols')))
|
|
159
|
+
.forEach(({ arch, dir }) => fs.cpSync(path.join(dir, 'symbols'), path.join(to, 'symbols', arch), { recursive: true }));
|
|
157
160
|
fs.cpSync(path.join(parts[0].dir, 'app'), path.join(main, 'assets', 'app'), { recursive: true });
|
|
158
161
|
if (icons) {
|
|
159
162
|
Object.entries({ ...icons, 'mipmap-anydpi/ic_launcher.xml': ADAPTIVE_ICON }).forEach(([file, content]) => {
|
|
@@ -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 './appStoreConnect.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
|
+
], `${products}/device.log`, { 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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PLAY_SCOPE, keepSignIn } from './googlePlay.js';
|
|
2
|
+
import { signIn } from './googleSignIn.js';
|
|
3
|
+
import { askYesNo } from '../utils/ask.js';
|
|
4
|
+
|
|
5
|
+
// Signs the user in to Google Play with their Google account, in their browser, once they say yes to question, and keeps
|
|
6
|
+
// the sign-in in the system's keychain for later publishes. Google's page lets the user leave Play's access out, so the
|
|
7
|
+
// sign-in is asked for again until it has it, or is null once the user says no.
|
|
8
|
+
export async function signInToPlay(question) {
|
|
9
|
+
let asked = question;
|
|
10
|
+
while (await askYesNo(asked)) {
|
|
11
|
+
const { refresh_token: refreshToken, scope = '' } = await signIn(PLAY_SCOPE);
|
|
12
|
+
if (scope.split(' ').includes(PLAY_SCOPE)) {
|
|
13
|
+
if (!keepSignIn(refreshToken)) console.log('crossbind: this machine has no keychain to keep the sign-in in, so the next publish signs in again.');
|
|
14
|
+
return { refreshToken };
|
|
15
|
+
}
|
|
16
|
+
asked = "The sign-in left Google Play's access out, its box on Google's page. Sign in again? [Y/n] ";
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import state from '../state/index.js';
|
|
3
|
+
import { SIGNING } from './androidGradle.js';
|
|
4
|
+
import {
|
|
5
|
+
androidAppId, androidVersion, appBuildNumber, appOutputOf, clearAppOutputs,
|
|
6
|
+
} from './buildApp.js';
|
|
7
|
+
import {
|
|
8
|
+
PLAY, highestVersionCode, keptSignIn, playCredentials, uploadBundle,
|
|
9
|
+
} from './googlePlay.js';
|
|
10
|
+
import { signInToPlay } from './playSetup.js';
|
|
11
|
+
import { askAppId } from './appSetup.js';
|
|
12
|
+
import { makeUploadKey, releaseSigningEnv } from './uploadKey.js';
|
|
13
|
+
import { askLine, askYesNo, hasTerminal } from '../utils/ask.js';
|
|
14
|
+
import loadSystemConfig from '../utils/systemConfig.js';
|
|
15
|
+
import openUrl from '../utils/openUrl.js';
|
|
16
|
+
|
|
17
|
+
const PLAY_CONSOLE = 'https://play.google.com/console';
|
|
18
|
+
const NO_APP_ID = 'crossbind: Google Play knows an app by its package name, app.id, which crossbind.config.js does not set: set it, as com.example.myapp, before the first publish, as Play keeps it for good.';
|
|
19
|
+
const NO_UPLOAD_KEY = `crossbind: Google Play takes bundles signed with your upload key: crossbind app upload-key makes one, or ${SIGNING.keystore}, ${SIGNING.alias} and ${SIGNING.password} name yours.`;
|
|
20
|
+
const NO_CREDENTIALS = `crossbind: Google Play takes uploads from your Google account, which a publish on a terminal signs in to, or from a service account: set ${PLAY.serviceAccount} to its e-mail, with gcloud signed in, ${PLAY.key} to its JSON key, or ${PLAY.token} to an access token of it; app.md's Publishing says how to make one.`;
|
|
21
|
+
const SIGN_IN = 'Google Play takes uploads from your Google account once crossbind is signed in to it. Sign in now, in your browser? [Y/n] ';
|
|
22
|
+
const ANOTHER_ACCOUNT = 'Sign in with another Google account? [Y/n] ';
|
|
23
|
+
|
|
24
|
+
// The highest versionCode Play has seen, and the credentials that saw it, once the user has done what Play asks for,
|
|
25
|
+
// which only the user can do: in Play Console, which crossbind opens before it waits for Enter and checks again, or, for
|
|
26
|
+
// a Google account without release rights, by signing in with another.
|
|
27
|
+
async function highestWithPlayConsole(appId, credentials) {
|
|
28
|
+
let current = credentials;
|
|
29
|
+
for (;;) {
|
|
30
|
+
try {
|
|
31
|
+
return { highest: await highestVersionCode(appId, current), credentials: current };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.log(error.message);
|
|
34
|
+
if (error.status === 403 && current.refreshToken) {
|
|
35
|
+
current = await signInToPlay(ANOTHER_ACCOUNT);
|
|
36
|
+
if (!current) throw error;
|
|
37
|
+
} else {
|
|
38
|
+
if ([403, 404].includes(error.status)) {
|
|
39
|
+
console.log(`crossbind: opening ${PLAY_CONSOLE}.`);
|
|
40
|
+
openUrl(PLAY_CONSOLE);
|
|
41
|
+
}
|
|
42
|
+
await askLine('Press Enter once that is done to check again, or Ctrl-C to stop. ');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Builds an Android app with the next versionCode Google Play takes, signed with the upload key, and releases its bundle
|
|
49
|
+
// on a track. All it needs is checked before the build, which takes minutes: on a terminal, what is missing is asked for
|
|
50
|
+
// and set up; without one, as in CI, every missing piece is named at once.
|
|
51
|
+
export async function publishAndroid({ track, status, viteArgs }, { runVite }) {
|
|
52
|
+
const isGuided = hasTerminal();
|
|
53
|
+
const missing = [];
|
|
54
|
+
let appId = state.config.app.id === undefined ? null : androidAppId();
|
|
55
|
+
if (!appId && isGuided) appId = await askAppId('Google Play', 'package name');
|
|
56
|
+
if (!appId) missing.push(NO_APP_ID);
|
|
57
|
+
|
|
58
|
+
let signing = await releaseSigningEnv();
|
|
59
|
+
if (!signing.isSigned && isGuided && await askYesNo('This machine has no upload key, which Google Play takes bundles signed with. Make one now? [Y/n] ')) {
|
|
60
|
+
await makeUploadKey();
|
|
61
|
+
state.config.system = loadSystemConfig();
|
|
62
|
+
signing = await releaseSigningEnv();
|
|
63
|
+
}
|
|
64
|
+
if (!signing.isSigned) missing.push(NO_UPLOAD_KEY);
|
|
65
|
+
|
|
66
|
+
let credentials = playCredentials(process.env, state.config.system) ?? await keptSignIn();
|
|
67
|
+
if (!credentials && isGuided) credentials = await signInToPlay(SIGN_IN);
|
|
68
|
+
if (!credentials) missing.push(NO_CREDENTIALS);
|
|
69
|
+
if (missing.length > 0) throw new Error(missing.join('\n'));
|
|
70
|
+
const version = state.config.package?.version ?? '1.0.0';
|
|
71
|
+
console.log(`crossbind: publishing ${appId} ${version} to Google Play's ${track} track.`);
|
|
72
|
+
|
|
73
|
+
let highest;
|
|
74
|
+
if (isGuided) ({ highest, credentials } = await highestWithPlayConsole(appId, credentials));
|
|
75
|
+
else highest = await highestVersionCode(appId, credentials);
|
|
76
|
+
const versionCode = appBuildNumber() ?? Math.max(androidVersion().code, highest + 1);
|
|
77
|
+
console.log(highest
|
|
78
|
+
? `crossbind: Google Play has seen versionCode ${highest} at most, so this build is ${versionCode}.`
|
|
79
|
+
: `crossbind: Google Play has no build of ${appId} yet, so this build is ${versionCode}.`);
|
|
80
|
+
|
|
81
|
+
clearAppOutputs();
|
|
82
|
+
const exitCode = await runVite(['build', ...viteArgs], {
|
|
83
|
+
...process.env, ...signing.env, CROSSBIND_APP_PLATFORM: 'android', CROSSBIND_APP_BUILD_NUMBER: String(versionCode),
|
|
84
|
+
});
|
|
85
|
+
if (exitCode !== 0) throw Object.assign(new Error(`crossbind: the build ended with exit code ${exitCode}, so nothing went to Google Play.`), { exitCode });
|
|
86
|
+
const project = appOutputOf('android');
|
|
87
|
+
const aab = `${project}.aab`;
|
|
88
|
+
if (!fs.existsSync(aab)) throw new Error(`crossbind: the build made no bundle at ${aab}, so nothing went to Google Play.`);
|
|
89
|
+
const symbols = fs.existsSync(`${project}-symbols.zip`) ? `${project}-symbols.zip` : null;
|
|
90
|
+
|
|
91
|
+
console.log(`crossbind: uploading the bundle${symbols ? ' and its native debug symbols' : ''} to Google Play.`);
|
|
92
|
+
const released = await uploadBundle(appId, credentials, { aab, symbols, track, status });
|
|
93
|
+
const app = `${appId} ${version} (versionCode ${released.versionCode})`;
|
|
94
|
+
if (released.status !== 'draft') {
|
|
95
|
+
console.log(`crossbind: ${app} is out on Google Play's ${track} track.`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.log(`crossbind: ${app} is a draft on Google Play's ${track} track.`);
|
|
99
|
+
if (status !== 'draft') {
|
|
100
|
+
console.log("crossbind: Play takes drafts alone until an app's first release is out: roll it out once in Play Console, under Test and release, and later publishes go out by themselves.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import state from '../state/index.js';
|
|
2
|
+
import { appStoreKey } from './appleSigning.js';
|
|
3
|
+
import {
|
|
4
|
+
appBuildNumber, appOutputOf, appTeam, appVersion, clearAppOutputs,
|
|
5
|
+
} from './buildApp.js';
|
|
6
|
+
import {
|
|
7
|
+
addHermesDsym, archiveIosApp, archivedBinary, archivedPrivacy, uploadIosArchive, xcodeTeams,
|
|
8
|
+
} from './appStoreConnect.js';
|
|
9
|
+
import {
|
|
10
|
+
askAppId, askEncryption, askIcon, askPrivacy,
|
|
11
|
+
} from './appSetup.js';
|
|
12
|
+
import { appEncryption, appId } from '../utils/appOptions.js';
|
|
13
|
+
import { resolveHermesDsym } from '../utils/hermes.js';
|
|
14
|
+
import { unreasonedImports } from '../utils/requiredReasonApis.js';
|
|
15
|
+
import { askLine, askYesNo, hasTerminal } from '../utils/ask.js';
|
|
16
|
+
import loadSystemConfig, { updateSystemConfig } from '../utils/systemConfig.js';
|
|
17
|
+
import openUrl from '../utils/openUrl.js';
|
|
18
|
+
|
|
19
|
+
const NO_APP_ID = 'crossbind: App Store Connect knows an app by its bundle id, app.id, which crossbind.config.js does not set: set it, as com.example.myapp, before the first publish, as Apple keeps it for good.';
|
|
20
|
+
const NO_TEAM = "crossbind: App Store Connect takes uploads from an Apple Developer Program team, which neither app.team in crossbind.config.js nor this machine's XCODE_DEVELOPMENT_TEAM names: set either to its id, which developer.apple.com/account shows under Membership details.";
|
|
21
|
+
const NO_ICON = 'crossbind: App Store Connect takes no iOS app without an icon: set app.icon in crossbind.config.js to a square PNG, at best 1024 × 1024.';
|
|
22
|
+
const NO_ENCRYPTION = 'crossbind: app.encryption is not set, so App Store Connect asks whether this build uses encryption before TestFlight hands it out; set it in crossbind.config.js to answer for every build.';
|
|
23
|
+
const TEAM_ID = /^[A-Z0-9]{10}$/;
|
|
24
|
+
|
|
25
|
+
async function pickTeam(teams) {
|
|
26
|
+
if (teams.length === 1) {
|
|
27
|
+
const [{ id, name }] = teams;
|
|
28
|
+
return await askYesNo(`Upload as team ${id}, ${name}, which Xcode is signed in to? [Y/n] `) ? teams[0] : null;
|
|
29
|
+
}
|
|
30
|
+
console.log('Xcode is signed in to these teams:');
|
|
31
|
+
teams.forEach(({ id, name }, index) => console.log(` ${index + 1}) ${id} ${name}`));
|
|
32
|
+
for (;;) {
|
|
33
|
+
const team = teams[Number((await askLine(`Which one uploads, 1 to ${teams.length}? `)).trim()) - 1];
|
|
34
|
+
if (team) return team;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A team id the user types, or null once they have signed in to Xcode, for its teams to be read again.
|
|
39
|
+
async function typedTeam() {
|
|
40
|
+
console.log("crossbind: Xcode is signed in to no such team, as a free personal team is none. Sign in to Xcode, under Settings › Accounts, with your membership's Apple Account, or type the team's id, which developer.apple.com/account shows under Membership details.");
|
|
41
|
+
for (;;) {
|
|
42
|
+
const id = (await askLine('Team id, or Enter once Xcode is signed in: ')).trim().toUpperCase();
|
|
43
|
+
if (!id) return null;
|
|
44
|
+
if (TEAM_ID.test(id)) return id;
|
|
45
|
+
console.log('That is no team id, which is ten letters and digits.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The team the user picks of those Xcode is signed in to, or types, which this machine keeps as XCODE_DEVELOPMENT_TEAM.
|
|
50
|
+
async function askTeam() {
|
|
51
|
+
console.log('crossbind: App Store Connect takes uploads from an Apple Developer Program team, which neither app.team nor XCODE_DEVELOPMENT_TEAM names.');
|
|
52
|
+
let id = null;
|
|
53
|
+
while (!id) {
|
|
54
|
+
const teams = xcodeTeams();
|
|
55
|
+
if (teams.length > 0) {
|
|
56
|
+
const team = await pickTeam(teams);
|
|
57
|
+
if (!team) throw new Error(NO_TEAM);
|
|
58
|
+
({ id } = team);
|
|
59
|
+
} else {
|
|
60
|
+
id = await typedTeam();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
updateSystemConfig({ XCODE_DEVELOPMENT_TEAM: id });
|
|
64
|
+
state.config.system = loadSystemConfig();
|
|
65
|
+
if (appTeam() !== id) throw new Error(`crossbind: ~/.crossbind.json did not take XCODE_DEVELOPMENT_TEAM: set app.team: '${id}' in crossbind.config.js.`);
|
|
66
|
+
console.log(`crossbind: ~/.crossbind.json keeps ${id} as XCODE_DEVELOPMENT_TEAM.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A step that failed for what only the user can mend, in Xcode or on Apple's sites, runs again once they press Enter,
|
|
70
|
+
// after crossbind opened the page that mends it where it knows one. Any other failure, or one without a terminal, stops
|
|
71
|
+
// the publish.
|
|
72
|
+
async function untilDone(isGuided, step) {
|
|
73
|
+
for (;;) {
|
|
74
|
+
try {
|
|
75
|
+
return step();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!isGuided || !error.isMendable) throw error;
|
|
78
|
+
console.log(error.message);
|
|
79
|
+
if (error.page) {
|
|
80
|
+
console.log(`crossbind: opening ${error.page}.`);
|
|
81
|
+
openUrl(error.page);
|
|
82
|
+
}
|
|
83
|
+
await askLine('Press Enter once that is done to try again, or Ctrl-C to stop. ');
|
|
84
|
+
console.log('crossbind: trying again, which can take a few minutes.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function buildIosApp(viteArgs, runVite) {
|
|
90
|
+
clearAppOutputs();
|
|
91
|
+
const exitCode = await runVite(['build', ...viteArgs], { ...process.env, CROSSBIND_APP_PLATFORM: 'ios' });
|
|
92
|
+
if (exitCode !== 0) throw Object.assign(new Error(`crossbind: the build ended with exit code ${exitCode}, so nothing went to App Store Connect.`), { exitCode });
|
|
93
|
+
return appOutputOf('ios');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const unreasonedError = (unreasoned) => new Error(`crossbind: App Store Connect refuses an iOS app that calls required reason API without Apple's reasons for it (ITMS-91053), and this app calls ${unreasoned.map(({ category, symbols }) => `${category} (${symbols.join(', ')})`).join(' and ')} with no reason in its privacy manifest. Give app.privacy Apple's reasons, as { fileTimestamp: ['C617.1'] }; Apple's "Describing use of required reason API" says what each covers. Nothing went to App Store Connect.`);
|
|
97
|
+
|
|
98
|
+
// Builds an iOS app, archives it and uploads it to App Store Connect, whose TestFlight hands it out. All it needs is
|
|
99
|
+
// checked before the build, which takes minutes: on a terminal, what is missing is asked for and set up; without one,
|
|
100
|
+
// as in CI, every missing piece is named at once. The archived app then shows what required reason API it calls.
|
|
101
|
+
export async function publishIos({ viteArgs }, { runVite }) {
|
|
102
|
+
if (process.platform !== 'darwin') throw new Error('crossbind: iOS apps build and go to App Store Connect on macOS, with Xcode.');
|
|
103
|
+
const isGuided = hasTerminal();
|
|
104
|
+
const key = appStoreKey();
|
|
105
|
+
const missing = [];
|
|
106
|
+
if (state.config.app.id === undefined) {
|
|
107
|
+
if (isGuided) await askAppId('App Store Connect', 'bundle id');
|
|
108
|
+
else missing.push(NO_APP_ID);
|
|
109
|
+
}
|
|
110
|
+
if (!appTeam()) {
|
|
111
|
+
if (isGuided) await askTeam();
|
|
112
|
+
else missing.push(NO_TEAM);
|
|
113
|
+
}
|
|
114
|
+
if (state.config.app.icon === undefined) {
|
|
115
|
+
if (isGuided) await askIcon();
|
|
116
|
+
else missing.push(NO_ICON);
|
|
117
|
+
}
|
|
118
|
+
if (missing.length > 0) throw new Error(missing.join('\n'));
|
|
119
|
+
if (appEncryption(state.config) === null) {
|
|
120
|
+
if (isGuided) await askEncryption();
|
|
121
|
+
else console.warn(NO_ENCRYPTION);
|
|
122
|
+
}
|
|
123
|
+
const hermesDsym = await resolveHermesDsym();
|
|
124
|
+
|
|
125
|
+
const facts = { id: appId(state.config), team: appTeam() };
|
|
126
|
+
console.log(`crossbind: publishing ${facts.id} ${appVersion()} to App Store Connect for team ${facts.team}; building the app first.`);
|
|
127
|
+
let archive;
|
|
128
|
+
for (;;) {
|
|
129
|
+
const project = await buildIosApp(viteArgs, runVite);
|
|
130
|
+
console.log('crossbind: archiving the app, which takes a few minutes.');
|
|
131
|
+
archive = await untilDone(isGuided, () => archiveIosApp(project, { facts }));
|
|
132
|
+
const unreasoned = unreasonedImports(archivedBinary(archive), archivedPrivacy(archive));
|
|
133
|
+
if (unreasoned.length === 0) break;
|
|
134
|
+
if (!isGuided) throw unreasonedError(unreasoned);
|
|
135
|
+
await askPrivacy(unreasoned);
|
|
136
|
+
console.log('crossbind: building the app again, with those reasons in its privacy manifest.');
|
|
137
|
+
}
|
|
138
|
+
addHermesDsym(archive, hermesDsym);
|
|
139
|
+
console.log(`crossbind: uploading the app to App Store Connect for team ${facts.team} with ${key ? `the API key ${key.keyId}` : "Xcode's account"}, which signs it.`);
|
|
140
|
+
const build = appBuildNumber();
|
|
141
|
+
await untilDone(isGuided, () => uploadIosArchive(archive, {
|
|
142
|
+
team: facts.team, key, isBuildManaged: build === null, facts,
|
|
143
|
+
}));
|
|
144
|
+
const app = build === null ? `${facts.id} ${appVersion()}, numbered by Xcode after the builds App Store Connect has of it,` : `${facts.id} ${appVersion()} (build ${build})`;
|
|
145
|
+
console.log(`crossbind: ${app} is on App Store Connect. TestFlight hands it out once Apple has processed it, usually within 5 to 30 minutes, and Apple mails when it has.`);
|
|
146
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
SIGNING, machineUploadKey, releaseSigning, uploadKeyEnv,
|
|
7
|
+
} from './androidGradle.js';
|
|
8
|
+
import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
|
|
9
|
+
import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
|
|
10
|
+
import getOsUserAndGroupId from '../utils/getOsUserAndGroupId.js';
|
|
11
|
+
import loadSystemConfig, { updateSystemConfig } from '../utils/systemConfig.js';
|
|
12
|
+
import { hasKeychain, readSecret, storeSecret } from '../utils/keychain.js';
|
|
13
|
+
import { PASSWORD_RULES, makePassword, passwordProblem } from '../utils/password.js';
|
|
14
|
+
import { askHidden, askLine, hasTerminal } from '../utils/ask.js';
|
|
15
|
+
|
|
16
|
+
const ROLE = 'android-app';
|
|
17
|
+
const PLATFORM = 'linux/amd64';
|
|
18
|
+
const ALIAS = 'upload';
|
|
19
|
+
// Nobody sees an upload key's name: an app installed from Google Play carries Google's signature.
|
|
20
|
+
const NAME = 'CN=Android upload key';
|
|
21
|
+
// Google Play wants a key valid past 2033; 10,000 days is 27 years.
|
|
22
|
+
const VALIDITY_DAYS = 10000;
|
|
23
|
+
|
|
24
|
+
// Where `crossbind app upload-key` makes this machine's upload key.
|
|
25
|
+
export const uploadKeyFile = () => path.join(os.homedir(), '.crossbind', 'keys', 'android-upload.p12');
|
|
26
|
+
|
|
27
|
+
// keytool in crossbind's android-app image, which an Android app's build pulls anyway: with no network, the key's folder
|
|
28
|
+
// alone, and the password by name in the environment, never on a command line.
|
|
29
|
+
function keytool(keystore, command, args, password, { isReadOnly = false } = {}) {
|
|
30
|
+
pullDockerImage(ROLE, PLATFORM);
|
|
31
|
+
const result = spawnSync('docker', [
|
|
32
|
+
'run', '--rm', ...DOCKER_RUN_SECURITY_ARGS, '--network', 'none', '--platform', PLATFORM, '--user', getOsUserAndGroupId(),
|
|
33
|
+
'-v', `${path.dirname(keystore)}:/keys${isReadOnly ? ':ro' : ''}`, '-e', SIGNING.password, getDockerImage(ROLE, PLATFORM),
|
|
34
|
+
'keytool', command, '-keystore', `/keys/${path.basename(keystore)}`, ...args, '-storepass:env', SIGNING.password,
|
|
35
|
+
], { encoding: 'utf8', env: { ...process.env, [SIGNING.password]: password } });
|
|
36
|
+
if (result.error) throw new Error(`crossbind: cannot run keytool in the ${ROLE} image: ${result.error.message}`, { cause: result.error });
|
|
37
|
+
return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The password the user types for a new key, held to the rules and typed twice the same.
|
|
41
|
+
async function typedPassword() {
|
|
42
|
+
console.error(`The upload key's password needs:\n${PASSWORD_RULES.map((rule) => ` - ${rule}`).join('\n')}`);
|
|
43
|
+
for (;;) {
|
|
44
|
+
const password = await askHidden('Password: ');
|
|
45
|
+
const problem = passwordProblem(password);
|
|
46
|
+
if (problem) {
|
|
47
|
+
console.error(`That one will not do: ${problem}.`);
|
|
48
|
+
} else if (await askHidden('Again: ') === password) {
|
|
49
|
+
return password;
|
|
50
|
+
} else {
|
|
51
|
+
console.error('The two did not match.');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A new key's password: the environment's, else one crossbind makes or one the user types. Only a keychain keeps a made
|
|
57
|
+
// one, so without a keychain the user types their own.
|
|
58
|
+
async function newPassword(canKeep) {
|
|
59
|
+
const given = process.env[SIGNING.password];
|
|
60
|
+
if (given) {
|
|
61
|
+
const problem = passwordProblem(given);
|
|
62
|
+
if (problem) throw new Error(`crossbind: ${SIGNING.password} cannot lock an upload key: ${problem}.`);
|
|
63
|
+
return { password: given, isMade: false };
|
|
64
|
+
}
|
|
65
|
+
if (!hasTerminal()) throw new Error(`crossbind: an upload key needs a password: run this on a terminal, which asks for one, or set ${SIGNING.password}.`);
|
|
66
|
+
const choice = canKeep
|
|
67
|
+
? await askLine("The upload key's password:\n 1) make a strong one for me (recommended)\n 2) I will type my own\nChoose 1 or 2 [1]: ")
|
|
68
|
+
: '2';
|
|
69
|
+
if (choice.trim() !== '2') return { password: makePassword(), isMade: true };
|
|
70
|
+
return { password: await typedPassword(), isMade: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Makes a key this machine's upload key, its password kept in the system keychain where it can be.
|
|
74
|
+
function keep(keystore, alias, password, isKeychain) {
|
|
75
|
+
const isKept = isKeychain && storeSecret(keystore, password);
|
|
76
|
+
updateSystemConfig({ ANDROID_KEYSTORE: keystore, ANDROID_KEY_ALIAS: alias });
|
|
77
|
+
console.log(isKept
|
|
78
|
+
? 'crossbind: the system keychain keeps its password, so builds sign without asking.'
|
|
79
|
+
: `crossbind: builds ask for its password, as no keychain keeps it; set ${SIGNING.password} where no one can type it, as in CI.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Makes this machine's upload key, which signs its Android apps' bundles for Google Play. It never makes one over a key
|
|
83
|
+
// the machine has.
|
|
84
|
+
export async function makeUploadKey({ isKeychain = true } = {}) {
|
|
85
|
+
const system = loadSystemConfig();
|
|
86
|
+
if (system.ANDROID_KEYSTORE) {
|
|
87
|
+
throw new Error(`crossbind: this machine's upload key is ${system.ANDROID_KEYSTORE}, and crossbind never makes another over it, as Google Play takes an app's updates only signed with the key it knows. To make a new one anyway, drop it first with crossbind config delete ANDROID_KEYSTORE.`);
|
|
88
|
+
}
|
|
89
|
+
const file = uploadKeyFile();
|
|
90
|
+
if (fs.existsSync(file)) {
|
|
91
|
+
throw new Error(`crossbind: ${file} is there already, and crossbind never replaces an upload key: use it with crossbind app upload-key --use ${file}.`);
|
|
92
|
+
}
|
|
93
|
+
const canKeep = isKeychain && hasKeychain();
|
|
94
|
+
const { password, isMade } = await newPassword(canKeep);
|
|
95
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
96
|
+
const made = keytool(file, '-genkeypair', ['-storetype', 'PKCS12', '-alias', ALIAS, '-keyalg', 'RSA', '-keysize', '2048',
|
|
97
|
+
'-validity', String(VALIDITY_DAYS), '-dname', NAME], password);
|
|
98
|
+
if (made.status !== 0) throw new Error(`crossbind: keytool did not make the upload key:\n${made.output}`);
|
|
99
|
+
fs.chmodSync(file, 0o600);
|
|
100
|
+
console.log(`crossbind: your upload key is ${file}.`);
|
|
101
|
+
if (isMade) console.log(`crossbind: its password, shown this once: ${password}`);
|
|
102
|
+
keep(file, ALIAS, password, canKeep);
|
|
103
|
+
console.log(`crossbind: keep a copy of ${file} and of its password somewhere safe, as a password manager. Google keeps only the key's certificate: a lost key takes an upload key reset in Play Console, which takes effect 72 hours later.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Makes a key the user has this machine's upload key, once it opens with its password and alias.
|
|
107
|
+
export async function useUploadKey(file, { alias = ALIAS, isKeychain = true } = {}) {
|
|
108
|
+
const keystore = path.resolve(file);
|
|
109
|
+
if (!fs.statSync(keystore, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${keystore} is no file.`);
|
|
110
|
+
const password = process.env[SIGNING.password] || (hasTerminal() ? await askHidden(`Password of ${keystore}: `) : '');
|
|
111
|
+
if (!password) throw new Error(`crossbind: ${keystore} needs its password: run this on a terminal, which asks for it, or set ${SIGNING.password}.`);
|
|
112
|
+
const opened = keytool(keystore, '-list', ['-alias', alias], password, { isReadOnly: true });
|
|
113
|
+
if (opened.status !== 0) throw new Error(`crossbind: ${keystore} did not open with that password and the alias ${alias}:\n${opened.output}`);
|
|
114
|
+
console.log(`crossbind: this machine's upload key is ${keystore}, alias ${alias}.`);
|
|
115
|
+
keep(keystore, alias, password, isKeychain);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The environment an Android release builds in, with the environment's upload key, else this machine's, whose password
|
|
119
|
+
// the keychain keeps or the terminal gives, and whether a key signs it: checked before the native build, not after it.
|
|
120
|
+
export async function releaseSigningEnv() {
|
|
121
|
+
const key = machineUploadKey();
|
|
122
|
+
const password = key && !key.password && hasTerminal() ? await askHidden(`Password of this machine's upload key, ${key.keystore}: `) : null;
|
|
123
|
+
const env = key ? uploadKeyEnv({ ...key, password: key.password || password }) : {};
|
|
124
|
+
return { env, isSigned: releaseSigning({ ...process.env, ...env }) !== null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The password the keychain keeps for this machine's upload key, for another machine or CI.
|
|
128
|
+
export function showUploadKeyPassword() {
|
|
129
|
+
const { ANDROID_KEYSTORE: keystore } = loadSystemConfig();
|
|
130
|
+
if (!keystore) throw new Error('crossbind: this machine has no upload key: crossbind app upload-key makes one.');
|
|
131
|
+
const password = readSecret(keystore);
|
|
132
|
+
if (!password) throw new Error(`crossbind: no keychain keeps the password of ${keystore}.`);
|
|
133
|
+
console.log(password);
|
|
134
|
+
}
|