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
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { secretFile } from '../utils/secretFile.js';
|
|
5
6
|
|
|
6
7
|
// The Developer ID that signs a macOS app and the App Store Connect API key Apple notarizes it with, named by the
|
|
7
8
|
// environment alone so that no secret reaches a file.
|
|
@@ -12,6 +13,10 @@ export const APPLE_SIGNING = {
|
|
|
12
13
|
issuer: 'CROSSBIND_APPLE_API_ISSUER',
|
|
13
14
|
};
|
|
14
15
|
const NOTARY = [APPLE_SIGNING.key, APPLE_SIGNING.keyId, APPLE_SIGNING.issuer];
|
|
16
|
+
// App Store Connect gives a key's file as AuthKey_<key id>.p8, so the file's own name tells its ID.
|
|
17
|
+
const KEY_FILE = /^AuthKey_([A-Z0-9]{10})\.p8$/;
|
|
18
|
+
// A key's own text, as CI's secrets give it, in place of its file's path.
|
|
19
|
+
const KEY_TEXT = /^\s*-----BEGIN /;
|
|
15
20
|
|
|
16
21
|
const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
|
|
17
22
|
|
|
@@ -30,16 +35,32 @@ function signingIdentities() {
|
|
|
30
35
|
.map(([, hash, name]) => ({ hash, name }));
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
// The App Store Connect API key the environment names, with which Apple notarizes a macOS app and xcodebuild uploads an
|
|
39
|
+
// iOS one in place of Xcode's account, or null: all of it or none, but for its ID where its file's name tells it. The key
|
|
40
|
+
// is its file, or its text, which goes to a file as the tools take files.
|
|
41
|
+
export function appStoreKey(env = process.env) {
|
|
42
|
+
const set = NOTARY.filter((name) => env[name]);
|
|
43
|
+
if (!set.length) return null;
|
|
44
|
+
const given = env[APPLE_SIGNING.key] ?? '';
|
|
45
|
+
const isText = KEY_TEXT.test(given);
|
|
46
|
+
const keyId = env[APPLE_SIGNING.keyId] || (isText ? undefined : path.basename(given).match(KEY_FILE)?.[1]);
|
|
47
|
+
const missing = NOTARY.filter((name) => !env[name] && !(name === APPLE_SIGNING.keyId && keyId));
|
|
48
|
+
if (missing.length) {
|
|
49
|
+
const byName = missing.includes(APPLE_SIGNING.keyId) && !isText ? ', the ID unless its file keeps the name App Store Connect gives it, AuthKey_<key id>.p8' : '';
|
|
50
|
+
throw new Error(`crossbind: ${listed(set)} set, but ${listed(missing)} not: an App Store Connect API key takes all three${byName}.`);
|
|
51
|
+
}
|
|
52
|
+
const key = isText ? secretFile('AuthKey.p8', `${given.trim()}\n`) : path.resolve(given);
|
|
53
|
+
if (!fs.statSync(key, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${APPLE_SIGNING.key} names ${key}, which is no file.`);
|
|
54
|
+
return { key, keyId, issuer: env[APPLE_SIGNING.issuer] };
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
// The identity a macOS app is signed with and the key Apple notarizes it with, or null without an identity: the app is
|
|
34
|
-
// then signed ad hoc, which opens it on the Mac that built it alone
|
|
35
|
-
// renewed certificates of one name do not share.
|
|
58
|
+
// then signed ad hoc, which opens it on the Mac that built it alone, even with the key, which iOS uploads take too.
|
|
59
|
+
// codesign takes the identity's hash, which the renewed certificates of one name do not share.
|
|
36
60
|
export function appleSigning(env = process.env) {
|
|
37
|
-
const notary = NOTARY.filter((name) => env[name]);
|
|
38
61
|
const identity = env[APPLE_SIGNING.identity];
|
|
39
|
-
if (!identity)
|
|
40
|
-
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
62
|
+
if (!identity) return null;
|
|
63
|
+
const notary = appStoreKey(env);
|
|
43
64
|
const found = signingIdentities().filter(({ hash, name }) => hash === identity.toUpperCase() || name.includes(identity));
|
|
44
65
|
const matches = [...new Map(found.map((match) => [match.hash, match])).values()];
|
|
45
66
|
if (!matches.length) throw new Error(`crossbind: ${APPLE_SIGNING.identity} names ${JSON.stringify(identity)}, which no valid code signing identity in the keychain is; \`security find-identity -v -p codesigning\` lists them.`);
|
|
@@ -47,13 +68,10 @@ export function appleSigning(env = process.env) {
|
|
|
47
68
|
throw new Error(`crossbind: ${APPLE_SIGNING.identity} names ${JSON.stringify(identity)}, which ${matches.length} identities in the keychain are: ${matches.map(({ hash, name }) => `${hash} "${name}"`).join(', ')}. Set it to one of their hashes.`);
|
|
48
69
|
}
|
|
49
70
|
const [{ hash, name }] = matches;
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const key = path.resolve(env[APPLE_SIGNING.key]);
|
|
55
|
-
if (!fs.statSync(key, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${APPLE_SIGNING.key} names ${key}, which is no file.`);
|
|
56
|
-
return { identity: hash, notary: { key, keyId: env[APPLE_SIGNING.keyId], issuer: env[APPLE_SIGNING.issuer] } };
|
|
71
|
+
if (notary && !name.startsWith('Developer ID Application:')) {
|
|
72
|
+
throw new Error(`crossbind: Apple notarizes an app signed with a Developer ID Application certificate, which ${JSON.stringify(name)} is not.`);
|
|
73
|
+
}
|
|
74
|
+
return { identity: hash, notary };
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
// The ticket Apple issues can take a moment to reach the servers stapler asks for it.
|
package/src/actions/buildApp.js
CHANGED
|
@@ -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, appDisplayName, appHeader, appId, appPermissions,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
@@ -195,9 +218,12 @@ const infoPlist = (name, version, hasIcon, urlSchemes) => xmlPlist({
|
|
|
195
218
|
CFBundleShortVersionString: version,
|
|
196
219
|
...(urlSchemes.length > 0 ? { CFBundleURLTypes: [{ CFBundleURLName: appId(state.config), CFBundleURLSchemes: urlSchemes }] } : {}),
|
|
197
220
|
CFBundleVersion: version,
|
|
221
|
+
...(appCategory(state.config) ? { LSApplicationCategoryType: appCategory(state.config).darwin } : {}),
|
|
198
222
|
LSMinimumSystemVersion: DARWIN_DEPLOYMENT_TARGET,
|
|
199
223
|
NSPrincipalClass: 'NSApplication',
|
|
200
224
|
NSHighResolutionCapable: true,
|
|
225
|
+
// The app menu's About item shows it.
|
|
226
|
+
...(appCopyright(state.config) ? { NSHumanReadableCopyright: appCopyright(state.config) } : {}),
|
|
201
227
|
...usageDescriptions(state.config, 'darwin'),
|
|
202
228
|
});
|
|
203
229
|
|
|
@@ -257,13 +283,16 @@ function assembleIosPart(target, { library, linkArgs, hbc, hermes }) {
|
|
|
257
283
|
}
|
|
258
284
|
|
|
259
285
|
// What an Android ABI target brings to the app's Gradle project: the host, the libraries beside it and the packages'
|
|
260
|
-
// 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/.
|
|
261
288
|
function assembleAndroidPart(target, { libDir, sharedLibs, hbc }) {
|
|
262
289
|
const part = `${state.config.paths.build}/${target.appName}`;
|
|
263
290
|
fs.rmSync(part, { recursive: true, force: true });
|
|
264
291
|
fs.mkdirSync(`${part}/lib`, { recursive: true });
|
|
265
|
-
|
|
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]
|
|
266
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 });
|
|
267
296
|
copyAppFiles(`${part}/app`, hbc, target);
|
|
268
297
|
}
|
|
269
298
|
|
|
@@ -324,6 +353,34 @@ function shipMacApp(app, signing) {
|
|
|
324
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}.`);
|
|
325
354
|
}
|
|
326
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
|
+
|
|
327
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
|
|
328
385
|
// type.
|
|
329
386
|
export function appOutputPath(target) {
|
|
@@ -339,10 +396,14 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
|
|
|
339
396
|
if (target.platform === 'ios') {
|
|
340
397
|
const parts = appParts(target).map(({ arch, dir }) => ({ sdk: arch, dir }));
|
|
341
398
|
const icon = readAppIcon(state.config);
|
|
399
|
+
const privacy = appPrivacy(state.config);
|
|
400
|
+
const build = appBuildNumber();
|
|
342
401
|
writeXcodeProject(to, {
|
|
343
|
-
parts, identity: { id: appId(state.config), team: appTeam() }, label: appDisplayName(state.config), version: appVersion(),
|
|
344
|
-
|
|
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,
|
|
345
405
|
});
|
|
406
|
+
if (target.buildType === 'release') warnUnreasonedApis(to, privacy);
|
|
346
407
|
} else if (target.platform === 'android') {
|
|
347
408
|
const icon = readAppIcon(state.config);
|
|
348
409
|
writeGradleProject(to, {
|
|
@@ -370,7 +431,10 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
|
|
|
370
431
|
if (page) shipMacApp(to, signing);
|
|
371
432
|
} else if (target.platform === 'android' && target.buildType === 'release') {
|
|
372
433
|
packageAndroidRelease(to);
|
|
373
|
-
}
|
|
434
|
+
}
|
|
435
|
+
if (['ios', 'android'].includes(target.platform)) {
|
|
436
|
+
recordAppOutput(target.platform, to);
|
|
437
|
+
} else if (target.platform !== 'darwin') {
|
|
374
438
|
if (target.platform === 'win32') signWindowsApp(to, target, page);
|
|
375
439
|
archiveApp(to, target);
|
|
376
440
|
}
|
|
@@ -415,6 +479,7 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
415
479
|
inputs: headerDirs.flatMap((dir) => (fs.existsSync(dir) ? findFiles('**/*.h', { cwd: dir }) : [dir])),
|
|
416
480
|
});
|
|
417
481
|
const { libs, linkArgs, extraSources } = linkInputs(target, { bridge: BRIDGE });
|
|
482
|
+
const sharedLibs = libs.filter((lib) => lib.endsWith('.so'));
|
|
418
483
|
|
|
419
484
|
const fingerprintFile = `${build}/${target.appName}.fingerprint`;
|
|
420
485
|
const fingerprint = getContentHash(JSON.stringify({
|
|
@@ -471,6 +536,8 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
471
536
|
`-DCROSSBIND_EXTRA_SOURCES=${extraSources.join(';')}`,
|
|
472
537
|
`-DCROSSBIND_LINK_ARGS=${linkArgs.join(';')}`,
|
|
473
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(';')}`] : []),
|
|
474
541
|
...cmakeArgs,
|
|
475
542
|
], platformPrefix, target);
|
|
476
543
|
run(null, ['cmake', '--build', '.', '-j', String(cpuCount)], platformPrefix, target, { exclusive: true });
|
|
@@ -487,7 +554,7 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
487
554
|
if (target.platform === 'ios') {
|
|
488
555
|
assembleIosPart(target, { library: `${workDir}/lib${name}.a`, linkArgs, hbc: `${workDir}/host.hbc`, hermes });
|
|
489
556
|
} else if (target.platform === 'android') {
|
|
490
|
-
assembleAndroidPart(target, { libDir: workDir, sharedLibs
|
|
557
|
+
assembleAndroidPart(target, { libDir: workDir, sharedLibs, hbc: `${workDir}/host.hbc` });
|
|
491
558
|
} else {
|
|
492
559
|
assembleApp(target, { executable: `${workDir}/${path.basename(appLayout(target).executable)}`, hbc: `${workDir}/host.hbc`, hermes, icon });
|
|
493
560
|
}
|
|
Binary file
|
|
@@ -7,6 +7,7 @@ import state from '../state/index.js';
|
|
|
7
7
|
import buildLib from './buildLib.js';
|
|
8
8
|
import buildBinTools from './buildBinTools.js';
|
|
9
9
|
import { copyBundledLicenses } from '../utils/bundledLicenses.js';
|
|
10
|
+
import { applyRecipePatches, refreshPatchedSource, writePatchedSourceStamp } from '../utils/recipePatches.js';
|
|
10
11
|
|
|
11
12
|
export default async function buildExternal(targetParams, options = {}) {
|
|
12
13
|
const version = state.config.package.nativeVersion;
|
|
@@ -14,9 +15,15 @@ export default async function buildExternal(targetParams, options = {}) {
|
|
|
14
15
|
throw new Error(`crossbind: "${state.config.general?.name}" has a crossbind.build recipe but no "nativeVersion" in package.json — cannot build from source.`);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
const { sha256, replaceList, copyToSource, copyToDist } = state.config.build;
|
|
18
|
+
const { sha256, replaceList, copyToSource, copyToDist, patches } = state.config.build;
|
|
19
|
+
refreshPatchedSource(state.config.paths.build, patches);
|
|
18
20
|
const isNewlyCreated = await downloadAndExtractFile(sourcesOf(state.config.build, version), state.config.paths.build, sha256);
|
|
19
21
|
const sourcePath = `${state.config.paths.build}/source`;
|
|
22
|
+
// Upstream fixes are diffs against the source as released, so they go in before crossbind's own edits.
|
|
23
|
+
if (isNewlyCreated) {
|
|
24
|
+
applyRecipePatches(sourcePath, patches);
|
|
25
|
+
writePatchedSourceStamp(state.config.paths.build, patches);
|
|
26
|
+
}
|
|
20
27
|
if (isNewlyCreated && replaceList) {
|
|
21
28
|
replaceList.forEach(({ regex, replacement, paths }) => {
|
|
22
29
|
replace({
|
package/src/actions/createLib.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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
|
|
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;
|