1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Mobile: Fixes #12880: Fix plugin support (#12890)

This commit is contained in:
Henry Heino
2025-08-06 02:23:40 -07:00
committed by GitHub
parent 788033cb5f
commit b6d32831c6
8 changed files with 192 additions and 45 deletions

View File

@@ -739,6 +739,7 @@ packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/global-style.js packages/app-mobile/components/global-style.js
packages/app-mobile/components/plugins/PluginNotification.js packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.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/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.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/shim.js
packages/lib/string-utils.test.js packages/lib/string-utils.test.js
packages/lib/string-utils.js packages/lib/string-utils.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js packages/lib/testing/syncTargetUtils.js

2
.gitignore vendored
View File

@@ -712,6 +712,7 @@ packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/global-style.js packages/app-mobile/components/global-style.js
packages/app-mobile/components/plugins/PluginNotification.js packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.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/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.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/shim.js
packages/lib/string-utils.test.js packages/lib/string-utils.test.js
packages/lib/string-utils.js packages/lib/string-utils.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js packages/lib/testing/syncTargetUtils.js

View File

@@ -15,7 +15,7 @@ const logger = Logger.create('ExtendedWebView');
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => { const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const dom = useMemo(() => { const dom = useMemo(() => {
// Note: Adding `runScripts: 'dangerously'` to allow running inline <script></script>s. // Note: Adding `runScripts: 'dangerously'` to allow running inline <script></script>s.
// Use with caution. // Use with caution -- don't load untrusted WebView HTML while testing.
return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true }); return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true });
}, [props.html]); }, [props.html]);
@@ -57,6 +57,43 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
// JSDOM polyfills // JSDOM polyfills
dom.window.eval(` dom.window.eval(`
window.scrollBy = (_amount) => { }; 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(` dom.window.eval(`
@@ -71,7 +108,12 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, },
}); });
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]); }, [dom]);
const onLoadEndRef = useRef(props.onLoadEnd); const onLoadEndRef = useRef(props.onLoadEnd);

View File

@@ -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<AppState>;
interface WrapperProps { }
const WrappedPluginRunnerWebView: React.FC<WrapperProps> = _props => {
return <TestProviderStack store={store}>
<PluginRunnerWebView/>
</TestProviderStack>;
};
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,
'<h1>Test!</h1>',
);
await joplin.views.dialogs.open(dialogHandle)
`,
});
render(<WrappedPluginRunnerWebView/>);
// 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!');
});
});
});

View File

@@ -158,6 +158,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
const injectedJs = ` const injectedJs = `
if (!window.loadedBackgroundPage) { if (!window.loadedBackgroundPage) {
${shim.injectedJs('pluginBackgroundPage')} ${shim.injectedJs('pluginBackgroundPage')}
window.pluginBackgroundPage = pluginBackgroundPage;
console.log('Loaded PluginRunnerWebView.'); console.log('Loaded PluginRunnerWebView.');
// Necessary, because React Native WebView can re-run injectedJs // Necessary, because React Native WebView can re-run injectedJs

View File

@@ -101,6 +101,8 @@ const PluginUserWebView = (props: Props) => {
return ` return `
if (!window.backgroundPageLoaded) { if (!window.backgroundPageLoaded) {
${shim.injectedJs('pluginBackgroundPage')} ${shim.injectedJs('pluginBackgroundPage')}
window.pluginBackgroundPage = pluginBackgroundPage;
pluginBackgroundPage.initializeDialogWebView( pluginBackgroundPage.initializeDialogWebView(
${JSON.stringify(messageChannelId)} ${JSON.stringify(messageChannelId)}
); );
@@ -120,6 +122,7 @@ const PluginUserWebView = (props: Props) => {
<ExtendedWebView <ExtendedWebView
style={props.style} style={props.style}
baseDirectory={plugin.baseDir} baseDirectory={plugin.baseDir}
testID='joplin__PluginDialogWebView'
webviewInstanceId='joplin__PluginDialogWebView' webviewInstanceId='joplin__PluginDialogWebView'
html={html} html={html}
hasPluginScripts={true} hasPluginScripts={true}

View File

@@ -1,14 +1,12 @@
import Setting from '../../models/Setting'; import Setting from '../../models/Setting';
import PluginService, { defaultPluginSetting } from '../../services/plugins/PluginService'; import PluginService from '../../services/plugins/PluginService';
import { PluginManifest } from '../../services/plugins/utils/types';
import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import { writeFile } from 'fs-extra';
import { join } from 'path';
import loadPlugins, { Props as LoadPluginsProps } from './loadPlugins'; import loadPlugins, { Props as LoadPluginsProps } from './loadPlugins';
import MockPluginRunner from './testing/MockPluginRunner'; import MockPluginRunner from './testing/MockPluginRunner';
import reducer, { State, defaultState } from '../../reducer'; import reducer, { State, defaultState } from '../../reducer';
import { Action, createStore } from 'redux'; import { Action, createStore } from 'redux';
import MockPlatformImplementation from './testing/MockPlatformImplementation'; import MockPlatformImplementation from './testing/MockPlatformImplementation';
import createTestPlugin from '../../testing/plugins/createTestPlugin';
const createMockReduxStore = () => { const createMockReduxStore = () => {
return createStore((state: State = defaultState, action: Action<string>) => { return createStore((state: State = defaultState, action: Action<string>) => {
@@ -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 = { const defaultManifestProperties = {
manifest_version: 1, manifest_version: 1,
version: '0.1.0', version: '0.1.0',
@@ -66,18 +37,18 @@ describe('loadPlugins', () => {
}); });
test('should load only enabled plugins', async () => { test('should load only enabled plugins', async () => {
await addPluginWithManifest({ await createTestPlugin({
...defaultManifestProperties, ...defaultManifestProperties,
id: 'this.is.a.test.1', id: 'this.is.a.test.1',
name: 'Disabled Plugin', name: 'Disabled Plugin',
}, false); }, { enabled: false });
const enabledPluginId = 'this.is.a.test.2'; const enabledPluginId = 'this.is.a.test.2';
await addPluginWithManifest({ await createTestPlugin({
...defaultManifestProperties, ...defaultManifestProperties,
id: enabledPluginId, id: enabledPluginId,
name: 'Enabled Plugin', name: 'Enabled Plugin',
}, true); });
const pluginRunner = new MockPluginRunner(); const pluginRunner = new MockPluginRunner();
const store = createMockReduxStore(); const store = createMockReduxStore();
@@ -110,20 +81,23 @@ describe('loadPlugins', () => {
test('should reload all plugins when reloadAll is true', async () => { test('should reload all plugins when reloadAll is true', async () => {
const enabledCount = 3; const enabledCount = 3;
for (let i = 0; i < enabledCount; i++) { for (let i = 0; i < enabledCount; i++) {
await addPluginWithManifest({ await createTestPlugin({
...defaultManifestProperties, ...defaultManifestProperties,
id: `joplin.test.plugin.${i}`, id: `joplin.test.plugin.${i}`,
name: `Enabled Plugin ${i}`, name: `Enabled Plugin ${i}`,
}, true); });
} }
const disabledCount = 6; const disabledCount = 6;
const disabledPlugins = [];
for (let i = 0; i < disabledCount; i++) { for (let i = 0; i < disabledCount; i++) {
await addPluginWithManifest({ disabledPlugins.push(
...defaultManifestProperties, await createTestPlugin({
id: `joplin.test.plugin.disabled.${i}`, ...defaultManifestProperties,
name: `Disabled Plugin ${i}`, id: `joplin.test.plugin.disabled.${i}`,
}, false); name: `Disabled Plugin ${i}`,
}, { enabled: false }),
);
} }
const pluginRunner = new MockPluginRunner(); const pluginRunner = new MockPluginRunner();
@@ -146,8 +120,11 @@ describe('loadPlugins', () => {
// No plugins were running before -- there were no plugins to stop // No plugins were running before -- there were no plugins to stop
expect(pluginRunner.stopCalledTimes).toBe(0); 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. // 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') }); 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']; expectedRunningIds = ['joplin.test.plugin.0', 'joplin.test.plugin.1', 'joplin.test.plugin.2', 'joplin.test.plugin.disabled.2'];
await pluginRunner.waitForAllToBeRunning(expectedRunningIds); await pluginRunner.waitForAllToBeRunning(expectedRunningIds);

View File

@@ -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;