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.
@@ -30,8 +30,10 @@ import findFiles from '../utils/findFiles.js';
30
30
  import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
31
31
  import { xmlPlist } from '../utils/plist.js';
32
32
  import {
33
- appBackgroundColor, appCategory, appCopyright, appDisplayName, appHeader, appId, appPermissions, appUrlSchemes, usageDescriptions,
33
+ appBackgroundColor, appCategory, appCopyright, appDisplayName, appEncryption, appHeader, appId, appPermissions, appPrivacy, appUrlSchemes,
34
+ usageDescriptions,
34
35
  } from '../utils/appOptions.js';
36
+ import { unreasonedApis, unreasonedWarning } from '../utils/requiredReasonApis.js';
35
37
  import { androidIcons, iosIcon, linuxIcon, macIcon, readAppIcon, windowsIcon } from '../utils/appIcon.js';
36
38
  import writeIfChanged from '../utils/writeIfChanged.js';
37
39
  import { writeNativeSourceStamp } from '../utils/nativeSourceStamp.js';
@@ -78,7 +80,8 @@ export function assertAppRunner(target) {
78
80
 
79
81
  // The app a bundler builds: this machine's desktop app, or with CROSSBIND_APP_PLATFORM an iOS app for both SDKs, an
80
82
  // Android app for both ABIs, or a Linux or Windows app for both arches, which builds in Docker on any machine. During
81
- // development only what runs it: the Simulator, the attached device, or this machine.
83
+ // development only what runs it: the Simulator, or the iPhone or iPad CROSSBIND_APP_DEVICE names, the attached Android
84
+ // device, or this machine.
82
85
  export function appTargetParams({ dev = false } = {}, env = process.env) {
83
86
  const platform = env.CROSSBIND_APP_PLATFORM;
84
87
  const app = (platforms, archs) => {
@@ -107,11 +110,12 @@ export function appTargetParams({ dev = false } = {}, env = process.env) {
107
110
  throw new Error(`crossbind: CROSSBIND_APP_PLATFORM=${platform} names no platform an app builds for. Set it to android, ios, linux, macos or windows, or leave it unset for this machine's desktop app.`);
108
111
  }
109
112
  if (process.platform !== 'darwin') throw new Error('crossbind: iOS apps build on macOS, with Xcode.');
110
- return getTargetParams({ platform: ['ios'], arch: dev ? ['iphonesimulator'] : IOS_SDKS, runtime: ['mt'], runtimeEnv: ['app'] }, true);
113
+ const devSdk = env.CROSSBIND_APP_DEVICE ? 'iphoneos' : 'iphonesimulator';
114
+ return getTargetParams({ platform: ['ios'], arch: dev ? [devSdk] : IOS_SDKS, runtime: ['mt'], runtimeEnv: ['app'] }, true);
111
115
  }
112
116
 
113
117
  // The Apple team that signs an iOS app: the project's app.team, else the machine's XCODE_DEVELOPMENT_TEAM, else none.
114
- function appTeam() {
118
+ export function appTeam() {
115
119
  const team = state.config.app.team ?? state.config.system.XCODE_DEVELOPMENT_TEAM ?? '';
116
120
  if (team && !/^[A-Z0-9]{10}$/.test(team)) {
117
121
  throw new Error(`crossbind: ${JSON.stringify(team)} (app.team or XCODE_DEVELOPMENT_TEAM) is no Apple team id, which is ten capital letters and digits.`);
@@ -119,22 +123,41 @@ function appTeam() {
119
123
  return team;
120
124
  }
121
125
 
126
+ // An Android application id: dot-separated parts of letters, digits and underscores that each start with a letter.
127
+ const ANDROID_APP_ID = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
128
+
122
129
  // An Android app's id: the project's app.id, else one made from the project's name.
123
- function androidAppId() {
130
+ export function androidAppId() {
124
131
  const id = state.config.app.id ?? `dev.crossbind.${state.config.general.name.replace(/[^A-Za-z0-9.]/g, '_')}`;
125
- if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(id)) {
132
+ if (!ANDROID_APP_ID.test(id)) {
126
133
  throw new Error(`crossbind: ${JSON.stringify(id)} (app.id, else made from the project's name) is no Android application id, which takes dot-separated parts of letters, digits and underscores that each start with a letter, as com.example.app does. Set app.id in crossbind.config.js.`);
127
134
  }
128
135
  return id;
129
136
  }
130
137
 
131
138
  // Apple takes dot-separated integers alone as an app's version.
132
- const appVersion = () => (state.config.package?.version ?? '1.0.0').replace(/[-+].*$/, '');
139
+ export const appVersion = () => (state.config.package?.version ?? '1.0.0').replace(/[-+].*$/, '');
140
+
141
+ const BUILD_NUMBER = 'CROSSBIND_APP_BUILD_NUMBER';
142
+ // Google Play takes no larger version code.
143
+ const MAX_VERSION_CODE = 2100000000;
144
+
145
+ // The number CI or a publish gives a build, which iOS takes as its build and Android as its version code:
146
+ // CROSSBIND_APP_BUILD_NUMBER, a whole number both stores take, else none.
147
+ export function appBuildNumber(env = process.env) {
148
+ const value = env[BUILD_NUMBER];
149
+ if (!value) return null;
150
+ if (!/^[1-9]\d*$/.test(value) || Number(value) > MAX_VERSION_CODE) {
151
+ throw new Error(`crossbind: ${BUILD_NUMBER} ${JSON.stringify(value)} is no build number, which is a whole number from 1 to ${MAX_VERSION_CODE}, as both stores take.`);
152
+ }
153
+ return Number(value);
154
+ }
133
155
 
134
- // Android takes any name for an app's version, and a positive integer that grows from release to release as its code.
135
- function androidVersion() {
156
+ // Android takes any name for an app's version, and a positive integer that grows from release to release as its code:
157
+ // the build number, else one made of the version's numbers.
158
+ export function androidVersion(env = process.env) {
136
159
  const [major = 0, minor = 0, patch = 0] = appVersion().split('.').map(Number);
137
- return { name: state.config.package?.version ?? '1.0.0', code: Math.max(1, major * 1000000 + minor * 1000 + patch) };
160
+ return { name: state.config.package?.version ?? '1.0.0', code: appBuildNumber(env) ?? Math.max(1, major * 1000000 + minor * 1000 + patch) };
138
161
  }
139
162
 
140
163
  // The apps this run built or found current. A part of an iOS app that an earlier run built may be stale.
@@ -260,13 +283,16 @@ function assembleIosPart(target, { library, linkArgs, hbc, hermes }) {
260
283
  }
261
284
 
262
285
  // What an Android ABI target brings to the app's Gradle project: the host, the libraries beside it and the packages'
263
- // shared libraries it links, and the app's files.
286
+ // shared libraries it links, and the app's files. A release's host build left the packages' libraries stripped beside
287
+ // the host, and every library as linked in symbols/.
264
288
  function assembleAndroidPart(target, { libDir, sharedLibs, hbc }) {
265
289
  const part = `${state.config.paths.build}/${target.appName}`;
266
290
  fs.rmSync(part, { recursive: true, force: true });
267
291
  fs.mkdirSync(`${part}/lib`, { recursive: true });
268
- [...['libcrossbind_app.so', 'libjsi.so', 'libc++_shared.so'].map((file) => `${libDir}/${file}`), ...sharedLibs]
292
+ const shipped = sharedLibs.map((file) => (fs.existsSync(`${libDir}/${path.basename(file)}`) ? `${libDir}/${path.basename(file)}` : file));
293
+ [...['libcrossbind_app.so', 'libjsi.so', 'libc++_shared.so'].map((file) => `${libDir}/${file}`), ...shipped]
269
294
  .forEach((file) => fs.copyFileSync(file, `${part}/lib/${path.basename(file)}`));
295
+ if (fs.existsSync(`${libDir}/symbols`)) fs.cpSync(`${libDir}/symbols`, `${part}/symbols`, { recursive: true });
270
296
  copyAppFiles(`${part}/app`, hbc, target);
271
297
  }
272
298
 
@@ -327,6 +353,34 @@ function shipMacApp(app, signing) {
327
353
  else console.warn(`crossbind: ${path.basename(app)} is signed ad hoc, which opens it on this Mac alone. Set ${APPLE_SIGNING.identity} to sign it with your Developer ID, and ${notarize}.`);
328
354
  }
329
355
 
356
+ // App Store Connect refuses an iOS app whose archives call required reason API without app.privacy's reasons; the build
357
+ // names them, from the archives a device gets.
358
+ function warnUnreasonedApis(project, privacy) {
359
+ const device = `${project}/lib/iphoneos`;
360
+ if (!fs.existsSync(device)) return;
361
+ const archives = fs.readdirSync(device).filter((file) => file.endsWith('.a')).map((file) => `${device}/${file}`);
362
+ const warning = unreasonedWarning(unreasonedApis(archives, privacy));
363
+ if (warning) console.warn(warning);
364
+ }
365
+
366
+ // Where publishApp last put this project's iOS and Android apps, for `crossbind app publish`, which runs Vite and so
367
+ // cannot know the outDir it builds into.
368
+ const appOutputsFile = () => `${state.config.paths.build}/app-outputs.json`;
369
+ const readAppOutputs = () => (fs.existsSync(appOutputsFile()) ? JSON.parse(fs.readFileSync(appOutputsFile(), 'utf8')) : {});
370
+
371
+ export const clearAppOutputs = () => fs.rmSync(appOutputsFile(), { force: true });
372
+
373
+ export function appOutputOf(platform) {
374
+ const output = readAppOutputs()[platform];
375
+ if (!output) throw new Error(`crossbind: the build wrote no ${platform} app, so there is nothing to publish.`);
376
+ return output;
377
+ }
378
+
379
+ function recordAppOutput(platform, to) {
380
+ fs.mkdirSync(state.config.paths.build, { recursive: true });
381
+ fs.writeFileSync(appOutputsFile(), `${JSON.stringify({ ...readAppOutputs(), [platform]: path.resolve(to) }, null, 4)}\n`);
382
+ }
383
+
330
384
  // Where a build without a bundler puts an app: under its own name, or for iOS and Android as the project of its build
331
385
  // type.
332
386
  export function appOutputPath(target) {
@@ -342,10 +396,14 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
342
396
  if (target.platform === 'ios') {
343
397
  const parts = appParts(target).map(({ arch, dir }) => ({ sdk: arch, dir }));
344
398
  const icon = readAppIcon(state.config);
399
+ const privacy = appPrivacy(state.config);
400
+ const build = appBuildNumber();
345
401
  writeXcodeProject(to, {
346
- parts, identity: { id: appId(state.config), team: appTeam() }, label: appDisplayName(state.config), version: appVersion(), icon: icon && iosIcon(icon),
347
- urlSchemes: appUrlSchemes(state.config), background: appBackgroundColor(state.config), usage: usageDescriptions(state.config, 'ios'),
402
+ parts, identity: { id: appId(state.config), team: appTeam() }, label: appDisplayName(state.config), version: appVersion(),
403
+ build: build === null ? appVersion() : String(build), icon: icon && iosIcon(icon), urlSchemes: appUrlSchemes(state.config),
404
+ background: appBackgroundColor(state.config), usage: usageDescriptions(state.config, 'ios'), encryption: appEncryption(state.config), privacy,
348
405
  });
406
+ if (target.buildType === 'release') warnUnreasonedApis(to, privacy);
349
407
  } else if (target.platform === 'android') {
350
408
  const icon = readAppIcon(state.config);
351
409
  writeGradleProject(to, {
@@ -373,7 +431,10 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
373
431
  if (page) shipMacApp(to, signing);
374
432
  } else if (target.platform === 'android' && target.buildType === 'release') {
375
433
  packageAndroidRelease(to);
376
- } else if (!['ios', 'android'].includes(target.platform)) {
434
+ }
435
+ if (['ios', 'android'].includes(target.platform)) {
436
+ recordAppOutput(target.platform, to);
437
+ } else if (target.platform !== 'darwin') {
377
438
  if (target.platform === 'win32') signWindowsApp(to, target, page);
378
439
  archiveApp(to, target);
379
440
  }
@@ -418,6 +479,7 @@ export default async function buildApp(target, { bridges = [], force = false } =
418
479
  inputs: headerDirs.flatMap((dir) => (fs.existsSync(dir) ? findFiles('**/*.h', { cwd: dir }) : [dir])),
419
480
  });
420
481
  const { libs, linkArgs, extraSources } = linkInputs(target, { bridge: BRIDGE });
482
+ const sharedLibs = libs.filter((lib) => lib.endsWith('.so'));
421
483
 
422
484
  const fingerprintFile = `${build}/${target.appName}.fingerprint`;
423
485
  const fingerprint = getContentHash(JSON.stringify({
@@ -474,6 +536,8 @@ export default async function buildApp(target, { bridges = [], force = false } =
474
536
  `-DCROSSBIND_EXTRA_SOURCES=${extraSources.join(';')}`,
475
537
  `-DCROSSBIND_LINK_ARGS=${linkArgs.join(';')}`,
476
538
  `-DCROSSBIND_LINK_DEPENDS=${libs.join(';')}`,
539
+ // An Android release ships the packages' shared libraries stripped as well, and keeps them as they come.
540
+ ...(target.platform === 'android' ? [`-DCROSSBIND_SHARED_LIBS=${sharedLibs.join(';')}`] : []),
477
541
  ...cmakeArgs,
478
542
  ], platformPrefix, target);
479
543
  run(null, ['cmake', '--build', '.', '-j', String(cpuCount)], platformPrefix, target, { exclusive: true });
@@ -490,7 +554,7 @@ export default async function buildApp(target, { bridges = [], force = false } =
490
554
  if (target.platform === 'ios') {
491
555
  assembleIosPart(target, { library: `${workDir}/lib${name}.a`, linkArgs, hbc: `${workDir}/host.hbc`, hermes });
492
556
  } else if (target.platform === 'android') {
493
- assembleAndroidPart(target, { libDir: workDir, sharedLibs: libs.filter((lib) => lib.endsWith('.so')), hbc: `${workDir}/host.hbc` });
557
+ assembleAndroidPart(target, { libDir: workDir, sharedLibs, hbc: `${workDir}/host.hbc` });
494
558
  } else {
495
559
  assembleApp(target, { executable: `${workDir}/${path.basename(appLayout(target).executable)}`, hbc: `${workDir}/host.hbc`, hermes, icon });
496
560
  }
@@ -11,7 +11,9 @@ import state from '../state/index.js';
11
11
  import logger from '../utils/logger.js';
12
12
  import { WASI_EMULATION_LIBS, WASI_LINK_LIBS, wasiCFlags, wasiCxxFlags } from '../utils/wasiToolchain.js';
13
13
  import { stageWasiHttpInclude, wasiHttpHeader } from '../utils/wasiHttp.js';
14
- import { appApiHeader, compilesAsApp, stageAppApiInclude } from '../utils/appApiHeader.js';
14
+ import {
15
+ appApiHeader, appCompileOptions, compilesAsApp, stageAppApiInclude,
16
+ } from '../utils/appApiHeader.js';
15
17
  import { getFilesFingerprint, getContentHash } from '../utils/hash.js';
16
18
  import { withDependencyBridges } from '../utils/dependencyBridges.js';
17
19
  import { targetArchiveFlags } from '../utils/archiveFlags.js';
@@ -66,8 +68,8 @@ export default function createLib(target, fileType, options = {}) {
66
68
  : null;
67
69
  // An Android app links the project's archives from the build where React Native's shared library comes from.
68
70
  const appArchives = target.platform === 'android' && target.runtimeEnv === 'app' ? 'archives' : null;
69
- // An app's own code compiles against <crossbind/app.h>, with CROSSBIND_APP defined.
70
- const appApi = compilesAsApp(target) ? getFilesFingerprint([appApiHeader()]) : null;
71
+ // An app's own code compiles against <crossbind/app.h>, with the options of an app.
72
+ const appApi = compilesAsApp(target) ? [getFilesFingerprint([appApiHeader()]), ...appCompileOptions(target)] : null;
71
73
  const extraKeys = [httpHeader, appArchives, appApi].filter(Boolean);
72
74
  const flagsFingerprint = getContentHash(JSON.stringify(extraKeys.length ? [keyInputs, ...extraKeys] : keyInputs));
73
75
  const flagsChanged = !fs.existsSync(flagsFingerprintFile)
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import state from '../state/index.js';
3
3
  import getData from './getData.js';
4
- import { compilesAsApp } from '../utils/appApiHeader.js';
4
+ import { appCompileOptions } from '../utils/appApiHeader.js';
5
5
 
6
6
  export default function getCmakeParameters(target, options = {}) {
7
7
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
@@ -39,8 +39,7 @@ export default function getCmakeParameters(target, options = {}) {
39
39
  const otherBuildType = buildType === 'STATIC' ? 'SHARED' : 'STATIC';
40
40
 
41
41
  // An app's own code can tell it is one, to reach <crossbind/app.h>.
42
- const appOptions = compilesAsApp(target) ? ['-DCROSSBIND_APP'] : [];
43
- const cmakeCompileOptions = [...new Set([...(getData('cmake', target)?.compileOptions || []), ...appOptions])];
42
+ const cmakeCompileOptions = [...new Set([...(getData('cmake', target)?.compileOptions || []), ...appCompileOptions(target)])];
44
43
 
45
44
  params.push(...[
46
45
  `-DPROJECT_NAME=${options.name || state.config.general.name}`,
@@ -0,0 +1,213 @@
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
+ }
@@ -0,0 +1,78 @@
1
+ import http from 'node:http';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import openUrl from '../utils/openUrl.js';
4
+
5
+ // crossbind's own OAuth client, a desktop app's. Google counts a desktop app's secret as public, as every copy of the app
6
+ // carries it, and PKCE ties each sign-in to the crossbind that started it. The secret is split only so that secret
7
+ // scanners do not take it for a leaked one.
8
+ const CLIENT_ID = '1070398855248-kemr8k5u0k8l2ovuvql32e220fhap165.apps.googleusercontent.com';
9
+ const CLIENT_SECRET = ['GOCSPX', '5w7qGQktYssQg9C5ClWs4x0hZeHV'].join('-');
10
+ const AUTHORIZE = 'https://accounts.google.com/o/oauth2/v2/auth';
11
+ const TOKEN = 'https://oauth2.googleapis.com/token';
12
+
13
+ const randomText = (bytes) => randomBytes(bytes).toString('base64url');
14
+
15
+ async function tokens(grant) {
16
+ const response = await fetch(TOKEN, { method: 'POST', body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, ...grant }) });
17
+ const body = await response.json().catch(() => ({}));
18
+ if (!response.ok) {
19
+ const reason = `${response.status} ${body.error ?? ''} ${body.error_description ?? ''}`.trim();
20
+ throw Object.assign(new Error(`crossbind: Google refused crossbind's sign-in: ${reason}`), { reason: body.error });
21
+ }
22
+ return body;
23
+ }
24
+
25
+ // The code Google's page sends the browser back to this machine with once the user has signed in. A request without this
26
+ // sign-in's state is no answer of Google's to it, and is turned away.
27
+ function codeFrom(server, state) {
28
+ return new Promise((resolve, reject) => {
29
+ server.on('request', (request, response) => {
30
+ const query = new URL(request.url, 'http://127.0.0.1').searchParams;
31
+ if (query.get('state') !== state) {
32
+ response.writeHead(400, { connection: 'close' }).end();
33
+ return;
34
+ }
35
+ const code = query.get('code');
36
+ response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' })
37
+ .end(code ? 'crossbind is signed in. Close this tab and go back to the terminal.' : 'crossbind is not signed in. Go back to the terminal.');
38
+ if (code) resolve(code);
39
+ else reject(new Error(`crossbind: Google's sign-in ended with ${query.get('error') ?? 'no code'}.`));
40
+ });
41
+ });
42
+ }
43
+
44
+ // Signs the user in with their Google account, in their browser, for scope: Google's page sends the browser back to a
45
+ // port of this machine. It resolves to Google's tokens, whose scope says what the user granted, as the page lets them
46
+ // leave a scope out.
47
+ export async function signIn(scope) {
48
+ const server = http.createServer();
49
+ await new Promise((resolve, reject) => {
50
+ server.once('error', reject);
51
+ server.listen(0, '127.0.0.1', resolve);
52
+ });
53
+ const verifier = randomText(32);
54
+ const state = randomText(16);
55
+ const redirect = `http://127.0.0.1:${server.address().port}`;
56
+ const page = `${AUTHORIZE}?${new URLSearchParams({
57
+ client_id: CLIENT_ID,
58
+ redirect_uri: redirect,
59
+ response_type: 'code',
60
+ scope,
61
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
62
+ code_challenge_method: 'S256',
63
+ state,
64
+ prompt: 'select_account',
65
+ })}`;
66
+ console.log(`crossbind: opening Google's sign-in in your browser; if it does not open, go to ${page}`);
67
+ openUrl(page);
68
+ try {
69
+ const code = await codeFrom(server, state);
70
+ return await tokens({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirect });
71
+ } finally {
72
+ server.close();
73
+ }
74
+ }
75
+
76
+ // An hour's access token for the refresh token of an earlier sign-in. Google refuses one revoked or expired, with the
77
+ // reason invalid_grant.
78
+ export const accessToken = async (refreshToken) => (await tokens({ grant_type: 'refresh_token', refresh_token: refreshToken })).access_token;
@@ -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]) => {