crossbind 2.0.0-beta.66 → 2.0.0-beta.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -23
- package/package.json +2 -2
- package/src/actions/androidGradle.js +44 -9
- package/src/actions/appInstaller.js +8 -5
- package/src/actions/appSetup.js +116 -0
- package/src/actions/appStoreConnect.js +188 -0
- package/src/actions/appleSigning.js +32 -14
- package/src/actions/buildApp.js +83 -16
- package/src/actions/buildBinTools.js +0 -0
- package/src/actions/buildExternal.js +8 -1
- package/src/actions/createLib.js +5 -3
- package/src/actions/getCmakeParameters.js +2 -3
- package/src/actions/googlePlay.js +213 -0
- package/src/actions/googleSignIn.js +78 -0
- package/src/actions/gradleProject.js +3 -0
- package/src/actions/iosDevice.js +200 -0
- package/src/actions/playSetup.js +19 -0
- package/src/actions/publishAndroid.js +102 -0
- package/src/actions/publishIos.js +146 -0
- package/src/actions/uploadKey.js +134 -0
- package/src/actions/xcodeProject.js +30 -9
- package/src/app/index.d.ts +43 -1
- package/src/app/index.js +64 -3
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +81 -20
- package/src/index.js +3 -0
- package/src/program.js +3 -1
- package/src/utils/appApiHeader.js +4 -0
- package/src/utils/appOptions.js +77 -0
- package/src/utils/ask.js +38 -0
- package/src/utils/configWriter.js +64 -0
- package/src/utils/embindArgumentGuards.js +8 -5
- package/src/utils/embindCallArguments.js +6 -6
- package/src/utils/hermes.js +24 -0
- package/src/utils/keychain.js +54 -0
- package/src/utils/openUrl.js +11 -0
- package/src/utils/overrideDependency.js +3 -0
- package/src/utils/password.js +34 -0
- package/src/utils/recipePatches.js +61 -0
- package/src/utils/requiredReasonApis.js +77 -0
- package/src/utils/secretFile.js +23 -0
- package/src/utils/sourceFingerprint.js +8 -1
- package/src/utils/systemConfig.js +6 -0
- package/src/utils/systemKeys.js +16 -0
package/src/utils/appOptions.js
CHANGED
|
@@ -11,6 +11,16 @@ const WINDOWS_VERSION = /^(\d+(?:\.\d+){0,3})(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
|
11
11
|
// What app.openPath can let the page's shell.openPath open: the app's folders as app.paths() names them, and what a
|
|
12
12
|
// dialog picked during this run.
|
|
13
13
|
const OPEN_PATH_PLACES = ['data', 'cache', 'logs', 'picked'];
|
|
14
|
+
// Apple's app categories, which macOS shows an app under, each with the main categories of the freedesktop menu
|
|
15
|
+
// specification a Linux menu shows it under. An audio or a video app is in AudioVideo as well, as the specification asks.
|
|
16
|
+
const GAMES = ['', 'action-', 'adventure-', 'arcade-', 'board-', 'card-', 'casino-', 'dice-', 'educational-', 'family-', 'kids-', 'music-', 'puzzle-',
|
|
17
|
+
'racing-', 'role-playing-', 'simulation-', 'sports-', 'strategy-', 'trivia-', 'word-'].map((kind) => `${kind}games`);
|
|
18
|
+
const APP_CATEGORIES = {
|
|
19
|
+
business: 'Office', 'developer-tools': 'Development', education: 'Education', entertainment: 'AudioVideo', finance: 'Office',
|
|
20
|
+
'graphics-design': 'Graphics', 'healthcare-fitness': 'Utility', lifestyle: 'Utility', medical: 'Science', music: 'Audio;AudioVideo', news: 'Network',
|
|
21
|
+
photography: 'Graphics', productivity: 'Office', reference: 'Education', 'social-networking': 'Network', sports: 'Utility', travel: 'Utility',
|
|
22
|
+
utilities: 'Utility', video: 'Video;AudioVideo', weather: 'Utility', ...Object.fromEntries(GAMES.map((category) => [category, 'Game'])),
|
|
23
|
+
};
|
|
14
24
|
|
|
15
25
|
// An app's bundle id, which also names its folders on the desktop: app.id, else one made from the project's name.
|
|
16
26
|
export function appId(config) {
|
|
@@ -38,6 +48,28 @@ export function appDisplayName(config) {
|
|
|
38
48
|
return name;
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
// The copyright line a macOS app's About panel and a Windows executable's details show: app.copyright, else none. The
|
|
52
|
+
// host takes it as a C string and a Windows resource string, as it takes the name.
|
|
53
|
+
export function appCopyright(config) {
|
|
54
|
+
const copyright = config.app.copyright;
|
|
55
|
+
if (copyright === undefined) return null;
|
|
56
|
+
if (typeof copyright !== 'string' || !copyright.trim() || /["\\\p{Cc}]/u.test(copyright)) {
|
|
57
|
+
throw new Error(`crossbind: app.copyright ${JSON.stringify(copyright)} is no copyright line, which is text without a quotation mark, a backslash or a control character, as '© 2026 Example Inc.' is.`);
|
|
58
|
+
}
|
|
59
|
+
return copyright;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The category app.category names, under which macOS shows the app, and the ones a Linux menu shows it under; none
|
|
63
|
+
// without app.category.
|
|
64
|
+
export function appCategory(config) {
|
|
65
|
+
const category = config.app.category;
|
|
66
|
+
if (category === undefined) return null;
|
|
67
|
+
if (!Object.hasOwn(APP_CATEGORIES, category)) {
|
|
68
|
+
throw new Error(`crossbind: app.category ${JSON.stringify(category)} is none of Apple's app categories, which are ${Object.keys(APP_CATEGORIES).join(', ')}.`);
|
|
69
|
+
}
|
|
70
|
+
return { darwin: `public.app-category.${category}`, linux: APP_CATEGORIES[category] };
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
// The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes and
|
|
42
74
|
// whether it opens full screen. Its backgroundColor, which a phone takes as well, is appBackgroundColor's.
|
|
43
75
|
export function appWindow(config) {
|
|
@@ -114,6 +146,46 @@ export function usageDescriptions(config, platform) {
|
|
|
114
146
|
return Object.fromEntries(Object.entries(appPermissions(config)).flatMap(([name, reason]) => keys[name].map((key) => [key, reason])));
|
|
115
147
|
}
|
|
116
148
|
|
|
149
|
+
// Apple's categories of required reason API, each with the type a privacy manifest names it by and the reasons Apple
|
|
150
|
+
// takes for it, from "Describing use of required reason API".
|
|
151
|
+
export const PRIVACY_CATEGORIES = {
|
|
152
|
+
fileTimestamp: { type: 'NSPrivacyAccessedAPICategoryFileTimestamp', reasons: ['DDA9.1', 'C617.1', '3B52.1', '0A2A.1'] },
|
|
153
|
+
systemBootTime: { type: 'NSPrivacyAccessedAPICategorySystemBootTime', reasons: ['35F9.1', '8FFB.1', '3D61.1'] },
|
|
154
|
+
diskSpace: { type: 'NSPrivacyAccessedAPICategoryDiskSpace', reasons: ['85F4.1', 'E174.1', '7D9E.1', 'B728.1'] },
|
|
155
|
+
activeKeyboards: { type: 'NSPrivacyAccessedAPICategoryActiveKeyboards', reasons: ['3EC4.1', '54BD.1'] },
|
|
156
|
+
userDefaults: { type: 'NSPrivacyAccessedAPICategoryUserDefaults', reasons: ['CA92.1', '1C8F.1', 'C56D.1', 'AC6B.1'] },
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// The reasons an iOS app's privacy manifest gives for the required reason API it calls: app.privacy, each category with
|
|
160
|
+
// Apple's reasons that fit, as { fileTimestamp: ['C617.1'] }.
|
|
161
|
+
export function appPrivacy(config) {
|
|
162
|
+
const privacy = config.app.privacy ?? {};
|
|
163
|
+
const names = Object.keys(PRIVACY_CATEGORIES).join(', ');
|
|
164
|
+
if (typeof privacy !== 'object' || privacy === null || Array.isArray(privacy)) {
|
|
165
|
+
throw new Error(`crossbind: app.privacy ${JSON.stringify(privacy)} is no object of ${names}, each with Apple's reasons for it, as { fileTimestamp: ['C617.1'] } is.`);
|
|
166
|
+
}
|
|
167
|
+
const unknown = Object.keys(privacy).filter((name) => !Object.hasOwn(PRIVACY_CATEGORIES, name));
|
|
168
|
+
if (unknown.length > 0) throw new Error(`crossbind: app.privacy takes ${names}; ${unknown.join(', ')} is none of them.`);
|
|
169
|
+
Object.entries(privacy).forEach(([name, reasons]) => {
|
|
170
|
+
const allowed = PRIVACY_CATEGORIES[name].reasons;
|
|
171
|
+
if (!Array.isArray(reasons) || reasons.length === 0 || reasons.some((reason) => !allowed.includes(reason))) {
|
|
172
|
+
throw new Error(`crossbind: app.privacy.${name} ${JSON.stringify(reasons)} is no list of Apple's reasons for it, which are ${allowed.join(', ')}; Apple's "Describing use of required reason API" says what each covers.`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return privacy;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Whether an iOS app uses encryption that export rules do not exempt, which its Info.plist tells App Store Connect so that
|
|
179
|
+
// it stops asking for every build: app.encryption. It is a legal statement, so there is none without it.
|
|
180
|
+
export function appEncryption(config) {
|
|
181
|
+
const encryption = config.app.encryption;
|
|
182
|
+
if (encryption === undefined) return null;
|
|
183
|
+
if (typeof encryption !== 'boolean') {
|
|
184
|
+
throw new Error(`crossbind: app.encryption ${JSON.stringify(encryption)} is no boolean: set it to false when the app uses no encryption, or only what export rules exempt as the system's HTTPS, and to true when it uses any other.`);
|
|
185
|
+
}
|
|
186
|
+
return encryption;
|
|
187
|
+
}
|
|
188
|
+
|
|
117
189
|
// The version a Windows app's executable carries: package.json's, whose numbers are its file version.
|
|
118
190
|
function windowsVersion(config) {
|
|
119
191
|
const version = config.package?.version ?? '1.0.0';
|
|
@@ -249,6 +321,10 @@ export function appHeader(config, platform) {
|
|
|
249
321
|
...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
|
|
250
322
|
];
|
|
251
323
|
if (!['darwin', 'linux', 'win32'].includes(platform)) return [comment, openPath, ...page, ''].join('\n');
|
|
324
|
+
// The category goes to the Info.plist and the desktop entry, which the build writes later; any desktop refuses a
|
|
325
|
+
// wrong one here.
|
|
326
|
+
const copyright = appCopyright(config);
|
|
327
|
+
appCategory(config);
|
|
252
328
|
const lines = [
|
|
253
329
|
comment,
|
|
254
330
|
openPath,
|
|
@@ -277,6 +353,7 @@ export function appHeader(config, platform) {
|
|
|
277
353
|
if (platform === 'win32') {
|
|
278
354
|
const version = windowsVersion(config);
|
|
279
355
|
lines.push(`#define CROSSBIND_APP_VERSION "${version.text}"`, `#define CROSSBIND_APP_VERSION_NUMBERS ${version.numbers}`);
|
|
356
|
+
if (copyright) lines.push(`#define CROSSBIND_APP_COPYRIGHT "${copyright}"`);
|
|
280
357
|
// The build writes the icon beside this header.
|
|
281
358
|
if (config.app.icon !== undefined) lines.push('#define CROSSBIND_APP_ICON 1');
|
|
282
359
|
}
|
package/src/utils/ask.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
|
|
4
|
+
// Whether someone is at a terminal to answer a question, as no one is in CI or behind a pipe.
|
|
5
|
+
export const hasTerminal = () => Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
6
|
+
|
|
7
|
+
function ask(question, { input, output, isHidden }) {
|
|
8
|
+
output.write(question);
|
|
9
|
+
// A hidden answer is echoed into nothing, while the terminal, in raw mode, shows none of it either.
|
|
10
|
+
const echo = isHidden ? new Writable({ write: (chunk, encoding, done) => done() }) : output;
|
|
11
|
+
const reader = readline.createInterface({ input, output: echo, terminal: isHidden });
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let isAnswered = false;
|
|
14
|
+
reader.once('line', (line) => {
|
|
15
|
+
isAnswered = true;
|
|
16
|
+
reader.close();
|
|
17
|
+
if (isHidden) output.write('\n');
|
|
18
|
+
resolve(line);
|
|
19
|
+
});
|
|
20
|
+
reader.once('SIGINT', () => {
|
|
21
|
+
reader.close();
|
|
22
|
+
output.write('\n');
|
|
23
|
+
reject(new Error('crossbind: stopped at the question.'));
|
|
24
|
+
});
|
|
25
|
+
reader.once('close', () => {
|
|
26
|
+
if (!isAnswered) reject(new Error('crossbind: the terminal closed before an answer.'));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// An answer typed on the terminal that the terminal does not show, as a password.
|
|
32
|
+
export const askHidden = (question, { input = process.stdin, output = process.stderr } = {}) => ask(question, { input, output, isHidden: true });
|
|
33
|
+
|
|
34
|
+
// An answer typed on the terminal.
|
|
35
|
+
export const askLine = (question, { input = process.stdin, output = process.stderr } = {}) => ask(question, { input, output, isHidden: false });
|
|
36
|
+
|
|
37
|
+
// A yes or no typed on the terminal: yes unless it starts with n.
|
|
38
|
+
export const askYesNo = async (question, streams) => !/^\s*n/i.test(await askLine(question, streams));
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { parseAst } from 'rollup/parseAst';
|
|
3
|
+
|
|
4
|
+
// The kinds of crossbind.config whose default export can be an object literal to write into.
|
|
5
|
+
const CONFIG_EXTENSIONS = ['js', 'mjs', 'cjs'];
|
|
6
|
+
const DEFAULT_INDENT = ' ';
|
|
7
|
+
|
|
8
|
+
const indentOf = (source, at) => source.slice(source.lastIndexOf('\n', at - 1) + 1).match(/^[ \t]*/)[0];
|
|
9
|
+
const isOneLine = (source, from, to) => !source.slice(from, to).includes('\n');
|
|
10
|
+
const insert = (source, at, text) => `${source.slice(0, at)}${text}${source.slice(at)}`;
|
|
11
|
+
const propertyOf = (object, name) => object.properties.find((property) => property.type === 'Property' && !property.computed
|
|
12
|
+
&& (property.key.name ?? property.key.value) === name);
|
|
13
|
+
// A spread may carry the key, and would win over one written before it.
|
|
14
|
+
const hasSpread = (object) => object.properties.some((property) => property.type === 'SpreadElement');
|
|
15
|
+
// A value as one writes it in a config by hand: { fileTimestamp: ['C617.1'] }.
|
|
16
|
+
function literal(value) {
|
|
17
|
+
if (typeof value === 'string') return `'${value.replace(/[\\']/g, '\\$&')}'`;
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map(literal).join(', ')}]`;
|
|
19
|
+
if (value !== null && typeof value === 'object') return `{ ${Object.entries(value).map(([key, item]) => `${key}: ${literal(item)}`).join(', ')} }`;
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
export const entry = (key, value) => `${key}: ${literal(value)}`;
|
|
23
|
+
|
|
24
|
+
// How far an object's keys sit in from the line it opens on.
|
|
25
|
+
function indentUnit(source, object) {
|
|
26
|
+
const [first] = object.properties;
|
|
27
|
+
if (!first || isOneLine(source, object.start, first.start)) return DEFAULT_INDENT;
|
|
28
|
+
return indentOf(source, first.start).slice(indentOf(source, object.start).length) || DEFAULT_INDENT;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The config's source with `key: value` first in its app section, which it makes where there is none, or null where it
|
|
32
|
+
// cannot place it safely: the default export must be an object literal, its app one too, without the key or a spread.
|
|
33
|
+
export function withAppOption(source, key, value) {
|
|
34
|
+
const exported = parseAst(source).body.find((node) => node.type === 'ExportDefaultDeclaration')?.declaration;
|
|
35
|
+
if (exported?.type !== 'ObjectExpression') return null;
|
|
36
|
+
const unit = indentUnit(source, exported);
|
|
37
|
+
const text = entry(key, value);
|
|
38
|
+
const app = propertyOf(exported, 'app');
|
|
39
|
+
if (!app) {
|
|
40
|
+
if (hasSpread(exported)) return null;
|
|
41
|
+
const [first] = exported.properties;
|
|
42
|
+
if (!first || isOneLine(source, exported.start, first.start)) return insert(source, exported.start + 1, ` app: { ${text} },`);
|
|
43
|
+
const indent = indentOf(source, first.start);
|
|
44
|
+
return insert(source, exported.start + 1, `\n${indent}app: {\n${indent}${unit}${text},\n${indent}},`);
|
|
45
|
+
}
|
|
46
|
+
if (app.value.type !== 'ObjectExpression' || propertyOf(app.value, key) || hasSpread(app.value)) return null;
|
|
47
|
+
const [first] = app.value.properties;
|
|
48
|
+
if (!first) {
|
|
49
|
+
const indent = indentOf(source, app.start);
|
|
50
|
+
return `${source.slice(0, app.value.start)}{\n${indent}${unit}${text},\n${indent}}${source.slice(app.value.end)}`;
|
|
51
|
+
}
|
|
52
|
+
if (isOneLine(source, app.value.start, first.start)) return insert(source, app.value.start + 1, ` ${text},`);
|
|
53
|
+
return insert(source, app.value.start + 1, `\n${indentOf(source, first.start)}${text},`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Writes `key: value` into the project's crossbind.config, and says whether it could.
|
|
57
|
+
export function writeAppOption(project, key, value) {
|
|
58
|
+
const file = CONFIG_EXTENSIONS.map((extension) => `${project}/crossbind.config.${extension}`).find((candidate) => fs.existsSync(candidate));
|
|
59
|
+
if (!file) return false;
|
|
60
|
+
const changed = withAppOption(fs.readFileSync(file, 'utf8'), key, value);
|
|
61
|
+
if (changed === null) return false;
|
|
62
|
+
fs.writeFileSync(file, changed);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -26,8 +26,9 @@ const GUARDS = [
|
|
|
26
26
|
},
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
// The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'
|
|
30
|
-
|
|
29
|
+
// The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'`;
|
|
30
|
+
// emscripten 6.0.10 and later write `===`, and their release glue drops the braces.
|
|
31
|
+
const BIGINT_FROM_NUMBER = /if\s*\(\s*typeof value\s*===?\s*["']number["']\s*\)\s*(?:\{\s*value\s*=\s*BigInt\(value\)\s*;?\s*\}|value\s*=\s*BigInt\(value\)\s*;?)/g;
|
|
31
32
|
const SAFE_BIGINT_FROM_NUMBER = 'if(typeof value=="number"){if(!Number.isSafeInteger(value))throw new TypeError("a 64-bit integer parameter takes a BigInt or a safe integer Number, got "+value);value=BigInt(value)}';
|
|
32
33
|
|
|
33
34
|
// Returns the rewritten glue and whether a bigint registration kept a conversion this rewrite does not recognise.
|
|
@@ -43,7 +44,7 @@ const DEBUG_GUARDS = [
|
|
|
43
44
|
{
|
|
44
45
|
registration: '__embind_register_integer',
|
|
45
46
|
end: 'readValueFromPointer: integerReadValueFromPointer(',
|
|
46
|
-
from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value
|
|
47
|
+
from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value !==? ["']number["'])/,
|
|
47
48
|
to: 'toWireType: (destructors, value) => {$1'
|
|
48
49
|
+ 'if (typeof value == "string" && size == 1 && value.length == 1) value = value.charCodeAt(0);$1'
|
|
49
50
|
+ 'if (value !== null && typeof value == "object" && typeof value.value == "number") value = value.value;$1',
|
|
@@ -72,9 +73,11 @@ function guardDebugRegistration(glue, { registration, end, from, to, applied })
|
|
|
72
73
|
return { text: glue.slice(0, start) + rewritten + glue.slice(stop), missed: rewritten === code };
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
// Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect.
|
|
76
|
+
// Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect. Emscripten
|
|
77
|
+
// 6.0.10 and later write a registration the glue uses once into the wasm imports, as `name:(` rather than `name=(`; a
|
|
78
|
+
// debug glue's wasm imports only name it (`name: __name`).
|
|
76
79
|
export function guardEmbindArguments(glue) {
|
|
77
|
-
const missed = GUARDS.filter(({ registration, from, to }) =>
|
|
80
|
+
const missed = GUARDS.filter(({ registration, from, to }) => new RegExp(`${registration}[=:]\\(`).test(glue) && !glue.includes(from) && !glue.includes(to))
|
|
78
81
|
.map(({ registration }) => registration);
|
|
79
82
|
const released = GUARDS.reduce((current, { from, to }) => current.split(from).join(to), glue);
|
|
80
83
|
return DEBUG_GUARDS.reduce(({ text, missed: names }, guard) => {
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// once it returns, or once its promise settles for an async (_JSPI) function, so a call started before
|
|
4
4
|
// then overwrites them and the earlier call frees the later call's arguments instead of its own, which
|
|
5
5
|
// leak: two pending calls of one _JSPI method, or a method called again from a JavaScript callback it
|
|
6
|
-
// runs. Emscripten 6.0.9
|
|
7
|
-
// the three inside the invoker, so each call gets its own.
|
|
6
|
+
// runs. Emscripten 6.0.9 and 6.0.13 build it this way, as did upstream main on 2026-09-30; this rewrite
|
|
7
|
+
// declares the three inside the invoker, so each call gets its own.
|
|
8
8
|
|
|
9
9
|
// The release glue spells it on one line, the debug glue one statement per line with an argument
|
|
10
|
-
// count check first.
|
|
11
|
-
const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
|
|
12
|
-
const PER_CALL = '
|
|
13
|
-
const SEPARATED = /var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
|
|
10
|
+
// count check first. Emscripten 6.0.10 and later hand the release invoker straight to createNamedFunction.
|
|
11
|
+
const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*((?:var invokerFn\s*=\s*|return createNamedFunction\(humanName,\s*)function\s*\(\.\.\.args\)\s*\{)(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
|
|
12
|
+
const PER_CALL = '$1$2var argsWired=new Array(expectedArgCount);var invokerFuncArgs=[];var destructors=[];';
|
|
13
|
+
const SEPARATED = /(?:var invokerFn\s*=\s*|return createNamedFunction\(humanName,\s*)function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
|
|
14
14
|
|
|
15
15
|
// Returns the rewritten glue and whether it still holds an invoker this rewrite does not recognise.
|
|
16
16
|
export function separateCallArguments(glue) {
|
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,11 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', '""'] };
|
|
4
|
+
|
|
5
|
+
// Opens an address in the user's browser where the system can; whoever calls it prints the address too.
|
|
6
|
+
export default function openUrl(url) {
|
|
7
|
+
const [program, ...args] = OPENERS[process.platform] ?? ['xdg-open'];
|
|
8
|
+
const opener = spawn(program, [...args, url], { stdio: 'ignore', detached: true });
|
|
9
|
+
opener.on('error', () => {});
|
|
10
|
+
opener.unref();
|
|
11
|
+
}
|
|
@@ -90,9 +90,12 @@ export function getOverrideKey(dep) {
|
|
|
90
90
|
override,
|
|
91
91
|
(key, value) => (typeof value === 'function' ? `fn:${value.toString()}` : value),
|
|
92
92
|
);
|
|
93
|
+
// The package version too: a new release of the package can build the same upstream release differently, as one
|
|
94
|
+
// that adds a patch does, and the rebuild cached under the old one would keep linking the old build.
|
|
93
95
|
const parts = [
|
|
94
96
|
dep.general?.name || '',
|
|
95
97
|
override.nativeVersion || dep.package?.nativeVersion || '',
|
|
98
|
+
dep.package?.version || '',
|
|
96
99
|
serial,
|
|
97
100
|
];
|
|
98
101
|
return getContentHash(parts.join(' '));
|
|
@@ -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');
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getContentHash } from './hash.js';
|
|
6
|
+
|
|
7
|
+
const patchPath = (file) => (file instanceof URL ? fileURLToPath(file) : file);
|
|
8
|
+
|
|
9
|
+
// Inside the tree, which every extraction replaces whole: a tree says it is patched only once its own patches went in,
|
|
10
|
+
// so neither one a patch failed on nor one a stopped run left behind passes, whatever stamp an earlier tree had.
|
|
11
|
+
const stampOf = (buildPath) => path.join(buildPath, 'source', '.crossbind-patches');
|
|
12
|
+
|
|
13
|
+
export function recipePatchesFingerprint(patches) {
|
|
14
|
+
if (!patches?.length) return null;
|
|
15
|
+
return getContentHash(JSON.stringify(patches.map(({ file }) => fs.readFileSync(patchPath(file), 'utf8'))));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// downloadAndExtractFile keeps a tree whose archive still matches, so a tree patched under another set
|
|
19
|
+
// would keep its old patches or miss the new ones: it is extracted again instead.
|
|
20
|
+
export function refreshPatchedSource(buildPath, patches) {
|
|
21
|
+
const stamp = stampOf(buildPath);
|
|
22
|
+
const recorded = fs.existsSync(stamp) ? fs.readFileSync(stamp, 'utf8') : '';
|
|
23
|
+
if (recorded !== (recipePatchesFingerprint(patches) ?? '')) {
|
|
24
|
+
fs.rmSync(path.join(buildPath, 'source'), { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writePatchedSourceStamp(buildPath, patches) {
|
|
29
|
+
const fingerprint = recipePatchesFingerprint(patches);
|
|
30
|
+
if (fingerprint) fs.writeFileSync(stampOf(buildPath), fingerprint);
|
|
31
|
+
else fs.rmSync(stampOf(buildPath), { force: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// git apply is atomic and refuses a hunk whose context does not match, so a source the patch no longer
|
|
35
|
+
// fits stops the build instead of shipping unpatched. It works on the files alone: a repository named in the
|
|
36
|
+
// environment, as a git hook passes on, would make it skip every path, and one the downloaded tree carries could run
|
|
37
|
+
// its filters on the host, so GIT_DIR names none. autocrlf, which Git for Windows sets, would give the patched files
|
|
38
|
+
// CRLF endings.
|
|
39
|
+
export function applyRecipePatches(sourcePath, patches) {
|
|
40
|
+
const env = {
|
|
41
|
+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
|
|
42
|
+
GIT_DIR: `${sourcePath}.no-repository`,
|
|
43
|
+
};
|
|
44
|
+
for (const { file } of patches ?? []) {
|
|
45
|
+
const patch = patchPath(file);
|
|
46
|
+
try {
|
|
47
|
+
execFileSync('git', ['-c', 'core.autocrlf=false', 'apply', '--whitespace=nowarn', '--', patch], {
|
|
48
|
+
cwd: sourcePath,
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
env,
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT') throw new Error(`crossbind: applying ${path.basename(patch)} needs git on the PATH.`, { cause: error });
|
|
54
|
+
throw new Error(
|
|
55
|
+
`crossbind: ${path.basename(patch)} does not apply to the extracted source. A recipe's patches are diffs against the release it pins: `
|
|
56
|
+
+ `drop the patch once a release includes it.\n${String(error.stderr ?? error.message).trim()}`,
|
|
57
|
+
{ cause: error },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { getContentHash } from './hash.js';
|
|
3
|
+
import { recipePatchesFingerprint } from './recipePatches.js';
|
|
3
4
|
|
|
4
5
|
// The prebuilt-lib cache in buildLib is existence-only, so a nativeVersion or recipe-sha256 bump
|
|
5
6
|
// would otherwise be served from the previous build - shipping the old upstream under the new
|
|
@@ -12,7 +13,13 @@ export function getSourceFingerprint(config, target) {
|
|
|
12
13
|
// Local-source packages pin neither; buildLib checks their native source inventory instead.
|
|
13
14
|
if (!nativeVersion && !sha256) return null;
|
|
14
15
|
const sourceReplacements = target ? config?.build?.sourceReplaceList?.(target, config.allDependencyPaths?.[target.path] ?? {}) : undefined;
|
|
15
|
-
|
|
16
|
+
const patches = recipePatchesFingerprint(config?.build?.patches);
|
|
17
|
+
return getContentHash(JSON.stringify({
|
|
18
|
+
nativeVersion,
|
|
19
|
+
sha256,
|
|
20
|
+
...(sourceReplacements?.length ? { sourceReplacements } : {}),
|
|
21
|
+
...(patches ? { patches } : {}),
|
|
22
|
+
}));
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function isSourceFingerprintStale(libdir, fingerprint) {
|
|
@@ -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,8 @@ export default function loadSystemConfig() {
|
|
|
11
12
|
.map(([key, value]) => [key, value.default]);
|
|
12
13
|
return { ...system, ...Object.fromEntries(defaults) };
|
|
13
14
|
}
|
|
15
|
+
|
|
16
|
+
// Sets keys of ~/.crossbind.json, as `crossbind config set` does, keeping the others.
|
|
17
|
+
export function updateSystemConfig(values) {
|
|
18
|
+
writeJson(systemConfigPath(), { ...loadSystemConfig(), ...values });
|
|
19
|
+
}
|