From 16c77ccdc871da5924f719995e0cafa84cf891cd Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 14 Jul 2023 11:27:28 -0400 Subject: [PATCH] refactor: use capacitor types for native plugins (#27755) Issue number: Internal --------- ## What is the current behavior? Ionic currently detects and uses Capacitor APIs for different plugins (haptics, status bar and keyboard). This implementation does not have type safety and can result in unexpected behaviors. ## What is the new behavior? - Adds `@capacitor/core`, `@capacitor/keyboard`, `@capacitor/haptics` and `@capacitor/status-bar` as dev dependencies. These should _only_ be used with `import type { }`. - Refactors the plugin usages to be typed against the plugin packages, while using a duplicate enum when needing a value. This allows us to not bundle the capacitor plugins with Ionic Framework. - Introduces a `getCapacitor()` function for interacting with the `window.Capacitor` object through a typed object. **How does it work?** The idea is we want the type safety from the Capacitor packages, without directly bundling that source code within Ionic Framework. This means we use the Capacitor deps where a type is needed, but clone any enums where a value is referenced. If a Capacitor dep changes the supported values, Typescript will fail to compile and that will signal to use to update our enum values to match any changes. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Dev-build: `7.1.2-dev.11688696027.1c4d4ad1` Tested against a demo app for some of the core behavior: https://github.com/sean-perkins/capacitor-ionic-plugins-demo --- .github/dependabot.yml | 4 + core/package-lock.json | 70 ++++++++++ core/package.json | 4 + core/src/components/modal/utils.ts | 2 + core/src/components/refresher/refresher.tsx | 4 +- .../utils/input-shims/hacks/scroll-assist.ts | 3 +- .../src/utils/keyboard/keyboard-controller.ts | 2 +- core/src/utils/native/capacitor.ts | 9 ++ core/src/utils/native/haptic.ts | 125 +++++++++++++++--- core/src/utils/native/keyboard.ts | 47 +++++-- core/src/utils/native/native-interface.ts | 20 +-- core/src/utils/native/status-bar.ts | 21 ++- 12 files changed, 262 insertions(+), 49 deletions(-) create mode 100644 core/src/utils/native/capacitor.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ea5f9a729c..8484a6f0af 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,7 @@ updates: - dependency-name: "@stencil/sass" - dependency-name: "@stencil/vue-output-target" - dependency-name: "ionicons" + - dependency-name: "@capacitor/core" + - dependency-name: "@capacitor/keyboard" + - dependency-name: "@capacitor/haptics" + - dependency-name: "@capacitor/status-bar" diff --git a/core/package-lock.json b/core/package-lock.json index cf2db537ae..56c8e8053d 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -15,6 +15,10 @@ }, "devDependencies": { "@axe-core/playwright": "^4.7.3", + "@capacitor/core": "^5.1.1", + "@capacitor/haptics": "^5.0.5", + "@capacitor/keyboard": "^5.0.5", + "@capacitor/status-bar": "^5.0.5", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", "@jest/core": "^27.5.1", @@ -602,6 +606,42 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@capacitor/core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.1.1.tgz", + "integrity": "sha512-17rZMGpZYQgBAmUS1uAS1GrRkPAQiUfpTfvd2wWS9z87GoTL/5vtpPAtI0/XLtr4eve2TsATY2r8BaW2NfEO6Q==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/haptics": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-5.0.5.tgz", + "integrity": "sha512-iDgmCehrdc0Sdob/y3KjAX0sll/y9PTP6hJubwTpIpRt3kOKGDhMMNkUGRKm6myTIRPpa2Mm8jWCSYaA4pPq4g==", + "dev": true, + "peerDependencies": { + "@capacitor/core": "^5.0.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-5.0.5.tgz", + "integrity": "sha512-pB2o15C8Cz3QqDcToU4H0B/i+LLXYPQVtShukywMlVEJZ6UUy+KSK8XCg/YPDZBY9E0lSprHw4+NBqH0HTYRKQ==", + "dev": true, + "peerDependencies": { + "@capacitor/core": "^5.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-5.0.5.tgz", + "integrity": "sha512-8ykkIbndeAaATrAYcr4CLSplTeR6CU15h8trXV3DgLXlFQAC6E/WJnoMy1QL61n5rHh725nixqwCTepcgGx/rw==", + "dev": true, + "peerDependencies": { + "@capacitor/core": "^5.0.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", @@ -10743,6 +10783,36 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@capacitor/core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.1.1.tgz", + "integrity": "sha512-17rZMGpZYQgBAmUS1uAS1GrRkPAQiUfpTfvd2wWS9z87GoTL/5vtpPAtI0/XLtr4eve2TsATY2r8BaW2NfEO6Q==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, + "@capacitor/haptics": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-5.0.5.tgz", + "integrity": "sha512-iDgmCehrdc0Sdob/y3KjAX0sll/y9PTP6hJubwTpIpRt3kOKGDhMMNkUGRKm6myTIRPpa2Mm8jWCSYaA4pPq4g==", + "dev": true, + "requires": {} + }, + "@capacitor/keyboard": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-5.0.5.tgz", + "integrity": "sha512-pB2o15C8Cz3QqDcToU4H0B/i+LLXYPQVtShukywMlVEJZ6UUy+KSK8XCg/YPDZBY9E0lSprHw4+NBqH0HTYRKQ==", + "dev": true, + "requires": {} + }, + "@capacitor/status-bar": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-5.0.5.tgz", + "integrity": "sha512-8ykkIbndeAaATrAYcr4CLSplTeR6CU15h8trXV3DgLXlFQAC6E/WJnoMy1QL61n5rHh725nixqwCTepcgGx/rw==", + "dev": true, + "requires": {} + }, "@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", diff --git a/core/package.json b/core/package.json index abc9dfa32e..905a07d00c 100644 --- a/core/package.json +++ b/core/package.json @@ -37,6 +37,10 @@ }, "devDependencies": { "@axe-core/playwright": "^4.7.3", + "@capacitor/core": "^5.1.1", + "@capacitor/haptics": "^5.0.5", + "@capacitor/keyboard": "^5.0.5", + "@capacitor/status-bar": "^5.0.5", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", "@jest/core": "^27.5.1", diff --git a/core/src/components/modal/utils.ts b/core/src/components/modal/utils.ts index 90129839ae..e2ea7e0142 100644 --- a/core/src/components/modal/utils.ts +++ b/core/src/components/modal/utils.ts @@ -74,6 +74,7 @@ export const getBackdropValueForSheet = (x: number, backdropBreakpoint: number) * support for Style.Default. */ export const setCardStatusBarDark = () => { + // TODO FW-4696 Remove supportDefaultStatusBarStyle in Ionic v8 if (!win || win.innerWidth >= 768 || !StatusBar.supportsDefaultStatusBarStyle()) { return; } @@ -82,6 +83,7 @@ export const setCardStatusBarDark = () => { }; export const setCardStatusBarDefault = (defaultStyle = Style.Default) => { + // TODO FW-4696 Remove supportDefaultStatusBarStyle in Ionic v8 if (!win || win.innerWidth >= 768 || !StatusBar.supportsDefaultStatusBarStyle()) { return; } diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index 858f5c394d..f762ffe7ac 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -8,7 +8,7 @@ import { printIonContentErrorMsg, } from '@utils/content'; import { clamp, componentOnReady, getElementRoot, raf, transitionEndAsync } from '@utils/helpers'; -import { hapticImpact } from '@utils/native/haptic'; +import { ImpactStyle, hapticImpact } from '@utils/native/haptic'; import { getIonMode } from '../../global/ionic-global'; import type { Animation, Gesture, GestureDetail } from '../../interface'; @@ -246,7 +246,7 @@ export class Refresher implements ComponentInterface { if (!this.didRefresh) { this.beginRefresh(); this.didRefresh = true; - hapticImpact({ style: 'light' }); + hapticImpact({ style: ImpactStyle.Light }); /** * Translate the content element otherwise when pointer is removed diff --git a/core/src/utils/input-shims/hacks/scroll-assist.ts b/core/src/utils/input-shims/hacks/scroll-assist.ts index 6de8832f99..97928d608e 100644 --- a/core/src/utils/input-shims/hacks/scroll-assist.ts +++ b/core/src/utils/input-shims/hacks/scroll-assist.ts @@ -1,6 +1,7 @@ +import type { KeyboardResizeOptions } from '@capacitor/keyboard'; + import { getScrollElement, scrollByPoint } from '../../content'; import { raf } from '../../helpers'; -import type { KeyboardResizeOptions } from '../../native/keyboard'; import { KeyboardResize } from '../../native/keyboard'; import { relocateInput, SCROLL_AMOUNT_PADDING } from './common'; diff --git a/core/src/utils/keyboard/keyboard-controller.ts b/core/src/utils/keyboard/keyboard-controller.ts index a0e2a7c230..2cf0c19d34 100644 --- a/core/src/utils/keyboard/keyboard-controller.ts +++ b/core/src/utils/keyboard/keyboard-controller.ts @@ -1,6 +1,6 @@ import { doc, win } from '@utils/browser'; -import { KeyboardResize, Keyboard } from '../native/keyboard'; +import { Keyboard, KeyboardResize } from '../native/keyboard'; /** * The element that resizes when the keyboard opens diff --git a/core/src/utils/native/capacitor.ts b/core/src/utils/native/capacitor.ts new file mode 100644 index 0000000000..5554f28f6e --- /dev/null +++ b/core/src/utils/native/capacitor.ts @@ -0,0 +1,9 @@ +import type { CapacitorGlobal } from '@capacitor/core'; +import { win } from '@utils/browser'; + +export const getCapacitor = () => { + if (win !== undefined) { + return (win as any).Capacitor as CapacitorGlobal; + } + return undefined; +}; diff --git a/core/src/utils/native/haptic.ts b/core/src/utils/native/haptic.ts index d56d442c39..cbcc909586 100644 --- a/core/src/utils/native/haptic.ts +++ b/core/src/utils/native/haptic.ts @@ -1,24 +1,91 @@ -// Main types for this API +import type { + HapticsPlugin, + NotificationType as CapacitorNotificationType, + ImpactStyle as CapacitorImpactStyle, +} from '@capacitor/haptics'; + +import { getCapacitor } from './capacitor'; + +export enum ImpactStyle { + /** + * A collision between large, heavy user interface elements + * + * @since 1.0.0 + */ + Heavy = 'HEAVY', + /** + * A collision between moderately sized user interface elements + * + * @since 1.0.0 + */ + Medium = 'MEDIUM', + /** + * A collision between small, light user interface elements + * + * @since 1.0.0 + */ + Light = 'LIGHT', +} + interface HapticImpactOptions { - style: 'light' | 'medium' | 'heavy'; + style: CapacitorImpactStyle; +} + +export enum NotificationType { + /** + * A notification feedback type indicating that a task has completed successfully + * + * @since 1.0.0 + */ + Success = 'SUCCESS', + /** + * A notification feedback type indicating that a task has produced a warning + * + * @since 1.0.0 + */ + Warning = 'WARNING', + /** + * A notification feedback type indicating that a task has failed + * + * @since 1.0.0 + */ + Error = 'ERROR', } interface HapticNotificationOptions { - style: 'success' | 'warning' | 'error'; + type: CapacitorNotificationType; +} + +interface TapticEngine { + gestureSelectionStart: () => void; + gestureSelectionChanged: () => void; + gestureSelectionEnd: () => void; } const HapticEngine = { - getEngine() { - const win = window as any; - return win.TapticEngine || (win.Capacitor?.isPluginAvailable('Haptics') && win.Capacitor.Plugins.Haptics); + getEngine(): HapticsPlugin | undefined { + const tapticEngine = (window as any).TapticEngine; + if (tapticEngine) { + // Cordova + // TODO FW-4707 - Remove this in Ionic 8 + return tapticEngine; + } + const capacitor = getCapacitor(); + + if (capacitor?.isPluginAvailable('Haptics')) { + // Capacitor + return capacitor.Plugins.Haptics as HapticsPlugin; + } + return undefined; }, available() { - const win = window as any; const engine = this.getEngine(); if (!engine) { return false; } + const capacitor = getCapacitor(); + /** * Developers can manually import the * Haptics plugin in their app which will cause @@ -28,25 +95,30 @@ const HapticEngine = { * the Vibrate API. This check avoids that error * if the browser does not support the Vibrate API. */ - if (win.Capacitor?.getPlatform() === 'web') { + if (capacitor?.getPlatform() === 'web') { return typeof navigator !== 'undefined' && navigator.vibrate !== undefined; } return true; }, isCordova() { - return !!(window as any).TapticEngine; + return (window as any).TapticEngine !== undefined; }, isCapacitor() { - const win = window as any; - return !!win.Capacitor; + return getCapacitor() !== undefined; }, impact(options: HapticImpactOptions) { const engine = this.getEngine(); if (!engine) { return; } - const style = this.isCapacitor() ? options.style.toUpperCase() : options.style; + /** + * To provide backwards compatibility with Cordova apps, + * we convert the style to lowercase. + * + * TODO: FW-4707 - Remove this in Ionic 8 + */ + const style = this.isCapacitor() ? options.style : (options.style.toLowerCase() as ImpactStyle); engine.impact({ style }); }, notification(options: HapticNotificationOptions) { @@ -54,11 +126,24 @@ const HapticEngine = { if (!engine) { return; } - const style = this.isCapacitor() ? options.style.toUpperCase() : options.style; - engine.notification({ style }); + /** + * To provide backwards compatibility with Cordova apps, + * we convert the style to lowercase. + * + * TODO: FW-4707 - Remove this in Ionic 8 + */ + const type = this.isCapacitor() ? options.type : (options.type.toLowerCase() as NotificationType); + engine.notification({ type }); }, selection() { - this.impact({ style: 'light' }); + /** + * To provide backwards compatibility with Cordova apps, + * we convert the style to lowercase. + * + * TODO: FW-4707 - Remove this in Ionic 8 + */ + const style = this.isCapacitor() ? ImpactStyle.Light : ('light' as ImpactStyle); + this.impact({ style }); }, selectionStart() { const engine = this.getEngine(); @@ -68,7 +153,7 @@ const HapticEngine = { if (this.isCapacitor()) { engine.selectionStart(); } else { - engine.gestureSelectionStart(); + (engine as unknown as TapticEngine).gestureSelectionStart(); } }, selectionChanged() { @@ -79,7 +164,7 @@ const HapticEngine = { if (this.isCapacitor()) { engine.selectionChanged(); } else { - engine.gestureSelectionChanged(); + (engine as unknown as TapticEngine).gestureSelectionChanged(); } }, selectionEnd() { @@ -90,7 +175,7 @@ const HapticEngine = { if (this.isCapacitor()) { engine.selectionEnd(); } else { - engine.gestureSelectionEnd(); + (engine as unknown as TapticEngine).gestureSelectionEnd(); } }, }; @@ -135,7 +220,7 @@ export const hapticSelectionEnd = () => { /** * Use this to indicate success/failure/warning to the user. - * options should be of the type `{ type: 'success' }` (or `warning`/`error`) + * options should be of the type `{ type: NotificationType.SUCCESS }` (or `WARNING`/`ERROR`) */ export const hapticNotification = (options: HapticNotificationOptions) => { hapticAvailable() && HapticEngine.notification(options); @@ -143,7 +228,7 @@ export const hapticNotification = (options: HapticNotificationOptions) => { /** * Use this to indicate success/failure/warning to the user. - * options should be of the type `{ style: 'light' }` (or `medium`/`heavy`) + * options should be of the type `{ style: ImpactStyle.LIGHT }` (or `MEDIUM`/`HEAVY`) */ export const hapticImpact = (options: HapticImpactOptions) => { hapticAvailable() && HapticEngine.impact(options); diff --git a/core/src/utils/native/keyboard.ts b/core/src/utils/native/keyboard.ts index 6a4e88efbe..cefcd6a64b 100644 --- a/core/src/utils/native/keyboard.ts +++ b/core/src/utils/native/keyboard.ts @@ -1,30 +1,55 @@ -import { win } from '../browser'; +import type { CapacitorException } from '@capacitor/core'; +import type { KeyboardPlugin, KeyboardResizeOptions } from '@capacitor/keyboard'; -import type { NativePluginError } from './native-interface'; - -// Interfaces source: https://capacitorjs.com/docs/apis/keyboard#interfaces -export interface KeyboardResizeOptions { - mode: KeyboardResize; -} +import { getCapacitor } from './capacitor'; +import { ExceptionCode } from './native-interface'; export enum KeyboardResize { + /** + * Only the `body` HTML element will be resized. + * Relative units are not affected, because the viewport does not change. + * + * @since 1.0.0 + */ Body = 'body', + /** + * Only the `ion-app` HTML element will be resized. + * Use it only for Ionic Framework apps. + * + * @since 1.0.0 + */ Ionic = 'ionic', + /** + * The whole native Web View will be resized when the keyboard shows/hides. + * This affects the `vh` relative unit. + * + * @since 1.0.0 + */ Native = 'native', + /** + * Neither the app nor the Web View are resized. + * + * @since 1.0.0 + */ None = 'none', } export const Keyboard = { - getEngine() { - return (win as any)?.Capacitor?.isPluginAvailable('Keyboard') && (win as any)?.Capacitor.Plugins.Keyboard; + getEngine(): KeyboardPlugin | undefined { + const capacitor = getCapacitor(); + + if (capacitor?.isPluginAvailable('Keyboard')) { + return capacitor.Plugins.Keyboard as KeyboardPlugin; + } + return undefined; }, getResizeMode(): Promise { const engine = this.getEngine(); if (!engine?.getResizeMode) { return Promise.resolve(undefined); } - return engine.getResizeMode().catch((e: NativePluginError) => { - if (e.code === 'UNIMPLEMENTED') { + return engine.getResizeMode().catch((e: CapacitorException) => { + if (e.code === ExceptionCode.Unimplemented) { // If the native implementation is not available // we treat it the same as if the plugin is not available. return undefined; diff --git a/core/src/utils/native/native-interface.ts b/core/src/utils/native/native-interface.ts index a486f455aa..f43c00e4ae 100644 --- a/core/src/utils/native/native-interface.ts +++ b/core/src/utils/native/native-interface.ts @@ -1,13 +1,17 @@ -/** - * Used to represent a generic error from a native plugin call. - */ -export interface NativePluginError { +export enum ExceptionCode { /** - * The error code. + * API is not implemented. + * + * This usually means the API can't be used because it is not implemented for + * the current platform. */ - code?: string; + Unimplemented = 'UNIMPLEMENTED', /** - * The error message. + * API is not available. + * + * This means the API can't be used right now because: + * - it is currently missing a prerequisite, such as network connectivity + * - it requires a particular platform or browser version */ - message?: string; + Unavailable = 'UNAVAILABLE', } diff --git a/core/src/utils/native/status-bar.ts b/core/src/utils/native/status-bar.ts index f57a15c5be..8d41d0264e 100644 --- a/core/src/utils/native/status-bar.ts +++ b/core/src/utils/native/status-bar.ts @@ -1,7 +1,9 @@ -import { win } from '../browser'; +import type { StatusBarPlugin, Style as StatusBarStyle } from '@capacitor/status-bar'; + +import { getCapacitor } from './capacitor'; interface StyleOptions { - style: Style; + style: StatusBarStyle; } export enum Style { @@ -11,17 +13,24 @@ export enum Style { } export const StatusBar = { - getEngine() { - return (win as any)?.Capacitor?.isPluginAvailable('StatusBar') && (win as any)?.Capacitor.Plugins.StatusBar; + getEngine(): StatusBarPlugin | undefined { + const capacitor = getCapacitor(); + + if (capacitor?.isPluginAvailable('StatusBar')) { + return capacitor.Plugins.StatusBar as StatusBarPlugin; + } + return undefined; }, + // TODO FW-4696 Remove supportDefaultStatusBarStyle in Ionic v8 supportsDefaultStatusBarStyle() { + const capacitor = getCapacitor() as any; /** * The 'DEFAULT' status bar style was added * to the @capacitor/status-bar plugin in Capacitor 3. * PluginHeaders is only supported in Capacitor 3+, * so we can use this to detect Capacitor 3. */ - return !!(win as any)?.Capacitor?.PluginHeaders; + return !!capacitor?.PluginHeaders; }, setStyle(options: StyleOptions) { const engine = this.getEngine(); @@ -31,7 +40,7 @@ export const StatusBar = { engine.setStyle(options); }, - getStyle: async function (): Promise