crossbind 2.0.0-beta.67 → 2.0.0-beta.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -24
- package/package.json +69 -59
- package/src/actions/addExtension.js +191 -0
- package/src/actions/androidGradle.js +69 -25
- package/src/actions/appInstaller.js +69 -153
- package/src/actions/appSwift.js +66 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +229 -67
- package/src/actions/buildNode.js +6 -1
- package/src/actions/buildWasm.js +0 -9
- package/src/actions/createInterface.js +17 -8
- package/src/actions/createLib.js +5 -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/getCmakeParameters.js +2 -3
- package/src/actions/gradleProject.js +199 -16
- package/src/actions/iosDevice.js +200 -0
- package/src/actions/iosSimulator.js +3 -3
- package/src/actions/licenses.js +83 -34
- package/src/actions/linuxPackages.js +48 -0
- package/src/actions/mavenVerification.js +37 -0
- package/src/actions/publish.js +220 -0
- package/src/actions/uploadKey.js +134 -0
- package/src/actions/windowsSigning.js +1 -1
- package/src/actions/xcodeProject.js +63 -17
- package/src/actions/xcodeTargets.js +122 -0
- package/src/actions/xcodebuild.js +65 -0
- package/src/app/index.d.ts +126 -133
- package/src/app/index.js +126 -188
- package/src/assets/configure/install.sh +0 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +79 -26
- package/src/index.js +5 -1
- package/src/program.js +35 -4
- package/src/state/index.js +2 -1
- package/src/state/loadConfig.js +16 -5
- package/src/utils/appApiHeader.js +4 -0
- package/src/utils/appExtensions.js +761 -0
- package/src/utils/appFiles.js +58 -0
- package/src/utils/appLaunches.js +249 -0
- package/src/utils/appOptions.js +144 -95
- package/src/utils/appTargets.js +168 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/bridgeTools.js +66 -0
- package/src/utils/configChanges.js +30 -0
- package/src/utils/configWriter.js +181 -0
- package/src/utils/extensionSteps.js +135 -0
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +12 -0
- package/src/utils/password.js +34 -0
- package/src/utils/plist.js +5 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/resolveAppHost.js +7 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/systemConfig.js +19 -0
- package/src/utils/systemKeys.js +16 -0
- package/vitest.config.js +6 -1
- package/src/actions/appUpdate.js +0 -68
- package/src/actions/extensions.js +0 -7
package/src/utils/hermes.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import withDirLock from './dirLock.js';
|
|
5
5
|
import downloadAndExtractFile from './downloadAndExtractFile.js';
|
|
6
|
+
import logger from './logger.js';
|
|
6
7
|
|
|
7
8
|
// The Hermes React Native publishes on Maven Central for Apple platforms: the macOS framework, the iOS xcframework,
|
|
8
9
|
// hermesc and the headers. A new version also moves third_party/jsi of @crossbind/core-app-host to its tag, and the
|
|
@@ -25,6 +26,29 @@ export default async function resolveHermes() {
|
|
|
25
26
|
return `${dir}/source/destroot`;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// The dSYMs of the release's frameworks, with which App Store Connect shows Hermes' frames in an iOS app's crash reports
|
|
30
|
+
// as functions and lines.
|
|
31
|
+
export const HERMES_DSYM = {
|
|
32
|
+
url: `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${HERMES.version}/hermes-ios-${HERMES.version}-hermes-framework-dSYM-release.tar.gz`,
|
|
33
|
+
sha256: '5cea3dd57ecadb42c40d1cc8acf2e178dbfbe6a230410742d997ff3fe313a9c0',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The dSYM of the device's framework, downloaded once and checked against the pinned sha256. The release's 360 MB hold
|
|
37
|
+
// every Apple platform's, of which only the device's 80 MB are kept.
|
|
38
|
+
export async function resolveHermesDsym() {
|
|
39
|
+
const dir = `${hermesDir()}-dsym`;
|
|
40
|
+
const dsym = `${dir}/source/iphoneos/hermesvm.framework.dSYM`;
|
|
41
|
+
if (!fs.existsSync(dsym)) logger.info(`crossbind: downloading the dSYM of Hermes ${HERMES.version} once, 360 MB of which 80 MB stay, for crash reports.`);
|
|
42
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
43
|
+
await withDirLock(`${dir}.lock`, async () => {
|
|
44
|
+
await downloadAndExtractFile(HERMES_DSYM.url, dir, HERMES_DSYM.sha256);
|
|
45
|
+
fs.rmSync(`${dir}/${path.basename(HERMES_DSYM.url)}`, { force: true });
|
|
46
|
+
fs.readdirSync(`${dir}/source`).filter((entry) => entry !== 'iphoneos')
|
|
47
|
+
.forEach((entry) => fs.rmSync(`${dir}/source/${entry}`, { recursive: true, force: true }));
|
|
48
|
+
});
|
|
49
|
+
return dsym;
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
// Linux and Windows have no published Hermes, so crossbind's linux and windows images build the release's source, with
|
|
29
53
|
// their static libc++: JSI hands std types across, and the host and the project's archives build there too.
|
|
30
54
|
export const HERMES_SOURCE = {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// The keychain names a secret by a service and an account: an upload key's password by this service, with the key's own
|
|
4
|
+
// path as the account.
|
|
5
|
+
const SERVICE = 'crossbind upload key';
|
|
6
|
+
// security -i reads a command line whose quoted words take a backslash before a quote or a backslash.
|
|
7
|
+
const quoted = (word) => `"${word.replace(/[\\"]/g, '\\$&')}"`;
|
|
8
|
+
// Windows PowerShell, which imports its core modules from its own home: a pwsh 7 parent's PSModulePath hides them.
|
|
9
|
+
const powershell = (script) => ['-NoProfile', '-NonInteractive', '-Command', `Import-Module (Join-Path $PSHOME 'Modules\\Microsoft.PowerShell.Security'); ${script}`];
|
|
10
|
+
const STORE_DPAPI = 'ConvertTo-SecureString -String $env:CROSSBIND_KEYCHAIN_SECRET -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -LiteralPath $env:CROSSBIND_KEYCHAIN_FILE -NoNewline';
|
|
11
|
+
const READ_DPAPI = "$secret = Get-Content -LiteralPath $env:CROSSBIND_KEYCHAIN_FILE -Raw | ConvertTo-SecureString; [Console]::Out.Write([System.Net.NetworkCredential]::new('', $secret).Password)";
|
|
12
|
+
|
|
13
|
+
const run = (program, args, options = {}) => spawnSync(program, args, { encoding: 'utf8', ...options });
|
|
14
|
+
|
|
15
|
+
// Each system's keychain, given the secret on stdin or in the environment, never on a command line. On Windows it is the
|
|
16
|
+
// user's DPAPI, in a file beside the key that only that user on that machine can read, so the path alone names it.
|
|
17
|
+
const KEYCHAINS = {
|
|
18
|
+
darwin: {
|
|
19
|
+
store: (service, key, secret) => run('security', ['-i'], { input: `add-generic-password -U -s ${quoted(service)} -a ${quoted(key)} -w ${quoted(secret)}\n` }),
|
|
20
|
+
read: (service, key) => run('security', ['find-generic-password', '-s', service, '-a', key, '-w']),
|
|
21
|
+
},
|
|
22
|
+
win32: {
|
|
23
|
+
store: (service, key, secret) => run('powershell.exe', powershell(STORE_DPAPI), {
|
|
24
|
+
env: { ...process.env, CROSSBIND_KEYCHAIN_SECRET: secret, CROSSBIND_KEYCHAIN_FILE: `${key}.dpapi` },
|
|
25
|
+
}),
|
|
26
|
+
read: (service, key) => run('powershell.exe', powershell(READ_DPAPI), { env: { ...process.env, CROSSBIND_KEYCHAIN_FILE: `${key}.dpapi` } }),
|
|
27
|
+
},
|
|
28
|
+
linux: {
|
|
29
|
+
store: (service, key, secret) => run('secret-tool', ['store', '--label', service, 'service', service, 'account', key], { input: secret }),
|
|
30
|
+
read: (service, key) => run('secret-tool', ['lookup', 'service', service, 'account', key]),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const keychainOf = (platform) => KEYCHAINS[platform] ?? KEYCHAINS.linux;
|
|
35
|
+
|
|
36
|
+
// Whether the system has a keychain to keep a password in: macOS and Windows always, Linux where secret-tool is.
|
|
37
|
+
export const hasKeychain = ({ platform = process.platform } = {}) => ['darwin', 'win32'].includes(platform) || !run('secret-tool', ['--help']).error;
|
|
38
|
+
|
|
39
|
+
// An upload key's password, or another service's secret, from the system's keychain, or null when it keeps none or there
|
|
40
|
+
// is no keychain.
|
|
41
|
+
export function readSecret(key, { platform = process.platform, service = SERVICE } = {}) {
|
|
42
|
+
const result = keychainOf(platform).read(service, key);
|
|
43
|
+
if (result.error || result.status !== 0) return null;
|
|
44
|
+
// security ends what it prints with a newline.
|
|
45
|
+
return result.stdout.replace(/\n$/, '') || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keeps an upload key's password, or another service's secret, in the system's keychain, and says whether it did: Linux
|
|
49
|
+
// without secret-tool and its service has none. A secret counts as kept once it reads back, as security -i fails without
|
|
50
|
+
// an exit code.
|
|
51
|
+
export function storeSecret(key, secret, { platform = process.platform, service = SERVICE } = {}) {
|
|
52
|
+
keychainOf(platform).store(service, key, secret);
|
|
53
|
+
return readSecret(key, { platform, service }) === secret;
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Windows opens it without a shell, whose & would end the command inside the address.
|
|
4
|
+
const OPENERS = { darwin: ['open'], win32: ['rundll32', 'url.dll,FileProtocolHandler'] };
|
|
5
|
+
|
|
6
|
+
// Opens an address in the user's browser where the system can; whoever calls it prints the address too.
|
|
7
|
+
export default function openUrl(url) {
|
|
8
|
+
const [program, ...args] = OPENERS[process.platform] ?? ['xdg-open'];
|
|
9
|
+
const opener = spawn(program, [...args, url], { stdio: 'ignore', detached: true });
|
|
10
|
+
opener.on('error', () => {});
|
|
11
|
+
opener.unref();
|
|
12
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// NIST SP 800-63B-4's least length for a password that alone guards what it locks, as an upload key's does.
|
|
4
|
+
export const MIN_PASSWORD_LENGTH = 15;
|
|
5
|
+
// Rows a password can run along, either way and around: the alphabet, the digits, and a keyboard's.
|
|
6
|
+
const ROWS = ['abcdefghijklmnopqrstuvwxyz', '0123456789', 'qwertyuiopasdfghjklzxcvbnm', '1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p', 'q1w2e3r4t5y6u7i8o9p0'];
|
|
7
|
+
// Long passwords others try first, which the rules above let through.
|
|
8
|
+
const GUESSED = ['correcthorsebatterystaple', 'thequickbrownfoxjumpsoverthelazydog', 'crossbinduploadkey', 'androiduploadkey'];
|
|
9
|
+
|
|
10
|
+
// What the user reads before typing an upload key's password.
|
|
11
|
+
export const PASSWORD_RULES = [
|
|
12
|
+
`${MIN_PASSWORD_LENGTH} characters or more; spaces and any characters are welcome, so a few words make a good one`,
|
|
13
|
+
'not one part repeated, not a run as 1234, abcd or qwerty, and not a well-known password',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const runsAlong = (text, row) => {
|
|
17
|
+
const around = row.repeat(Math.ceil(text.length / row.length) + 1);
|
|
18
|
+
return around.includes(text) || [...around].reverse().join('').includes(text);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Why a password is too weak to lock an upload key, or null when it is not. NIST asks for no mix of kinds of characters.
|
|
22
|
+
export function passwordProblem(password) {
|
|
23
|
+
const length = [...password].length;
|
|
24
|
+
if (length < MIN_PASSWORD_LENGTH) return `it has ${length} characters and needs at least ${MIN_PASSWORD_LENGTH}`;
|
|
25
|
+
if (/\p{Cc}/u.test(password)) return 'it has a control character, which no terminal types';
|
|
26
|
+
const plain = password.toLowerCase();
|
|
27
|
+
if (/^(.+?)\1+$/su.test(plain)) return 'it repeats one part';
|
|
28
|
+
if (ROWS.some((row) => runsAlong(plain, row))) return 'it runs along a row, as 1234, abcd and qwerty do';
|
|
29
|
+
if (GUESSED.includes(plain.replace(/\s+/g, ''))) return 'it is a password others guess first';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A password crossbind makes: 18 random bytes, 144 bits, as 24 characters any terminal and keytool take.
|
|
34
|
+
export const makePassword = () => randomBytes(18).toString('base64url');
|
package/src/utils/plist.js
CHANGED
|
@@ -5,6 +5,11 @@ const escapeXml = (text) => String(text).replace(/&/g, '&').replace(/</g, '&
|
|
|
5
5
|
|
|
6
6
|
function xmlLines(value, indent) {
|
|
7
7
|
if (typeof value === 'boolean') return [`${indent}<${value}/>`];
|
|
8
|
+
if (typeof value === 'number') {
|
|
9
|
+
// An integer an integer element reads back the same, and any other number a real.
|
|
10
|
+
const type = Number.isSafeInteger(value) ? 'integer' : 'real';
|
|
11
|
+
return [`${indent}<${type}>${value}</${type}>`];
|
|
12
|
+
}
|
|
8
13
|
const inner = indent + XML_INDENT;
|
|
9
14
|
if (Array.isArray(value)) return [`${indent}<array>`, ...value.flatMap((item) => xmlLines(item, inner)), `${indent}</array>`];
|
|
10
15
|
if (typeof value === 'object') {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
// The C symbols of Apple's required reason API an iOS archive can import, each with its categories; getattrlist and its
|
|
5
|
+
// kin are in two. A selector, as systemUptime or activeInputModes is, is no symbol, so nm finds none of those.
|
|
6
|
+
const SYMBOLS = {
|
|
7
|
+
_stat: ['fileTimestamp'],
|
|
8
|
+
_fstat: ['fileTimestamp'],
|
|
9
|
+
_fstatat: ['fileTimestamp'],
|
|
10
|
+
_lstat: ['fileTimestamp'],
|
|
11
|
+
_getattrlistbulk: ['fileTimestamp'],
|
|
12
|
+
_getattrlist: ['fileTimestamp', 'diskSpace'],
|
|
13
|
+
_fgetattrlist: ['fileTimestamp', 'diskSpace'],
|
|
14
|
+
_getattrlistat: ['fileTimestamp', 'diskSpace'],
|
|
15
|
+
_statfs: ['diskSpace'],
|
|
16
|
+
_statvfs: ['diskSpace'],
|
|
17
|
+
_fstatfs: ['diskSpace'],
|
|
18
|
+
_fstatvfs: ['diskSpace'],
|
|
19
|
+
_mach_absolute_time: ['systemBootTime'],
|
|
20
|
+
'_OBJC_CLASS_$_NSUserDefaults': ['userDefaults'],
|
|
21
|
+
};
|
|
22
|
+
// nm's list of a large library, as GDAL's, runs to megabytes.
|
|
23
|
+
const NM_OUTPUT_LIMIT = 256 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
// The categories that the symbols of `nm -u -j` output call, each with those symbols.
|
|
26
|
+
export function reasonCategories(output) {
|
|
27
|
+
const categories = new Map();
|
|
28
|
+
output.split('\n').map((line) => line.trim()).filter((symbol) => Object.hasOwn(SYMBOLS, symbol)).forEach((symbol) => {
|
|
29
|
+
SYMBOLS[symbol].forEach((category) => categories.set(category, (categories.get(category) ?? new Set()).add(symbol)));
|
|
30
|
+
});
|
|
31
|
+
return categories;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The categories an archive or a linked binary imports, as Xcode's nm lists them, whatever nm comes first on the PATH.
|
|
35
|
+
// Its LLVM bitcode reader stays off: the symbol table tells all, and the bitcode Rust's standard library embeds comes
|
|
36
|
+
// from a newer LLVM than an older Xcode reads.
|
|
37
|
+
function importedCategories(file) {
|
|
38
|
+
const result = spawnSync('xcrun', ['nm', '--no-llvm-bc', '-u', '-j', file], { encoding: 'utf8', maxBuffer: NM_OUTPUT_LIMIT });
|
|
39
|
+
if (result.status !== 0) throw new Error(`crossbind: cannot list what ${file} calls: ${result.stderr || result.error?.message}`);
|
|
40
|
+
return reasonCategories(result.stdout);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// What the archives an iOS app links call of required reason API with no reason in app.privacy: each library, by the
|
|
44
|
+
// name its archive has, with each such category and its symbols.
|
|
45
|
+
export function unreasonedApis(archives, privacy) {
|
|
46
|
+
const found = new Map();
|
|
47
|
+
archives.forEach((archive) => {
|
|
48
|
+
const library = path.basename(archive).replace(/^\d+-/, '').replace(/^lib/, '').replace(/\.a$/, '');
|
|
49
|
+
importedCategories(archive).forEach((symbols, category) => {
|
|
50
|
+
if (Object.hasOwn(privacy, category)) return;
|
|
51
|
+
const key = `${library}\0${category}`;
|
|
52
|
+
found.set(key, new Set([...(found.get(key) ?? []), ...symbols]));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return [...found].map(([key, symbols]) => {
|
|
56
|
+
const [library, category] = key.split('\0');
|
|
57
|
+
return { library, category, symbols: [...symbols].sort() };
|
|
58
|
+
}).sort((a, b) => a.library.localeCompare(b.library) || a.category.localeCompare(b.category));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What an iOS app's linked binary calls of required reason API with no reason in app.privacy, each category with its
|
|
62
|
+
// symbols: what the linker kept of its archives, which App Store Connect checks.
|
|
63
|
+
export const unreasonedImports = (binary, privacy) => [...importedCategories(binary)]
|
|
64
|
+
.filter(([category]) => !Object.hasOwn(privacy, category))
|
|
65
|
+
.map(([category, symbols]) => ({ category, symbols: [...symbols].sort() }))
|
|
66
|
+
.sort((a, b) => a.category.localeCompare(b.category));
|
|
67
|
+
|
|
68
|
+
// The warning of what the archives of an iOS app reference of required reason API without its reasons, or none. The
|
|
69
|
+
// linker may leave some of it out of the app, as it left _stat, _lstat and _fstatat out of the conformance app, whose
|
|
70
|
+
// Rust references them; the app keeps what it calls.
|
|
71
|
+
export function unreasonedWarning(found) {
|
|
72
|
+
if (found.length === 0) return null;
|
|
73
|
+
const uses = new Map();
|
|
74
|
+
found.forEach(({ library, category, symbols }) => uses.set(library, [...(uses.get(library) ?? []), `${category} (${symbols.join(', ')})`]));
|
|
75
|
+
const references = [...uses].map(([library, list]) => `${library} references ${list.join(' and ')}`).join('; ');
|
|
76
|
+
return `crossbind: App Store Connect refuses an iOS app that calls required reason API without its reasons (ITMS-91053), and the archives this app links reference some with no reason in app.privacy: ${references}. The linker may leave some out. Give app.privacy Apple's reasons for what the app calls, as { fileTimestamp: ['C617.1'] }; Apple's "Describing use of required reason API" says what each covers.`;
|
|
77
|
+
}
|
|
@@ -19,3 +19,10 @@ export function resolveAppEmbindJsiRoot() {
|
|
|
19
19
|
const req = createRequire(path.join(resolveAppHostRoot(), 'package.json'));
|
|
20
20
|
return path.dirname(fs.realpathSync(req.resolve('@crossbind/core-embind-jsi/package.json')));
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// crossbind/app as an app's page imports it, which the app host assembles with what crossbind's own extensions give the page.
|
|
24
|
+
export function resolveAppPageApi() {
|
|
25
|
+
const file = path.join(resolveAppHostRoot(), 'js', 'app.js');
|
|
26
|
+
if (!fs.existsSync(file)) throw new Error(`crossbind: the app host at ${resolveAppHostRoot()} has no crossbind/app for an app's page: update ${PKG} to match crossbind.`);
|
|
27
|
+
return fs.realpathSync(file);
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
let folder = null;
|
|
6
|
+
|
|
7
|
+
// Removes the folder of the secrets written, as crossbind does when it exits.
|
|
8
|
+
export function removeSecretFiles() {
|
|
9
|
+
if (folder) fs.rmSync(folder, { recursive: true, force: true });
|
|
10
|
+
folder = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A secret the environment holds as its text, as CI's secrets come, written to a file of a folder only this user opens,
|
|
14
|
+
// as the tools that take it read files. The folder goes when crossbind exits.
|
|
15
|
+
export function secretFile(name, data) {
|
|
16
|
+
if (!folder) {
|
|
17
|
+
folder = fs.mkdtempSync(path.join(os.tmpdir(), 'crossbind-secrets-'));
|
|
18
|
+
process.once('exit', removeSecretFiles);
|
|
19
|
+
}
|
|
20
|
+
const file = path.join(folder, name);
|
|
21
|
+
fs.writeFileSync(file, data, { mode: 0o600 });
|
|
22
|
+
return file;
|
|
23
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import loadJson from './loadJson.js';
|
|
3
3
|
import systemKeys from './systemKeys.js';
|
|
4
|
+
import writeJson from './writeJson.js';
|
|
4
5
|
|
|
5
6
|
export const systemConfigPath = () => `${os.homedir()}/.crossbind.json`;
|
|
6
7
|
|
|
@@ -11,3 +12,21 @@ export default function loadSystemConfig() {
|
|
|
11
12
|
.map(([key, value]) => [key, value.default]);
|
|
12
13
|
return { ...system, ...Object.fromEntries(defaults) };
|
|
13
14
|
}
|
|
15
|
+
|
|
16
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
// The settings of an extension's own, which ~/.crossbind.json keeps apart from crossbind's, under extensions by the
|
|
19
|
+
// extension's name.
|
|
20
|
+
export function extensionSettingsOf(system, name) {
|
|
21
|
+
const own = system.extensions === undefined ? {} : isObject(system.extensions) && (system.extensions[name] ?? {});
|
|
22
|
+
if (!isObject(own)) throw new Error(`crossbind: ${systemConfigPath()} keeps the settings of the extension ${name} as no object of them, under extensions: fix it.`);
|
|
23
|
+
return own;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sets keys of ~/.crossbind.json, as `crossbind config set` does, keeping the others, and with { name, values } settings
|
|
27
|
+
// of that extension's own.
|
|
28
|
+
export function updateSystemConfig(values, extension = null) {
|
|
29
|
+
const system = loadSystemConfig();
|
|
30
|
+
const own = extension && { extensions: { ...system.extensions, [extension.name]: { ...extensionSettingsOf(system, extension.name), ...extension.values } } };
|
|
31
|
+
writeJson(systemConfigPath(), { ...system, ...values, ...own });
|
|
32
|
+
}
|
package/src/utils/systemKeys.js
CHANGED
|
@@ -3,6 +3,22 @@ const systemKeys = {
|
|
|
3
3
|
description: 'The unique identifier of the development team used for code signing and app distribution in Xcode.',
|
|
4
4
|
default: '',
|
|
5
5
|
},
|
|
6
|
+
ANDROID_KEYSTORE: {
|
|
7
|
+
description: "This machine's upload key, which signs an Android app's release and its bundle for Google Play; crossbind app upload-key sets it. The CROSSBIND_ANDROID_KEYSTORE environment variable overrides it.",
|
|
8
|
+
default: '',
|
|
9
|
+
},
|
|
10
|
+
ANDROID_KEY_ALIAS: {
|
|
11
|
+
description: 'The alias of the upload key in ANDROID_KEYSTORE. The CROSSBIND_ANDROID_KEY_ALIAS environment variable overrides it.',
|
|
12
|
+
default: '',
|
|
13
|
+
},
|
|
14
|
+
GOOGLE_PLAY_SERVICE_ACCOUNT: {
|
|
15
|
+
description: "The e-mail of the service account crossbind app publish uploads to Google Play as, through this machine's gcloud login, so no key of it exists. The CROSSBIND_GOOGLE_PLAY_SERVICE_ACCOUNT environment variable overrides it.",
|
|
16
|
+
default: '',
|
|
17
|
+
},
|
|
18
|
+
GOOGLE_PLAY_KEY: {
|
|
19
|
+
description: 'The JSON key of the service account crossbind app publish uploads to Google Play as, where gcloud is not, as in CI. The CROSSBIND_GOOGLE_PLAY_KEY environment variable overrides it.',
|
|
20
|
+
default: '',
|
|
21
|
+
},
|
|
6
22
|
RUNNER: {
|
|
7
23
|
description: 'Where toolchain steps run: DOCKER_RUN, DOCKER_EXEC, LOCAL, or REMOTE on the runners REMOTE_URL and REMOTE_URL_<IMAGE> name; a machine that names none builds on crossbind cloud once signed in with crossbind login. The CROSSBIND_RUNNER environment variable overrides it.',
|
|
8
24
|
options: ['DOCKER_RUN', 'DOCKER_EXEC', 'LOCAL', 'REMOTE'],
|
package/vitest.config.js
CHANGED
|
@@ -52,7 +52,12 @@ export default defineConfig({
|
|
|
52
52
|
// exercised a machine without that image already cached, and android builds shipped
|
|
53
53
|
// broken to every Apple Silicon user. Which command line these actions build IS the
|
|
54
54
|
// behaviour, so the layer is measured.
|
|
55
|
-
|
|
55
|
+
// crossbind's own extensions' build-side code, which left src/ as packages of their own, is measured with it.
|
|
56
|
+
include: [
|
|
57
|
+
'src/utils/**/*.js', 'src/state/**/*.js', 'src/assets/**/*.js', 'src/actions/**/*.js',
|
|
58
|
+
'../../extensions/*/crossbind.extension.js', '../../extensions/*/src/**/*.js', '../../core/store-kit/*.js',
|
|
59
|
+
],
|
|
60
|
+
allowExternal: true,
|
|
56
61
|
all: true,
|
|
57
62
|
// A floor with a one-point band, not a ratchet. Coverage is not identical across
|
|
58
63
|
// environments - resolveEmbindRust walks the real node_modules layout, so which of its
|
package/src/actions/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
|
-
}
|