crossbind 2.0.0-beta.68 → 2.0.0-beta.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +26 -17
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/buildApp.js +172 -74
- package/src/actions/buildNode.js +6 -1
- package/src/actions/buildWasm.js +0 -9
- package/src/actions/createInterface.js +17 -8
- package/src/actions/createLib.js +0 -4
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/gradleProject.js +196 -16
- package/src/actions/iosDevice.js +2 -2
- package/src/actions/iosSimulator.js +3 -3
- package/src/actions/licenses.js +83 -34
- package/src/actions/linuxPackages.js +48 -0
- package/src/actions/mavenVerification.js +37 -0
- package/src/actions/publish.js +220 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +49 -24
- package/src/actions/xcodeTargets.js +122 -0
- package/src/actions/xcodebuild.js +65 -0
- package/src/app/index.d.ts +126 -133
- package/src/app/index.js +126 -188
- package/src/assets/configure/install.sh +0 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +20 -28
- package/src/index.js +2 -1
- package/src/program.js +32 -3
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- package/src/utils/appExtensions.js +761 -0
- package/src/utils/appFiles.js +58 -0
- package/src/utils/appLaunches.js +249 -0
- package/src/utils/appOptions.js +109 -100
- package/src/utils/appTargets.js +168 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +143 -26
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/openUrl.js +2 -1
- package/src/utils/plist.js +5 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/systemConfig.js +16 -3
- package/vitest.config.js +6 -1
- package/src/actions/appSetup.js +0 -116
- package/src/actions/appStoreConnect.js +0 -188
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
- package/src/actions/googlePlay.js +0 -213
- package/src/actions/googleSignIn.js +0 -78
- package/src/actions/playSetup.js +0 -19
- package/src/actions/publishAndroid.js +0 -102
- package/src/actions/publishIos.js +0 -146
|
@@ -19,3 +19,10 @@ export function resolveAppEmbindJsiRoot() {
|
|
|
19
19
|
const req = createRequire(path.join(resolveAppHostRoot(), 'package.json'));
|
|
20
20
|
return path.dirname(fs.realpathSync(req.resolve('@crossbind/core-embind-jsi/package.json')));
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// crossbind/app as an app's page imports it, which the app host assembles with what crossbind's own extensions give the page.
|
|
24
|
+
export function resolveAppPageApi() {
|
|
25
|
+
const file = path.join(resolveAppHostRoot(), 'js', 'app.js');
|
|
26
|
+
if (!fs.existsSync(file)) throw new Error(`crossbind: the app host at ${resolveAppHostRoot()} has no crossbind/app for an app's page: update ${PKG} to match crossbind.`);
|
|
27
|
+
return fs.realpathSync(file);
|
|
28
|
+
}
|
|
@@ -13,7 +13,20 @@ export default function loadSystemConfig() {
|
|
|
13
13
|
return { ...system, ...Object.fromEntries(defaults) };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
// The settings of an extension's own, which ~/.crossbind.json keeps apart from crossbind's, under extensions by the
|
|
19
|
+
// extension's name.
|
|
20
|
+
export function extensionSettingsOf(system, name) {
|
|
21
|
+
const own = system.extensions === undefined ? {} : isObject(system.extensions) && (system.extensions[name] ?? {});
|
|
22
|
+
if (!isObject(own)) throw new Error(`crossbind: ${systemConfigPath()} keeps the settings of the extension ${name} as no object of them, under extensions: fix it.`);
|
|
23
|
+
return own;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sets keys of ~/.crossbind.json, as `crossbind config set` does, keeping the others, and with { name, values } settings
|
|
27
|
+
// of that extension's own.
|
|
28
|
+
export function updateSystemConfig(values, extension = null) {
|
|
29
|
+
const system = loadSystemConfig();
|
|
30
|
+
const own = extension && { extensions: { ...system.extensions, [extension.name]: { ...extensionSettingsOf(system, extension.name), ...extension.values } } };
|
|
31
|
+
writeJson(systemConfigPath(), { ...system, ...values, ...own });
|
|
19
32
|
}
|
package/vitest.config.js
CHANGED
|
@@ -52,7 +52,12 @@ export default defineConfig({
|
|
|
52
52
|
// exercised a machine without that image already cached, and android builds shipped
|
|
53
53
|
// broken to every Apple Silicon user. Which command line these actions build IS the
|
|
54
54
|
// behaviour, so the layer is measured.
|
|
55
|
-
|
|
55
|
+
// crossbind's own extensions' build-side code, which left src/ as packages of their own, is measured with it.
|
|
56
|
+
include: [
|
|
57
|
+
'src/utils/**/*.js', 'src/state/**/*.js', 'src/assets/**/*.js', 'src/actions/**/*.js',
|
|
58
|
+
'../../extensions/*/crossbind.extension.js', '../../extensions/*/src/**/*.js', '../../core/store-kit/*.js',
|
|
59
|
+
],
|
|
60
|
+
allowExternal: true,
|
|
56
61
|
all: true,
|
|
57
62
|
// A floor with a one-point band, not a ratchet. Coverage is not identical across
|
|
58
63
|
// environments - resolveEmbindRust walks the real node_modules layout, so which of its
|
package/src/actions/appSetup.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import state from '../state/index.js';
|
|
2
|
-
import { askLine, askYesNo } from '../utils/ask.js';
|
|
3
|
-
import { entry, writeAppOption } from '../utils/configWriter.js';
|
|
4
|
-
import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
|
|
5
|
-
import { readAppIcon } from '../utils/appIcon.js';
|
|
6
|
-
|
|
7
|
-
// What only the project can answer, which `crossbind app publish` asks on a terminal and writes into crossbind.config.js
|
|
8
|
-
// once the user says yes.
|
|
9
|
-
|
|
10
|
-
// An id both stores take: dot-separated parts of letters and digits, each starting with a letter, as Apple's take no
|
|
11
|
-
// underscore and Google's no hyphen.
|
|
12
|
-
const STORE_APP_ID = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
|
|
13
|
-
|
|
14
|
-
const REASONS_PAGE = 'https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api';
|
|
15
|
-
// Apple's reasons an app may give, in short; that page has their rules. 0A2A.1 and C56D.1 are for third-party SDKs alone.
|
|
16
|
-
const REASONS = {
|
|
17
|
-
'DDA9.1': 'to show file timestamps to the user; they stay on the device',
|
|
18
|
-
'C617.1': "for the timestamps, size or other metadata of files in the app's container, app group container or CloudKit container",
|
|
19
|
-
'3B52.1': 'for those of files or folders the user granted access to, as with a document picker',
|
|
20
|
-
'35F9.1': 'to measure the time between events in the app, or for timers; only that time may leave the device',
|
|
21
|
-
'8FFB.1': "to turn the times of the app's events into absolute timestamps, which may leave the device; the boot time may not",
|
|
22
|
-
'3D61.1': 'in an optional bug report the user chooses to send, which shows it prominently',
|
|
23
|
-
'85F4.1': 'to show disk space to the user; it stays on the device',
|
|
24
|
-
'E174.1': 'to check for space before writing files, or to delete files when space runs low, in a way the user notices; it stays on the device',
|
|
25
|
-
'7D9E.1': 'in an optional bug report the user chooses to send, which shows it prominently',
|
|
26
|
-
'B728.1': 'in a health research app, to tell participants that low disk space hampers the research',
|
|
27
|
-
'3EC4.1': 'in a custom keyboard app, to find the keyboards active on the device',
|
|
28
|
-
'54BD.1': 'to show the right interface for the active keyboard in text fields, in a way the user notices; it stays on the device',
|
|
29
|
-
'CA92.1': 'for information only the app itself reads and writes',
|
|
30
|
-
'1C8F.1': "for information only the apps and extensions of the app's App Group read and write",
|
|
31
|
-
'AC6B.1': 'for the managed app configuration MDM sets, or the feedback MDM reads',
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Writes app.<key> into crossbind.config.js once the user says yes, and into this run's config. One the config sets
|
|
35
|
-
// already is the user's to change.
|
|
36
|
-
async function writeAppAnswer(key, value) {
|
|
37
|
-
const text = entry(key, value);
|
|
38
|
-
if (state.config.app[key] !== undefined) throw new Error(`crossbind: set app.${text} in crossbind.config.js in place of the app.${key} there, and publish again.`);
|
|
39
|
-
if (!await askYesNo(`Write app.${text} into crossbind.config.js? [Y/n] `)) throw new Error(`crossbind: set app.${text} in crossbind.config.js to publish.`);
|
|
40
|
-
if (!writeAppOption(state.config.paths.project, key, value)) {
|
|
41
|
-
throw new Error(`crossbind: crossbind.config.js is no config crossbind can write app.${key} into safely: set app.${text} in it yourself.`);
|
|
42
|
-
}
|
|
43
|
-
state.config.app = { ...state.config.app, [key]: value };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Asks for the app's id, which the store keeps for good, under the name the store gives it.
|
|
47
|
-
export async function askAppId(store, term) {
|
|
48
|
-
console.log(`crossbind: ${store} knows an app by its ${term}, app.id, and keeps it for good.`);
|
|
49
|
-
for (;;) {
|
|
50
|
-
const id = (await askLine(`The ${term}, as com.example.myapp: `)).trim();
|
|
51
|
-
if (STORE_APP_ID.test(id)) {
|
|
52
|
-
await writeAppAnswer('id', id);
|
|
53
|
-
return id;
|
|
54
|
-
}
|
|
55
|
-
console.log(`That is no ${term} both stores take, which is dot-separated parts of letters and digits, each starting with a letter.`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function iconProblem(icon) {
|
|
60
|
-
try {
|
|
61
|
-
readAppIcon({ ...state.config, app: { ...state.config.app, icon } });
|
|
62
|
-
return null;
|
|
63
|
-
} catch (error) {
|
|
64
|
-
return error.message;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Asks for the app's icon, without which App Store Connect takes no iOS app.
|
|
69
|
-
export async function askIcon() {
|
|
70
|
-
console.log('crossbind: App Store Connect takes no iOS app without an icon, app.icon: a square PNG, at best 1024 × 1024.');
|
|
71
|
-
for (;;) {
|
|
72
|
-
const icon = (await askLine("Its path, from the project's folder: ")).trim();
|
|
73
|
-
const problem = iconProblem(icon);
|
|
74
|
-
if (!problem) {
|
|
75
|
-
await writeAppAnswer('icon', icon);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
console.log(problem);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Asks whether the app's encryption is iOS's own alone, which App Store Connect otherwise asks for every build. The answer
|
|
83
|
-
// is a legal statement, so there is no default.
|
|
84
|
-
export async function askEncryption() {
|
|
85
|
-
console.log('crossbind: App Store Connect asks for every build whether the app uses encryption that export rules do not exempt, until app.encryption answers it.');
|
|
86
|
-
for (;;) {
|
|
87
|
-
const answer = (await askLine('Does the app use only the encryption iOS provides, as HTTPS through iOS, or none at all? [y/n] ')).trim();
|
|
88
|
-
if (/^[yn]/i.test(answer)) {
|
|
89
|
-
await writeAppAnswer('encryption', /^n/i.test(answer));
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
console.log('Answer y or n.');
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function askReasons(reasons) {
|
|
97
|
-
for (;;) {
|
|
98
|
-
const picks = (await askLine('Which apply, as 1 or 1,2? ')).split(',').map((pick) => pick.trim()).filter(Boolean);
|
|
99
|
-
const chosen = picks.map((pick) => reasons[Number(pick) - 1]);
|
|
100
|
-
if (chosen.length > 0 && chosen.every(Boolean)) return [...new Set(chosen)];
|
|
101
|
-
console.log(`Name one or more of 1 to ${reasons.length}, separated by commas.`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Asks for Apple's reasons for each category of required reason API the app calls with none in app.privacy.
|
|
106
|
-
export async function askPrivacy(unreasoned) {
|
|
107
|
-
console.log(`crossbind: App Store Connect refuses an iOS app that calls required reason API without Apple's reasons for it (ITMS-91053), which app.privacy gives; ${REASONS_PAGE} has the rules of each.`);
|
|
108
|
-
const privacy = { ...state.config.app.privacy };
|
|
109
|
-
for (const { category, symbols } of unreasoned) {
|
|
110
|
-
const reasons = PRIVACY_CATEGORIES[category].reasons.filter((reason) => Object.hasOwn(REASONS, reason));
|
|
111
|
-
console.log(`The app calls ${category} API (${symbols.join(', ')}). Apple's reasons for it:`);
|
|
112
|
-
reasons.forEach((reason, index) => console.log(` ${index + 1}) ${reason} ${REASONS[reason]}`));
|
|
113
|
-
privacy[category] = await askReasons(reasons);
|
|
114
|
-
}
|
|
115
|
-
await writeAppAnswer('privacy', privacy);
|
|
116
|
-
}
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import state from '../state/index.js';
|
|
5
|
-
import { APPLE_SIGNING } from './appleSigning.js';
|
|
6
|
-
import { EXEC_MAX_BUFFER } from './run.js';
|
|
7
|
-
import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
|
|
8
|
-
import { xmlPlist } from '../utils/plist.js';
|
|
9
|
-
|
|
10
|
-
export const APP_STORE_CONNECT = 'https://appstoreconnect.apple.com/apps';
|
|
11
|
-
const DEVELOPER_ACCOUNT = 'https://developer.apple.com/account';
|
|
12
|
-
// The lines of a failed xcodebuild's output that say why; its log has the rest.
|
|
13
|
-
const REASON_LINES = 15;
|
|
14
|
-
|
|
15
|
-
// An upload to an app App Store Connect does not have yet, which only its site makes.
|
|
16
|
-
const MISSING_APP = {
|
|
17
|
-
pattern: /No suitable application records were found|App record with bundle identifier .* not found/i,
|
|
18
|
-
hint: ({ id }) => `App Store Connect has no app ${id}: create it at ${APP_STORE_CONNECT}, a new iOS app with ${id} as its bundle ID, which this publish registered with Apple.`,
|
|
19
|
-
page: APP_STORE_CONNECT,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// What a failed archive or upload means where xcodebuild's own words leave it unclear: what the user mends in Xcode or
|
|
23
|
-
// on Apple's sites, and the page where.
|
|
24
|
-
const CAUSES = [
|
|
25
|
-
{
|
|
26
|
-
pattern: /login details for account .* were rejected|No Accounts?\b|No Account for Team/i,
|
|
27
|
-
hint: ({ team }) => `Xcode is signed in to no account of team ${team}, or its sign-in has lapsed: sign in again in Xcode, under Settings › Accounts.`,
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
pattern: /PLA Update available|Program License Agreement/i,
|
|
31
|
-
hint: () => `Apple has a new Program License Agreement, which the team's Account Holder accepts at ${DEVELOPER_ACCOUNT}.`,
|
|
32
|
-
page: DEVELOPER_ACCOUNT,
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
pattern: /Cloud signing permission error/i,
|
|
36
|
-
hint: () => `the API key may not sign in the cloud, which takes its Admin role: use an Admin key, make an Apple Distribution certificate once in Xcode, under Settings › Accounts › Manage Certificates, or unset ${APPLE_SIGNING.key} to upload with Xcode's account.`,
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
pattern: /cannot be registered to your development team because it is not available/i,
|
|
40
|
-
hint: ({ id }) => `another team registered the bundle id ${id} with Apple first: set app.id in crossbind.config.js to an id of your own, as one under a domain you own.`,
|
|
41
|
-
// The id is in what was built, so the same step tried again fails the same way.
|
|
42
|
-
needsRebuild: true,
|
|
43
|
-
},
|
|
44
|
-
MISSING_APP,
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
// Where a publish keeps the archive, its export and their logs.
|
|
48
|
-
const storeDir = () => `${state.config.paths.build}/app-store`;
|
|
49
|
-
|
|
50
|
-
const keyArgs = (key) => (key ? ['-authenticationKeyPath', key.key, '-authenticationKeyID', key.keyId, '-authenticationKeyIssuerID', key.issuer] : []);
|
|
51
|
-
|
|
52
|
-
// Runs xcodebuild with its output in `log`, which it returns. Its failure names the lines that say why, and what to do
|
|
53
|
-
// where those leave it unclear.
|
|
54
|
-
export function xcodebuild(what, args, log, facts) {
|
|
55
|
-
const out = fs.openSync(log, 'w');
|
|
56
|
-
let result;
|
|
57
|
-
try {
|
|
58
|
-
result = spawnSync('xcodebuild', args, { stdio: ['ignore', out, out] });
|
|
59
|
-
} finally {
|
|
60
|
-
fs.closeSync(out);
|
|
61
|
-
}
|
|
62
|
-
if (result.error) throw new Error(`crossbind: cannot run xcodebuild, which comes with Xcode: ${result.error.message}`, { cause: result.error });
|
|
63
|
-
if (result.signal) throw new Error(`crossbind: ${what} stopped on ${result.signal}.`);
|
|
64
|
-
const output = fs.readFileSync(log, 'utf8');
|
|
65
|
-
if (result.status === 0) return output;
|
|
66
|
-
const reasons = [...new Set(output.split('\n').map((line) => line.trim()).filter((line) => /\berror\b|ITMS-/i.test(line)))].slice(-REASON_LINES);
|
|
67
|
-
const cause = CAUSES.find(({ pattern }) => pattern.test(output));
|
|
68
|
-
const lines = [`crossbind: ${what} failed; xcodebuild's log is ${log}.`, ...reasons.map((line) => ` ${line}`), ...(cause ? [`crossbind: ${cause.hint(facts)}`] : [])];
|
|
69
|
-
throw Object.assign(new Error(lines.join('\n')), { isMendable: Boolean(cause) && !cause.needsRebuild, page: cause?.page ?? null, reason: cause ?? null });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const archivedApp = (archive) => `${archive}/Products/Applications/${state.config.general.name}.app`;
|
|
73
|
-
|
|
74
|
-
// The entitlements file the Xcode project names, or null without one.
|
|
75
|
-
function projectEntitlements(project) {
|
|
76
|
-
const settings = fs.readFileSync(`${project}/${state.config.general.name}.xcodeproj/project.pbxproj`, 'utf8');
|
|
77
|
-
const file = settings.match(/"?CODE_SIGN_ENTITLEMENTS"?\s*=\s*"?([^";]+)"?;/)?.[1];
|
|
78
|
-
return file ? path.resolve(project, file.replace('$(SRCROOT)/', '')) : null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Signs an archived app ad hoc, its frameworks first, which takes no certificate. The signature carries the app's
|
|
82
|
-
// entitlements to the upload, which an unsigned app would lose, and the upload replaces it with its distribution one.
|
|
83
|
-
function signAdHoc(app, entitlements) {
|
|
84
|
-
const frameworks = fs.existsSync(`${app}/Frameworks`) ? fs.readdirSync(`${app}/Frameworks`).map((name) => `${app}/Frameworks/${name}`) : [];
|
|
85
|
-
[...frameworks, app].forEach((code) => {
|
|
86
|
-
const withEntitlements = code === app && entitlements ? ['--entitlements', entitlements, '--generate-entitlement-der'] : [];
|
|
87
|
-
const result = spawnSync('codesign', ['--force', '--sign', '-', '--timestamp=none', ...withEntitlements, code], { encoding: 'utf8' });
|
|
88
|
-
if (result.error || result.status !== 0) {
|
|
89
|
-
throw new Error(`crossbind: codesign could not sign ${code} ad hoc: ${(result.stderr || result.error?.message || '').trim()}`);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Archives the app of the iOS Xcode project crossbind wrote unsigned, then signs it ad hoc with the entitlements the
|
|
95
|
-
// project names. The upload signs it for distribution, so the archive needs no development certificate, profile, device
|
|
96
|
-
// or Apple account.
|
|
97
|
-
export function archiveIosApp(project, { facts }) {
|
|
98
|
-
const { name } = state.config.general;
|
|
99
|
-
const archive = `${storeDir()}/${name}.xcarchive`;
|
|
100
|
-
fs.rmSync(archive, { recursive: true, force: true });
|
|
101
|
-
fs.mkdirSync(storeDir(), { recursive: true });
|
|
102
|
-
xcodebuild('the archive', [
|
|
103
|
-
'archive', '-project', `${project}/${name}.xcodeproj`, '-scheme', name, '-configuration', 'Release', '-destination', 'generic/platform=iOS',
|
|
104
|
-
'-archivePath', archive, 'CODE_SIGNING_ALLOWED=NO',
|
|
105
|
-
], `${storeDir()}/archive.log`, facts);
|
|
106
|
-
signAdHoc(archivedApp(archive), projectEntitlements(project));
|
|
107
|
-
return archive;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export const archivedBinary = (archive) => `${archivedApp(archive)}/${state.config.general.name}`;
|
|
111
|
-
|
|
112
|
-
// The reasons the archived app's privacy manifest gives, by category: what App Store Connect reads, whatever the config
|
|
113
|
-
// the build read said.
|
|
114
|
-
export function archivedPrivacy(archive) {
|
|
115
|
-
const manifest = `${archivedApp(archive)}/PrivacyInfo.xcprivacy`;
|
|
116
|
-
if (!fs.existsSync(manifest)) return {};
|
|
117
|
-
const result = spawnSync('plutil', ['-convert', 'json', '-o', '-', manifest], { encoding: 'utf8' });
|
|
118
|
-
if (result.status !== 0) throw new Error(`crossbind: cannot read ${manifest}: ${result.stderr || result.error?.message}`);
|
|
119
|
-
const names = Object.fromEntries(Object.entries(PRIVACY_CATEGORIES).map(([name, { type }]) => [type, name]));
|
|
120
|
-
return Object.fromEntries((JSON.parse(result.stdout).NSPrivacyAccessedAPITypes ?? [])
|
|
121
|
-
.filter(({ NSPrivacyAccessedAPIType: type }) => Object.hasOwn(names, type))
|
|
122
|
-
.map(({ NSPrivacyAccessedAPIType: type, NSPrivacyAccessedAPITypeReasons: reasons }) => [names[type], reasons]));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Hermes comes prebuilt, so the archive has no dSYM of it, which App Store Connect asks for.
|
|
126
|
-
export function addHermesDsym(archive, dsym) {
|
|
127
|
-
fs.cpSync(dsym, `${archive}/dSYMs/hermesvm.framework.dSYM`, { recursive: true });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// The options of an export for App Store Connect: an upload with the app's dSYMs, or an .ipa alone.
|
|
131
|
-
function writeExportOptions(file, { destination, team, isBuildManaged }) {
|
|
132
|
-
fs.writeFileSync(file, xmlPlist({
|
|
133
|
-
destination,
|
|
134
|
-
manageAppVersionAndBuildNumber: isBuildManaged,
|
|
135
|
-
method: 'app-store-connect',
|
|
136
|
-
signingStyle: 'automatic',
|
|
137
|
-
teamID: team,
|
|
138
|
-
uploadSymbols: destination === 'upload',
|
|
139
|
-
}));
|
|
140
|
-
return file;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Registers the app's bundle id with Apple, as App Store Connect's form for a new app lists registered ids alone. An
|
|
144
|
-
// upload asks for the app record before it signs, so it registers nothing; an export to an .ipa, with no build number
|
|
145
|
-
// to look up, signs at once, which registers the id.
|
|
146
|
-
function registerBundleId(archive, { team, key, facts }) {
|
|
147
|
-
const options = writeExportOptions(`${storeDir()}/RegisterOptions.plist`, { destination: 'export', team, isBuildManaged: false });
|
|
148
|
-
xcodebuild('the registration of the bundle id', [
|
|
149
|
-
'-exportArchive', '-archivePath', archive, '-exportOptionsPlist', options, '-exportPath', `${storeDir()}/register`, '-allowProvisioningUpdates',
|
|
150
|
-
...keyArgs(key),
|
|
151
|
-
], `${storeDir()}/register.log`, facts);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Exports the archive for App Store Connect and uploads it with its dSYMs, signed for distribution in the cloud by Xcode's
|
|
155
|
-
// account or an Admin key, else with an Apple Distribution certificate in the keychain, with the entitlements of its ad
|
|
156
|
-
// hoc signature, which the App ID gets the capabilities of. Xcode numbers the build unless CROSSBIND_APP_BUILD_NUMBER
|
|
157
|
-
// did, and writes nothing to -exportPath, which xcodebuild asks for all the same.
|
|
158
|
-
export function uploadIosArchive(archive, { team, key, isBuildManaged, facts }) {
|
|
159
|
-
const options = writeExportOptions(`${storeDir()}/ExportOptions.plist`, { destination: 'upload', team, isBuildManaged });
|
|
160
|
-
let output;
|
|
161
|
-
try {
|
|
162
|
-
output = xcodebuild('the upload', [
|
|
163
|
-
'-exportArchive', '-archivePath', archive, '-exportOptionsPlist', options, '-exportPath', `${storeDir()}/export`, '-allowProvisioningUpdates',
|
|
164
|
-
...keyArgs(key),
|
|
165
|
-
], `${storeDir()}/upload.log`, facts);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
if (error.reason === MISSING_APP) {
|
|
168
|
-
console.log(`crossbind: App Store Connect has no app ${facts.id} yet, so this publish registers its bundle id with Apple first.`);
|
|
169
|
-
registerBundleId(archive, { team, key, facts });
|
|
170
|
-
}
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
// What App Store Connect warns of, as a dSYM it could not take, goes up with the app all the same.
|
|
174
|
-
output.split('\n').filter((line) => line.startsWith('warning: exportArchive')).forEach((line) => console.warn(`crossbind: ${line}`));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// The teams of the accounts signed in to Xcode that upload to App Store Connect, which a free personal team does not,
|
|
178
|
-
// from Xcode's settings; `withPersonal` adds those free teams, which sign for their own devices.
|
|
179
|
-
export function xcodeTeams({ withPersonal = false } = {}) {
|
|
180
|
-
const settings = spawnSync('defaults', ['export', 'com.apple.dt.Xcode', '-'], { maxBuffer: EXEC_MAX_BUFFER });
|
|
181
|
-
if (settings.status !== 0) return [];
|
|
182
|
-
const teams = spawnSync('plutil', ['-extract', 'IDEProvisioningTeamByIdentifier', 'json', '-o', '-', '-'], {
|
|
183
|
-
input: settings.stdout, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER,
|
|
184
|
-
});
|
|
185
|
-
if (teams.status !== 0) return [];
|
|
186
|
-
const signing = Object.values(JSON.parse(teams.stdout)).flat().filter((team) => withPersonal || !team.isFreeProvisioningTeam);
|
|
187
|
-
return [...new Map(signing.map((team) => [team.teamID, { id: team.teamID, name: team.teamName }])).values()];
|
|
188
|
-
}
|
package/src/actions/appUpdate.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import run from './run.js';
|
|
5
|
-
import state from '../state/index.js';
|
|
6
|
-
import { appId, appUpdater } from '../utils/appOptions.js';
|
|
7
|
-
|
|
8
|
-
// The private key that signs a desktop app's updates, named by the environment alone so that no secret reaches a file.
|
|
9
|
-
export const UPDATER_KEY = 'CROSSBIND_UPDATER_PRIVATE_KEY';
|
|
10
|
-
// The fixed DER around an Ed25519 key's 32 bytes, private and public.
|
|
11
|
-
const PRIVATE_DER = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
12
|
-
const PUBLIC_DER = Buffer.from('302a300506032b6570032100', 'hex');
|
|
13
|
-
|
|
14
|
-
const publicKeyOf = (privateKey) => crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(PUBLIC_DER.length);
|
|
15
|
-
|
|
16
|
-
// A new key pair: the public key for app.updater.publicKey, the private one for CROSSBIND_UPDATER_PRIVATE_KEY.
|
|
17
|
-
export function newUpdaterKey() {
|
|
18
|
-
const { privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
19
|
-
return {
|
|
20
|
-
publicKey: publicKeyOf(privateKey).toString('base64'),
|
|
21
|
-
privateKey: privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PRIVATE_DER.length).toString('base64'),
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// The key a build signs updates with, or null without CROSSBIND_UPDATER_PRIVATE_KEY. It has to be the private half of
|
|
26
|
-
// app.updater.publicKey, or the app would refuse what it signs.
|
|
27
|
-
export function updaterSigning(env = process.env) {
|
|
28
|
-
if (!env[UPDATER_KEY]) return null;
|
|
29
|
-
const updater = appUpdater(state.config);
|
|
30
|
-
if (!updater) throw new Error(`crossbind: ${UPDATER_KEY} is set, but crossbind.config.js has no app.updater, which tells an app where its updates are.`);
|
|
31
|
-
const seed = Buffer.from(env[UPDATER_KEY], 'base64');
|
|
32
|
-
if (seed.length !== 32) throw new Error(`crossbind: ${UPDATER_KEY} is no Ed25519 private key, which \`crossbind app updater-key\` prints.`);
|
|
33
|
-
const privateKey = crypto.createPrivateKey({ key: Buffer.concat([PRIVATE_DER, seed]), format: 'der', type: 'pkcs8' });
|
|
34
|
-
if (!publicKeyOf(privateKey).equals(updater.publicKey)) {
|
|
35
|
-
throw new Error(`crossbind: ${UPDATER_KEY} is not the private key of app.updater.publicKey, so the app would refuse the updates it signs.`);
|
|
36
|
-
}
|
|
37
|
-
return { privateKey, version: updater.version };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// What an update's signature covers: the app's id, the platform and arch it is for, its version and its file's SHA-512,
|
|
41
|
-
// so that no file passes for another app's, platform's or version's. The host checks the same text.
|
|
42
|
-
export function signedText(id, target, version, file) {
|
|
43
|
-
const hash = crypto.createHash('sha512').update(fs.readFileSync(file)).digest('hex');
|
|
44
|
-
return `crossbind update\n${id}\n${target}\n${version}\n${hash}\n`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// The file a desktop app updates itself from, beside it: a macOS app as a zip, which keeps its signature and Apple's
|
|
48
|
-
// ticket, a Windows app's setup program and a Linux app's tar.gz.
|
|
49
|
-
function updateFile(app, target) {
|
|
50
|
-
if (target.platform === 'win32') return `${app}-setup.exe`;
|
|
51
|
-
if (target.platform === 'linux') return `${app}.tar.gz`;
|
|
52
|
-
const zip = path.join(path.dirname(app), `${state.config.general.name}-darwin-${target.arch}.app.zip`);
|
|
53
|
-
fs.rmSync(zip, { force: true });
|
|
54
|
-
run(null, ['ditto', '-c', '-k', '--keepParent', app, zip], null, target);
|
|
55
|
-
return zip;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// With a key in the environment, the update a desktop app finds at app.updater.url, beside the app: its file, and the
|
|
59
|
-
// manifest that gives its version, the file's name and its signature, <name>-<platform>-<arch>.update.json.
|
|
60
|
-
export default function writeUpdate(app, target) {
|
|
61
|
-
const signing = updaterSigning();
|
|
62
|
-
if (!signing) return;
|
|
63
|
-
const file = updateFile(app, target);
|
|
64
|
-
const name = `${target.platform}-${target.arch}`;
|
|
65
|
-
const signature = crypto.sign(null, Buffer.from(signedText(appId(state.config), name, signing.version, file)), signing.privateKey).toString('base64');
|
|
66
|
-
const manifest = { version: signing.version, file: path.basename(file), signature };
|
|
67
|
-
fs.writeFileSync(path.join(path.dirname(app), `${state.config.general.name}-${name}.update.json`), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
68
|
-
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { createSign } from 'node:crypto';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { accessToken } from './googleSignIn.js';
|
|
7
|
-
import { readSecret, storeSecret } from '../utils/keychain.js';
|
|
8
|
-
|
|
9
|
-
// Google Play's credentials, which the environment or ~/.crossbind.json names: a service account's e-mail, whose token
|
|
10
|
-
// the user's gcloud login buys with no key, or its JSON key; or, from the environment alone, an access token made
|
|
11
|
-
// elsewhere, as CI makes one with no key.
|
|
12
|
-
export const PLAY = { key: 'CROSSBIND_GOOGLE_PLAY_KEY', serviceAccount: 'CROSSBIND_GOOGLE_PLAY_SERVICE_ACCOUNT', token: 'CROSSBIND_GOOGLE_PLAY_TOKEN' };
|
|
13
|
-
export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
|
14
|
-
// The keychain keeps the user's Google sign-in for Play under this service; on Windows, beside this path.
|
|
15
|
-
const SIGN_IN_SERVICE = 'crossbind google play';
|
|
16
|
-
const signInKey = () => path.join(os.homedir(), '.crossbind', 'google-play');
|
|
17
|
-
const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications';
|
|
18
|
-
const UPLOAD = 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications';
|
|
19
|
-
const IAM = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts';
|
|
20
|
-
// The longest a service account's token lasts.
|
|
21
|
-
const TOKEN_SECONDS = 3600;
|
|
22
|
-
// Play's answer for an app whose first release Play Console has not rolled out yet.
|
|
23
|
-
const DRAFT_APP = /status draft may be created on draft app/;
|
|
24
|
-
|
|
25
|
-
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
26
|
-
|
|
27
|
-
// A JSON key's own text, as CI's secrets give it, in place of its file's path.
|
|
28
|
-
const KEY_TEXT = /^\s*\{/;
|
|
29
|
-
|
|
30
|
-
// A service account key from its JSON, which `source` names where it is none, never repeating the text.
|
|
31
|
-
function parseKey(json, source, isText) {
|
|
32
|
-
let key;
|
|
33
|
-
try {
|
|
34
|
-
key = JSON.parse(json);
|
|
35
|
-
} catch {
|
|
36
|
-
throw new Error(`crossbind: ${source} ${isText ? 'holds' : 'is'} no JSON, so it is no service account key.`);
|
|
37
|
-
}
|
|
38
|
-
const missing = ['client_email', 'private_key', 'token_uri'].filter((field) => !key[field]);
|
|
39
|
-
if (missing.length) throw new Error(`crossbind: ${source} has no ${missing.join(', ')}, so it is no service account key.`);
|
|
40
|
-
return key;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function readKey(file) {
|
|
44
|
-
if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${PLAY.key} names ${file}, which is no file.`);
|
|
45
|
-
return parseKey(fs.readFileSync(file, 'utf8'), `${file} (${PLAY.key})`, false);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// The credentials Play takes from this environment and machine, or null without any: one of the three kinds.
|
|
49
|
-
export function playCredentials(env = process.env, system = {}) {
|
|
50
|
-
const file = env[PLAY.key] || system.GOOGLE_PLAY_KEY;
|
|
51
|
-
const account = env[PLAY.serviceAccount] || system.GOOGLE_PLAY_SERVICE_ACCOUNT;
|
|
52
|
-
const token = env[PLAY.token];
|
|
53
|
-
if (file && account) {
|
|
54
|
-
throw new Error(`crossbind: both ${PLAY.key} and ${PLAY.serviceAccount} are set, or their keys in ~/.crossbind.json: set one, the service account's e-mail to use your gcloud login, or its JSON key.`);
|
|
55
|
-
}
|
|
56
|
-
if (token && (file || account)) {
|
|
57
|
-
throw new Error(`crossbind: ${PLAY.token} is set, and so is ${file ? PLAY.key : PLAY.serviceAccount} or its key in ~/.crossbind.json: set one.`);
|
|
58
|
-
}
|
|
59
|
-
if (token) return { token };
|
|
60
|
-
if (account) return { account };
|
|
61
|
-
if (!file) return null;
|
|
62
|
-
if (KEY_TEXT.test(env[PLAY.key] ?? '')) {
|
|
63
|
-
const key = parseKey(env[PLAY.key], PLAY.key, true);
|
|
64
|
-
return { account: key.client_email, key, file: null };
|
|
65
|
-
}
|
|
66
|
-
const key = readKey(file);
|
|
67
|
-
return { account: key.client_email, key, file };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The user's Google sign-in for Play that this machine keeps, or null where it keeps none or Google no longer takes it.
|
|
71
|
-
export async function keptSignIn() {
|
|
72
|
-
const refreshToken = readSecret(signInKey(), { service: SIGN_IN_SERVICE });
|
|
73
|
-
if (!refreshToken) return null;
|
|
74
|
-
try {
|
|
75
|
-
await accessToken(refreshToken);
|
|
76
|
-
} catch (error) {
|
|
77
|
-
if (error.reason !== 'invalid_grant') throw error;
|
|
78
|
-
console.log('crossbind: Google no longer takes the sign-in this machine keeps for Google Play, as when it was revoked.');
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
return { refreshToken };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Keeps the user's Google sign-in for Play in the system's keychain, and says whether it did.
|
|
85
|
-
export function keepSignIn(refreshToken) {
|
|
86
|
-
fs.mkdirSync(path.dirname(signInKey()), { recursive: true });
|
|
87
|
-
return storeSecret(signInKey(), refreshToken, { service: SIGN_IN_SERVICE });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function keyToken({ account, key }) {
|
|
91
|
-
const now = Math.floor(Date.now() / 1000);
|
|
92
|
-
const unsigned = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ iss: account, scope: PLAY_SCOPE, aud: key.token_uri, iat: now, exp: now + TOKEN_SECONDS })}`;
|
|
93
|
-
const signature = createSign('RSA-SHA256').update(unsigned).sign(key.private_key).toString('base64url');
|
|
94
|
-
const response = await fetch(key.token_uri, {
|
|
95
|
-
method: 'POST',
|
|
96
|
-
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${unsigned}.${signature}` }),
|
|
97
|
-
});
|
|
98
|
-
const body = await response.json().catch(() => ({}));
|
|
99
|
-
if (!response.ok) throw new Error(`crossbind: Google refused the token of ${account}: ${response.status} ${body.error ?? ''} ${body.error_description ?? ''}`.trim());
|
|
100
|
-
return body.access_token;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// The user's own gcloud login buys an hour's token of the service account, so no key of it exists anywhere.
|
|
104
|
-
async function accountToken({ account }) {
|
|
105
|
-
const own = spawnSync('gcloud', ['auth', 'print-access-token'], { encoding: 'utf8' });
|
|
106
|
-
if (own.error || own.status !== 0) {
|
|
107
|
-
throw new Error(`crossbind: gcloud gave no token of yours: sign in with gcloud auth login, or name the service account's JSON key with ${PLAY.key} instead.\n${(own.stderr || own.error?.message || '').trim()}`);
|
|
108
|
-
}
|
|
109
|
-
const response = await fetch(`${IAM}/${account}:generateAccessToken`, {
|
|
110
|
-
method: 'POST',
|
|
111
|
-
headers: { authorization: `Bearer ${own.stdout.trim()}`, 'content-type': 'application/json' },
|
|
112
|
-
body: JSON.stringify({ scope: [PLAY_SCOPE] }),
|
|
113
|
-
});
|
|
114
|
-
const body = await response.json().catch(() => ({}));
|
|
115
|
-
if (!response.ok) {
|
|
116
|
-
throw new Error(`crossbind: Google gave no token of ${account}: ${response.status} ${body.error?.message ?? ''}\nyour gcloud account needs the Service Account Token Creator role on it, and its project the IAM Service Account Credentials API.`);
|
|
117
|
-
}
|
|
118
|
-
return body.accessToken;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// An hour's token of Play's API: the one from the environment, or one for the user's Google sign-in or for the service
|
|
122
|
-
// account.
|
|
123
|
-
export async function playToken(credentials) {
|
|
124
|
-
if (credentials.token) return credentials.token;
|
|
125
|
-
if (credentials.refreshToken) return accessToken(credentials.refreshToken);
|
|
126
|
-
return credentials.key ? keyToken(credentials) : accountToken(credentials);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function playError(status, message, method, url, appId, credentials) {
|
|
130
|
-
const route = new URL(url).pathname.replace(/^.*\/applications\/[^/]+/, '') || '/';
|
|
131
|
-
let text = `crossbind: Google Play answered ${method} ${route} with ${status}: ${message}`;
|
|
132
|
-
if (status === 404 && route === '/edits') {
|
|
133
|
-
text = `crossbind: Play Console has no app ${appId}: create it there, with ${appId} as its package name, as Play's API makes none.`;
|
|
134
|
-
} else if (status === 401 && credentials.token) {
|
|
135
|
-
text = `crossbind: Google Play takes ${PLAY.token} no more: it has expired, as access tokens do after an hour, or it is no access token; make it in the publish's own job.`;
|
|
136
|
-
} else if (status === 403 && credentials.token && /insufficient authentication scopes/i.test(message)) {
|
|
137
|
-
text = `crossbind: ${PLAY.token} lacks the scope ${PLAY_SCOPE}, which Google Play's API takes: ask for it where the token is made, as google-github-actions/auth's access_token_scopes does.`;
|
|
138
|
-
} else if (status === 403 && credentials.refreshToken) {
|
|
139
|
-
text = `crossbind: the Google account you signed in with has no release rights for ${appId}: sign in with one that has, or have it invited in Play Console, under Users and permissions, with "Release apps to testing tracks" for the app.`;
|
|
140
|
-
} else if (status === 403) {
|
|
141
|
-
const account = credentials.token ? `the account behind ${PLAY.token}` : credentials.account;
|
|
142
|
-
text = `crossbind: ${account} has no release rights for ${appId}: invite it in Play Console, under Users and permissions, with "Release apps to testing tracks" for the app.`;
|
|
143
|
-
}
|
|
144
|
-
return Object.assign(new Error(text), { status, playMessage: message });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// A caller of Play's API for one app, whose failures say what to do where they can.
|
|
148
|
-
async function playCaller(appId, credentials) {
|
|
149
|
-
const token = await playToken(credentials);
|
|
150
|
-
return async (method, url, body) => {
|
|
151
|
-
const isBinary = Buffer.isBuffer(body);
|
|
152
|
-
const response = await fetch(url, {
|
|
153
|
-
method,
|
|
154
|
-
headers: { authorization: `Bearer ${token}`, 'content-type': isBinary ? 'application/octet-stream' : 'application/json' },
|
|
155
|
-
body: isBinary || body === undefined ? body : JSON.stringify(body),
|
|
156
|
-
});
|
|
157
|
-
const text = await response.text();
|
|
158
|
-
if (response.ok) return text ? JSON.parse(text) : {};
|
|
159
|
-
let message = text;
|
|
160
|
-
try {
|
|
161
|
-
message = JSON.parse(text).error?.message ?? text;
|
|
162
|
-
} catch {
|
|
163
|
-
// Not Google's JSON error, so the text is the message.
|
|
164
|
-
}
|
|
165
|
-
throw playError(response.status, message, method, url, appId, credentials);
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// The highest versionCode Play has seen of an app, in its bundles, its APKs and its tracks' releases; 0 for none.
|
|
170
|
-
export async function highestVersionCode(appId, credentials) {
|
|
171
|
-
const call = await playCaller(appId, credentials);
|
|
172
|
-
const edits = `${API}/${appId}/edits`;
|
|
173
|
-
const edit = await call('POST', edits, {});
|
|
174
|
-
try {
|
|
175
|
-
const { tracks = [] } = await call('GET', `${edits}/${edit.id}/tracks`);
|
|
176
|
-
const { bundles = [] } = await call('GET', `${edits}/${edit.id}/bundles`);
|
|
177
|
-
const { apks = [] } = await call('GET', `${edits}/${edit.id}/apks`);
|
|
178
|
-
const released = tracks.flatMap(({ releases = [] }) => releases.flatMap(({ versionCodes = [] }) => versionCodes));
|
|
179
|
-
return Math.max(0, ...[...bundles, ...apks].map(({ versionCode }) => versionCode), ...released.map(Number));
|
|
180
|
-
} finally {
|
|
181
|
-
await call('DELETE', `${edits}/${edit.id}`).catch(() => {});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Uploads a bundle, with the zip of its native debug symbols if there is one, and releases it on a track, as status
|
|
186
|
-
// says, in one edit. An app never released takes a draft alone, which its first rollout in Play Console makes live, so
|
|
187
|
-
// its release becomes one.
|
|
188
|
-
export async function uploadBundle(appId, credentials, { aab, symbols = null, track, status }) {
|
|
189
|
-
const call = await playCaller(appId, credentials);
|
|
190
|
-
const edits = `${API}/${appId}/edits`;
|
|
191
|
-
const edit = await call('POST', edits, {});
|
|
192
|
-
try {
|
|
193
|
-
const { versionCode } = await call('POST', `${UPLOAD}/${appId}/edits/${edit.id}/bundles?uploadType=media`, fs.readFileSync(aab));
|
|
194
|
-
if (symbols) {
|
|
195
|
-
await call('POST', `${UPLOAD}/${appId}/edits/${edit.id}/apks/${versionCode}/deobfuscationFiles/nativeCode?uploadType=media`, fs.readFileSync(symbols));
|
|
196
|
-
}
|
|
197
|
-
const release = async (releaseStatus) => {
|
|
198
|
-
await call('PUT', `${edits}/${edit.id}/tracks/${track}`, { track, releases: [{ versionCodes: [String(versionCode)], status: releaseStatus }] });
|
|
199
|
-
await call('POST', `${edits}/${edit.id}:commit`);
|
|
200
|
-
return { versionCode, status: releaseStatus };
|
|
201
|
-
};
|
|
202
|
-
try {
|
|
203
|
-
return await release(status);
|
|
204
|
-
} catch (error) {
|
|
205
|
-
if (status === 'draft' || !DRAFT_APP.test(error.playMessage ?? '')) throw error;
|
|
206
|
-
return await release('draft');
|
|
207
|
-
}
|
|
208
|
-
} catch (error) {
|
|
209
|
-
// An edit left open expires by itself; the first failure is the one to report.
|
|
210
|
-
await call('DELETE', `${edits}/${edit.id}`).catch(() => {});
|
|
211
|
-
throw error;
|
|
212
|
-
}
|
|
213
|
-
}
|