crossbind 2.0.0-beta.64 → 2.0.0-beta.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +6 -1
  2. package/src/actions/androidDevice.js +77 -0
  3. package/src/actions/androidGradle.js +121 -0
  4. package/src/actions/appInstaller.js +174 -0
  5. package/src/actions/appUpdate.js +68 -0
  6. package/src/actions/appleSigning.js +96 -0
  7. package/src/actions/buildApp.js +509 -0
  8. package/src/actions/buildDependencies.js +4 -3
  9. package/src/actions/buildExternal.js +3 -3
  10. package/src/actions/buildLib.js +1 -1
  11. package/src/actions/buildNode.js +15 -10
  12. package/src/actions/createInterface.js +1 -2
  13. package/src/actions/createLib.js +13 -5
  14. package/src/actions/getCmakeParameters.js +6 -2
  15. package/src/actions/getDependLibs.js +8 -2
  16. package/src/actions/getLinkInputs.js +10 -7
  17. package/src/actions/gradleProject.js +171 -0
  18. package/src/actions/iosSimulator.js +63 -0
  19. package/src/actions/licenses.js +189 -14
  20. package/src/actions/nativeBridges.js +35 -0
  21. package/src/actions/run.js +3 -3
  22. package/src/actions/target.js +2 -1
  23. package/src/actions/windowsSigning.js +96 -0
  24. package/src/actions/xcodeProject.js +186 -0
  25. package/src/app/index.d.ts +129 -0
  26. package/src/app/index.js +184 -0
  27. package/src/assets/js-runtime/adapters/worker-comlink.js +8 -1
  28. package/src/assets/toolchain-digests.json +24 -18
  29. package/src/bin.js +84 -32
  30. package/src/index.js +7 -0
  31. package/src/integration/getCrossbindScript.js +15 -1
  32. package/src/state/index.js +5 -1
  33. package/src/state/loadConfig.js +2 -0
  34. package/src/utils/appApiHeader.js +29 -0
  35. package/src/utils/appIcon.js +174 -0
  36. package/src/utils/appOptions.js +284 -0
  37. package/src/utils/appRustCrates.js +3 -1
  38. package/src/utils/downloadAndExtractFile.js +42 -14
  39. package/src/utils/embindRsFingerprint.js +10 -3
  40. package/src/utils/hermes.js +40 -0
  41. package/src/utils/plist.js +42 -0
  42. package/src/utils/pullDockerImage.js +9 -3
  43. package/src/utils/replaceBasePathForDocker.js +8 -0
  44. package/src/utils/resolveAppHost.js +21 -0
  45. package/src/utils/selectRunner.js +1 -1
  46. package/src/utils/systemKeys.js +4 -0
  47. package/src/utils/targets.js +19 -3
  48. package/src/utils/writeIfChanged.js +1 -1
  49. package/vitest.config.js +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crossbind",
3
- "version": "2.0.0-beta.64",
3
+ "version": "2.0.0-beta.66",
4
4
  "license": "MIT",
5
5
  "homepage": "https://crossbind.dev",
6
6
  "repository": "https://github.com/crossbind/crossbind.git",
@@ -16,6 +16,10 @@
16
16
  "exports": {
17
17
  ".": "./src/index.js",
18
18
  "./node/dev": "./src/node/dev.mjs",
19
+ "./app": {
20
+ "types": "./src/app/index.d.ts",
21
+ "default": "./src/app/index.js"
22
+ },
19
23
  "./*": "./*"
20
24
  },
21
25
  "scripts": {
@@ -37,6 +41,7 @@
37
41
  "glob": "^13.0.6",
38
42
  "log-update": "^8.0.0",
39
43
  "picocolors": "^1.1.1",
44
+ "pngjs": "^7.0.0",
40
45
  "replace": "^1.2.2",
41
46
  "rollup": "^4.63.4",
42
47
  "spdx-expression-parse": "^5.0.0",
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { ANDROID_ABIS } from './gradleProject.js';
6
+ import { EXEC_MAX_BUFFER } from './run.js';
7
+
8
+ const ACTIVITY = 'dev.crossbind.app.CrossbindActivity';
9
+
10
+ // The adb that reaches the device: the one on PATH, else the platform tools of ANDROID_HOME's SDK or Android Studio's.
11
+ // crossbind installs none.
12
+ export function adbPath() {
13
+ const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT
14
+ || path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Android/sdk' : 'Android/Sdk');
15
+ const found = [...(process.env.PATH ?? '').split(path.delimiter).map((dir) => path.join(dir, 'adb')), path.join(sdk, 'platform-tools', 'adb')]
16
+ .find((file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile());
17
+ if (!found) {
18
+ throw new Error('crossbind: an Android app runs on a device through adb, which is not on PATH nor in an Android SDK. Install Android\'s platform tools: `brew install --cask android-platform-tools` on macOS, `sudo apt install adb` on Debian and Ubuntu, or Android Studio.');
19
+ }
20
+ return found;
21
+ }
22
+
23
+ function tool(program, args, options = {}) {
24
+ const result = spawnSync(program, args, { encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, ...options });
25
+ if (result.status !== 0) {
26
+ throw new Error(`crossbind: ${path.basename(program)} ${args.join(' ')} failed: ${`${result.stdout ?? ''}${result.stderr ?? ''}`.trim() || result.error?.message}`);
27
+ }
28
+ return result.stdout;
29
+ }
30
+
31
+ const adb = (args) => tool(adbPath(), args);
32
+ // adb shell hands the device's shell one line, which splits it again.
33
+ const quoted = (text) => `'${String(text).replace(/'/g, "'\\''")}'`;
34
+
35
+ // The device an app runs on in development and in tests: the one ANDROID_SERIAL names, else the only one attached, and
36
+ // the ABI of it the app builds for. crossbind starts no emulator.
37
+ export function attachedDevice() {
38
+ const serials = adb(['devices']).split('\n').slice(1).map((line) => line.trim().split('\t'))
39
+ .filter(([, status]) => status === 'device').map(([serial]) => serial);
40
+ const serial = process.env.ANDROID_SERIAL || (serials.length === 1 ? serials[0] : null);
41
+ if (!serial) {
42
+ throw new Error(serials.length === 0
43
+ ? 'crossbind: no Android device is attached. Start an emulator, in Android Studio\'s Device Manager or with `emulator -avd <name>`, or connect a phone with USB debugging on.'
44
+ : `crossbind: ${serials.length} Android devices are attached (${serials.join(', ')}). Set ANDROID_SERIAL to the one the app runs on.`);
45
+ }
46
+ const abis = adb(['-s', serial, 'shell', 'getprop', 'ro.product.cpu.abilist']).trim().split(',');
47
+ const abi = abis.find((one) => ANDROID_ABIS.includes(one));
48
+ if (!abi) throw new Error(`crossbind: the Android device ${serial} runs ${abis.join(', ')}, and apps build for ${ANDROID_ABIS.join(' and ')}.`);
49
+ return { serial, abi };
50
+ }
51
+
52
+ // Installs an APK on the attached device and opens it, with `env` as the activity's extras; a dev server's page on this
53
+ // machine reaches the device through adb reverse. The process returned carries the app's log, its page's console among
54
+ // it, and ends with the app; close() ends the app.
55
+ export default function openOnDevice(apk, { env = {}, stdio = 'pipe' } = {}) {
56
+ const { serial } = attachedDevice();
57
+ const id = JSON.parse(fs.readFileSync(path.join(path.dirname(apk), 'output-metadata.json'), 'utf8')).applicationId;
58
+ try {
59
+ adb(['-s', serial, 'install', '-r', '-d', apk]);
60
+ } catch (error) {
61
+ if (!error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error;
62
+ // As the one Google Play installs: only an uninstall replaces it, which takes its data, so the developer runs it.
63
+ throw new Error(`crossbind: ${id} on ${serial} is signed with another key than this build, so it cannot be updated; \`adb -s ${serial} uninstall ${id}\` removes it, with its data, and the next run installs this one.`, { cause: error });
64
+ }
65
+ const devPort = env.CROSSBIND_APP_URL && new URL(env.CROSSBIND_APP_URL).port;
66
+ if (devPort) adb(['-s', serial, 'reverse', `tcp:${devPort}`, `tcp:${devPort}`]);
67
+ const extras = Object.entries(env).flatMap(([key, value]) => ['--es', key, quoted(value)]);
68
+ adb(['-s', serial, 'shell', 'am', 'start', '-S', '-W', '-n', `${id}/${ACTIVITY}`, ...extras]);
69
+ const pid = spawnSync(adbPath(), ['-s', serial, 'shell', 'pidof', id], { encoding: 'utf8' }).stdout?.trim();
70
+ if (!pid) throw new Error(`crossbind: ${id} stopped as it started on ${serial}; its log is in \`adb logcat\`.`);
71
+ // The log of the app's process alone, without the divider logcat starts with, until the process ends. The pattern
72
+ // that ends logcat names it first, so it matches not the shell whose command line holds it too.
73
+ const logcat = `logcat -v raw --pid=${pid} -s Crossbind:V`;
74
+ const child = spawn(adbPath(), ['-s', serial, 'shell',
75
+ `${logcat} | grep -v '^--------- ' & while [ -d /proc/${pid} ]; do sleep 1; done; pkill -f '^${logcat}'`], { stdio });
76
+ return { child, close: () => spawnSync(adbPath(), ['-s', serial, 'shell', 'am', 'force-stop', id]) };
77
+ }
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import state from '../state/index.js';
6
+ import getOsUserAndGroupId from '../utils/getOsUserAndGroupId.js';
7
+ import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
8
+ import { DOCKER_BASE, isUnderBase } from '../utils/replaceBasePathForDocker.js';
9
+ import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
10
+ import { getContentHash } from '../utils/hash.js';
11
+
12
+ const ROLE = 'android-app';
13
+ const PLATFORM = 'linux/amd64';
14
+ const KEYSTORE = '/tmp/crossbind/keystore';
15
+ const GRADLE = ['gradle', '--offline', '--no-watch-fs', '--console=plain', '-q'];
16
+ const OUTPUT_LIMIT = 64 * 1024 * 1024;
17
+ const FAILURE_LINES = 40;
18
+
19
+ // The upload key a release is signed with, named by the environment alone so that no secret reaches a file.
20
+ export const SIGNING = {
21
+ keystore: 'CROSSBIND_ANDROID_KEYSTORE',
22
+ alias: 'CROSSBIND_ANDROID_KEY_ALIAS',
23
+ password: 'CROSSBIND_ANDROID_KEYSTORE_PASSWORD',
24
+ keyPassword: 'CROSSBIND_ANDROID_KEY_PASSWORD',
25
+ };
26
+
27
+ const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
28
+
29
+ // The keystore of the upload key, or null for none: a release is then signed with the debug key.
30
+ export function releaseSigning(env = process.env) {
31
+ if (!env[SIGNING.keystore]) {
32
+ const stray = [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]);
33
+ if (stray.length) throw new Error(`crossbind: ${listed(stray)} set without ${SIGNING.keystore}, the keystore of the upload key.`);
34
+ return null;
35
+ }
36
+ const missing = [SIGNING.alias, SIGNING.password].filter((name) => !env[name]);
37
+ if (missing.length) throw new Error(`crossbind: ${SIGNING.keystore} is set, but ${listed(missing)} not.`);
38
+ const file = path.resolve(env[SIGNING.keystore]);
39
+ if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${SIGNING.keystore} names ${file}, which is no file.`);
40
+ return { file, names: [SIGNING.alias, SIGNING.password, SIGNING.keyPassword].filter((name) => env[name]) };
41
+ }
42
+
43
+ // Gradle's caches and the Android tools' home outlive a container; the second keeps the debug key, which a new one would
44
+ // replace, so that a later build still installs over the last.
45
+ function homes() {
46
+ const root = path.join(os.homedir(), '.crossbind', 'android');
47
+ return [[path.join(root, 'gradle'), '/var/cache/crossbind/gradle'], [path.join(root, 'home'), '/var/cache/crossbind/android']];
48
+ }
49
+
50
+ // A container of crossbind's android-app image, with no network: the project under the build's base, the homes, and
51
+ // the keystore read-only, its passwords passed by name from this environment.
52
+ function containerArgs(signing) {
53
+ return [
54
+ ...DOCKER_RUN_SECURITY_ARGS, '--network', 'none', '--platform', PLATFORM, '--user', getOsUserAndGroupId(),
55
+ '-v', `${state.config.paths.base}:${DOCKER_BASE}`,
56
+ ...homes().flatMap(([host, container]) => {
57
+ fs.mkdirSync(host, { recursive: true });
58
+ return ['-v', `${host}:${container}`];
59
+ }),
60
+ ...(signing ? ['-v', `${signing.file}:${KEYSTORE}:ro`, '-e', `${SIGNING.keystore}=${KEYSTORE}`, ...signing.names.flatMap((name) => ['-e', name])] : []),
61
+ ];
62
+ }
63
+
64
+ function docker(args, what) {
65
+ const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT });
66
+ if (result.error) throw new Error(`crossbind: cannot run docker for ${what}: ${result.error.message}`, { cause: result.error });
67
+ if (result.status !== 0) {
68
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split('\n').slice(-FAILURE_LINES).join('\n');
69
+ throw new Error(`crossbind: ${what} failed in the android-app image with exit code ${result.status}:\n${output}`);
70
+ }
71
+ }
72
+
73
+ // The container sees the project's base alone, under its own POSIX path whatever the host's separators.
74
+ function inContainer(project) {
75
+ const dir = path.resolve(project);
76
+ if (!isUnderBase(dir, state.config.paths.base)) {
77
+ throw new Error(`crossbind: the Android project ${dir} is outside ${state.config.paths.base}, the project's base and all the android-app container sees. Build the app into a directory under it.`);
78
+ }
79
+ return path.posix.join(DOCKER_BASE, ...path.relative(state.config.paths.base, dir).split(path.sep));
80
+ }
81
+
82
+ // An Android app as the files it ships: its release APK, and with an upload key also the bundle Google Play takes, beside
83
+ // the Gradle project as <project>.apk and <project>.aab.
84
+ export function packageAndroidRelease(project) {
85
+ const signing = releaseSigning();
86
+ pullDockerImage(ROLE, PLATFORM);
87
+ docker(['run', '--rm', ...containerArgs(signing), '-w', inContainer(project), getDockerImage(ROLE, PLATFORM),
88
+ ...GRADLE, '--no-daemon', 'assembleRelease', ...(signing ? ['bundleRelease'] : [])], `packaging ${path.basename(project)}`);
89
+ const outputs = `${project}/app/build/outputs`;
90
+ fs.copyFileSync(`${outputs}/apk/release/app-release.apk`, `${project}.apk`);
91
+ fs.rmSync(`${project}.aab`, { force: true });
92
+ if (signing) {
93
+ fs.copyFileSync(`${outputs}/bundle/release/app-release.aab`, `${project}.aab`);
94
+ } else {
95
+ console.warn(`crossbind: ${path.basename(project)}.apk is signed with this machine's debug key: it installs for testing, and Google Play takes no app signed so. Set ${SIGNING.keystore}, ${SIGNING.alias} and ${SIGNING.password} to sign it with your upload key and make the bundle too.`);
96
+ }
97
+ }
98
+
99
+ // During development one container per project, whose Gradle daemon stays warm from one build to the next; it goes with
100
+ // the process.
101
+ let devContainer = null;
102
+
103
+ function startDevContainer() {
104
+ const name = `crossbind-android-app-${getContentHash(state.config.paths.project).slice(0, 12)}`;
105
+ pullDockerImage(ROLE, PLATFORM);
106
+ // One a crashed session left behind holds the name.
107
+ spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' });
108
+ docker(['run', '--detach', '--rm', '--init', '--name', name, ...containerArgs(null), getDockerImage(ROLE, PLATFORM), 'sleep', 'infinity'],
109
+ 'starting the development container');
110
+ process.once('exit', () => spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' }));
111
+ // A closed terminal ends the session through its exit hooks, as Ctrl-C does.
112
+ process.once('SIGHUP', () => process.exit(129));
113
+ return name;
114
+ }
115
+
116
+ // Builds an Android app's Gradle project as a debug APK, signed with the debug key the homes keep, and returns the APK.
117
+ export function buildApk(project) {
118
+ devContainer ??= startDevContainer();
119
+ docker(['exec', '-w', inContainer(project), devContainer, ...GRADLE, 'assembleDebug'], `building ${path.basename(project)}`);
120
+ return path.join(project, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
121
+ }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import run from './run.js';
5
+ import { appleSigning } from './appleSigning.js';
6
+ import { updaterSigning } from './appUpdate.js';
7
+ import { inWindowsImage, jsignScript, windowsSigning } from './windowsSigning.js';
8
+ import state from '../state/index.js';
9
+ import { appDisplayName, appId, appUrlSchemes } from '../utils/appOptions.js';
10
+ import { readAppIcon, windowsIcon } from '../utils/appIcon.js';
11
+ import { imageRoleFor } from '../utils/pullDockerImage.js';
12
+ import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
13
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
14
+
15
+ const DEBIAN_ARCH = { x64: 'amd64', arm64: 'arm64' };
16
+ // What a Linux app runs on: WebKitGTK 4.1 from its 2.40 release, and the GTK its window is.
17
+ const DEBIAN_DEPENDS = 'libwebkit2gtk-4.1-0 (>= 2.40), libgtk-3-0';
18
+
19
+ const packageVersion = () => state.config.package?.version ?? '1.0.0';
20
+
21
+ // The container that packages a Linux or Windows app sees the project's base alone.
22
+ export function assertUnderBase(app, target) {
23
+ if (isUnderBase(app, state.config.paths.base)) return;
24
+ const kind = target.platform === 'win32' ? 'Windows' : 'Linux';
25
+ throw new Error(`crossbind: the ${kind} app ${app} is outside ${state.config.paths.base}, the project's base and all the ${imageRoleFor(target)} container sees. Build the app into a directory under it.`);
26
+ }
27
+
28
+ // A Debian package's name: the project's, in lower case, with a hyphen for each underscore and from its first letter
29
+ // or digit.
30
+ export function debianName(name) {
31
+ const debian = name.toLowerCase().replaceAll('_', '-').replace(/^[^a-z0-9]+/, '');
32
+ if (!/^[a-z0-9][a-z0-9+.-]+$/.test(debian)) {
33
+ throw new Error(`crossbind: general.name ${JSON.stringify(name)} makes no Debian package name, which is two or more lowercase letters, digits, +, . and -, starting with a letter or digit. Change general.name in crossbind.config.js.`);
34
+ }
35
+ return debian;
36
+ }
37
+
38
+ // A Debian version sorts the pre-releases of a release before it, as semver does: 1.0.0-beta.1 is 1.0.0~beta.1.
39
+ export const debianVersion = (version) => version.replace('-', '~');
40
+
41
+ // package.json's author as Debian names a maintainer, by name and email address.
42
+ export function maintainer(author) {
43
+ if (typeof author === 'string') return author.replace(/\(.*\)/, '').trim() || null;
44
+ if (!author?.name) return null;
45
+ return author.email ? `${author.name} <${author.email}>` : author.name;
46
+ }
47
+
48
+ // The control file of a Linux app's package. dpkg warns of a package without a maintainer in every command it runs
49
+ // afterwards, so without package.json's author the app's name stands for one.
50
+ export function debianControl({ name, version, arch, author, description, label }) {
51
+ return [
52
+ `Package: ${name}`,
53
+ `Version: ${debianVersion(version)}`,
54
+ `Architecture: ${DEBIAN_ARCH[arch]}`,
55
+ `Maintainer: ${maintainer(author) ?? label}`,
56
+ `Depends: ${DEBIAN_DEPENDS}`,
57
+ `Description: ${(description || label).replace(/\s+/g, ' ').trim()}`,
58
+ '',
59
+ ].join('\n');
60
+ }
61
+
62
+ // The desktop entry a Linux menu shows an app by. GTK names the app's window after the command that started it, and
63
+ // GNOME on Wayland shows the icon of the entry whose file has that name.
64
+ // A link of one of its schemes opens it with the link as its argument, %u.
65
+ export function desktopEntry({ label, command, hasIcon, urlSchemes = [] }) {
66
+ const isLinked = urlSchemes.length > 0;
67
+ return [
68
+ '[Desktop Entry]', 'Type=Application', `Name=${label}`, `Exec=${command}${isLinked ? ' %u' : ''}`, ...(hasIcon ? [`Icon=${command}`] : []),
69
+ ...(isLinked ? [`MimeType=${urlSchemes.map((scheme) => `x-scheme-handler/${scheme};`).join('')}`] : []), 'Terminal=false', '',
70
+ ].join('\n');
71
+ }
72
+
73
+ // A disk image of a macOS app beside a link to /Applications, onto which its user drags it. hdiutil copies a link
74
+ // inside the folder it is given as a link, where it would copy all of /Applications for the link itself. The image is
75
+ // not signed: Gatekeeper would then judge it as well, where the app in it is what Apple notarizes.
76
+ function diskImage(app, target) {
77
+ const image = app.replace(/\.app$/, '.dmg');
78
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'crossbind-dmg-'));
79
+ try {
80
+ // The image's root takes the folder's permissions, which mkdtemp keeps to its owner.
81
+ fs.chmodSync(folder, 0o755);
82
+ fs.cpSync(app, path.join(folder, path.basename(app)), { recursive: true, verbatimSymlinks: true });
83
+ fs.symlinkSync('/Applications', path.join(folder, 'Applications'));
84
+ fs.rmSync(image, { force: true });
85
+ run(null, ['hdiutil', 'create', '-quiet', '-volname', appDisplayName(state.config), '-srcfolder', folder, '-fs', 'HFS+', '-format', 'UDZO', image], null, target);
86
+ } finally {
87
+ fs.rmSync(folder, { recursive: true, force: true });
88
+ }
89
+ }
90
+
91
+ // A Debian package of a Linux app, which dpkg-deb builds in the linux image: the app in /usr/lib/<name>, its command
92
+ // in /usr/bin, and the desktop entry and icon a menu shows it by.
93
+ function debianPackage(app, target) {
94
+ assertUnderBase(app, target);
95
+ const { general, paths } = state.config;
96
+ const name = debianName(general.name);
97
+ const label = appDisplayName(state.config);
98
+ const root = `${paths.build}/${path.basename(app)}.deb`;
99
+ const dir = (relative) => {
100
+ fs.mkdirSync(`${root}/${relative}`, { recursive: true });
101
+ return `${root}/${relative}`;
102
+ };
103
+ fs.rmSync(root, { recursive: true, force: true });
104
+ fs.cpSync(app, `${dir('usr/lib')}/${name}`, { recursive: true, verbatimSymlinks: true });
105
+ fs.symlinkSync(`../lib/${name}/${general.name}`, `${dir('usr/bin')}/${name}`);
106
+ const icon = `${app}/resources/icon.png`;
107
+ const hasIcon = fs.existsSync(icon);
108
+ if (hasIcon) fs.copyFileSync(icon, `${dir('usr/share/icons/hicolor/512x512/apps')}/${name}.png`);
109
+ fs.writeFileSync(`${dir('usr/share/applications')}/${name}.desktop`, desktopEntry({ label, command: name, hasIcon, urlSchemes: appUrlSchemes(state.config) }));
110
+ const { author, description } = state.config.package ?? {};
111
+ fs.writeFileSync(`${dir('DEBIAN')}/control`, debianControl({ name, version: packageVersion(), arch: target.arch, author, description, label }));
112
+ run(null, ['dpkg-deb', '--build', '--root-owner-group', root, `${app}.deb`], null, target);
113
+ fs.rmSync(root, { recursive: true, force: true });
114
+ }
115
+
116
+ // The name a Windows app's setup program gives its Start menu shortcut and folder, which Windows takes as a file name.
117
+ function windowsName() {
118
+ const label = appDisplayName(state.config);
119
+ if (/[<>:/|?*]|[ .]$/.test(label)) {
120
+ throw new Error(`crossbind: ${JSON.stringify(label)}, the name of a Windows app's Start menu shortcut and folder, is no file name Windows takes, which holds none of < > : / | ? * and ends in neither a space nor a dot. Change app.name in crossbind.config.js.`);
121
+ }
122
+ return label;
123
+ }
124
+
125
+ // A Windows setup program reads a dollar sign in its text as the start of a variable.
126
+ const nsisText = (text) => text.replaceAll('$', () => '$$');
127
+
128
+ // The schemes a Windows app's setup program registers, as a macro that calls the one it is given on each.
129
+ const schemesMacro = (schemes) => ['!macro CROSSBIND_URL_SCHEMES DO', ...schemes.map((scheme) => ` !insertmacro \${DO} "${scheme}"`), '!macroend', ''].join('\n');
130
+
131
+ // A Windows app's setup program, which NSIS builds in the windows image from the host's installer.nsi; with a key in
132
+ // the environment, jsign signs it and its uninstaller as NSIS writes them.
133
+ function windowsSetup(app) {
134
+ const signing = windowsSigning();
135
+ const icon = readAppIcon(state.config);
136
+ const iconFile = `${state.config.paths.build}/${path.basename(app)}.ico`;
137
+ if (icon) fs.writeFileSync(iconFile, windowsIcon(icon));
138
+ const urlSchemes = appUrlSchemes(state.config);
139
+ const schemesFile = `${state.config.paths.build}/${path.basename(app)}.schemes.nsh`;
140
+ if (urlSchemes.length > 0) fs.writeFileSync(schemesFile, schemesMacro(urlSchemes));
141
+ inWindowsImage([
142
+ 'makensis', '-V2', '-INPUTCHARSET', 'UTF8',
143
+ `-DNAME=${nsisText(windowsName())}`,
144
+ `-DEXE=${nsisText(`${state.config.general.name}.exe`)}`,
145
+ `-DID=${appId(state.config)}`,
146
+ `-DVERSION=${nsisText(packageVersion())}`,
147
+ `-DAPP=${app}`,
148
+ `-DOUTFILE=${app}-setup.exe`,
149
+ ...(icon ? [`-DICON=${iconFile}`] : []),
150
+ ...(urlSchemes.length > 0 ? [`-DSCHEMES=${schemesFile}`] : []),
151
+ ...(signing ? [`-DSIGNER=${jsignScript()}`] : []),
152
+ `${resolveAppHostRoot()}/windows/installer.nsi`,
153
+ ], path.dirname(app), signing);
154
+ }
155
+
156
+ // What an app's installer takes from the app and the environment, checked before anything compiles: the names, the keys
157
+ // that sign a macOS or Windows app, and the one that signs a desktop app's update.
158
+ export function checkInstaller(target) {
159
+ if (target.platform === 'darwin') appleSigning();
160
+ else if (target.platform === 'linux') debianName(state.config.general.name);
161
+ else if (target.platform === 'win32') {
162
+ windowsName();
163
+ windowsSigning();
164
+ }
165
+ if (['darwin', 'linux', 'win32'].includes(target.platform)) updaterSigning();
166
+ }
167
+
168
+ // What installs a desktop app, written beside it: a disk image on macOS, a Debian package on Linux and a setup program
169
+ // on Windows.
170
+ export default function writeInstaller(app, target) {
171
+ if (target.platform === 'darwin') diskImage(app, target);
172
+ else if (target.platform === 'linux') debianPackage(app, target);
173
+ else if (target.platform === 'win32') windowsSetup(app);
174
+ }
@@ -0,0 +1,68 @@
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
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ // The Developer ID that signs a macOS app and the App Store Connect API key Apple notarizes it with, named by the
7
+ // environment alone so that no secret reaches a file.
8
+ export const APPLE_SIGNING = {
9
+ identity: 'CROSSBIND_APPLE_SIGNING_IDENTITY',
10
+ key: 'CROSSBIND_APPLE_API_KEY',
11
+ keyId: 'CROSSBIND_APPLE_API_KEY_ID',
12
+ issuer: 'CROSSBIND_APPLE_API_ISSUER',
13
+ };
14
+ const NOTARY = [APPLE_SIGNING.key, APPLE_SIGNING.keyId, APPLE_SIGNING.issuer];
15
+
16
+ const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
17
+
18
+ function tool(command, args) {
19
+ const result = spawnSync(command, args, { encoding: 'utf8' });
20
+ if (result.error) throw new Error(`crossbind: cannot run ${command}: ${result.error.message}`, { cause: result.error });
21
+ if (result.status !== 0) throw new Error(`crossbind: ${command} ${args[0]} failed with exit code ${result.status}:\n${`${result.stdout}${result.stderr}`.trim()}`);
22
+ return result.stdout;
23
+ }
24
+
25
+ // The keychain's valid code signing identities, each a SHA-1 hash and a certificate's name.
26
+ function signingIdentities() {
27
+ return tool('security', ['find-identity', '-v', '-p', 'codesigning']).split('\n')
28
+ .map((line) => line.match(/^\s*\d+\)\s+([0-9A-F]{40})\s+"(.*)"$/))
29
+ .filter(Boolean)
30
+ .map(([, hash, name]) => ({ hash, name }));
31
+ }
32
+
33
+ // The identity a macOS app is signed with and the key Apple notarizes it with, or null without an identity: the app is
34
+ // then signed ad hoc, which opens it on the Mac that built it alone. codesign takes the identity's hash, which the
35
+ // renewed certificates of one name do not share.
36
+ export function appleSigning(env = process.env) {
37
+ const notary = NOTARY.filter((name) => env[name]);
38
+ const identity = env[APPLE_SIGNING.identity];
39
+ if (!identity) {
40
+ if (notary.length) throw new Error(`crossbind: ${listed(notary)} set without ${APPLE_SIGNING.identity}, the Developer ID that signs the app Apple notarizes.`);
41
+ return null;
42
+ }
43
+ const found = signingIdentities().filter(({ hash, name }) => hash === identity.toUpperCase() || name.includes(identity));
44
+ const matches = [...new Map(found.map((match) => [match.hash, match])).values()];
45
+ if (!matches.length) throw new Error(`crossbind: ${APPLE_SIGNING.identity} names ${JSON.stringify(identity)}, which no valid code signing identity in the keychain is; \`security find-identity -v -p codesigning\` lists them.`);
46
+ if (matches.length > 1) {
47
+ throw new Error(`crossbind: ${APPLE_SIGNING.identity} names ${JSON.stringify(identity)}, which ${matches.length} identities in the keychain are: ${matches.map(({ hash, name }) => `${hash} "${name}"`).join(', ')}. Set it to one of their hashes.`);
48
+ }
49
+ const [{ hash, name }] = matches;
50
+ if (!notary.length) return { identity: hash, notary: null };
51
+ const missing = NOTARY.filter((variable) => !env[variable]);
52
+ if (missing.length) throw new Error(`crossbind: ${listed(notary)} set, but ${listed(missing)} not: notarizing takes all three.`);
53
+ if (!name.startsWith('Developer ID Application:')) throw new Error(`crossbind: Apple notarizes an app signed with a Developer ID Application certificate, which ${JSON.stringify(name)} is not.`);
54
+ const key = path.resolve(env[APPLE_SIGNING.key]);
55
+ if (!fs.statSync(key, { throwIfNoEntry: false })?.isFile()) throw new Error(`crossbind: ${APPLE_SIGNING.key} names ${key}, which is no file.`);
56
+ return { identity: hash, notary: { key, keyId: env[APPLE_SIGNING.keyId], issuer: env[APPLE_SIGNING.issuer] } };
57
+ }
58
+
59
+ // The ticket Apple issues can take a moment to reach the servers stapler asks for it.
60
+ function staple(app) {
61
+ let result;
62
+ for (let attempt = 0; attempt < 3; attempt += 1) {
63
+ if (attempt > 0) spawnSync('sleep', ['15']);
64
+ result = spawnSync('xcrun', ['stapler', 'staple', app], { encoding: 'utf8' });
65
+ if (result.status === 0) return;
66
+ }
67
+ throw new Error(`crossbind: xcrun stapler failed with exit code ${result.status}:\n${`${result.stdout ?? ''}${result.stderr ?? ''}`.trim()}`);
68
+ }
69
+
70
+ // Apple notarizes a signed app sent to it as a zip, which takes a few minutes; the ticket stapled to the app then opens
71
+ // it on any Mac, offline too.
72
+ export function notarizeApp(app, notary) {
73
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossbind-notary-'));
74
+ const zip = path.join(dir, `${path.basename(app)}.zip`);
75
+ const key = ['--key', notary.key, '--key-id', notary.keyId, '--issuer', notary.issuer];
76
+ try {
77
+ tool('ditto', ['-c', '-k', '--keepParent', app, zip]);
78
+ console.log(`crossbind: Apple is notarizing ${path.basename(app)}, which takes a few minutes.`);
79
+ // notarytool's verdict is in its JSON, whatever its exit code.
80
+ const submit = spawnSync('xcrun', ['notarytool', 'submit', zip, ...key, '--wait', '--timeout', '2h', '--output-format', 'json'], { encoding: 'utf8' });
81
+ let verdict;
82
+ try {
83
+ verdict = JSON.parse(submit.stdout);
84
+ } catch (error) {
85
+ throw new Error(`crossbind: xcrun notarytool failed with exit code ${submit.status}:\n${`${submit.stdout ?? ''}${submit.stderr ?? ''}`.trim() || submit.error?.message}`, { cause: error });
86
+ }
87
+ const { id, status, message } = verdict;
88
+ if (status !== 'Accepted') {
89
+ const log = spawnSync('xcrun', ['notarytool', 'log', id, ...key], { encoding: 'utf8' }).stdout ?? '';
90
+ throw new Error(`crossbind: Apple did not notarize ${path.basename(app)} (${status}${message ? `: ${message}` : ''}). Its log:\n${log.trim()}`);
91
+ }
92
+ staple(app);
93
+ } finally {
94
+ fs.rmSync(dir, { recursive: true, force: true });
95
+ }
96
+ }