crossbind 2.0.0-beta.65 → 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.
- package/package.json +6 -1
- package/src/actions/androidDevice.js +1 -1
- package/src/actions/appInstaller.js +174 -0
- package/src/actions/appUpdate.js +68 -0
- package/src/actions/appleSigning.js +96 -0
- package/src/actions/buildApp.js +107 -32
- package/src/actions/createLib.js +10 -5
- package/src/actions/getCmakeParameters.js +4 -1
- package/src/actions/gradleProject.js +65 -4
- package/src/actions/licenses.js +31 -2
- package/src/actions/windowsSigning.js +96 -0
- package/src/actions/xcodeProject.js +44 -10
- package/src/app/index.d.ts +129 -0
- package/src/app/index.js +184 -0
- package/src/assets/toolchain-digests.json +20 -20
- package/src/bin.js +9 -0
- package/src/index.js +2 -1
- package/src/state/loadConfig.js +1 -1
- package/src/utils/appApiHeader.js +29 -0
- package/src/utils/appIcon.js +174 -0
- package/src/utils/appOptions.js +284 -0
- package/src/utils/writeIfChanged.js +1 -1
- package/vitest.config.js +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crossbind",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
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",
|
|
@@ -9,7 +9,7 @@ const ACTIVITY = 'dev.crossbind.app.CrossbindActivity';
|
|
|
9
9
|
|
|
10
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
11
|
// crossbind installs none.
|
|
12
|
-
function adbPath() {
|
|
12
|
+
export function adbPath() {
|
|
13
13
|
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT
|
|
14
14
|
|| path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Android/sdk' : 'Android/Sdk');
|
|
15
15
|
const found = [...(process.env.PATH ?? '').split(path.delimiter).map((dir) => path.join(dir, 'adb')), path.join(sdk, 'platform-tools', 'adb')]
|
|
@@ -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
|
+
}
|
package/src/actions/buildApp.js
CHANGED
|
@@ -20,17 +20,25 @@ import writeXcodeProject, { HERMES_SLICES, IOS_SDKS } from './xcodeProject.js';
|
|
|
20
20
|
import writeGradleProject, { ANDROID_ABIS } from './gradleProject.js';
|
|
21
21
|
import { attachedDevice } from './androidDevice.js';
|
|
22
22
|
import { packageAndroidRelease } from './androidGradle.js';
|
|
23
|
+
import writeInstaller, { assertUnderBase } from './appInstaller.js';
|
|
24
|
+
import writeUpdate from './appUpdate.js';
|
|
25
|
+
import { APPLE_SIGNING, appleSigning, notarizeApp } from './appleSigning.js';
|
|
26
|
+
import { WINDOWS_SIGNING, signWindows, windowsSigning } from './windowsSigning.js';
|
|
23
27
|
import state from '../state/index.js';
|
|
24
28
|
import logger from '../utils/logger.js';
|
|
25
29
|
import findFiles from '../utils/findFiles.js';
|
|
26
30
|
import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
|
|
27
31
|
import { xmlPlist } from '../utils/plist.js';
|
|
32
|
+
import {
|
|
33
|
+
appBackgroundColor, appDisplayName, appHeader, appId, appPermissions, appUrlSchemes, usageDescriptions,
|
|
34
|
+
} from '../utils/appOptions.js';
|
|
35
|
+
import { androidIcons, iosIcon, linuxIcon, macIcon, readAppIcon, windowsIcon } from '../utils/appIcon.js';
|
|
36
|
+
import writeIfChanged from '../utils/writeIfChanged.js';
|
|
28
37
|
import { writeNativeSourceStamp } from '../utils/nativeSourceStamp.js';
|
|
29
38
|
import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
|
|
30
39
|
import resolveAppHostRoot, { resolveAppEmbindJsiRoot } from '../utils/resolveAppHost.js';
|
|
31
40
|
import resolveHermes, { HERMES } from '../utils/hermes.js';
|
|
32
41
|
import { getDockerImage, imageRoleFor } from '../utils/pullDockerImage.js';
|
|
33
|
-
import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
|
|
34
42
|
import { chosenRunner } from '../utils/selectRunner.js';
|
|
35
43
|
import { DARWIN_DEPLOYMENT_TARGET } from '../utils/darwinToolchain.js';
|
|
36
44
|
|
|
@@ -102,15 +110,6 @@ export function appTargetParams({ dev = false } = {}, env = process.env) {
|
|
|
102
110
|
return getTargetParams({ platform: ['ios'], arch: dev ? ['iphonesimulator'] : IOS_SDKS, runtime: ['mt'], runtimeEnv: ['app'] }, true);
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
// An app's bundle id: the project's app.id, else one made from the project's name.
|
|
106
|
-
function appId() {
|
|
107
|
-
const id = state.config.app.id ?? `dev.crossbind.${state.config.general.name.replace(/[^A-Za-z0-9.-]/g, '-')}`;
|
|
108
|
-
if (!/^[A-Za-z0-9.-]+$/.test(id)) {
|
|
109
|
-
throw new Error(`crossbind: app.id ${JSON.stringify(id)} is no bundle id, which takes letters, digits, hyphens and dots, as com.example.app does.`);
|
|
110
|
-
}
|
|
111
|
-
return id;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
113
|
// The Apple team that signs an iOS app: the project's app.team, else the machine's XCODE_DEVELOPMENT_TEAM, else none.
|
|
115
114
|
function appTeam() {
|
|
116
115
|
const team = state.config.app.team ?? state.config.system.XCODE_DEVELOPMENT_TEAM ?? '';
|
|
@@ -182,16 +181,24 @@ async function bundle(input, file, plugins) {
|
|
|
182
181
|
await build.close();
|
|
183
182
|
}
|
|
184
183
|
|
|
185
|
-
|
|
184
|
+
// A development build claims no scheme: the built app, of the same id, owns them, and a link it opens shows its own page.
|
|
185
|
+
const urlSchemesOf = (target) => (target.buildType === 'release' ? appUrlSchemes(state.config) : []);
|
|
186
|
+
|
|
187
|
+
const infoPlist = (name, version, hasIcon, urlSchemes) => xmlPlist({
|
|
188
|
+
CFBundleDisplayName: appDisplayName(state.config),
|
|
186
189
|
CFBundleExecutable: name,
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
// Contents/Resources/icon.icns.
|
|
191
|
+
...(hasIcon ? { CFBundleIconFile: 'icon' } : {}),
|
|
192
|
+
CFBundleIdentifier: appId(state.config),
|
|
193
|
+
CFBundleName: appDisplayName(state.config),
|
|
189
194
|
CFBundlePackageType: 'APPL',
|
|
190
195
|
CFBundleShortVersionString: version,
|
|
196
|
+
...(urlSchemes.length > 0 ? { CFBundleURLTypes: [{ CFBundleURLName: appId(state.config), CFBundleURLSchemes: urlSchemes }] } : {}),
|
|
191
197
|
CFBundleVersion: version,
|
|
192
198
|
LSMinimumSystemVersion: DARWIN_DEPLOYMENT_TARGET,
|
|
193
199
|
NSPrincipalClass: 'NSApplication',
|
|
194
200
|
NSHighResolutionCapable: true,
|
|
201
|
+
...usageDescriptions(state.config, 'darwin'),
|
|
195
202
|
});
|
|
196
203
|
|
|
197
204
|
// The host's bytecode and the data the packages ship, beside the page's folder.
|
|
@@ -203,8 +210,9 @@ function copyAppFiles(dir, hbc, target) {
|
|
|
203
210
|
});
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
// Everything but the page: the host and the app's files, and on macOS Hermes beside them.
|
|
207
|
-
|
|
213
|
+
// Everything but the page: the host and the app's files, its icon, and on macOS Hermes beside them. A Windows host
|
|
214
|
+
// carries its icon itself.
|
|
215
|
+
function assembleApp(target, { executable, hbc, hermes, icon }) {
|
|
208
216
|
const app = `${state.config.paths.build}/${target.appName}`;
|
|
209
217
|
const { name } = state.config.general;
|
|
210
218
|
const layout = appLayout(target);
|
|
@@ -212,8 +220,10 @@ function assembleApp(target, { executable, hbc, hermes }) {
|
|
|
212
220
|
fs.mkdirSync(path.dirname(`${app}/${layout.executable}`), { recursive: true });
|
|
213
221
|
fs.copyFileSync(executable, `${app}/${layout.executable}`);
|
|
214
222
|
copyAppFiles(`${app}/${layout.resources}`, hbc, target);
|
|
223
|
+
if (target.platform === 'linux' && icon) fs.writeFileSync(`${app}/${layout.resources}/icon.png`, linuxIcon(icon));
|
|
215
224
|
if (target.platform !== 'darwin') return;
|
|
216
|
-
fs.writeFileSync(`${app}/Contents/Info.plist`, infoPlist(name, appVersion()));
|
|
225
|
+
fs.writeFileSync(`${app}/Contents/Info.plist`, infoPlist(name, appVersion(), Boolean(icon), urlSchemesOf(target)));
|
|
226
|
+
if (icon) fs.writeFileSync(`${app}/Contents/Resources/icon.icns`, macIcon(icon));
|
|
217
227
|
const framework = `${app}/Contents/Frameworks/hermesvm.framework`;
|
|
218
228
|
fs.cpSync(`${hermes}/Library/Frameworks/macosx/hermesvm.framework`, framework, { recursive: true, verbatimSymlinks: true });
|
|
219
229
|
// The release carries Intel and Apple silicon; an app of one arch needs its own half.
|
|
@@ -266,9 +276,7 @@ const appParts = (target) => state.targets
|
|
|
266
276
|
// image.
|
|
267
277
|
function archiveApp(app, target) {
|
|
268
278
|
if (target.platform === 'win32') {
|
|
269
|
-
|
|
270
|
-
throw new Error(`crossbind: the Windows app ${app} is outside ${state.config.paths.base}, the project's base and all the windows container sees. Build the app into a directory under it.`);
|
|
271
|
-
}
|
|
279
|
+
assertUnderBase(app, target);
|
|
272
280
|
fs.rmSync(`${app}.zip`, { force: true });
|
|
273
281
|
run(null, ['zip', '-q', '-r', '-X', `${path.basename(app)}.zip`, path.basename(app)], null, target, { workdir: path.dirname(app) });
|
|
274
282
|
return;
|
|
@@ -280,11 +288,40 @@ function archiveApp(app, target) {
|
|
|
280
288
|
if (result.status !== 0) throw new Error(`crossbind: cannot archive ${app}: ${result.stderr || result.error?.message}`);
|
|
281
289
|
}
|
|
282
290
|
|
|
291
|
+
const ENTITLEMENTS = {
|
|
292
|
+
camera: 'com.apple.security.device.camera',
|
|
293
|
+
microphone: 'com.apple.security.device.audio-input',
|
|
294
|
+
location: 'com.apple.security.personal-information.location',
|
|
295
|
+
};
|
|
296
|
+
|
|
283
297
|
// Apple silicon runs signed code only, and a bundle's signature covers every file in it, so an app is signed once the
|
|
284
|
-
// page is in:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
298
|
+
// page is in: with the Developer ID the environment names, in the hardened runtime notarizing asks for, or else ad hoc,
|
|
299
|
+
// which runs on the machine that built it.
|
|
300
|
+
function signApp(app, target, signing) {
|
|
301
|
+
const sign = signing ? ['--sign', signing.identity, '--options', 'runtime', '--timestamp'] : ['--sign', '-', '--timestamp=none'];
|
|
302
|
+
run(null, ['codesign', '--force', ...sign, `${app}/Contents/Frameworks/hermesvm.framework`], null, target);
|
|
303
|
+
// The hardened runtime keeps the app from the devices of app.permissions unless its signature claims them.
|
|
304
|
+
const claims = Object.keys(appPermissions(state.config)).map((name) => ENTITLEMENTS[name]);
|
|
305
|
+
const entitlements = claims.length > 0 ? `${state.config.paths.build}/${path.basename(app)}.entitlements` : null;
|
|
306
|
+
if (entitlements) fs.writeFileSync(entitlements, xmlPlist(Object.fromEntries(claims.map((claim) => [claim, true]))));
|
|
307
|
+
run(null, ['codesign', '--force', ...sign, ...(entitlements ? ['--entitlements', entitlements] : []), app], null, target);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// A Windows app is signed before its zip and its setup program are made of it; one with its page that is not, the build
|
|
311
|
+
// says SmartScreen warns of.
|
|
312
|
+
function signWindowsApp(app, target, page) {
|
|
313
|
+
assertUnderBase(app, target);
|
|
314
|
+
const signing = windowsSigning();
|
|
315
|
+
if (signing) signWindows([`${app}/${appLayout(target).executable}`], signing);
|
|
316
|
+
else if (page) console.warn(`crossbind: ${path.basename(app)} is not signed, so SmartScreen warns of it and of its setup program once they are downloaded. Set ${WINDOWS_SIGNING.storetype} and the key it names to sign them with Authenticode.`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A macOS app with its page is the one that ships: Apple notarizes it, or the build says why another Mac refuses it.
|
|
320
|
+
function shipMacApp(app, signing) {
|
|
321
|
+
const notarize = `${APPLE_SIGNING.key}, ${APPLE_SIGNING.keyId} and ${APPLE_SIGNING.issuer} to have Apple notarize it`;
|
|
322
|
+
if (signing?.notary) notarizeApp(app, signing.notary);
|
|
323
|
+
else if (signing) console.warn(`crossbind: ${path.basename(app)} is signed with your Developer ID but not notarized, so another Mac opens it only once its user allows it in System Settings. Set ${notarize}.`);
|
|
324
|
+
else console.warn(`crossbind: ${path.basename(app)} is signed ad hoc, which opens it on this Mac alone. Set ${APPLE_SIGNING.identity} to sign it with your Developer ID, and ${notarize}.`);
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
// Where a build without a bundler puts an app: under its own name, or for iOS and Android as the project of its build
|
|
@@ -301,9 +338,17 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
|
|
|
301
338
|
const built = `${state.config.paths.build}/${target.appName}`;
|
|
302
339
|
if (target.platform === 'ios') {
|
|
303
340
|
const parts = appParts(target).map(({ arch, dir }) => ({ sdk: arch, dir }));
|
|
304
|
-
|
|
341
|
+
const icon = readAppIcon(state.config);
|
|
342
|
+
writeXcodeProject(to, {
|
|
343
|
+
parts, identity: { id: appId(state.config), team: appTeam() }, label: appDisplayName(state.config), version: appVersion(), icon: icon && iosIcon(icon),
|
|
344
|
+
urlSchemes: appUrlSchemes(state.config), background: appBackgroundColor(state.config), usage: usageDescriptions(state.config, 'ios'),
|
|
345
|
+
});
|
|
305
346
|
} else if (target.platform === 'android') {
|
|
306
|
-
|
|
347
|
+
const icon = readAppIcon(state.config);
|
|
348
|
+
writeGradleProject(to, {
|
|
349
|
+
parts: appParts(target), id: androidAppId(), label: appDisplayName(state.config), version: androidVersion(), icons: icon && androidIcons(icon),
|
|
350
|
+
urlSchemes: appUrlSchemes(state.config), background: appBackgroundColor(state.config), permissions: Object.keys(appPermissions(state.config)),
|
|
351
|
+
});
|
|
307
352
|
} else if (path.resolve(to) !== path.resolve(built)) {
|
|
308
353
|
fs.rmSync(to, { recursive: true, force: true });
|
|
309
354
|
fs.cpSync(built, to, { recursive: true, verbatimSymlinks: true });
|
|
@@ -311,15 +356,29 @@ export function publishApp(target, { to = appOutputPath(target), page = null } =
|
|
|
311
356
|
if (page) {
|
|
312
357
|
// The app may land inside the page's own directory, which it then leaves out, with every app built there and what
|
|
313
358
|
// packages it.
|
|
314
|
-
const apps = new Set([path.basename(to), ...state.targets.filter((t) => t.runtimeEnv === 'app')
|
|
315
|
-
|
|
359
|
+
const apps = new Set([path.basename(to), ...state.targets.filter((t) => t.runtimeEnv === 'app')
|
|
360
|
+
.flatMap((t) => [appOutputName(t), `${state.config.general.name}-${t.platform}-${t.arch}`])]);
|
|
361
|
+
const isApp = (entry) => entry.endsWith('.app') || apps.has(entry.replace(/\.dmg$/, '.app'))
|
|
362
|
+
|| apps.has(entry.replace(/(\.tar\.gz|\.app\.zip|\.zip|\.apk|\.aab|\.deb|-setup\.exe|\.update\.json)$/, ''));
|
|
316
363
|
fs.readdirSync(page).filter((entry) => !isApp(entry)).forEach((entry) => {
|
|
317
364
|
fs.cpSync(`${page}/${entry}`, `${to}/${appLayout(target).resources}/page/${entry}`, { recursive: true });
|
|
318
365
|
});
|
|
319
366
|
}
|
|
320
|
-
if (target.platform === 'darwin')
|
|
321
|
-
|
|
322
|
-
|
|
367
|
+
if (target.platform === 'darwin') {
|
|
368
|
+
const signing = appleSigning();
|
|
369
|
+
signApp(to, target, signing);
|
|
370
|
+
if (page) shipMacApp(to, signing);
|
|
371
|
+
} else if (target.platform === 'android' && target.buildType === 'release') {
|
|
372
|
+
packageAndroidRelease(to);
|
|
373
|
+
} else if (!['ios', 'android'].includes(target.platform)) {
|
|
374
|
+
if (target.platform === 'win32') signWindowsApp(to, target, page);
|
|
375
|
+
archiveApp(to, target);
|
|
376
|
+
}
|
|
377
|
+
// A desktop app with its page ships with what installs it, and with an updater's key with its update too.
|
|
378
|
+
if (page && ['darwin', 'linux', 'win32'].includes(target.platform)) {
|
|
379
|
+
writeInstaller(to, target);
|
|
380
|
+
writeUpdate(to, target);
|
|
381
|
+
}
|
|
323
382
|
return to;
|
|
324
383
|
}
|
|
325
384
|
|
|
@@ -334,6 +393,10 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
334
393
|
logger.info(`[${target.path}] app skipped (cargo package - staticlib only)`);
|
|
335
394
|
return false;
|
|
336
395
|
}
|
|
396
|
+
// Where the page opens files, a desktop host's title and window, a Windows one's version, and the app's icon: a bad
|
|
397
|
+
// app section stops the build before anything compiles.
|
|
398
|
+
const header = appHeader(state.config, target.platform);
|
|
399
|
+
const icon = readAppIcon(state.config);
|
|
337
400
|
|
|
338
401
|
const buildType = target.buildType === 'release' ? 'Release' : 'Debug';
|
|
339
402
|
const { build } = state.config.paths;
|
|
@@ -355,7 +418,12 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
355
418
|
|
|
356
419
|
const fingerprintFile = `${build}/${target.appName}.fingerprint`;
|
|
357
420
|
const fingerprint = getContentHash(JSON.stringify({
|
|
358
|
-
builder: getFilesFingerprint([fileURLToPath(import.meta.url)]),
|
|
421
|
+
builder: getFilesFingerprint([fileURLToPath(import.meta.url), fileURLToPath(new URL('../utils/appIcon.js', import.meta.url))]),
|
|
422
|
+
header,
|
|
423
|
+
// A desktop app carries its icon; an iOS or Android one gets it as its project is written.
|
|
424
|
+
icon: ['ios', 'android'].includes(target.platform) ? null : icon?.hash ?? null,
|
|
425
|
+
// A macOS app's bundle takes its id, name and version from it.
|
|
426
|
+
infoPlist: target.platform === 'darwin' ? infoPlist(state.config.general.name, appVersion(), Boolean(icon), urlSchemesOf(target)) : null,
|
|
359
427
|
hermes: HERMES.version,
|
|
360
428
|
inputs,
|
|
361
429
|
linkArgs,
|
|
@@ -386,6 +454,13 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
386
454
|
const hostName = target.platform === 'android' ? 'crossbind_app' : name;
|
|
387
455
|
logger.startStep(target, 'app');
|
|
388
456
|
resetCMakeCacheForSource(workDir, `${appHostRoot}/cpp`, target);
|
|
457
|
+
// The host includes it from its build directory; written only when it changed, it recompiles nothing otherwise.
|
|
458
|
+
writeIfChanged(`${workDir}/crossbind_app.h`, header);
|
|
459
|
+
// A Windows host's executable carries the app's icon, which its resource script takes from beside the header.
|
|
460
|
+
if (target.platform === 'win32') {
|
|
461
|
+
if (icon) writeIfChanged(`${workDir}/icon.ico`, windowsIcon(icon));
|
|
462
|
+
else fs.rmSync(`${workDir}/icon.ico`, { force: true });
|
|
463
|
+
}
|
|
389
464
|
run(null, [
|
|
390
465
|
// An iOS host builds with crossbind's iOS toolchain file rather than as an Xcode project of its own.
|
|
391
466
|
target.platform === 'ios' ? 'ios-cmake' : 'cmake', `${appHostRoot}/cpp`,
|
|
@@ -414,7 +489,7 @@ export default async function buildApp(target, { bridges = [], force = false } =
|
|
|
414
489
|
} else if (target.platform === 'android') {
|
|
415
490
|
assembleAndroidPart(target, { libDir: workDir, sharedLibs: libs.filter((lib) => lib.endsWith('.so')), hbc: `${workDir}/host.hbc` });
|
|
416
491
|
} else {
|
|
417
|
-
assembleApp(target, { executable: `${workDir}/${path.basename(appLayout(target).executable)}`, hbc: `${workDir}/host.hbc`, hermes });
|
|
492
|
+
assembleApp(target, { executable: `${workDir}/${path.basename(appLayout(target).executable)}`, hbc: `${workDir}/host.hbc`, hermes, icon });
|
|
418
493
|
}
|
|
419
494
|
logger.doneStep(target, 'app');
|
|
420
495
|
|