crossbind 2.0.0-beta.68 → 2.0.0-beta.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +70 -59
- package/src/actions/addExtension.js +196 -0
- package/src/actions/androidGradle.js +26 -17
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/buildApp.js +177 -74
- package/src/actions/buildDependencies.js +3 -3
- package/src/actions/buildNode.js +6 -1
- package/src/actions/buildWasm.js +0 -9
- package/src/actions/createImportedBridges.js +10 -8
- package/src/actions/createInterface.js +40 -29
- package/src/actions/createLib.js +3 -7
- package/src/actions/extensionApi.js +298 -0
- package/src/actions/extensionCommands.js +41 -0
- package/src/actions/extensionsInApp.js +19 -0
- package/src/actions/getAllBridges.js +20 -5
- package/src/actions/getDependLibs.js +5 -0
- package/src/actions/getLinkInputs.js +2 -1
- package/src/actions/gradleProject.js +207 -16
- package/src/actions/iosDevice.js +2 -2
- package/src/actions/iosSimulator.js +3 -3
- package/src/actions/licenses.js +113 -58
- package/src/actions/linuxPackages.js +48 -0
- package/src/actions/mavenVerification.js +37 -0
- package/src/actions/{prepareConanDependencies.js → preparePackageSources.js} +13 -12
- package/src/actions/publish.js +220 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/writeRuntimeEntry.js +2 -2
- package/src/actions/xcodeProject.js +60 -29
- package/src/actions/xcodeTargets.js +122 -0
- package/src/actions/xcodebuild.js +65 -0
- package/src/app/index.d.ts +144 -132
- package/src/app/index.js +147 -185
- package/src/assets/configure/install.sh +0 -0
- package/src/assets/node-import-hooks.mjs +7 -6
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +37 -37
- package/src/index.js +4 -3
- package/src/integration/getCrossbindScript.js +3 -2
- package/src/integration/getDependFilePath.js +10 -10
- package/src/program.js +32 -3
- package/src/state/{attachConanDependencies.js → attachPackageSources.js} +20 -16
- package/src/state/index.js +8 -6
- package/src/state/loadConfig.js +26 -9
- package/src/state/refreshPackageSources.js +19 -0
- package/src/utils/appExtensions.js +777 -0
- package/src/utils/appFiles.js +58 -0
- package/src/utils/appLaunches.js +249 -0
- package/src/utils/appOptions.js +142 -112
- package/src/utils/appRustCrates.js +2 -2
- package/src/utils/appTargets.js +168 -0
- package/src/utils/bridgeTools.js +71 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +143 -26
- package/src/utils/cppDts.js +11 -11
- package/src/utils/extensionSteps.js +172 -0
- package/src/utils/nativeImports.js +9 -2
- package/src/utils/nodeDevBuild.js +2 -1
- package/src/utils/openUrl.js +2 -1
- package/src/utils/packageSourceApi.js +87 -0
- package/src/utils/packageSourceImport.js +69 -0
- package/src/utils/packageSourceInstall.js +162 -0
- package/src/utils/{conanStage.js → packageSourceStage.js} +64 -90
- package/src/utils/packageSources.js +108 -0
- package/src/utils/plist.js +5 -0
- package/src/utils/printableText.js +5 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/runPackageSource.js +251 -0
- package/src/utils/rustBridgeGen.js +3 -3
- package/src/utils/systemConfig.js +16 -3
- package/src/utils/targetToolchain.js +77 -0
- package/vitest.config.js +6 -1
- package/src/actions/appSetup.js +0 -116
- package/src/actions/appStoreConnect.js +0 -188
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
- package/src/actions/googlePlay.js +0 -213
- package/src/actions/googleSignIn.js +0 -78
- package/src/actions/playSetup.js +0 -19
- package/src/actions/publishAndroid.js +0 -102
- package/src/actions/publishIos.js +0 -146
- package/src/state/refreshConanDependencies.js +0 -17
- package/src/utils/conanDependencies.js +0 -66
- package/src/utils/conanImport.js +0 -58
- package/src/utils/conanInstall.js +0 -154
- package/src/utils/conanProfile.js +0 -203
- package/src/utils/runConan.js +0 -257
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import state from '../state/index.js';
|
|
5
|
-
import { APPLE_SIGNING } from './appleSigning.js';
|
|
6
|
-
import { EXEC_MAX_BUFFER } from './run.js';
|
|
7
|
-
import { PRIVACY_CATEGORIES } from '../utils/appOptions.js';
|
|
8
|
-
import { xmlPlist } from '../utils/plist.js';
|
|
9
|
-
|
|
10
|
-
export const APP_STORE_CONNECT = 'https://appstoreconnect.apple.com/apps';
|
|
11
|
-
const DEVELOPER_ACCOUNT = 'https://developer.apple.com/account';
|
|
12
|
-
// The lines of a failed xcodebuild's output that say why; its log has the rest.
|
|
13
|
-
const REASON_LINES = 15;
|
|
14
|
-
|
|
15
|
-
// An upload to an app App Store Connect does not have yet, which only its site makes.
|
|
16
|
-
const MISSING_APP = {
|
|
17
|
-
pattern: /No suitable application records were found|App record with bundle identifier .* not found/i,
|
|
18
|
-
hint: ({ id }) => `App Store Connect has no app ${id}: create it at ${APP_STORE_CONNECT}, a new iOS app with ${id} as its bundle ID, which this publish registered with Apple.`,
|
|
19
|
-
page: APP_STORE_CONNECT,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// What a failed archive or upload means where xcodebuild's own words leave it unclear: what the user mends in Xcode or
|
|
23
|
-
// on Apple's sites, and the page where.
|
|
24
|
-
const CAUSES = [
|
|
25
|
-
{
|
|
26
|
-
pattern: /login details for account .* were rejected|No Accounts?\b|No Account for Team/i,
|
|
27
|
-
hint: ({ team }) => `Xcode is signed in to no account of team ${team}, or its sign-in has lapsed: sign in again in Xcode, under Settings › Accounts.`,
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
pattern: /PLA Update available|Program License Agreement/i,
|
|
31
|
-
hint: () => `Apple has a new Program License Agreement, which the team's Account Holder accepts at ${DEVELOPER_ACCOUNT}.`,
|
|
32
|
-
page: DEVELOPER_ACCOUNT,
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
pattern: /Cloud signing permission error/i,
|
|
36
|
-
hint: () => `the API key may not sign in the cloud, which takes its Admin role: use an Admin key, make an Apple Distribution certificate once in Xcode, under Settings › Accounts › Manage Certificates, or unset ${APPLE_SIGNING.key} to upload with Xcode's account.`,
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
pattern: /cannot be registered to your development team because it is not available/i,
|
|
40
|
-
hint: ({ id }) => `another team registered the bundle id ${id} with Apple first: set app.id in crossbind.config.js to an id of your own, as one under a domain you own.`,
|
|
41
|
-
// The id is in what was built, so the same step tried again fails the same way.
|
|
42
|
-
needsRebuild: true,
|
|
43
|
-
},
|
|
44
|
-
MISSING_APP,
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
// Where a publish keeps the archive, its export and their logs.
|
|
48
|
-
const storeDir = () => `${state.config.paths.build}/app-store`;
|
|
49
|
-
|
|
50
|
-
const keyArgs = (key) => (key ? ['-authenticationKeyPath', key.key, '-authenticationKeyID', key.keyId, '-authenticationKeyIssuerID', key.issuer] : []);
|
|
51
|
-
|
|
52
|
-
// Runs xcodebuild with its output in `log`, which it returns. Its failure names the lines that say why, and what to do
|
|
53
|
-
// where those leave it unclear.
|
|
54
|
-
export function xcodebuild(what, args, log, facts) {
|
|
55
|
-
const out = fs.openSync(log, 'w');
|
|
56
|
-
let result;
|
|
57
|
-
try {
|
|
58
|
-
result = spawnSync('xcodebuild', args, { stdio: ['ignore', out, out] });
|
|
59
|
-
} finally {
|
|
60
|
-
fs.closeSync(out);
|
|
61
|
-
}
|
|
62
|
-
if (result.error) throw new Error(`crossbind: cannot run xcodebuild, which comes with Xcode: ${result.error.message}`, { cause: result.error });
|
|
63
|
-
if (result.signal) throw new Error(`crossbind: ${what} stopped on ${result.signal}.`);
|
|
64
|
-
const output = fs.readFileSync(log, 'utf8');
|
|
65
|
-
if (result.status === 0) return output;
|
|
66
|
-
const reasons = [...new Set(output.split('\n').map((line) => line.trim()).filter((line) => /\berror\b|ITMS-/i.test(line)))].slice(-REASON_LINES);
|
|
67
|
-
const cause = CAUSES.find(({ pattern }) => pattern.test(output));
|
|
68
|
-
const lines = [`crossbind: ${what} failed; xcodebuild's log is ${log}.`, ...reasons.map((line) => ` ${line}`), ...(cause ? [`crossbind: ${cause.hint(facts)}`] : [])];
|
|
69
|
-
throw Object.assign(new Error(lines.join('\n')), { isMendable: Boolean(cause) && !cause.needsRebuild, page: cause?.page ?? null, reason: cause ?? null });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const archivedApp = (archive) => `${archive}/Products/Applications/${state.config.general.name}.app`;
|
|
73
|
-
|
|
74
|
-
// The entitlements file the Xcode project names, or null without one.
|
|
75
|
-
function projectEntitlements(project) {
|
|
76
|
-
const settings = fs.readFileSync(`${project}/${state.config.general.name}.xcodeproj/project.pbxproj`, 'utf8');
|
|
77
|
-
const file = settings.match(/"?CODE_SIGN_ENTITLEMENTS"?\s*=\s*"?([^";]+)"?;/)?.[1];
|
|
78
|
-
return file ? path.resolve(project, file.replace('$(SRCROOT)/', '')) : null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Signs an archived app ad hoc, its frameworks first, which takes no certificate. The signature carries the app's
|
|
82
|
-
// entitlements to the upload, which an unsigned app would lose, and the upload replaces it with its distribution one.
|
|
83
|
-
function signAdHoc(app, entitlements) {
|
|
84
|
-
const frameworks = fs.existsSync(`${app}/Frameworks`) ? fs.readdirSync(`${app}/Frameworks`).map((name) => `${app}/Frameworks/${name}`) : [];
|
|
85
|
-
[...frameworks, app].forEach((code) => {
|
|
86
|
-
const withEntitlements = code === app && entitlements ? ['--entitlements', entitlements, '--generate-entitlement-der'] : [];
|
|
87
|
-
const result = spawnSync('codesign', ['--force', '--sign', '-', '--timestamp=none', ...withEntitlements, code], { encoding: 'utf8' });
|
|
88
|
-
if (result.error || result.status !== 0) {
|
|
89
|
-
throw new Error(`crossbind: codesign could not sign ${code} ad hoc: ${(result.stderr || result.error?.message || '').trim()}`);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Archives the app of the iOS Xcode project crossbind wrote unsigned, then signs it ad hoc with the entitlements the
|
|
95
|
-
// project names. The upload signs it for distribution, so the archive needs no development certificate, profile, device
|
|
96
|
-
// or Apple account.
|
|
97
|
-
export function archiveIosApp(project, { facts }) {
|
|
98
|
-
const { name } = state.config.general;
|
|
99
|
-
const archive = `${storeDir()}/${name}.xcarchive`;
|
|
100
|
-
fs.rmSync(archive, { recursive: true, force: true });
|
|
101
|
-
fs.mkdirSync(storeDir(), { recursive: true });
|
|
102
|
-
xcodebuild('the archive', [
|
|
103
|
-
'archive', '-project', `${project}/${name}.xcodeproj`, '-scheme', name, '-configuration', 'Release', '-destination', 'generic/platform=iOS',
|
|
104
|
-
'-archivePath', archive, 'CODE_SIGNING_ALLOWED=NO',
|
|
105
|
-
], `${storeDir()}/archive.log`, facts);
|
|
106
|
-
signAdHoc(archivedApp(archive), projectEntitlements(project));
|
|
107
|
-
return archive;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export const archivedBinary = (archive) => `${archivedApp(archive)}/${state.config.general.name}`;
|
|
111
|
-
|
|
112
|
-
// The reasons the archived app's privacy manifest gives, by category: what App Store Connect reads, whatever the config
|
|
113
|
-
// the build read said.
|
|
114
|
-
export function archivedPrivacy(archive) {
|
|
115
|
-
const manifest = `${archivedApp(archive)}/PrivacyInfo.xcprivacy`;
|
|
116
|
-
if (!fs.existsSync(manifest)) return {};
|
|
117
|
-
const result = spawnSync('plutil', ['-convert', 'json', '-o', '-', manifest], { encoding: 'utf8' });
|
|
118
|
-
if (result.status !== 0) throw new Error(`crossbind: cannot read ${manifest}: ${result.stderr || result.error?.message}`);
|
|
119
|
-
const names = Object.fromEntries(Object.entries(PRIVACY_CATEGORIES).map(([name, { type }]) => [type, name]));
|
|
120
|
-
return Object.fromEntries((JSON.parse(result.stdout).NSPrivacyAccessedAPITypes ?? [])
|
|
121
|
-
.filter(({ NSPrivacyAccessedAPIType: type }) => Object.hasOwn(names, type))
|
|
122
|
-
.map(({ NSPrivacyAccessedAPIType: type, NSPrivacyAccessedAPITypeReasons: reasons }) => [names[type], reasons]));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Hermes comes prebuilt, so the archive has no dSYM of it, which App Store Connect asks for.
|
|
126
|
-
export function addHermesDsym(archive, dsym) {
|
|
127
|
-
fs.cpSync(dsym, `${archive}/dSYMs/hermesvm.framework.dSYM`, { recursive: true });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// The options of an export for App Store Connect: an upload with the app's dSYMs, or an .ipa alone.
|
|
131
|
-
function writeExportOptions(file, { destination, team, isBuildManaged }) {
|
|
132
|
-
fs.writeFileSync(file, xmlPlist({
|
|
133
|
-
destination,
|
|
134
|
-
manageAppVersionAndBuildNumber: isBuildManaged,
|
|
135
|
-
method: 'app-store-connect',
|
|
136
|
-
signingStyle: 'automatic',
|
|
137
|
-
teamID: team,
|
|
138
|
-
uploadSymbols: destination === 'upload',
|
|
139
|
-
}));
|
|
140
|
-
return file;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Registers the app's bundle id with Apple, as App Store Connect's form for a new app lists registered ids alone. An
|
|
144
|
-
// upload asks for the app record before it signs, so it registers nothing; an export to an .ipa, with no build number
|
|
145
|
-
// to look up, signs at once, which registers the id.
|
|
146
|
-
function registerBundleId(archive, { team, key, facts }) {
|
|
147
|
-
const options = writeExportOptions(`${storeDir()}/RegisterOptions.plist`, { destination: 'export', team, isBuildManaged: false });
|
|
148
|
-
xcodebuild('the registration of the bundle id', [
|
|
149
|
-
'-exportArchive', '-archivePath', archive, '-exportOptionsPlist', options, '-exportPath', `${storeDir()}/register`, '-allowProvisioningUpdates',
|
|
150
|
-
...keyArgs(key),
|
|
151
|
-
], `${storeDir()}/register.log`, facts);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Exports the archive for App Store Connect and uploads it with its dSYMs, signed for distribution in the cloud by Xcode's
|
|
155
|
-
// account or an Admin key, else with an Apple Distribution certificate in the keychain, with the entitlements of its ad
|
|
156
|
-
// hoc signature, which the App ID gets the capabilities of. Xcode numbers the build unless CROSSBIND_APP_BUILD_NUMBER
|
|
157
|
-
// did, and writes nothing to -exportPath, which xcodebuild asks for all the same.
|
|
158
|
-
export function uploadIosArchive(archive, { team, key, isBuildManaged, facts }) {
|
|
159
|
-
const options = writeExportOptions(`${storeDir()}/ExportOptions.plist`, { destination: 'upload', team, isBuildManaged });
|
|
160
|
-
let output;
|
|
161
|
-
try {
|
|
162
|
-
output = xcodebuild('the upload', [
|
|
163
|
-
'-exportArchive', '-archivePath', archive, '-exportOptionsPlist', options, '-exportPath', `${storeDir()}/export`, '-allowProvisioningUpdates',
|
|
164
|
-
...keyArgs(key),
|
|
165
|
-
], `${storeDir()}/upload.log`, facts);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
if (error.reason === MISSING_APP) {
|
|
168
|
-
console.log(`crossbind: App Store Connect has no app ${facts.id} yet, so this publish registers its bundle id with Apple first.`);
|
|
169
|
-
registerBundleId(archive, { team, key, facts });
|
|
170
|
-
}
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
// What App Store Connect warns of, as a dSYM it could not take, goes up with the app all the same.
|
|
174
|
-
output.split('\n').filter((line) => line.startsWith('warning: exportArchive')).forEach((line) => console.warn(`crossbind: ${line}`));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// The teams of the accounts signed in to Xcode that upload to App Store Connect, which a free personal team does not,
|
|
178
|
-
// from Xcode's settings; `withPersonal` adds those free teams, which sign for their own devices.
|
|
179
|
-
export function xcodeTeams({ withPersonal = false } = {}) {
|
|
180
|
-
const settings = spawnSync('defaults', ['export', 'com.apple.dt.Xcode', '-'], { maxBuffer: EXEC_MAX_BUFFER });
|
|
181
|
-
if (settings.status !== 0) return [];
|
|
182
|
-
const teams = spawnSync('plutil', ['-extract', 'IDEProvisioningTeamByIdentifier', 'json', '-o', '-', '-'], {
|
|
183
|
-
input: settings.stdout, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER,
|
|
184
|
-
});
|
|
185
|
-
if (teams.status !== 0) return [];
|
|
186
|
-
const signing = Object.values(JSON.parse(teams.stdout)).flat().filter((team) => withPersonal || !team.isFreeProvisioningTeam);
|
|
187
|
-
return [...new Map(signing.map((team) => [team.teamID, { id: team.teamID, name: team.teamName }])).values()];
|
|
188
|
-
}
|
package/src/actions/appUpdate.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import run from './run.js';
|
|
5
|
-
import state from '../state/index.js';
|
|
6
|
-
import { appId, appUpdater } from '../utils/appOptions.js';
|
|
7
|
-
|
|
8
|
-
// The private key that signs a desktop app's updates, named by the environment alone so that no secret reaches a file.
|
|
9
|
-
export const UPDATER_KEY = 'CROSSBIND_UPDATER_PRIVATE_KEY';
|
|
10
|
-
// The fixed DER around an Ed25519 key's 32 bytes, private and public.
|
|
11
|
-
const PRIVATE_DER = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
12
|
-
const PUBLIC_DER = Buffer.from('302a300506032b6570032100', 'hex');
|
|
13
|
-
|
|
14
|
-
const publicKeyOf = (privateKey) => crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(PUBLIC_DER.length);
|
|
15
|
-
|
|
16
|
-
// A new key pair: the public key for app.updater.publicKey, the private one for CROSSBIND_UPDATER_PRIVATE_KEY.
|
|
17
|
-
export function newUpdaterKey() {
|
|
18
|
-
const { privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
19
|
-
return {
|
|
20
|
-
publicKey: publicKeyOf(privateKey).toString('base64'),
|
|
21
|
-
privateKey: privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PRIVATE_DER.length).toString('base64'),
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// The key a build signs updates with, or null without CROSSBIND_UPDATER_PRIVATE_KEY. It has to be the private half of
|
|
26
|
-
// app.updater.publicKey, or the app would refuse what it signs.
|
|
27
|
-
export function updaterSigning(env = process.env) {
|
|
28
|
-
if (!env[UPDATER_KEY]) return null;
|
|
29
|
-
const updater = appUpdater(state.config);
|
|
30
|
-
if (!updater) throw new Error(`crossbind: ${UPDATER_KEY} is set, but crossbind.config.js has no app.updater, which tells an app where its updates are.`);
|
|
31
|
-
const seed = Buffer.from(env[UPDATER_KEY], 'base64');
|
|
32
|
-
if (seed.length !== 32) throw new Error(`crossbind: ${UPDATER_KEY} is no Ed25519 private key, which \`crossbind app updater-key\` prints.`);
|
|
33
|
-
const privateKey = crypto.createPrivateKey({ key: Buffer.concat([PRIVATE_DER, seed]), format: 'der', type: 'pkcs8' });
|
|
34
|
-
if (!publicKeyOf(privateKey).equals(updater.publicKey)) {
|
|
35
|
-
throw new Error(`crossbind: ${UPDATER_KEY} is not the private key of app.updater.publicKey, so the app would refuse the updates it signs.`);
|
|
36
|
-
}
|
|
37
|
-
return { privateKey, version: updater.version };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// What an update's signature covers: the app's id, the platform and arch it is for, its version and its file's SHA-512,
|
|
41
|
-
// so that no file passes for another app's, platform's or version's. The host checks the same text.
|
|
42
|
-
export function signedText(id, target, version, file) {
|
|
43
|
-
const hash = crypto.createHash('sha512').update(fs.readFileSync(file)).digest('hex');
|
|
44
|
-
return `crossbind update\n${id}\n${target}\n${version}\n${hash}\n`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// The file a desktop app updates itself from, beside it: a macOS app as a zip, which keeps its signature and Apple's
|
|
48
|
-
// ticket, a Windows app's setup program and a Linux app's tar.gz.
|
|
49
|
-
function updateFile(app, target) {
|
|
50
|
-
if (target.platform === 'win32') return `${app}-setup.exe`;
|
|
51
|
-
if (target.platform === 'linux') return `${app}.tar.gz`;
|
|
52
|
-
const zip = path.join(path.dirname(app), `${state.config.general.name}-darwin-${target.arch}.app.zip`);
|
|
53
|
-
fs.rmSync(zip, { force: true });
|
|
54
|
-
run(null, ['ditto', '-c', '-k', '--keepParent', app, zip], null, target);
|
|
55
|
-
return zip;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// With a key in the environment, the update a desktop app finds at app.updater.url, beside the app: its file, and the
|
|
59
|
-
// manifest that gives its version, the file's name and its signature, <name>-<platform>-<arch>.update.json.
|
|
60
|
-
export default function writeUpdate(app, target) {
|
|
61
|
-
const signing = updaterSigning();
|
|
62
|
-
if (!signing) return;
|
|
63
|
-
const file = updateFile(app, target);
|
|
64
|
-
const name = `${target.platform}-${target.arch}`;
|
|
65
|
-
const signature = crypto.sign(null, Buffer.from(signedText(appId(state.config), name, signing.version, file)), signing.privateKey).toString('base64');
|
|
66
|
-
const manifest = { version: signing.version, file: path.basename(file), signature };
|
|
67
|
-
fs.writeFileSync(path.join(path.dirname(app), `${state.config.general.name}-${name}.update.json`), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
68
|
-
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { createSign } from 'node:crypto';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { accessToken } from './googleSignIn.js';
|
|
7
|
-
import { readSecret, storeSecret } from '../utils/keychain.js';
|
|
8
|
-
|
|
9
|
-
// Google Play's credentials, which the environment or ~/.crossbind.json names: a service account's e-mail, whose token
|
|
10
|
-
// the user's gcloud login buys with no key, or its JSON key; or, from the environment alone, an access token made
|
|
11
|
-
// elsewhere, as CI makes one with no key.
|
|
12
|
-
export const PLAY = { key: 'CROSSBIND_GOOGLE_PLAY_KEY', serviceAccount: 'CROSSBIND_GOOGLE_PLAY_SERVICE_ACCOUNT', token: 'CROSSBIND_GOOGLE_PLAY_TOKEN' };
|
|
13
|
-
export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
|
14
|
-
// The keychain keeps the user's Google sign-in for Play under this service; on Windows, beside this path.
|
|
15
|
-
const SIGN_IN_SERVICE = 'crossbind google play';
|
|
16
|
-
const signInKey = () => path.join(os.homedir(), '.crossbind', 'google-play');
|
|
17
|
-
const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications';
|
|
18
|
-
const UPLOAD = 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications';
|
|
19
|
-
const IAM = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts';
|
|
20
|
-
// The longest a service account's token lasts.
|
|
21
|
-
const TOKEN_SECONDS = 3600;
|
|
22
|
-
// Play's answer for an app whose first release Play Console has not rolled out yet.
|
|
23
|
-
const DRAFT_APP = /status draft may be created on draft app/;
|
|
24
|
-
|
|
25
|
-
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
26
|
-
|
|
27
|
-
// A JSON key's own text, as CI's secrets give it, in place of its file's path.
|
|
28
|
-
const KEY_TEXT = /^\s*\{/;
|
|
29
|
-
|
|
30
|
-
// A service account key from its JSON, which `source` names where it is none, never repeating the text.
|
|
31
|
-
function parseKey(json, source, isText) {
|
|
32
|
-
let key;
|
|
33
|
-
try {
|
|
34
|
-
key = JSON.parse(json);
|
|
35
|
-
} catch {
|
|
36
|
-
throw new Error(`crossbind: ${source} ${isText ? 'holds' : 'is'} no JSON, so it is no service account key.`);
|
|
37
|
-
}
|
|
38
|
-
const missing = ['client_email', 'private_key', 'token_uri'].filter((field) => !key[field]);
|
|
39
|
-
if (missing.length) throw new Error(`crossbind: ${source} has no ${missing.join(', ')}, so it is no service account key.`);
|
|
40
|
-
return key;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function readKey(file) {
|
|
44
|
-
if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${PLAY.key} names ${file}, which is no file.`);
|
|
45
|
-
return parseKey(fs.readFileSync(file, 'utf8'), `${file} (${PLAY.key})`, false);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// The credentials Play takes from this environment and machine, or null without any: one of the three kinds.
|
|
49
|
-
export function playCredentials(env = process.env, system = {}) {
|
|
50
|
-
const file = env[PLAY.key] || system.GOOGLE_PLAY_KEY;
|
|
51
|
-
const account = env[PLAY.serviceAccount] || system.GOOGLE_PLAY_SERVICE_ACCOUNT;
|
|
52
|
-
const token = env[PLAY.token];
|
|
53
|
-
if (file && account) {
|
|
54
|
-
throw new Error(`crossbind: both ${PLAY.key} and ${PLAY.serviceAccount} are set, or their keys in ~/.crossbind.json: set one, the service account's e-mail to use your gcloud login, or its JSON key.`);
|
|
55
|
-
}
|
|
56
|
-
if (token && (file || account)) {
|
|
57
|
-
throw new Error(`crossbind: ${PLAY.token} is set, and so is ${file ? PLAY.key : PLAY.serviceAccount} or its key in ~/.crossbind.json: set one.`);
|
|
58
|
-
}
|
|
59
|
-
if (token) return { token };
|
|
60
|
-
if (account) return { account };
|
|
61
|
-
if (!file) return null;
|
|
62
|
-
if (KEY_TEXT.test(env[PLAY.key] ?? '')) {
|
|
63
|
-
const key = parseKey(env[PLAY.key], PLAY.key, true);
|
|
64
|
-
return { account: key.client_email, key, file: null };
|
|
65
|
-
}
|
|
66
|
-
const key = readKey(file);
|
|
67
|
-
return { account: key.client_email, key, file };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The user's Google sign-in for Play that this machine keeps, or null where it keeps none or Google no longer takes it.
|
|
71
|
-
export async function keptSignIn() {
|
|
72
|
-
const refreshToken = readSecret(signInKey(), { service: SIGN_IN_SERVICE });
|
|
73
|
-
if (!refreshToken) return null;
|
|
74
|
-
try {
|
|
75
|
-
await accessToken(refreshToken);
|
|
76
|
-
} catch (error) {
|
|
77
|
-
if (error.reason !== 'invalid_grant') throw error;
|
|
78
|
-
console.log('crossbind: Google no longer takes the sign-in this machine keeps for Google Play, as when it was revoked.');
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
return { refreshToken };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Keeps the user's Google sign-in for Play in the system's keychain, and says whether it did.
|
|
85
|
-
export function keepSignIn(refreshToken) {
|
|
86
|
-
fs.mkdirSync(path.dirname(signInKey()), { recursive: true });
|
|
87
|
-
return storeSecret(signInKey(), refreshToken, { service: SIGN_IN_SERVICE });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function keyToken({ account, key }) {
|
|
91
|
-
const now = Math.floor(Date.now() / 1000);
|
|
92
|
-
const unsigned = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ iss: account, scope: PLAY_SCOPE, aud: key.token_uri, iat: now, exp: now + TOKEN_SECONDS })}`;
|
|
93
|
-
const signature = createSign('RSA-SHA256').update(unsigned).sign(key.private_key).toString('base64url');
|
|
94
|
-
const response = await fetch(key.token_uri, {
|
|
95
|
-
method: 'POST',
|
|
96
|
-
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${unsigned}.${signature}` }),
|
|
97
|
-
});
|
|
98
|
-
const body = await response.json().catch(() => ({}));
|
|
99
|
-
if (!response.ok) throw new Error(`crossbind: Google refused the token of ${account}: ${response.status} ${body.error ?? ''} ${body.error_description ?? ''}`.trim());
|
|
100
|
-
return body.access_token;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// The user's own gcloud login buys an hour's token of the service account, so no key of it exists anywhere.
|
|
104
|
-
async function accountToken({ account }) {
|
|
105
|
-
const own = spawnSync('gcloud', ['auth', 'print-access-token'], { encoding: 'utf8' });
|
|
106
|
-
if (own.error || own.status !== 0) {
|
|
107
|
-
throw new Error(`crossbind: gcloud gave no token of yours: sign in with gcloud auth login, or name the service account's JSON key with ${PLAY.key} instead.\n${(own.stderr || own.error?.message || '').trim()}`);
|
|
108
|
-
}
|
|
109
|
-
const response = await fetch(`${IAM}/${account}:generateAccessToken`, {
|
|
110
|
-
method: 'POST',
|
|
111
|
-
headers: { authorization: `Bearer ${own.stdout.trim()}`, 'content-type': 'application/json' },
|
|
112
|
-
body: JSON.stringify({ scope: [PLAY_SCOPE] }),
|
|
113
|
-
});
|
|
114
|
-
const body = await response.json().catch(() => ({}));
|
|
115
|
-
if (!response.ok) {
|
|
116
|
-
throw new Error(`crossbind: Google gave no token of ${account}: ${response.status} ${body.error?.message ?? ''}\nyour gcloud account needs the Service Account Token Creator role on it, and its project the IAM Service Account Credentials API.`);
|
|
117
|
-
}
|
|
118
|
-
return body.accessToken;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// An hour's token of Play's API: the one from the environment, or one for the user's Google sign-in or for the service
|
|
122
|
-
// account.
|
|
123
|
-
export async function playToken(credentials) {
|
|
124
|
-
if (credentials.token) return credentials.token;
|
|
125
|
-
if (credentials.refreshToken) return accessToken(credentials.refreshToken);
|
|
126
|
-
return credentials.key ? keyToken(credentials) : accountToken(credentials);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function playError(status, message, method, url, appId, credentials) {
|
|
130
|
-
const route = new URL(url).pathname.replace(/^.*\/applications\/[^/]+/, '') || '/';
|
|
131
|
-
let text = `crossbind: Google Play answered ${method} ${route} with ${status}: ${message}`;
|
|
132
|
-
if (status === 404 && route === '/edits') {
|
|
133
|
-
text = `crossbind: Play Console has no app ${appId}: create it there, with ${appId} as its package name, as Play's API makes none.`;
|
|
134
|
-
} else if (status === 401 && credentials.token) {
|
|
135
|
-
text = `crossbind: Google Play takes ${PLAY.token} no more: it has expired, as access tokens do after an hour, or it is no access token; make it in the publish's own job.`;
|
|
136
|
-
} else if (status === 403 && credentials.token && /insufficient authentication scopes/i.test(message)) {
|
|
137
|
-
text = `crossbind: ${PLAY.token} lacks the scope ${PLAY_SCOPE}, which Google Play's API takes: ask for it where the token is made, as google-github-actions/auth's access_token_scopes does.`;
|
|
138
|
-
} else if (status === 403 && credentials.refreshToken) {
|
|
139
|
-
text = `crossbind: the Google account you signed in with has no release rights for ${appId}: sign in with one that has, or have it invited in Play Console, under Users and permissions, with "Release apps to testing tracks" for the app.`;
|
|
140
|
-
} else if (status === 403) {
|
|
141
|
-
const account = credentials.token ? `the account behind ${PLAY.token}` : credentials.account;
|
|
142
|
-
text = `crossbind: ${account} has no release rights for ${appId}: invite it in Play Console, under Users and permissions, with "Release apps to testing tracks" for the app.`;
|
|
143
|
-
}
|
|
144
|
-
return Object.assign(new Error(text), { status, playMessage: message });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// A caller of Play's API for one app, whose failures say what to do where they can.
|
|
148
|
-
async function playCaller(appId, credentials) {
|
|
149
|
-
const token = await playToken(credentials);
|
|
150
|
-
return async (method, url, body) => {
|
|
151
|
-
const isBinary = Buffer.isBuffer(body);
|
|
152
|
-
const response = await fetch(url, {
|
|
153
|
-
method,
|
|
154
|
-
headers: { authorization: `Bearer ${token}`, 'content-type': isBinary ? 'application/octet-stream' : 'application/json' },
|
|
155
|
-
body: isBinary || body === undefined ? body : JSON.stringify(body),
|
|
156
|
-
});
|
|
157
|
-
const text = await response.text();
|
|
158
|
-
if (response.ok) return text ? JSON.parse(text) : {};
|
|
159
|
-
let message = text;
|
|
160
|
-
try {
|
|
161
|
-
message = JSON.parse(text).error?.message ?? text;
|
|
162
|
-
} catch {
|
|
163
|
-
// Not Google's JSON error, so the text is the message.
|
|
164
|
-
}
|
|
165
|
-
throw playError(response.status, message, method, url, appId, credentials);
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// The highest versionCode Play has seen of an app, in its bundles, its APKs and its tracks' releases; 0 for none.
|
|
170
|
-
export async function highestVersionCode(appId, credentials) {
|
|
171
|
-
const call = await playCaller(appId, credentials);
|
|
172
|
-
const edits = `${API}/${appId}/edits`;
|
|
173
|
-
const edit = await call('POST', edits, {});
|
|
174
|
-
try {
|
|
175
|
-
const { tracks = [] } = await call('GET', `${edits}/${edit.id}/tracks`);
|
|
176
|
-
const { bundles = [] } = await call('GET', `${edits}/${edit.id}/bundles`);
|
|
177
|
-
const { apks = [] } = await call('GET', `${edits}/${edit.id}/apks`);
|
|
178
|
-
const released = tracks.flatMap(({ releases = [] }) => releases.flatMap(({ versionCodes = [] }) => versionCodes));
|
|
179
|
-
return Math.max(0, ...[...bundles, ...apks].map(({ versionCode }) => versionCode), ...released.map(Number));
|
|
180
|
-
} finally {
|
|
181
|
-
await call('DELETE', `${edits}/${edit.id}`).catch(() => {});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Uploads a bundle, with the zip of its native debug symbols if there is one, and releases it on a track, as status
|
|
186
|
-
// says, in one edit. An app never released takes a draft alone, which its first rollout in Play Console makes live, so
|
|
187
|
-
// its release becomes one.
|
|
188
|
-
export async function uploadBundle(appId, credentials, { aab, symbols = null, track, status }) {
|
|
189
|
-
const call = await playCaller(appId, credentials);
|
|
190
|
-
const edits = `${API}/${appId}/edits`;
|
|
191
|
-
const edit = await call('POST', edits, {});
|
|
192
|
-
try {
|
|
193
|
-
const { versionCode } = await call('POST', `${UPLOAD}/${appId}/edits/${edit.id}/bundles?uploadType=media`, fs.readFileSync(aab));
|
|
194
|
-
if (symbols) {
|
|
195
|
-
await call('POST', `${UPLOAD}/${appId}/edits/${edit.id}/apks/${versionCode}/deobfuscationFiles/nativeCode?uploadType=media`, fs.readFileSync(symbols));
|
|
196
|
-
}
|
|
197
|
-
const release = async (releaseStatus) => {
|
|
198
|
-
await call('PUT', `${edits}/${edit.id}/tracks/${track}`, { track, releases: [{ versionCodes: [String(versionCode)], status: releaseStatus }] });
|
|
199
|
-
await call('POST', `${edits}/${edit.id}:commit`);
|
|
200
|
-
return { versionCode, status: releaseStatus };
|
|
201
|
-
};
|
|
202
|
-
try {
|
|
203
|
-
return await release(status);
|
|
204
|
-
} catch (error) {
|
|
205
|
-
if (status === 'draft' || !DRAFT_APP.test(error.playMessage ?? '')) throw error;
|
|
206
|
-
return await release('draft');
|
|
207
|
-
}
|
|
208
|
-
} catch (error) {
|
|
209
|
-
// An edit left open expires by itself; the first failure is the one to report.
|
|
210
|
-
await call('DELETE', `${edits}/${edit.id}`).catch(() => {});
|
|
211
|
-
throw error;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
@@ -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;
|
package/src/actions/playSetup.js
DELETED
|
@@ -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
|
-
}
|