From b6d32831c6e402a4442e5904f45f8bef19b35b21 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Wed, 6 Aug 2025 02:23:40 -0700 Subject: [PATCH] Mobile: Fixes #12880: Fix plugin support (#12890) --- .eslintignore | 2 + .gitignore | 2 + .../components/ExtendedWebView/index.jest.tsx | 46 +++++++++++- .../plugins/PluginRunnerWebView.test.tsx | 71 +++++++++++++++++++ .../plugins/PluginRunnerWebView.tsx | 1 + .../plugins/dialogs/PluginUserWebView.tsx | 3 + .../lib/services/plugins/loadPlugins.test.ts | 63 ++++++---------- .../lib/testing/plugins/createTestPlugin.ts | 49 +++++++++++++ 8 files changed, 192 insertions(+), 45 deletions(-) create mode 100644 packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx create mode 100644 packages/lib/testing/plugins/createTestPlugin.ts diff --git a/.eslintignore b/.eslintignore index 2f0d78ef80..29e5508584 100644 --- a/.eslintignore +++ b/.eslintignore @@ -739,6 +739,7 @@ packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/global-style.js packages/app-mobile/components/plugins/PluginNotification.js packages/app-mobile/components/plugins/PluginRunner.js +packages/app-mobile/components/plugins/PluginRunnerWebView.test.js packages/app-mobile/components/plugins/PluginRunnerWebView.js packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js @@ -1584,6 +1585,7 @@ packages/lib/shim-init-node.js packages/lib/shim.js packages/lib/string-utils.test.js packages/lib/string-utils.js +packages/lib/testing/plugins/createTestPlugin.js packages/lib/testing/share/makeMockShareInvitation.js packages/lib/testing/share/mockShareService.js packages/lib/testing/syncTargetUtils.js diff --git a/.gitignore b/.gitignore index e139792e88..a55d35f8bb 100644 --- a/.gitignore +++ b/.gitignore @@ -712,6 +712,7 @@ packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/global-style.js packages/app-mobile/components/plugins/PluginNotification.js packages/app-mobile/components/plugins/PluginRunner.js +packages/app-mobile/components/plugins/PluginRunnerWebView.test.js packages/app-mobile/components/plugins/PluginRunnerWebView.js packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js @@ -1557,6 +1558,7 @@ packages/lib/shim-init-node.js packages/lib/shim.js packages/lib/string-utils.test.js packages/lib/string-utils.js +packages/lib/testing/plugins/createTestPlugin.js packages/lib/testing/share/makeMockShareInvitation.js packages/lib/testing/share/mockShareService.js packages/lib/testing/syncTargetUtils.js diff --git a/packages/app-mobile/components/ExtendedWebView/index.jest.tsx b/packages/app-mobile/components/ExtendedWebView/index.jest.tsx index 98838c4b96..107cdd01cb 100644 --- a/packages/app-mobile/components/ExtendedWebView/index.jest.tsx +++ b/packages/app-mobile/components/ExtendedWebView/index.jest.tsx @@ -15,7 +15,7 @@ const logger = Logger.create('ExtendedWebView'); const ExtendedWebView = (props: Props, ref: Ref) => { const dom = useMemo(() => { // Note: Adding `runScripts: 'dangerously'` to allow running inline s. - // Use with caution. + // Use with caution -- don't load untrusted WebView HTML while testing. return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true }); }, [props.html]); @@ -57,6 +57,43 @@ const ExtendedWebView = (props: Props, ref: Ref) => { // JSDOM polyfills dom.window.eval(` window.scrollBy = (_amount) => { }; + + // JSDOM iframes are missing certain functionality required by Joplin, + // including: + // - MessageEvent.source: Should point to the window that created a message. + // Joplin uses this to determine the source of messages in iframe-related IPC. + // - iframe.srcdoc: Used by Joplin to create plugin windows. + const polyfillIframeContentWindow = (contentWindow) => { + contentWindow.addEventListener('message', event => { + // Work around a missing ".source" property on events. + // See https://github.com/jsdom/jsdom/issues/2745#issuecomment-1207414024 + if (!event.source) { + contentWindow.dispatchEvent(new MessageEvent('message', { + source: window, + data: event.data, + })); + event.stopImmediatePropagation(); + } + }); + + contentWindow.parent.postMessage = (message) => { + window.dispatchEvent(new MessageEvent('message', { + data: message, + source: contentWindow, + })); + }; + }; + + Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', { + set(value) { + this.src = 'about:blank'; + setTimeout(() => { + this.contentDocument.write(value); + + polyfillIframeContentWindow(this.contentWindow); + }, 0); + }, + }); `); dom.window.eval(` @@ -71,7 +108,12 @@ const ExtendedWebView = (props: Props, ref: Ref) => { }, }); - dom.window.eval(injectedJavaScriptRef.current); + // Wrap the injected JavaScript in (() => {...})() to more closely + // match the behavior of injectedJavaScript on Android -- variables + // declared with "var" or "const" should not become global variables. + dom.window.eval(`(() => { + ${injectedJavaScriptRef.current} + })()`); }, [dom]); const onLoadEndRef = useRef(props.onLoadEnd); diff --git a/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx b/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx new file mode 100644 index 0000000000..b033f26e70 --- /dev/null +++ b/packages/app-mobile/components/plugins/PluginRunnerWebView.test.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; +import { AppState } from '../../utils/types'; +import { Store } from 'redux'; +import createMockReduxStore from '../../utils/testing/createMockReduxStore'; +import setupGlobalStore from '../../utils/testing/setupGlobalStore'; +import PluginRunnerWebView from './PluginRunnerWebView'; +import TestProviderStack from '../testing/TestProviderStack'; +import { render, waitFor } from '../../utils/testing/testingLibrary'; +import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin'; +import getWebViewDomById from '../../utils/testing/getWebViewDomById'; +import Setting from '@joplin/lib/models/Setting'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; + +let store: Store; + +interface WrapperProps { } + +const WrappedPluginRunnerWebView: React.FC = _props => { + return + + ; +}; + +const defaultManifestProperties = { + manifest_version: 1, + version: '0.1.0', + app_min_version: '2.3.4', + platforms: ['desktop', 'mobile'], + name: 'Some plugin name', +}; + +describe('PluginRunnerWebView', () => { + beforeEach(async () => { + await setupDatabaseAndSynchronizer(0); + await switchClient(0); + + store = createMockReduxStore(); + setupGlobalStore(store); + Setting.setValue('plugins.pluginSupportEnabled', true); + }); + + test('should load a plugin that shows a dialog', async () => { + const testPlugin = await createTestPlugin({ + ...defaultManifestProperties, + id: 'org.joplinapp.dialog-test', + }, { + onStart: ` + const dialogs = joplin.views.dialogs; + const dialogHandle = await dialogs.create('test-dialog'); + await dialogs.setHtml( + dialogHandle, + '

Test!

', + ); + await joplin.views.dialogs.open(dialogHandle) + `, + }); + render(); + + // Should load the plugin + await waitFor(async () => { + expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy(); + }); + + // Should show the dialog + await waitFor(async () => { + const dom = await getWebViewDomById('joplin__PluginDialogWebView'); + expect(dom.querySelector('h1').textContent).toBe('Test!'); + }); + }); +}); diff --git a/packages/app-mobile/components/plugins/PluginRunnerWebView.tsx b/packages/app-mobile/components/plugins/PluginRunnerWebView.tsx index 78b7d3ebf4..438f588609 100644 --- a/packages/app-mobile/components/plugins/PluginRunnerWebView.tsx +++ b/packages/app-mobile/components/plugins/PluginRunnerWebView.tsx @@ -158,6 +158,7 @@ const PluginRunnerWebViewComponent: React.FC = props => { const injectedJs = ` if (!window.loadedBackgroundPage) { ${shim.injectedJs('pluginBackgroundPage')} + window.pluginBackgroundPage = pluginBackgroundPage; console.log('Loaded PluginRunnerWebView.'); // Necessary, because React Native WebView can re-run injectedJs diff --git a/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx b/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx index 95f2e35503..802297ae62 100644 --- a/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx +++ b/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx @@ -101,6 +101,8 @@ const PluginUserWebView = (props: Props) => { return ` if (!window.backgroundPageLoaded) { ${shim.injectedJs('pluginBackgroundPage')} + window.pluginBackgroundPage = pluginBackgroundPage; + pluginBackgroundPage.initializeDialogWebView( ${JSON.stringify(messageChannelId)} ); @@ -120,6 +122,7 @@ const PluginUserWebView = (props: Props) => { { return createStore((state: State = defaultState, action: Action) => { @@ -16,33 +14,6 @@ const createMockReduxStore = () => { }); }; -const setPluginEnabled = (id: string, enabled: boolean) => { - const newPluginStates = { - ...Setting.value('plugins.states'), - [id]: { - ...defaultPluginSetting(), - enabled, - }, - }; - Setting.setValue('plugins.states', newPluginStates); -}; - -const addPluginWithManifest = async (manifest: PluginManifest, enabled: boolean) => { - const pluginSource = ` - /* joplin-manifest: - ${JSON.stringify(manifest)} - */ - - joplin.plugins.register({ - onStart: async function() { }, - }); - `; - const pluginPath = join(Setting.value('pluginDir'), `${manifest.id}.js`); - await writeFile(pluginPath, pluginSource, 'utf-8'); - - setPluginEnabled(manifest.id, enabled); -}; - const defaultManifestProperties = { manifest_version: 1, version: '0.1.0', @@ -66,18 +37,18 @@ describe('loadPlugins', () => { }); test('should load only enabled plugins', async () => { - await addPluginWithManifest({ + await createTestPlugin({ ...defaultManifestProperties, id: 'this.is.a.test.1', name: 'Disabled Plugin', - }, false); + }, { enabled: false }); const enabledPluginId = 'this.is.a.test.2'; - await addPluginWithManifest({ + await createTestPlugin({ ...defaultManifestProperties, id: enabledPluginId, name: 'Enabled Plugin', - }, true); + }); const pluginRunner = new MockPluginRunner(); const store = createMockReduxStore(); @@ -110,20 +81,23 @@ describe('loadPlugins', () => { test('should reload all plugins when reloadAll is true', async () => { const enabledCount = 3; for (let i = 0; i < enabledCount; i++) { - await addPluginWithManifest({ + await createTestPlugin({ ...defaultManifestProperties, id: `joplin.test.plugin.${i}`, name: `Enabled Plugin ${i}`, - }, true); + }); } const disabledCount = 6; + const disabledPlugins = []; for (let i = 0; i < disabledCount; i++) { - await addPluginWithManifest({ - ...defaultManifestProperties, - id: `joplin.test.plugin.disabled.${i}`, - name: `Disabled Plugin ${i}`, - }, false); + disabledPlugins.push( + await createTestPlugin({ + ...defaultManifestProperties, + id: `joplin.test.plugin.disabled.${i}`, + name: `Disabled Plugin ${i}`, + }, { enabled: false }), + ); } const pluginRunner = new MockPluginRunner(); @@ -146,8 +120,11 @@ describe('loadPlugins', () => { // No plugins were running before -- there were no plugins to stop expect(pluginRunner.stopCalledTimes).toBe(0); + const testPlugin = disabledPlugins[2]; + expect(testPlugin.manifest.id).toBe('joplin.test.plugin.disabled.2'); + // Enabling a plugin and reloading it should cause all plugins to load. - setPluginEnabled('joplin.test.plugin.disabled.2', true); + testPlugin.setEnabled(true); await loadPlugins({ ...loadPluginsOptions, pluginSettings: Setting.value('plugins.states') }); expectedRunningIds = ['joplin.test.plugin.0', 'joplin.test.plugin.1', 'joplin.test.plugin.2', 'joplin.test.plugin.disabled.2']; await pluginRunner.waitForAllToBeRunning(expectedRunningIds); diff --git a/packages/lib/testing/plugins/createTestPlugin.ts b/packages/lib/testing/plugins/createTestPlugin.ts new file mode 100644 index 0000000000..b6b734bf45 --- /dev/null +++ b/packages/lib/testing/plugins/createTestPlugin.ts @@ -0,0 +1,49 @@ +import { join } from 'path'; +import { PluginManifest } from '../../services/plugins/utils/types'; +import Setting from '../../models/Setting'; +import { writeFile } from 'fs-extra'; +import { defaultPluginSetting } from '../../services/plugins/PluginService'; + + +const setPluginEnabled = (id: string, enabled: boolean) => { + const newPluginStates = { + ...Setting.value('plugins.states'), + [id]: { + ...defaultPluginSetting(), + enabled, + }, + }; + Setting.setValue('plugins.states', newPluginStates); +}; + +interface Options { + onStart?: string; + enabled?: boolean; +} + +const createTestPlugin = async (manifest: PluginManifest, { onStart = '', enabled = true }: Options = {}) => { + const pluginSource = ` + /* joplin-manifest: + ${JSON.stringify(manifest)} + */ + + joplin.plugins.register({ + onStart: async function() { + ${onStart} + }, + }); + `; + const pluginPath = join(Setting.value('pluginDir'), `${manifest.id}.js`); + await writeFile(pluginPath, pluginSource, 'utf-8'); + + setPluginEnabled(manifest.id, enabled); + + return { + manifest, + setEnabled: (enabled: boolean) => { + setPluginEnabled(manifest.id, enabled); + }, + }; +}; + +export default createTestPlugin;