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.
@@ -11,6 +11,7 @@ import state from '../state/index.js';
11
11
  import logger from '../utils/logger.js';
12
12
  import { WASI_EMULATION_LIBS, WASI_LINK_LIBS, wasiCFlags, wasiCxxFlags } from '../utils/wasiToolchain.js';
13
13
  import { stageWasiHttpInclude, wasiHttpHeader } from '../utils/wasiHttp.js';
14
+ import { appApiHeader, compilesAsApp, stageAppApiInclude } from '../utils/appApiHeader.js';
14
15
  import { getFilesFingerprint, getContentHash } from '../utils/hash.js';
15
16
  import { withDependencyBridges } from '../utils/dependencyBridges.js';
16
17
  import { targetArchiveFlags } from '../utils/archiveFlags.js';
@@ -65,7 +66,9 @@ export default function createLib(target, fileType, options = {}) {
65
66
  : null;
66
67
  // An Android app links the project's archives from the build where React Native's shared library comes from.
67
68
  const appArchives = target.platform === 'android' && target.runtimeEnv === 'app' ? 'archives' : null;
68
- const extraKeys = [httpHeader, appArchives].filter(Boolean);
69
+ // An app's own code compiles against <crossbind/app.h>, with CROSSBIND_APP defined.
70
+ const appApi = compilesAsApp(target) ? getFilesFingerprint([appApiHeader()]) : null;
71
+ const extraKeys = [httpHeader, appArchives, appApi].filter(Boolean);
69
72
  const flagsFingerprint = getContentHash(JSON.stringify(extraKeys.length ? [keyInputs, ...extraKeys] : keyInputs));
70
73
  const flagsChanged = !fs.existsSync(flagsFingerprintFile)
71
74
  || fs.readFileSync(flagsFingerprintFile, { encoding: 'utf8' }) !== flagsFingerprint;
@@ -152,10 +155,12 @@ export default function createLib(target, fileType, options = {}) {
152
155
  buildEnv.params.push('-e', e);
153
156
  });
154
157
  } else {
155
- // The source of an http build includes <crossbind/http.h>.
156
- const headerDirs = target.platform === 'wasi'
157
- ? [...(options.headerDirs ?? []), stageWasiHttpInclude(state.config.paths.cli, `${buildPath}/crossbind-wasi-http`)]
158
- : options.headerDirs;
158
+ // The source of an http build includes <crossbind/http.h>, and an app's own code <crossbind/app.h>.
159
+ const headerDirs = [
160
+ ...(options.headerDirs ?? []),
161
+ ...(target.platform === 'wasi' ? [stageWasiHttpInclude(state.config.paths.cli, `${buildPath}/crossbind-wasi-http`)] : []),
162
+ ...(compilesAsApp(target) ? [stageAppApiInclude(`${buildPath}/crossbind-app`)] : []),
163
+ ];
159
164
  buildParams = getCmakeParameters(target, { ...options, nativeGlob, headerDirs });
160
165
 
161
166
  triggerExtensions('createLib', 'setFlagWithoutBuildConfig', [buildEnv]);
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import state from '../state/index.js';
3
3
  import getData from './getData.js';
4
+ import { compilesAsApp } from '../utils/appApiHeader.js';
4
5
 
5
6
  export default function getCmakeParameters(target, options = {}) {
6
7
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
@@ -37,7 +38,9 @@ export default function getCmakeParameters(target, options = {}) {
37
38
  const buildType = sharedPlatforms.includes(target.platform) && target.runtimeEnv !== 'app' ? 'SHARED' : 'STATIC';
38
39
  const otherBuildType = buildType === 'STATIC' ? 'SHARED' : 'STATIC';
39
40
 
40
- const cmakeCompileOptions = [...new Set(getData('cmake', target)?.compileOptions || [])];
41
+ // An app's own code can tell it is one, to reach <crossbind/app.h>.
42
+ const appOptions = compilesAsApp(target) ? ['-DCROSSBIND_APP'] : [];
43
+ const cmakeCompileOptions = [...new Set([...(getData('cmake', target)?.compileOptions || []), ...appOptions])];
41
44
 
42
45
  params.push(...[
43
46
  `-DPROJECT_NAME=${options.name || state.config.general.name}`,
@@ -14,7 +14,50 @@ export const ANDROIDX_WEBKIT = '1.17.1';
14
14
 
15
15
  const groovyString = (text) => `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
16
16
 
17
- const buildGradle = ({ id, label, version, abis }) => `// crossbind writes this project anew on every build: the module comes prebuilt in src/main/jniLibs/, and the app's
17
+ // The launcher shapes an adaptive icon: the app's icon in the middle of a white square.
18
+ const ADAPTIVE_ICON = `<?xml version="1.0" encoding="utf-8"?>
19
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
20
+ <background android:drawable="@android:color/white" />
21
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
22
+ </adaptive-icon>
23
+ `;
24
+
25
+ const DAY_NIGHT = '@android:style/Theme.DeviceDefault.DayNight';
26
+ // The activity's theme shows app.window.backgroundColor until the page paints, and the activity puts it behind the page,
27
+ // whose WebView would be white; transparent, which the activity takes for none, without it.
28
+ const backgroundValues = (color) => `<?xml version="1.0" encoding="utf-8"?>
29
+ <resources>
30
+ <color name="crossbind_background">${color ?? '#00000000'}</color>
31
+ <style name="CrossbindBackground" parent="${DAY_NIGHT}">
32
+ <item name="android:windowBackground">@color/crossbind_background</item>
33
+ </style>
34
+ </resources>
35
+ `;
36
+
37
+ // The Android permissions behind each of app.permissions, after the one every app asks for; the activity asks its user
38
+ // for them when the page first uses the device.
39
+ const INTERNET = ' <uses-permission android:name="android.permission.INTERNET" />';
40
+ const ANDROID_PERMISSIONS = {
41
+ camera: ['CAMERA'],
42
+ microphone: ['RECORD_AUDIO', 'MODIFY_AUDIO_SETTINGS'],
43
+ location: ['ACCESS_COARSE_LOCATION', 'ACCESS_FINE_LOCATION'],
44
+ };
45
+ const usesPermissions = (permissions) => permissions
46
+ .flatMap((name) => ANDROID_PERMISSIONS[name])
47
+ .map((permission) => ` <uses-permission android:name="android.permission.${permission}" />`);
48
+
49
+ // The links of app.urlSchemes the activity opens, from a browser as well, before the end of the activity's element.
50
+ const ACTIVITY_END = ' </activity>';
51
+ const urlFilter = (schemes) => [
52
+ ' <intent-filter>',
53
+ ' <action android:name="android.intent.action.VIEW" />',
54
+ ' <category android:name="android.intent.category.DEFAULT" />',
55
+ ' <category android:name="android.intent.category.BROWSABLE" />',
56
+ ...schemes.map((scheme) => ` <data android:scheme="${scheme}" />`),
57
+ ' </intent-filter>',
58
+ ].join('\n');
59
+
60
+ const buildGradle = ({ id, label, version, abis, icon, launchMode, theme }) => `// crossbind writes this project anew on every build: the module comes prebuilt in src/main/jniLibs/, and the app's
18
61
  // files are in src/main/assets/app/.
19
62
  plugins {
20
63
  id 'com.android.application'
@@ -31,7 +74,7 @@ android {
31
74
  targetSdk ${TARGET_SDK}
32
75
  versionCode ${version.code}
33
76
  versionName ${groovyString(version.name)}
34
- manifestPlaceholders = [appName: ${groovyString(label)}]
77
+ manifestPlaceholders = [appName: ${groovyString(label)}, appIcon: ${groovyString(icon)}, launchMode: ${groovyString(launchMode)}, appTheme: ${groovyString(theme)}]
35
78
  ndk {
36
79
  abiFilters ${abis.map(groovyString).join(', ')}
37
80
  }
@@ -95,16 +138,34 @@ dependencies {
95
138
 
96
139
  // An Android app as a Gradle project, written anew from the parts its ABI targets built: each brings its libraries,
97
140
  // the first also the app's files. Gradle adds Hermes and androidx.webkit from Maven, and makes the APK or the bundle.
98
- export default function writeGradleProject(to, { parts, id, version }) {
141
+ // The icons given are the files of the app's launcher icon under res/; without them it has the system's.
142
+ export default function writeGradleProject(to, { parts, id, label, version, icons = null, urlSchemes = [], background = null, permissions = [] }) {
99
143
  const { name } = state.config.general;
100
144
  if (parts.length === 0) throw new Error(`crossbind: no part of the Android app ${name} is built, so there is no Gradle project to write.`);
101
145
  fs.rmSync(to, { recursive: true, force: true });
102
146
  fs.cpSync(path.join(resolveAppHostRoot(), 'android'), to, { recursive: true });
103
147
  const main = path.join(to, 'app', 'src', 'main');
148
+ const manifest = path.join(main, 'AndroidManifest.xml');
149
+ const text = fs.readFileSync(manifest, 'utf8');
150
+ if (!text.includes(ACTIVITY_END) || !text.includes(INTERNET)) {
151
+ throw new Error(`crossbind: ${manifest}, from the app host, has no activity or permissions to give app.urlSchemes and app.permissions: update @crossbind/core-app-host to match crossbind.`);
152
+ }
153
+ fs.writeFileSync(manifest, text
154
+ .replace(INTERNET, [INTERNET, ...usesPermissions(permissions)].join('\n'))
155
+ .replace(ACTIVITY_END, urlSchemes.length > 0 ? `${urlFilter(urlSchemes)}\n${ACTIVITY_END}` : ACTIVITY_END));
104
156
  parts.forEach(({ arch, dir }) => fs.cpSync(path.join(dir, 'lib'), path.join(main, 'jniLibs', arch), { recursive: true }));
105
157
  fs.cpSync(path.join(parts[0].dir, 'app'), path.join(main, 'assets', 'app'), { recursive: true });
158
+ if (icons) {
159
+ Object.entries({ ...icons, 'mipmap-anydpi/ic_launcher.xml': ADAPTIVE_ICON }).forEach(([file, content]) => {
160
+ fs.mkdirSync(path.dirname(path.join(main, 'res', file)), { recursive: true });
161
+ fs.writeFileSync(path.join(main, 'res', file), content);
162
+ });
163
+ }
164
+ fs.mkdirSync(path.join(main, 'res', 'values'), { recursive: true });
165
+ fs.writeFileSync(path.join(main, 'res', 'values', 'background.xml'), backgroundValues(background));
106
166
  fs.writeFileSync(path.join(to, 'app', 'build.gradle'), buildGradle({
107
- id, label: name, version, abis: parts.map(({ arch }) => arch),
167
+ id, label, version, abis: parts.map(({ arch }) => arch), icon: icons ? '@mipmap/ic_launcher' : '@android:drawable/sym_def_app_icon',
168
+ launchMode: urlSchemes.length > 0 ? 'singleTask' : 'standard', theme: background ? '@style/CrossbindBackground' : DAY_NIGHT,
108
169
  }));
109
170
  return to;
110
171
  }
@@ -214,6 +214,7 @@ function nodeRuntimeRows({ isAddon }) {
214
214
  const WEBVIEW = { version: 'cbbdee44afff22867de9fd88a9fc8350d9bdd399', sha256: '10e972a2327b5681474f4aa4499e505eaa4ff659aa995380386f08fd6fc1b763' };
215
215
  const WEBVIEW2_SDK = { version: '1.0.4258.31', sha256: '56f7f4b8bf9aee4b8efefbbdd4f67d5f74ebd1b100ed0806da71bf76af481aa9' };
216
216
  const JSI_SHA256 = 'cb6274a22abc93ecc970066539815e716d3e47716e8f93a9971cf4eb5918ffc1';
217
+ const MONOCYPHER = { version: '4.0.3', sha256: '8cc9bc341a66249016db9bd70e9142d8d0aef9945973744b1ac05dbc55d8ee66' };
217
218
  // The fbjni the Hermes release for Android depends on.
218
219
  const FBJNI_VERSION = '0.7.0';
219
220
 
@@ -272,8 +273,8 @@ function androidAppRows() {
272
273
  ];
273
274
  }
274
275
 
275
- // The WebView2 SDK headers a Windows host compiles against. webview/webview loads the WebView2 Runtime itself, without
276
- // the SDK's loader.
276
+ // The WebView2 SDK headers a Windows host compiles against, and the NSIS its setup program carries. webview/webview
277
+ // loads the WebView2 Runtime itself, without the SDK's loader.
277
278
  function windowsAppRows(hostRoot) {
278
279
  const license = 'BSD-3-Clause';
279
280
  return [{
@@ -289,6 +290,19 @@ function windowsAppRows(hostRoot) {
289
290
  sourceUrl: `https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/${WEBVIEW2_SDK.version}/microsoft.web.webview2.${WEBVIEW2_SDK.version}.nupkg`,
290
291
  licenseNotes: "the WebView2 SDK's headers from the windows toolchain image, whose interface IDs the host compiles in",
291
292
  licenseText: textOf(path.join(hostRoot, 'third_party', 'webview2', 'LICENSE')),
293
+ }, {
294
+ npmName: '@crossbind/core-app-host',
295
+ name: 'nsis',
296
+ version: '3.11',
297
+ nativeVersion: '3.11',
298
+ sha256: null,
299
+ license: 'Zlib AND MIT',
300
+ licenseDeclared: 'Zlib AND MIT',
301
+ licenseSelected: null,
302
+ isCopyleft: false,
303
+ sourceUrl: 'https://sources.debian.org/src/nsis/3.11-1/',
304
+ licenseNotes: "the installer stub and plugins the app's setup program carries, from Debian's nsis in the windows toolchain image: zlib/libpng, but for the System plugin's Call.S, MIT",
305
+ licenseText: textOf(path.join(hostRoot, 'third_party', 'nsis', 'LICENSE')),
292
306
  }];
293
307
  }
294
308
 
@@ -336,6 +350,21 @@ function appRuntimeRows(platform) {
336
350
  licenseNotes: 'webview/webview, with the local patch @crossbind/core-app-host lists',
337
351
  licenseText: textOf(path.join(hostRoot, 'third_party', 'webview', 'LICENSE')),
338
352
  }]),
353
+ // A desktop host checks its updates with Monocypher, which the linker leaves out of one without app.updater.
354
+ ...(['ios', 'android'].includes(platform) || state.config.app?.updater === undefined ? [] : [{
355
+ npmName: '@crossbind/core-app-host',
356
+ name: 'monocypher',
357
+ version: MONOCYPHER.version,
358
+ nativeVersion: MONOCYPHER.version,
359
+ sha256: MONOCYPHER.sha256,
360
+ license: 'BSD-2-Clause OR CC0-1.0',
361
+ licenseDeclared: 'BSD-2-Clause OR CC0-1.0',
362
+ licenseSelected: null,
363
+ isCopyleft: false,
364
+ sourceUrl: `https://monocypher.org/download/monocypher-${MONOCYPHER.version}.tar.gz`,
365
+ licenseNotes: "Monocypher's Ed25519 and SHA-512, compiled into the host, which check the app's updates",
366
+ licenseText: textOf(path.join(hostRoot, 'third_party', 'monocypher', 'LICENCE.md')),
367
+ }]),
339
368
  ];
340
369
  }
341
370
 
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import state from '../state/index.js';
5
+ import getOsUserAndGroupId from '../utils/getOsUserAndGroupId.js';
6
+ import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
7
+ import replaceBasePathForDocker, { DOCKER_BASE } from '../utils/replaceBasePathForDocker.js';
8
+ import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
9
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
10
+
11
+ // The key that signs a Windows app and its setup program, named by the environment alone so that no secret reaches a
12
+ // file or a command line: jsign's own options, which the windows image's jsign takes through the host's jsign.sh.
13
+ export const WINDOWS_SIGNING = {
14
+ storetype: 'CROSSBIND_WINDOWS_SIGNING_STORETYPE',
15
+ keystore: 'CROSSBIND_WINDOWS_SIGNING_KEYSTORE',
16
+ alias: 'CROSSBIND_WINDOWS_SIGNING_ALIAS',
17
+ storepass: 'CROSSBIND_WINDOWS_SIGNING_STOREPASS',
18
+ keypass: 'CROSSBIND_WINDOWS_SIGNING_KEYPASS',
19
+ certfile: 'CROSSBIND_WINDOWS_SIGNING_CERTFILE',
20
+ tsaurl: 'CROSSBIND_WINDOWS_SIGNING_TSAURL',
21
+ };
22
+ // The store types of the image's jsign 7.6: files, which the container reads mounted, cloud key services, and hardware
23
+ // tokens, which it does not reach.
24
+ const FILE_STORES = ['PKCS12', 'JKS', 'JCEKS'];
25
+ const CLOUDS = ['AWS', 'AZUREKEYVAULT', 'DIGICERTONE', 'ESIGNER', 'GARASIGN', 'GOOGLECLOUD', 'HASHICORPVAULT', 'ORACLECLOUD', 'SIGNPATH', 'SIGNSERVER', 'TRUSTEDSIGNING'];
26
+ const TOKENS = ['PKCS11', 'CRYPTOCERTUM', 'ETOKEN', 'NITROKEY', 'OPENPGP', 'OPENSC', 'PIV', 'YUBIKEY'];
27
+ // Where the container reads the files the variables name.
28
+ const FILES = '/run/crossbind/signing';
29
+ const OUTPUT_LIMIT = 64 * 1024 * 1024;
30
+ const FAILURE_LINES = 40;
31
+
32
+ const listed = (names) => `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is'}`;
33
+
34
+ function existingFile(name, env) {
35
+ const file = path.resolve(env[name] ?? '');
36
+ if (!env[name] || !fs.statSync(file, { throwIfNoEntry: false })?.isFile()) {
37
+ throw new Error(`crossbind: ${name} names ${env[name] ? file : 'nothing'}, which is no file.`);
38
+ }
39
+ return file;
40
+ }
41
+
42
+ // The key a Windows app is signed with, or null without CROSSBIND_WINDOWS_SIGNING_STORETYPE: the app and its setup
43
+ // program are then not signed.
44
+ export function windowsSigning(env = process.env) {
45
+ const set = Object.values(WINDOWS_SIGNING).filter((name) => env[name]);
46
+ const storetype = env[WINDOWS_SIGNING.storetype]?.toUpperCase();
47
+ if (!storetype) {
48
+ if (set.length) throw new Error(`crossbind: ${listed(set)} set without ${WINDOWS_SIGNING.storetype}, the kind of key jsign signs a Windows app with.`);
49
+ return null;
50
+ }
51
+ if (TOKENS.includes(storetype)) {
52
+ throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is a hardware token, which crossbind's windows container does not reach. Sign with a cloud's key service, as Azure Trusted Signing (TRUSTEDSIGNING) or Azure Key Vault (AZUREKEYVAULT), or with a PKCS#12 file.`);
53
+ }
54
+ if (![...FILE_STORES, ...CLOUDS].includes(storetype)) {
55
+ throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is no store type jsign takes: ${[...FILE_STORES, ...CLOUDS].join(', ')}.`);
56
+ }
57
+ const files = {};
58
+ if (FILE_STORES.includes(storetype)) files.keystore = existingFile(WINDOWS_SIGNING.keystore, env);
59
+ if (env[WINDOWS_SIGNING.certfile]) files.certfile = existingFile(WINDOWS_SIGNING.certfile, env);
60
+ return { names: set, files };
61
+ }
62
+
63
+ // A command in crossbind's windows image, which sees the project's base, with the signing key's files mounted
64
+ // read-only under their own names and its other variables passed by name, so that their values stay off the command
65
+ // line. A failure names the tool it was, as the label.
66
+ export function inWindowsImage(command, workdir, signing = null, label = command[0]) {
67
+ const { base } = state.config.paths;
68
+ pullDockerImage('windows');
69
+ const files = Object.entries(signing?.files ?? {});
70
+ const mounted = files.map(([key]) => WINDOWS_SIGNING[key]);
71
+ const args = [
72
+ 'run', '--rm', ...DOCKER_RUN_SECURITY_ARGS, '--user', getOsUserAndGroupId(), '-v', `${base}:${DOCKER_BASE}`,
73
+ ...files.flatMap(([key, file]) => ['-v', `${file}:${FILES}/${key}:ro`, '-e', `${WINDOWS_SIGNING[key]}=${FILES}/${key}`]),
74
+ ...(signing?.names ?? []).filter((name) => !mounted.includes(name)).flatMap((name) => ['-e', name]),
75
+ // makensis reads its arguments in the locale's encoding, which the image leaves unset.
76
+ '-e', 'LC_ALL=C.UTF-8',
77
+ '-w', replaceBasePathForDocker(workdir, base),
78
+ getDockerImage('windows'),
79
+ ...replaceBasePathForDocker(command, base),
80
+ ];
81
+ const result = spawnSync('docker', args, { encoding: 'utf8', maxBuffer: OUTPUT_LIMIT });
82
+ if (result.error) throw new Error(`crossbind: cannot run docker for ${label}: ${result.error.message}`, { cause: result.error });
83
+ if (result.status !== 0) {
84
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split('\n').slice(-FAILURE_LINES).join('\n');
85
+ const end = result.signal ? `on ${result.signal}` : `with exit code ${result.status}`;
86
+ throw new Error(`crossbind: ${label} failed in the windows image ${end}:\n${output}`);
87
+ }
88
+ }
89
+
90
+ // The script the windows image signs a file with, which a setup program's build runs too.
91
+ export const jsignScript = () => `${resolveAppHostRoot()}/windows/jsign.sh`;
92
+
93
+ // Signs Windows executables in place with the key the environment names.
94
+ export function signWindows(files, signing) {
95
+ inWindowsImage(['sh', jsignScript(), ...files], path.dirname(files[0]), signing, 'jsign');
96
+ }
@@ -18,7 +18,7 @@ int main(int argc, char *argv[]) { return crossbind_app_main(argc, argv); }
18
18
 
19
19
  const OBJECT_NAMES = ['project', 'mainGroup', 'products', 'target', 'product', 'main', 'info', 'app', 'hermes', 'mainBuild',
20
20
  'appBuild', 'hermesLink', 'hermesEmbed', 'sources', 'frameworks', 'resources', 'embed', 'projectDebug', 'projectRelease',
21
- 'targetDebug', 'targetRelease', 'projectConfigs', 'targetConfigs'];
21
+ 'targetDebug', 'targetRelease', 'projectConfigs', 'targetConfigs', 'assets', 'assetsBuild'];
22
22
  // Xcode names its objects by 24 hex digits. Fixed ones leave the project unchanged while what it holds is.
23
23
  const ID = Object.fromEntries(OBJECT_NAMES.map((name, index) => [name, `CB${(index + 1).toString(16).toUpperCase().padStart(22, '0')}`]));
24
24
 
@@ -36,7 +36,10 @@ const PROJECT_SETTINGS = {
36
36
 
37
37
  const ORIENTATIONS = ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'];
38
38
 
39
- const infoPlist = (name) => xmlPlist({
39
+ // The color set of app.window.backgroundColor, which the launch screen shows.
40
+ const LAUNCH_COLOR = 'LaunchBackground';
41
+
42
+ const infoPlist = (name, urlSchemes, hasBackground, usage) => xmlPlist({
40
43
  CFBundleDevelopmentRegion: 'en',
41
44
  CFBundleDisplayName: name,
42
45
  CFBundleExecutable: '$(EXECUTABLE_NAME)',
@@ -45,22 +48,31 @@ const infoPlist = (name) => xmlPlist({
45
48
  CFBundleName: '$(PRODUCT_NAME)',
46
49
  CFBundlePackageType: 'APPL',
47
50
  CFBundleShortVersionString: '$(MARKETING_VERSION)',
51
+ ...(urlSchemes.length > 0 ? { CFBundleURLTypes: [{ CFBundleURLName: '$(PRODUCT_BUNDLE_IDENTIFIER)', CFBundleURLSchemes: urlSchemes }] } : {}),
48
52
  CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
49
53
  LSRequiresIPhoneOS: true,
50
54
  UIApplicationSceneManifest: { UIApplicationSupportsMultipleScenes: false },
51
- UILaunchScreen: {},
55
+ UILaunchScreen: hasBackground ? { UIColorName: LAUNCH_COLOR } : {},
52
56
  UISupportedInterfaceOrientations: ORIENTATIONS,
53
57
  'UISupportedInterfaceOrientations~ipad': [...ORIENTATIONS, 'UIInterfaceOrientationPortraitUpsideDown'],
58
+ // The reasons of app.permissions, without which iOS lets the app use none of those devices.
59
+ ...usage,
54
60
  });
55
61
 
56
- function objects(name, targetSettings) {
62
+ function objects(name, targetSettings, hasAssets) {
57
63
  const file = (path, type) => ({ isa: 'PBXFileReference', lastKnownFileType: type, path, sourceTree: '<group>' });
58
64
  const phase = (isa, files, extra = {}) => ({ isa, buildActionMask: 2147483647, files, runOnlyForDeploymentPostprocessing: 0, ...extra });
59
65
  const configuration = (configurationName, buildSettings) => ({ isa: 'XCBuildConfiguration', buildSettings, name: configurationName });
60
66
  const configurations = (debug, release) => ({
61
67
  isa: 'XCConfigurationList', buildConfigurations: [debug, release], defaultConfigurationIsVisible: 0, defaultConfigurationName: 'Release',
62
68
  });
69
+ // The app's icon and launch screen color, in an asset catalog.
70
+ const assets = hasAssets ? {
71
+ [ID.assets]: file('Assets.xcassets', 'folder.assetcatalog'),
72
+ [ID.assetsBuild]: { isa: 'PBXBuildFile', fileRef: ID.assets },
73
+ } : {};
63
74
  return {
75
+ ...assets,
64
76
  [ID.main]: file('main.m', 'sourcecode.c.objc'),
65
77
  [ID.info]: file('Info.plist', 'text.plist.xml'),
66
78
  [ID.app]: file('app', 'folder'),
@@ -74,9 +86,11 @@ function objects(name, targetSettings) {
74
86
  [ID.hermesEmbed]: { isa: 'PBXBuildFile', fileRef: ID.hermes, settings: { ATTRIBUTES: ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] } },
75
87
  [ID.sources]: phase('PBXSourcesBuildPhase', [ID.mainBuild]),
76
88
  [ID.frameworks]: phase('PBXFrameworksBuildPhase', [ID.hermesLink]),
77
- [ID.resources]: phase('PBXResourcesBuildPhase', [ID.appBuild]),
89
+ [ID.resources]: phase('PBXResourcesBuildPhase', [ID.appBuild, ...(hasAssets ? [ID.assetsBuild] : [])]),
78
90
  [ID.embed]: phase('PBXCopyFilesBuildPhase', [ID.hermesEmbed], { dstPath: '', dstSubfolderSpec: 10, name: 'Embed Frameworks' }),
79
- [ID.mainGroup]: { isa: 'PBXGroup', children: [ID.main, ID.info, ID.app, ID.hermes, ID.products], sourceTree: '<group>' },
91
+ [ID.mainGroup]: {
92
+ isa: 'PBXGroup', children: [ID.main, ID.info, ID.app, ...(hasAssets ? [ID.assets] : []), ID.hermes, ID.products], sourceTree: '<group>',
93
+ },
80
94
  [ID.products]: { isa: 'PBXGroup', children: [ID.product], name: 'Products', sourceTree: '<group>' },
81
95
  [ID.target]: {
82
96
  isa: 'PBXNativeTarget',
@@ -111,10 +125,28 @@ function objects(name, targetSettings) {
111
125
  };
112
126
  }
113
127
 
128
+ // The asset catalog: the app's icon, from which Xcode makes every size, and the launch screen's color.
129
+ function writeAssets(catalog, icon, background) {
130
+ const info = { author: 'xcode', version: 1 };
131
+ const assetSet = (set, contents) => {
132
+ fs.mkdirSync(`${catalog}/${set}`, { recursive: true });
133
+ fs.writeFileSync(`${catalog}/Contents.json`, JSON.stringify({ info }, null, 2));
134
+ fs.writeFileSync(`${catalog}/${set}/Contents.json`, JSON.stringify({ ...contents, info }, null, 2));
135
+ };
136
+ if (icon) {
137
+ assetSet('AppIcon.appiconset', { images: [{ filename: 'icon.png', idiom: 'universal', platform: 'ios', size: '1024x1024' }] });
138
+ fs.writeFileSync(`${catalog}/AppIcon.appiconset/icon.png`, icon);
139
+ }
140
+ if (background) {
141
+ const [red, green, blue] = [1, 3, 5].map((at) => `0x${background.slice(at, at + 2).toUpperCase()}`);
142
+ assetSet(`${LAUNCH_COLOR}.colorset`, { colors: [{ color: { 'color-space': 'srgb', components: { alpha: '1.000', red, green, blue } }, idiom: 'universal' }] });
143
+ }
144
+ }
145
+
114
146
  // An iOS app as an Xcode project, written anew from the parts its SDK targets built: each brings its archives and
115
147
  // their link line, and its slice of Hermes; the first also the app's files. Xcode signs the app and runs it, in the
116
- // Simulator or on a device.
117
- export default function writeXcodeProject(to, { parts, identity, version }) {
148
+ // Simulator or on a device, and makes every size of its icon from the one PNG given.
149
+ export default function writeXcodeProject(to, { parts, identity, label, version, icon = null, urlSchemes = [], background = null, usage = {} }) {
118
150
  const { name } = state.config.general;
119
151
  if (parts.length === 0) throw new Error(`crossbind: no part of the iOS app ${name} is built, so there is no Xcode project to write.`);
120
152
  fs.rmSync(to, { recursive: true, force: true });
@@ -133,8 +165,10 @@ export default function writeXcodeProject(to, { parts, identity, version }) {
133
165
  if (result.status !== 0) throw new Error(`crossbind: cannot make Hermes' xcframework for ${to}: ${result.stderr || result.error?.message}`);
134
166
 
135
167
  fs.writeFileSync(`${to}/main.m`, MAIN);
136
- fs.writeFileSync(`${to}/Info.plist`, infoPlist(name));
168
+ fs.writeFileSync(`${to}/Info.plist`, infoPlist(label, urlSchemes, Boolean(background), usage));
169
+ writeAssets(`${to}/Assets.xcassets`, icon, background);
137
170
  const targetSettings = {
171
+ ...(icon ? { ASSETCATALOG_COMPILER_APPICON_NAME: 'AppIcon' } : {}),
138
172
  PRODUCT_NAME: name,
139
173
  PRODUCT_BUNDLE_IDENTIFIER: identity.id,
140
174
  DEVELOPMENT_TEAM: identity.team,
@@ -146,7 +180,7 @@ export default function writeXcodeProject(to, { parts, identity, version }) {
146
180
  ...linkFlags,
147
181
  };
148
182
  fs.writeFileSync(`${to}/${name}.xcodeproj/project.pbxproj`, textPlist({
149
- archiveVersion: 1, classes: {}, objectVersion: 56, objects: objects(name, targetSettings), rootObject: ID.project,
183
+ archiveVersion: 1, classes: {}, objectVersion: 56, objects: objects(name, targetSettings, Boolean(icon || background)), rootObject: ID.project,
150
184
  }));
151
185
  return to;
152
186
  }
@@ -0,0 +1,129 @@
1
+ // What an app's host does for its page (target.runtimeEnv: 'app'); in any other build every call rejects.
2
+
3
+ /** A kind of file a dialog offers: its name, and the extensions, with or without the dot, that make it; "*" is any file. */
4
+ export interface FileFilter {
5
+ name: string;
6
+ extensions: string[];
7
+ }
8
+
9
+ export interface OpenOptions {
10
+ title?: string;
11
+ /** The folder the dialog shows first. */
12
+ defaultPath?: string;
13
+ filters?: FileFilter[];
14
+ /** Folders instead of files. */
15
+ directory?: boolean;
16
+ multiple?: boolean;
17
+ }
18
+
19
+ export interface SaveOptions {
20
+ title?: string;
21
+ /** The folder the dialog shows first. */
22
+ defaultPath?: string;
23
+ defaultName?: string;
24
+ filters?: FileFilter[];
25
+ }
26
+
27
+ export interface MessageOptions {
28
+ message: string;
29
+ /** Smaller text under the message. */
30
+ detail?: string;
31
+ /** info by default. Windows shows its icon for each, and macOS a caution sign for error; Linux, iOS and Android show none. */
32
+ kind?: 'info' | 'warning' | 'error';
33
+ /** One to three labels, the first the default; without them OK. */
34
+ buttons?: string[];
35
+ }
36
+
37
+ /** The app's own folders, made when first asked for: what it keeps, what it can lose, and its logs. */
38
+ export interface AppPaths {
39
+ data: string;
40
+ cache: string;
41
+ logs: string;
42
+ }
43
+
44
+ export declare const dialog: {
45
+ /** The paths of the files or folders the user picked, or null when they cancelled. */
46
+ open(options?: OpenOptions): Promise<string[] | null>;
47
+ /** The path the user chose to save to, or null when they cancelled. */
48
+ save(options?: SaveOptions): Promise<string | null>;
49
+ /** The index of the button the user clicked: Return gives the first, and Escape or closing the dialog the last. */
50
+ message(options: MessageOptions): Promise<number>;
51
+ };
52
+
53
+ export declare const shell: {
54
+ /** An http, https or mailto address in the default browser or mail app. */
55
+ openExternal(url: string | URL): Promise<void>;
56
+ /** A file in the app the system opens its kind with, where app.openPath in crossbind.config.js lets the page: nowhere without it. */
57
+ openPath(path: string): Promise<void>;
58
+ /** A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run. */
59
+ revealInFolder(path: string): Promise<void>;
60
+ };
61
+
62
+ export declare const app: {
63
+ paths(): Promise<AppPaths>;
64
+ /** Ends the app as closing its window does. */
65
+ quit(): Promise<void>;
66
+ /** The links of app.urlSchemes in crossbind.config.js that opened the app and have not reached the page yet, the one it was launched with among them. Once the page has asked, each later link comes as the event open-url while it listens, so listening first, then asking, misses none. */
67
+ launchUrls(): Promise<string[]>;
68
+ };
69
+
70
+ /** A key with CmdOrCtrl, Ctrl or Alt and maybe Shift, joined by +, as CmdOrCtrl+Shift+O is, or F1–F12, alone or with any of them. The key is A–Z, 0–9, F1–F12, Enter, Escape, Tab, Space, Backspace, Delete, Up, Down, Left, Right, Home, End, PageUp, PageDown, Plus, Minus, Comma or Period. */
71
+ export type Accelerator = string;
72
+
73
+ /** An item of a menu: one the page handles, by its id, a submenu, a separator, or a standard one by its role. */
74
+ export type MenuItem =
75
+ | { label: string; id?: string; accelerator?: Accelerator; enabled?: boolean; checked?: boolean }
76
+ | { label: string; items: MenuItem[] }
77
+ | { type: 'separator' }
78
+ | { role: 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll'; label?: string; accelerator?: never }
79
+ | { role: 'minimize' | 'close' | 'quit'; label?: string; accelerator?: Accelerator }
80
+ | { role: 'editMenu' | 'windowMenu'; label?: string };
81
+
82
+ /** A menu of the bar, by its items, or a standard one by its role. */
83
+ export type Menu = { label: string; items: MenuItem[] } | { role: 'editMenu' | 'windowMenu'; label?: string };
84
+
85
+ /** What the event menu carries: the id of the item clicked, and for one with checked whether it now is. */
86
+ export interface MenuClick {
87
+ id: string;
88
+ checked?: boolean;
89
+ }
90
+
91
+ export declare const menu: {
92
+ /** Sets the app's menus: on macOS after its app menu, which the app keeps, and on Windows and Linux in a bar of its window. A click of an item with an id is an event, menu, with a MenuClick. */
93
+ set(menus: Menu[]): Promise<void>;
94
+ };
95
+
96
+ /** What the event second-instance carries: a later launch's arguments, the program's own left out, and the folder it was launched in. */
97
+ export interface SecondInstance {
98
+ args: string[];
99
+ cwd: string;
100
+ }
101
+
102
+ /** What the event open-url carries: a link of one of app.urlSchemes that the system opened the app with. */
103
+ export interface OpenUrl {
104
+ url: string;
105
+ }
106
+
107
+ export declare const events: {
108
+ /** Calls listener with each click of an item with an id in the menus menu.set sets, until the function it returns is called. */
109
+ on(name: 'menu', listener: (click: MenuClick) => void): () => void;
110
+ /** Calls listener with each link of app.urlSchemes the system opens the app with once the page has called app.launchUrls(), until the function it returns is called. */
111
+ on(name: 'open-url', listener: (open: OpenUrl) => void): () => void;
112
+ /** Calls listener with each later launch of a desktop app that app.singleInstance keeps to one instance, which brings its window to the front as far as the system lets it, until the function it returns is called. */
113
+ on(name: 'second-instance', listener: (launch: SecondInstance) => void): () => void;
114
+ /** Calls listener with the value of each event of this name the app's native code emits from now on, until the function it returns is called. */
115
+ on(name: string, listener: (value: unknown) => void): () => void;
116
+ };
117
+
118
+ /** A newer version of the app, which app.updater.url in crossbind.config.js offers. */
119
+ export interface Update {
120
+ version: string;
121
+ }
122
+
123
+ /** One check or install runs at a time, and another rejects meanwhile. */
124
+ export declare const updater: {
125
+ /** The version app.updater.url offers when it is newer than the app's, or null. */
126
+ check(): Promise<Update | null>;
127
+ /** Downloads that version, checks that the key of app.updater.publicKey signed it and installs it in place of the app, which then quits and opens the new version. It rejects when that fails. */
128
+ install(): Promise<void>;
129
+ };