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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/package.json +69 -59
  3. package/src/actions/addExtension.js +191 -0
  4. package/src/actions/androidGradle.js +26 -17
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/buildApp.js +172 -74
  8. package/src/actions/buildNode.js +6 -1
  9. package/src/actions/buildWasm.js +0 -9
  10. package/src/actions/createInterface.js +17 -8
  11. package/src/actions/createLib.js +0 -4
  12. package/src/actions/extensionApi.js +298 -0
  13. package/src/actions/extensionCommands.js +41 -0
  14. package/src/actions/extensionsInApp.js +19 -0
  15. package/src/actions/gradleProject.js +196 -16
  16. package/src/actions/iosDevice.js +2 -2
  17. package/src/actions/iosSimulator.js +3 -3
  18. package/src/actions/licenses.js +83 -34
  19. package/src/actions/linuxPackages.js +48 -0
  20. package/src/actions/mavenVerification.js +37 -0
  21. package/src/actions/publish.js +220 -0
  22. package/src/actions/windowsSigning.js +1 -1
  23. package/src/actions/xcodeProject.js +49 -24
  24. package/src/actions/xcodeTargets.js +122 -0
  25. package/src/actions/xcodebuild.js +65 -0
  26. package/src/app/index.d.ts +126 -133
  27. package/src/app/index.js +126 -188
  28. package/src/assets/configure/install.sh +0 -0
  29. package/src/assets/toolchain-digests.json +20 -20
  30. package/src/bin.js +20 -28
  31. package/src/index.js +2 -1
  32. package/src/program.js +32 -3
  33. package/src/state/index.js +2 -1
  34. package/src/state/loadConfig.js +16 -5
  35. package/src/utils/appExtensions.js +761 -0
  36. package/src/utils/appFiles.js +58 -0
  37. package/src/utils/appLaunches.js +249 -0
  38. package/src/utils/appOptions.js +109 -100
  39. package/src/utils/appTargets.js +168 -0
  40. package/src/utils/bridgeTools.js +66 -0
  41. package/src/utils/configChanges.js +30 -0
  42. package/src/utils/configWriter.js +143 -26
  43. package/src/utils/extensionSteps.js +135 -0
  44. package/src/utils/openUrl.js +2 -1
  45. package/src/utils/plist.js +5 -0
  46. package/src/utils/resolveAppHost.js +7 -0
  47. package/src/utils/systemConfig.js +16 -3
  48. package/vitest.config.js +6 -1
  49. package/src/actions/appSetup.js +0 -116
  50. package/src/actions/appStoreConnect.js +0 -188
  51. package/src/actions/appUpdate.js +0 -68
  52. package/src/actions/extensions.js +0 -7
  53. package/src/actions/googlePlay.js +0 -213
  54. package/src/actions/googleSignIn.js +0 -78
  55. package/src/actions/playSetup.js +0 -19
  56. package/src/actions/publishAndroid.js +0 -102
  57. package/src/actions/publishIos.js +0 -146
@@ -1,78 +0,0 @@
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;
@@ -1,19 +0,0 @@
1
- import { PLAY_SCOPE, keepSignIn } from './googlePlay.js';
2
- import { signIn } from './googleSignIn.js';
3
- import { askYesNo } from '../utils/ask.js';
4
-
5
- // Signs the user in to Google Play with their Google account, in their browser, once they say yes to question, and keeps
6
- // the sign-in in the system's keychain for later publishes. Google's page lets the user leave Play's access out, so the
7
- // sign-in is asked for again until it has it, or is null once the user says no.
8
- export async function signInToPlay(question) {
9
- let asked = question;
10
- while (await askYesNo(asked)) {
11
- const { refresh_token: refreshToken, scope = '' } = await signIn(PLAY_SCOPE);
12
- if (scope.split(' ').includes(PLAY_SCOPE)) {
13
- if (!keepSignIn(refreshToken)) console.log('crossbind: this machine has no keychain to keep the sign-in in, so the next publish signs in again.');
14
- return { refreshToken };
15
- }
16
- asked = "The sign-in left Google Play's access out, its box on Google's page. Sign in again? [Y/n] ";
17
- }
18
- return null;
19
- }
@@ -1,102 +0,0 @@
1
- import fs from 'node:fs';
2
- import state from '../state/index.js';
3
- import { SIGNING } from './androidGradle.js';
4
- import {
5
- androidAppId, androidVersion, appBuildNumber, appOutputOf, clearAppOutputs,
6
- } from './buildApp.js';
7
- import {
8
- PLAY, highestVersionCode, keptSignIn, playCredentials, uploadBundle,
9
- } from './googlePlay.js';
10
- import { signInToPlay } from './playSetup.js';
11
- import { askAppId } from './appSetup.js';
12
- import { makeUploadKey, releaseSigningEnv } from './uploadKey.js';
13
- import { askLine, askYesNo, hasTerminal } from '../utils/ask.js';
14
- import loadSystemConfig from '../utils/systemConfig.js';
15
- import openUrl from '../utils/openUrl.js';
16
-
17
- const PLAY_CONSOLE = 'https://play.google.com/console';
18
- const NO_APP_ID = 'crossbind: Google Play knows an app by its package name, app.id, which crossbind.config.js does not set: set it, as com.example.myapp, before the first publish, as Play keeps it for good.';
19
- const NO_UPLOAD_KEY = `crossbind: Google Play takes bundles signed with your upload key: crossbind app upload-key makes one, or ${SIGNING.keystore}, ${SIGNING.alias} and ${SIGNING.password} name yours.`;
20
- const NO_CREDENTIALS = `crossbind: Google Play takes uploads from your Google account, which a publish on a terminal signs in to, or from a service account: set ${PLAY.serviceAccount} to its e-mail, with gcloud signed in, ${PLAY.key} to its JSON key, or ${PLAY.token} to an access token of it; app.md's Publishing says how to make one.`;
21
- const SIGN_IN = 'Google Play takes uploads from your Google account once crossbind is signed in to it. Sign in now, in your browser? [Y/n] ';
22
- const ANOTHER_ACCOUNT = 'Sign in with another Google account? [Y/n] ';
23
-
24
- // The highest versionCode Play has seen, and the credentials that saw it, once the user has done what Play asks for,
25
- // which only the user can do: in Play Console, which crossbind opens before it waits for Enter and checks again, or, for
26
- // a Google account without release rights, by signing in with another.
27
- async function highestWithPlayConsole(appId, credentials) {
28
- let current = credentials;
29
- for (;;) {
30
- try {
31
- return { highest: await highestVersionCode(appId, current), credentials: current };
32
- } catch (error) {
33
- console.log(error.message);
34
- if (error.status === 403 && current.refreshToken) {
35
- current = await signInToPlay(ANOTHER_ACCOUNT);
36
- if (!current) throw error;
37
- } else {
38
- if ([403, 404].includes(error.status)) {
39
- console.log(`crossbind: opening ${PLAY_CONSOLE}.`);
40
- openUrl(PLAY_CONSOLE);
41
- }
42
- await askLine('Press Enter once that is done to check again, or Ctrl-C to stop. ');
43
- }
44
- }
45
- }
46
- }
47
-
48
- // Builds an Android app with the next versionCode Google Play takes, signed with the upload key, and releases its bundle
49
- // on a track. All it needs is checked before the build, which takes minutes: on a terminal, what is missing is asked for
50
- // and set up; without one, as in CI, every missing piece is named at once.
51
- export async function publishAndroid({ track, status, viteArgs }, { runVite }) {
52
- const isGuided = hasTerminal();
53
- const missing = [];
54
- let appId = state.config.app.id === undefined ? null : androidAppId();
55
- if (!appId && isGuided) appId = await askAppId('Google Play', 'package name');
56
- if (!appId) missing.push(NO_APP_ID);
57
-
58
- let signing = await releaseSigningEnv();
59
- if (!signing.isSigned && isGuided && await askYesNo('This machine has no upload key, which Google Play takes bundles signed with. Make one now? [Y/n] ')) {
60
- await makeUploadKey();
61
- state.config.system = loadSystemConfig();
62
- signing = await releaseSigningEnv();
63
- }
64
- if (!signing.isSigned) missing.push(NO_UPLOAD_KEY);
65
-
66
- let credentials = playCredentials(process.env, state.config.system) ?? await keptSignIn();
67
- if (!credentials && isGuided) credentials = await signInToPlay(SIGN_IN);
68
- if (!credentials) missing.push(NO_CREDENTIALS);
69
- if (missing.length > 0) throw new Error(missing.join('\n'));
70
- const version = state.config.package?.version ?? '1.0.0';
71
- console.log(`crossbind: publishing ${appId} ${version} to Google Play's ${track} track.`);
72
-
73
- let highest;
74
- if (isGuided) ({ highest, credentials } = await highestWithPlayConsole(appId, credentials));
75
- else highest = await highestVersionCode(appId, credentials);
76
- const versionCode = appBuildNumber() ?? Math.max(androidVersion().code, highest + 1);
77
- console.log(highest
78
- ? `crossbind: Google Play has seen versionCode ${highest} at most, so this build is ${versionCode}.`
79
- : `crossbind: Google Play has no build of ${appId} yet, so this build is ${versionCode}.`);
80
-
81
- clearAppOutputs();
82
- const exitCode = await runVite(['build', ...viteArgs], {
83
- ...process.env, ...signing.env, CROSSBIND_APP_PLATFORM: 'android', CROSSBIND_APP_BUILD_NUMBER: String(versionCode),
84
- });
85
- if (exitCode !== 0) throw Object.assign(new Error(`crossbind: the build ended with exit code ${exitCode}, so nothing went to Google Play.`), { exitCode });
86
- const project = appOutputOf('android');
87
- const aab = `${project}.aab`;
88
- if (!fs.existsSync(aab)) throw new Error(`crossbind: the build made no bundle at ${aab}, so nothing went to Google Play.`);
89
- const symbols = fs.existsSync(`${project}-symbols.zip`) ? `${project}-symbols.zip` : null;
90
-
91
- console.log(`crossbind: uploading the bundle${symbols ? ' and its native debug symbols' : ''} to Google Play.`);
92
- const released = await uploadBundle(appId, credentials, { aab, symbols, track, status });
93
- const app = `${appId} ${version} (versionCode ${released.versionCode})`;
94
- if (released.status !== 'draft') {
95
- console.log(`crossbind: ${app} is out on Google Play's ${track} track.`);
96
- return;
97
- }
98
- console.log(`crossbind: ${app} is a draft on Google Play's ${track} track.`);
99
- if (status !== 'draft') {
100
- console.log("crossbind: Play takes drafts alone until an app's first release is out: roll it out once in Play Console, under Test and release, and later publishes go out by themselves.");
101
- }
102
- }
@@ -1,146 +0,0 @@
1
- import state from '../state/index.js';
2
- import { appStoreKey } from './appleSigning.js';
3
- import {
4
- appBuildNumber, appOutputOf, appTeam, appVersion, clearAppOutputs,
5
- } from './buildApp.js';
6
- import {
7
- addHermesDsym, archiveIosApp, archivedBinary, archivedPrivacy, uploadIosArchive, xcodeTeams,
8
- } from './appStoreConnect.js';
9
- import {
10
- askAppId, askEncryption, askIcon, askPrivacy,
11
- } from './appSetup.js';
12
- import { appEncryption, appId } from '../utils/appOptions.js';
13
- import { resolveHermesDsym } from '../utils/hermes.js';
14
- import { unreasonedImports } from '../utils/requiredReasonApis.js';
15
- import { askLine, askYesNo, hasTerminal } from '../utils/ask.js';
16
- import loadSystemConfig, { updateSystemConfig } from '../utils/systemConfig.js';
17
- import openUrl from '../utils/openUrl.js';
18
-
19
- const NO_APP_ID = 'crossbind: App Store Connect knows an app by its bundle id, app.id, which crossbind.config.js does not set: set it, as com.example.myapp, before the first publish, as Apple keeps it for good.';
20
- const NO_TEAM = "crossbind: App Store Connect takes uploads from an Apple Developer Program team, which neither app.team in crossbind.config.js nor this machine's XCODE_DEVELOPMENT_TEAM names: set either to its id, which developer.apple.com/account shows under Membership details.";
21
- const NO_ICON = 'crossbind: App Store Connect takes no iOS app without an icon: set app.icon in crossbind.config.js to a square PNG, at best 1024 × 1024.';
22
- const NO_ENCRYPTION = 'crossbind: app.encryption is not set, so App Store Connect asks whether this build uses encryption before TestFlight hands it out; set it in crossbind.config.js to answer for every build.';
23
- const TEAM_ID = /^[A-Z0-9]{10}$/;
24
-
25
- async function pickTeam(teams) {
26
- if (teams.length === 1) {
27
- const [{ id, name }] = teams;
28
- return await askYesNo(`Upload as team ${id}, ${name}, which Xcode is signed in to? [Y/n] `) ? teams[0] : null;
29
- }
30
- console.log('Xcode is signed in to these teams:');
31
- teams.forEach(({ id, name }, index) => console.log(` ${index + 1}) ${id} ${name}`));
32
- for (;;) {
33
- const team = teams[Number((await askLine(`Which one uploads, 1 to ${teams.length}? `)).trim()) - 1];
34
- if (team) return team;
35
- }
36
- }
37
-
38
- // A team id the user types, or null once they have signed in to Xcode, for its teams to be read again.
39
- async function typedTeam() {
40
- console.log("crossbind: Xcode is signed in to no such team, as a free personal team is none. Sign in to Xcode, under Settings › Accounts, with your membership's Apple Account, or type the team's id, which developer.apple.com/account shows under Membership details.");
41
- for (;;) {
42
- const id = (await askLine('Team id, or Enter once Xcode is signed in: ')).trim().toUpperCase();
43
- if (!id) return null;
44
- if (TEAM_ID.test(id)) return id;
45
- console.log('That is no team id, which is ten letters and digits.');
46
- }
47
- }
48
-
49
- // The team the user picks of those Xcode is signed in to, or types, which this machine keeps as XCODE_DEVELOPMENT_TEAM.
50
- async function askTeam() {
51
- console.log('crossbind: App Store Connect takes uploads from an Apple Developer Program team, which neither app.team nor XCODE_DEVELOPMENT_TEAM names.');
52
- let id = null;
53
- while (!id) {
54
- const teams = xcodeTeams();
55
- if (teams.length > 0) {
56
- const team = await pickTeam(teams);
57
- if (!team) throw new Error(NO_TEAM);
58
- ({ id } = team);
59
- } else {
60
- id = await typedTeam();
61
- }
62
- }
63
- updateSystemConfig({ XCODE_DEVELOPMENT_TEAM: id });
64
- state.config.system = loadSystemConfig();
65
- if (appTeam() !== id) throw new Error(`crossbind: ~/.crossbind.json did not take XCODE_DEVELOPMENT_TEAM: set app.team: '${id}' in crossbind.config.js.`);
66
- console.log(`crossbind: ~/.crossbind.json keeps ${id} as XCODE_DEVELOPMENT_TEAM.`);
67
- }
68
-
69
- // A step that failed for what only the user can mend, in Xcode or on Apple's sites, runs again once they press Enter,
70
- // after crossbind opened the page that mends it where it knows one. Any other failure, or one without a terminal, stops
71
- // the publish.
72
- async function untilDone(isGuided, step) {
73
- for (;;) {
74
- try {
75
- return step();
76
- } catch (error) {
77
- if (!isGuided || !error.isMendable) throw error;
78
- console.log(error.message);
79
- if (error.page) {
80
- console.log(`crossbind: opening ${error.page}.`);
81
- openUrl(error.page);
82
- }
83
- await askLine('Press Enter once that is done to try again, or Ctrl-C to stop. ');
84
- console.log('crossbind: trying again, which can take a few minutes.');
85
- }
86
- }
87
- }
88
-
89
- async function buildIosApp(viteArgs, runVite) {
90
- clearAppOutputs();
91
- const exitCode = await runVite(['build', ...viteArgs], { ...process.env, CROSSBIND_APP_PLATFORM: 'ios' });
92
- if (exitCode !== 0) throw Object.assign(new Error(`crossbind: the build ended with exit code ${exitCode}, so nothing went to App Store Connect.`), { exitCode });
93
- return appOutputOf('ios');
94
- }
95
-
96
- const unreasonedError = (unreasoned) => new Error(`crossbind: App Store Connect refuses an iOS app that calls required reason API without Apple's reasons for it (ITMS-91053), and this app calls ${unreasoned.map(({ category, symbols }) => `${category} (${symbols.join(', ')})`).join(' and ')} with no reason in its privacy manifest. Give app.privacy Apple's reasons, as { fileTimestamp: ['C617.1'] }; Apple's "Describing use of required reason API" says what each covers. Nothing went to App Store Connect.`);
97
-
98
- // Builds an iOS app, archives it and uploads it to App Store Connect, whose TestFlight hands it out. All it needs is
99
- // checked before the build, which takes minutes: on a terminal, what is missing is asked for and set up; without one,
100
- // as in CI, every missing piece is named at once. The archived app then shows what required reason API it calls.
101
- export async function publishIos({ viteArgs }, { runVite }) {
102
- if (process.platform !== 'darwin') throw new Error('crossbind: iOS apps build and go to App Store Connect on macOS, with Xcode.');
103
- const isGuided = hasTerminal();
104
- const key = appStoreKey();
105
- const missing = [];
106
- if (state.config.app.id === undefined) {
107
- if (isGuided) await askAppId('App Store Connect', 'bundle id');
108
- else missing.push(NO_APP_ID);
109
- }
110
- if (!appTeam()) {
111
- if (isGuided) await askTeam();
112
- else missing.push(NO_TEAM);
113
- }
114
- if (state.config.app.icon === undefined) {
115
- if (isGuided) await askIcon();
116
- else missing.push(NO_ICON);
117
- }
118
- if (missing.length > 0) throw new Error(missing.join('\n'));
119
- if (appEncryption(state.config) === null) {
120
- if (isGuided) await askEncryption();
121
- else console.warn(NO_ENCRYPTION);
122
- }
123
- const hermesDsym = await resolveHermesDsym();
124
-
125
- const facts = { id: appId(state.config), team: appTeam() };
126
- console.log(`crossbind: publishing ${facts.id} ${appVersion()} to App Store Connect for team ${facts.team}; building the app first.`);
127
- let archive;
128
- for (;;) {
129
- const project = await buildIosApp(viteArgs, runVite);
130
- console.log('crossbind: archiving the app, which takes a few minutes.');
131
- archive = await untilDone(isGuided, () => archiveIosApp(project, { facts }));
132
- const unreasoned = unreasonedImports(archivedBinary(archive), archivedPrivacy(archive));
133
- if (unreasoned.length === 0) break;
134
- if (!isGuided) throw unreasonedError(unreasoned);
135
- await askPrivacy(unreasoned);
136
- console.log('crossbind: building the app again, with those reasons in its privacy manifest.');
137
- }
138
- addHermesDsym(archive, hermesDsym);
139
- console.log(`crossbind: uploading the app to App Store Connect for team ${facts.team} with ${key ? `the API key ${key.keyId}` : "Xcode's account"}, which signs it.`);
140
- const build = appBuildNumber();
141
- await untilDone(isGuided, () => uploadIosArchive(archive, {
142
- team: facts.team, key, isBuildManaged: build === null, facts,
143
- }));
144
- const app = build === null ? `${facts.id} ${appVersion()}, numbered by Xcode after the builds App Store Connect has of it,` : `${facts.id} ${appVersion()} (build ${build})`;
145
- console.log(`crossbind: ${app} is on App Store Connect. TestFlight hands it out once Apple has processed it, usually within 5 to 30 minutes, and Apple mails when it has.`);
146
- }