diff --git a/cli/package.json b/cli/package.json index 99107c1dac..d5ee592dde 100644 --- a/cli/package.json +++ b/cli/package.json @@ -11,7 +11,8 @@ "engines": { "node": ">=10.3.0" }, - "main": "./dist/index.js", + "main": "dist/index.js", + "types": "dist/declarations.d.ts", "scripts": { "build": "npm run clean && npm run assets && tsc", "clean": "rimraf ./dist", @@ -23,7 +24,8 @@ "files": [ "assets/", "bin/", - "dist/" + "dist/**/*.js", + "dist/declarations.d.ts" ], "keywords": [ "ionic", diff --git a/cli/src/common.ts b/cli/src/common.ts index b046409096..8216f717d5 100644 --- a/cli/src/common.ts +++ b/cli/src/common.ts @@ -10,7 +10,7 @@ import { setTimeout } from 'timers'; import xml2js from 'xml2js'; import c from './colors'; -import type { Config, PackageJson, ExternalConfig } from './definitions'; +import type { Config, ExternalConfig, PackageJson } from './definitions'; import { output, logger } from './log'; export type CheckFunction = () => Promise; @@ -34,7 +34,7 @@ export async function checkWebDir(config: Config): Promise { `Please create it and make sure it has an ${c.strong( 'index.html', )} file. You can change the path of this directory in ${c.strong( - 'capacitor.config.json', + config.app.extConfigName, )} (${c.input( 'webDir', )} option). You may need to compile the web assets for your app (typically ${c.input( @@ -87,7 +87,7 @@ export async function checkAppConfig(config: Config): Promise { if (!config.app.appId) { return ( `Missing ${c.input('appId')} for new platform.\n` + - `Please add it in capacitor.config.json or run ${c.input( + `Please add it in ${config.app.extConfigName} or run ${c.input( 'npx cap init', )}.` ); @@ -95,7 +95,7 @@ export async function checkAppConfig(config: Config): Promise { if (!config.app.appName) { return ( `Missing ${c.input('appName')} for new platform.\n` + - `Please add it in capacitor.config.json or run ${c.input( + `Please add it in ${config.app.extConfigName} or run ${c.input( 'npx cap init', )}.` ); diff --git a/cli/src/config.ts b/cli/src/config.ts index 5a0739a7ad..f3007af1eb 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -2,37 +2,36 @@ import { pathExists, readJSON } from '@ionic/utils-fs'; import Debug from 'debug'; import { dirname, join, resolve } from 'path'; -import { runCommand } from './common'; +import c from './colors'; +import { logFatal, resolveNode, runCommand } from './common'; import type { + AndroidConfig, + AppConfig, + CLIConfig, Config, ExternalConfig, - CLIConfig, - AndroidConfig, IOSConfig, - PackageJson, WebConfig, } from './definitions'; import { OS } from './definitions'; +import { tryFn } from './util/fn'; +import { requireTS } from './util/node'; const debug = Debug('capacitor:config'); -export const EXTERNAL_CONFIG_FILE = 'capacitor.config.json'; - export async function loadConfig(): Promise { const appRootDir = process.cwd(); const cliRootDir = dirname(__dirname); - const extConfig = await loadExternalConfig( - resolve(appRootDir, EXTERNAL_CONFIG_FILE), - ); + const conf = await loadExtConfig(appRootDir); - const appId = extConfig.appId ?? ''; - const appName = extConfig.appName ?? ''; - const webDir = extConfig.webDir ?? 'www'; + const appId = conf.extConfig.appId ?? ''; + const appName = conf.extConfig.appName ?? ''; + const webDir = conf.extConfig.webDir ?? 'www'; const cli = await loadCLIConfig(cliRootDir); - const config = { - android: await loadAndroidConfig(appRootDir, extConfig, cli), - ios: await loadIOSConfig(appRootDir, extConfig, cli), + const config: Config = { + android: await loadAndroidConfig(appRootDir, conf.extConfig, cli), + ios: await loadIOSConfig(appRootDir, conf.extConfig, cli), web: await loadWebConfig(appRootDir, webDir), cli, app: { @@ -41,14 +40,12 @@ export async function loadConfig(): Promise { appName, webDir, webDirAbs: resolve(appRootDir, webDir), - package: (await readPackageJSON(resolve(appRootDir, 'package.json'))) ?? { + package: (await tryFn(readJSON, resolve(appRootDir, 'package.json'))) ?? { name: appName, version: '1.0.0', }, - extConfigName: EXTERNAL_CONFIG_FILE, - extConfigFilePath: resolve(appRootDir, EXTERNAL_CONFIG_FILE), - extConfig, - bundledWebRuntime: extConfig.bundledWebRuntime ?? false, + ...conf, + bundledWebRuntime: conf.extConfig.bundledWebRuntime ?? false, }, }; @@ -57,6 +54,86 @@ export async function loadConfig(): Promise { return config; } +type ExtConfigPairs = Pick< + AppConfig, + 'extConfigType' | 'extConfigName' | 'extConfigFilePath' | 'extConfig' +>; + +async function loadExtConfigTS( + rootDir: string, + extConfigName: string, + extConfigFilePath: string, +): Promise { + try { + const tsPath = resolveNode(rootDir, 'typescript'); + + if (!tsPath) { + logFatal( + 'Could not find installation of TypeScript.\n' + + `To use ${c.strong( + extConfigName, + )} files, you must install TypeScript in your project, e.g. w/ ${c.input( + 'npm install -D typescript', + )}`, + ); + } + + const ts = require(tsPath); // eslint-disable-line @typescript-eslint/no-var-requires + + return { + extConfigType: 'ts', + extConfigName, + extConfigFilePath: extConfigFilePath, + extConfig: requireTS(ts, extConfigFilePath) as any, + }; + } catch (e) { + logFatal(`Parsing ${c.strong(extConfigName)} failed.\n\n${e.stack ?? e}`); + } +} + +async function loadExtConfigJS( + rootDir: string, + extConfigName: string, + extConfigFilePath: string, +): Promise { + try { + return { + extConfigType: 'js', + extConfigName, + extConfigFilePath: extConfigFilePath, + extConfig: require(extConfigFilePath), + }; + } catch (e) { + logFatal(`Parsing ${c.strong(extConfigName)} failed.\n\n${e.stack ?? e}`); + } +} + +async function loadExtConfig(rootDir: string): Promise { + const extConfigNameTS = 'capacitor.config.ts'; + const extConfigFilePathTS = resolve(rootDir, extConfigNameTS); + + if (await pathExists(extConfigFilePathTS)) { + return loadExtConfigTS(rootDir, extConfigNameTS, extConfigFilePathTS); + } + + const extConfigNameJS = 'capacitor.config.js'; + const extConfigFilePathJS = resolve(rootDir, extConfigNameJS); + + if (await pathExists(extConfigFilePathJS)) { + return loadExtConfigJS(rootDir, extConfigNameJS, extConfigFilePathJS); + } + + const extConfigName = 'capacitor.config.json'; + const extConfigFilePath = resolve(rootDir, extConfigName); + + return { + extConfigType: 'json', + extConfigName, + extConfigFilePath: extConfigFilePath, + extConfig: (await tryFn(readJSON, extConfigFilePath)) ?? {}, + }; +} + async function loadCLIConfig(rootDir: string): Promise { const assetsName = 'assets'; @@ -202,19 +279,3 @@ async function determineAndroidStudioPath(os: OS): Promise { return ''; } - -async function loadExternalConfig(p: string): Promise { - try { - return await readJSON(p); - } catch (e) { - return {}; - } -} - -async function readPackageJSON(p: string): Promise { - try { - return await readJSON(p); - } catch (e) { - return null; - } -} diff --git a/cli/src/cordova.ts b/cli/src/cordova.ts index 1895487860..030ec8ea66 100644 --- a/cli/src/cordova.ts +++ b/cli/src/cordova.ts @@ -527,7 +527,7 @@ export async function getCordovaPreferences(config: Config): Promise { const answers = await logPrompt( `${c.strong( `Cordova preferences can be automatically ported to ${c.strong( - 'capacitor.config.json', + config.app.extConfigName, )}.`, )}\n` + `Keep in mind: Not all values can be automatically migrated from ${c.strong( @@ -550,8 +550,7 @@ export async function getCordovaPreferences(config: Config): Promise { { type: 'confirm', name: 'confirm', - message: - 'capacitor.config.json already contains Cordova preferences. Overwrite?', + message: `${config.app.extConfigName} already contains Cordova preferences. Overwrite?`, }, ], { onCancel: () => process.exit(1) }, diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts new file mode 100644 index 0000000000..310435a00f --- /dev/null +++ b/cli/src/declarations.ts @@ -0,0 +1,356 @@ +export interface CapacitorConfig { + /** + * The unique identifier of your packaged app. + * + * This is also known as the Bundle ID in iOS and the Application ID in + * Android. It must be in reverse domain name notation, generally + * representing a domain name that you or your company owns. + * + * @since 1.0.0 + */ + appId?: string; + + /** + * The human-friendly name of your app. + * + * This should be what you'd see in the App Store, but can be changed after + * within each native platform after it is generated. + * + * @since 1.0.0 + */ + appName?: string; + + /** + * The directory of your compiled web assets. + * + * This directory should contain the final `index.html` of your app. + * + * @since 1.0.0 + */ + webDir?: string; + + /** + * Whether to copy the Capacitor runtime bundle or not. + * + * If your app is not using a bundler, set this to `true`, then Capacitor + * will create a `capacitor.js` file that you'll need to add as a script in + * your `index.html` file. + * + * @since 1.0.0 + * @default false + */ + bundledWebRuntime?: boolean; + + /** + * Hide or show the native logs for iOS and Android. + * + * @since 2.1.0 + * @default false + */ + hideLogs?: boolean; + + /** + * User agent of Capacitor Web View. + * + * @since 1.4.0 + */ + overrideUserAgent?: string; + + /** + * String to append to the original user agent of Capacitor Web View. + * + * This is disregarded if `overrideUserAgent` is used. + * + * @since 1.4.0 + */ + appendUserAgent?: string; + + /** + * Background color of the Capacitor Web View. + * + * @since 1.1.0 + */ + backgroundColor?: string; + + android?: { + /** + * Specify a custom path to the native Android project. + * + * @since 3.0.0 + * @default android + */ + path?: string; + + /** + * User agent of Capacitor Web View on Android. + * + * Overrides global `overrideUserAgent` option. + * + * @since 1.4.0 + */ + overrideUserAgent?: string; + + /** + * String to append to the original user agent of Capacitor Web View for Android. + * + * Overrides global `appendUserAgent` option. + * + * This is disregarded if `overrideUserAgent` is used. + * + * @since 1.4.0 + */ + appendUserAgent?: string; + + /** + * Background color of the Capacitor Web View for Android. + * + * Overrides global `backgroundColor` option. + * + * @since 1.1.0 + */ + backgroundColor?: string; + + /** + * Enable mixed content in the Capacitor Web View for Android. + * + * [Mixed + * content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content) + * is disabled by default for security. During development, you may need to + * enable it to allow the Web View to load files from different schemes. + * + * **This is not intended for use in production.** + * + * @since 1.0.0 + * @default false + */ + allowMixedContent?: boolean; + + /** + * This enables a simpler keyboard which may have some limitations. + * + * This will capture JS keys using an alternative + * [`InputConnection`](https://developer.android.com/reference/android/view/inputmethod/InputConnection). + * + * @since 1.0.0 + * @default false + */ + captureInput?: boolean; + + /** + * Always enable debuggable web content. + * + * This is automatically enabled during development. + * + * @since 1.0.0 + * @default false + */ + webContentsDebuggingEnabled?: boolean; + + /** + * Hide or show the native logs for Android. + * + * Overrides global `hideLogs` option. + * + * @since 2.1.0 + * @default false + */ + hideLogs?: boolean; + }; + + ios?: { + /** + * Specify a custom path to the native iOS project. + * + * @since 3.0.0 + * @default ios + */ + path?: string; + + /** + * User agent of Capacitor Web View on iOS. + * + * Overrides global `overrideUserAgent` option. + * + * @since 1.4.0 + */ + overrideUserAgent?: string; + + /** + * String to append to the original user agent of Capacitor Web View for iOS. + * + * Overrides global `appendUserAgent` option. + * + * This is disregarded if `overrideUserAgent` is used. + * + * @since 1.4.0 + */ + appendUserAgent?: string; + + /** + * Background color of the Capacitor Web View for iOS. + * + * Overrides global `backgroundColor` option. + * + * @since 1.1.0 + */ + backgroundColor?: string; + + /** + * Configure the scroll view's content inset adjustment behavior. + * + * This will set the + * [`contentInsetAdjustmentBehavior`](https://developer.apple.com/documentation/uikit/uiscrollview/2902261-contentinsetadjustmentbehavior) + * property on the Web View's + * [`UIScrollView`](https://developer.apple.com/documentation/uikit/uiscrollview). + * + * @since 2.0.0 + * @default never + */ + contentInset?: 'automatic' | 'scrollableAxes' | 'never' | 'always'; + + /** + * Configure whether the scroll view is scrollable. + * + * This will set the + * [`isScrollEnabled`](https://developer.apple.com/documentation/uikit/uiscrollview/1619395-isscrollenabled) + * property on the Web View's + * [`UIScrollView`](https://developer.apple.com/documentation/uikit/uiscrollview). + * + * @since 1.0.0 + */ + scrollEnabled?: boolean; + + /** + * Configure the Swift version to be used in Cordova plugins. + * + * @since 1.0.0 + * @default 5.1 + */ + cordovaSwiftVersion?: string; + + /** + * Configure the minimum iOS version supported. + * + * @since 1.0.0 + * @default 11.0 + */ + minVersion?: string; + + /** + * Configure custom linker flags for compiling Cordova plugins. + * + * @since 1.0.0 + * @default [] + */ + cordovaLinkerFlags?: string[]; + + /** + * Allow destination previews when pressing on links. + * + * This will set the + * [`allowsLinkPreview`](https://developer.apple.com/documentation/webkit/wkwebview/1415000-allowslinkpreview) + * property on the Web View, instead of using the default value. + * + * @since 2.0.0 + */ + allowsLinkPreview?: boolean; + + /** + * Hide or show the native logs for iOS. + * + * Overrides global `hideLogs` option. + * + * @since 1.1.0 + * @default false + */ + hideLogs?: boolean; + }; + + server?: { + /** + * Configure the local hostname of the device. + * + * It is recommended to keep this as `localhost` as it allows the use of + * Web APIs that would otherwise require a [secure + * context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) + * such as + * [`navigator.geolocation`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/geolocation) + * and + * [`MediaDevices.getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia). + * + * @since 1.0.0 + * @default localhost + */ + hostname?: string; + + /** + * Configure the local scheme on iOS. + * + * This can be useful when migrating from + * [`cordova-plugin-ionic-webview`](https://github.com/ionic-team/cordova-plugin-ionic-webview), + * where the default scheme on iOS is `ionic`. + * + * @since 1.2.0 + * @default capacitor + */ + iosScheme?: string; + + /** + * Configure the local scheme on Android. + * + * @since 1.2.0 + * @default http + */ + androidScheme?: string; + + /** + * Load an external URL in the Web View. + * + * This is intended for use with live-reload servers. + * + * **This is not intended for use in production.** + * + * @since 1.0.0 + */ + url?: string; + + /** + * Allow cleartext traffic in the Web View. + * + * On Android, all cleartext traffic is disabled by default as of API 28. + * + * This is intended for use with live-reload servers where unencrypted HTTP + * traffic is often used. + * + * **This is not intended for use in production.** + * + * @since 1.5.0 + * @default false + */ + cleartext?: boolean; + + /** + * Set additional URLs the Web View can navigate to. + * + * By default, all external URLs are opened in the external browser (not + * the Web View). + * + * **This is not intended for use in production.** + * + * @since 1.0.0 + * @default [] + */ + allowNavigation?: string[]; + }; + + cordova?: { + /** + * Configure Cordova preferences. + * + * @since 1.3.0 + */ + preferences?: { [key: string]: string | undefined }; + }; + + plugins?: { [key: string]: any }; +} diff --git a/cli/src/definitions.ts b/cli/src/definitions.ts index 3ee0064a28..f41c7e34ee 100644 --- a/cli/src/definitions.ts +++ b/cli/src/definitions.ts @@ -1,3 +1,9 @@ +import type { CapacitorConfig } from './declarations'; + +type DeepReadonly = { readonly [P in keyof T]: DeepReadonly }; + +export type ExternalConfig = DeepReadonly; + export const enum OS { Unknown = 'unknown', Mac = 'mac', @@ -12,31 +18,6 @@ export interface PackageJson { readonly devDependencies?: { readonly [key: string]: string | undefined }; } -export interface ExternalConfig { - readonly windowsAndroidStudioPath?: string; - readonly linuxAndroidStudioPath?: string; - readonly appId?: string; - readonly appName?: string; - readonly webDir?: string; - readonly bundledWebRuntime?: boolean; - readonly android?: { - readonly path?: string; - }; - readonly ios?: { - readonly path?: string; - readonly cordovaSwiftVersion?: string; - readonly minVersion?: string; - readonly cordovaLinkerFlags?: string[]; - }; - readonly cordova?: { - readonly preferences?: { readonly [key: string]: string | undefined }; - }; - readonly plugins?: { readonly [key: string]: any }; - readonly server?: { - readonly cleartext?: boolean; - }; -} - export interface WindowsConfig { readonly androidStudioPath: string; } @@ -73,6 +54,7 @@ export interface AppConfig { readonly webDir: string; readonly webDirAbs: string; readonly package: PackageJson; + readonly extConfigType: 'json' | 'js' | 'ts'; readonly extConfigName: string; readonly extConfigFilePath: string; readonly extConfig: ExternalConfig; diff --git a/cli/src/tasks/copy.ts b/cli/src/tasks/copy.ts index f048b3c884..93c5f1d149 100644 --- a/cli/src/tasks/copy.ts +++ b/cli/src/tasks/copy.ts @@ -1,4 +1,4 @@ -import { copy as fsCopy, pathExists, remove } from '@ionic/utils-fs'; +import { copy as fsCopy, pathExists, remove, writeJSON } from '@ionic/utils-fs'; import { basename, join, relative, resolve } from 'path'; import c from '../colors'; @@ -117,15 +117,15 @@ async function copyNativeBridge(rootDir: string, nativeAbsDir: string) { async function copyCapacitorConfig(config: Config, nativeAbsDir: string) { const nativeRelDir = relative(config.app.rootDir, nativeAbsDir); - const configPath = resolve(config.app.extConfigFilePath); - if (!(await pathExists(configPath))) { - return; - } + const nativeConfigFile = 'capacitor.config.json'; + const nativeConfigFilePath = join(nativeAbsDir, nativeConfigFile); await runTask( - `Copying ${c.strong('capacitor.config.json')} to ${nativeRelDir}`, + `Creating ${c.strong(nativeConfigFile)} in ${nativeRelDir}`, async () => { - return fsCopy(configPath, join(nativeAbsDir, 'capacitor.config.json')); + await writeJSON(nativeConfigFilePath, config.app.extConfig, { + spaces: '\t', + }); }, ); } diff --git a/cli/src/tasks/init.ts b/cli/src/tasks/init.ts index a15222772f..9e1a9ce365 100644 --- a/cli/src/tasks/init.ts +++ b/cli/src/tasks/init.ts @@ -24,6 +24,16 @@ export async function initCommand( if (!checkInteractive(name, id)) { return; } + + if (config.app.extConfigType !== 'json') { + logFatal( + `Cannot run ${c.input( + 'init', + )} for a project using a non-JSON configuration file.\n` + + `Delete ${c.strong(config.app.extConfigName)} and try again.`, + ); + } + const appName = await getName(config, name); const appId = await getAppId(config, id); const webDir = isInteractive() @@ -38,7 +48,7 @@ export async function initCommand( const cordova = await getCordovaPreferences(config); await runTask( - `Creating ${c.strong('capacitor.config.json')} in ${c.input( + `Creating ${c.strong(config.app.extConfigName)} in ${c.input( config.app.rootDir, )}`, async () => { @@ -52,7 +62,7 @@ export async function initCommand( }, ); - printNextSteps(); + printNextSteps(config); } catch (e) { output.write( 'Usage: npx cap init appName appId\n' + @@ -62,8 +72,8 @@ export async function initCommand( } } -function printNextSteps() { - logSuccess(`${c.strong('capacitor.config.json')} created!`); +function printNextSteps(config: Config) { + logSuccess(`${c.strong(config.app.extConfigName)} created!`); output.write( `\nAdd platforms using ${c.input('npx cap add')}:\n` + ` ${c.input('npx cap add android')}\n` + diff --git a/cli/src/util/fn.ts b/cli/src/util/fn.ts new file mode 100644 index 0000000000..d18206cdba --- /dev/null +++ b/cli/src/util/fn.ts @@ -0,0 +1,12 @@ +export const tryFn = async Promise, R>( + fn: T, + ...args: any[] +): Promise => { + try { + return await fn(...args); + } catch { + // ignore + } + + return null; +}; diff --git a/cli/src/util/node.ts b/cli/src/util/node.ts new file mode 100644 index 0000000000..aa00bd7506 --- /dev/null +++ b/cli/src/util/node.ts @@ -0,0 +1,50 @@ +import { readFileSync } from '@ionic/utils-fs'; +import { resolve } from 'path'; +import type typescript from 'typescript'; + +interface NodeModuleWithCompile extends NodeJS.Module { + _compile?(code: string, filename: string): any; +} + +/** + * @see https://github.com/ionic-team/stencil/blob/HEAD/src/compiler/sys/node-require.ts + */ +export const requireTS = (ts: typeof typescript, p: string): unknown => { + const id = resolve(p); + + delete require.cache[id]; + + require.extensions['.ts'] = ( + module: NodeModuleWithCompile, + fileName: string, + ) => { + let sourceText = readFileSync(fileName, 'utf8'); + + if (fileName.endsWith('.ts')) { + const tsResults = ts.transpileModule(sourceText, { + fileName, + compilerOptions: { + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + strict: true, + target: ts.ScriptTarget.ES2017, + }, + reportDiagnostics: true, + }); + sourceText = tsResults.outputText; + } else { + // quick hack to turn a modern es module + // into and old school commonjs module + sourceText = sourceText.replace(/export\s+\w+\s+(\w+)/gm, 'exports.$1'); + } + + module._compile?.(sourceText, fileName); + }; + + const m = require(id); // eslint-disable-line @typescript-eslint/no-var-requires + + delete require.extensions['.ts']; + + return m; +}; diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 883db7d17b..b61c9e2276 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "declaration": true, "esModuleInterop": true, "importHelpers": true, "lib": ["es2019"],