diff --git a/.eslintignore b/.eslintignore index 23c9957f5..52a18a18a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -224,6 +224,15 @@ packages/app-cli/tests/support/plugins/multi_selection/api/types.js.map packages/app-cli/tests/support/plugins/multi_selection/src/index.d.ts packages/app-cli/tests/support/plugins/multi_selection/src/index.js packages/app-cli/tests/support/plugins/multi_selection/src/index.js.map +packages/app-cli/tests/support/plugins/post_messages/api/index.d.ts +packages/app-cli/tests/support/plugins/post_messages/api/index.js +packages/app-cli/tests/support/plugins/post_messages/api/index.js.map +packages/app-cli/tests/support/plugins/post_messages/api/types.d.ts +packages/app-cli/tests/support/plugins/post_messages/api/types.js +packages/app-cli/tests/support/plugins/post_messages/api/types.js.map +packages/app-cli/tests/support/plugins/post_messages/src/index.d.ts +packages/app-cli/tests/support/plugins/post_messages/src/index.js +packages/app-cli/tests/support/plugins/post_messages/src/index.js.map packages/app-cli/tests/support/plugins/register_command/api/index.d.ts packages/app-cli/tests/support/plugins/register_command/api/index.js packages/app-cli/tests/support/plugins/register_command/api/index.js.map @@ -1016,6 +1025,9 @@ packages/lib/services/KeymapService_keysRegExp.js.map packages/lib/services/KvStore.d.ts packages/lib/services/KvStore.js packages/lib/services/KvStore.js.map +packages/lib/services/PostMessageService.d.ts +packages/lib/services/PostMessageService.js +packages/lib/services/PostMessageService.js.map packages/lib/services/ResourceEditWatcher/index.d.ts packages/lib/services/ResourceEditWatcher/index.js packages/lib/services/ResourceEditWatcher/index.js.map @@ -1154,6 +1166,9 @@ packages/lib/services/plugins/api/Joplin.js.map packages/lib/services/plugins/api/JoplinCommands.d.ts packages/lib/services/plugins/api/JoplinCommands.js packages/lib/services/plugins/api/JoplinCommands.js.map +packages/lib/services/plugins/api/JoplinContentScripts.d.ts +packages/lib/services/plugins/api/JoplinContentScripts.js +packages/lib/services/plugins/api/JoplinContentScripts.js.map packages/lib/services/plugins/api/JoplinData.d.ts packages/lib/services/plugins/api/JoplinData.js packages/lib/services/plugins/api/JoplinData.js.map diff --git a/.gitignore b/.gitignore index 6c3fd8a69..54659efb6 100644 --- a/.gitignore +++ b/.gitignore @@ -213,6 +213,15 @@ packages/app-cli/tests/support/plugins/multi_selection/api/types.js.map packages/app-cli/tests/support/plugins/multi_selection/src/index.d.ts packages/app-cli/tests/support/plugins/multi_selection/src/index.js packages/app-cli/tests/support/plugins/multi_selection/src/index.js.map +packages/app-cli/tests/support/plugins/post_messages/api/index.d.ts +packages/app-cli/tests/support/plugins/post_messages/api/index.js +packages/app-cli/tests/support/plugins/post_messages/api/index.js.map +packages/app-cli/tests/support/plugins/post_messages/api/types.d.ts +packages/app-cli/tests/support/plugins/post_messages/api/types.js +packages/app-cli/tests/support/plugins/post_messages/api/types.js.map +packages/app-cli/tests/support/plugins/post_messages/src/index.d.ts +packages/app-cli/tests/support/plugins/post_messages/src/index.js +packages/app-cli/tests/support/plugins/post_messages/src/index.js.map packages/app-cli/tests/support/plugins/register_command/api/index.d.ts packages/app-cli/tests/support/plugins/register_command/api/index.js packages/app-cli/tests/support/plugins/register_command/api/index.js.map @@ -1005,6 +1014,9 @@ packages/lib/services/KeymapService_keysRegExp.js.map packages/lib/services/KvStore.d.ts packages/lib/services/KvStore.js packages/lib/services/KvStore.js.map +packages/lib/services/PostMessageService.d.ts +packages/lib/services/PostMessageService.js +packages/lib/services/PostMessageService.js.map packages/lib/services/ResourceEditWatcher/index.d.ts packages/lib/services/ResourceEditWatcher/index.js packages/lib/services/ResourceEditWatcher/index.js.map @@ -1143,6 +1155,9 @@ packages/lib/services/plugins/api/Joplin.js.map packages/lib/services/plugins/api/JoplinCommands.d.ts packages/lib/services/plugins/api/JoplinCommands.js packages/lib/services/plugins/api/JoplinCommands.js.map +packages/lib/services/plugins/api/JoplinContentScripts.d.ts +packages/lib/services/plugins/api/JoplinContentScripts.js +packages/lib/services/plugins/api/JoplinContentScripts.js.map packages/lib/services/plugins/api/JoplinData.d.ts packages/lib/services/plugins/api/JoplinData.js packages/lib/services/plugins/api/JoplinData.js.map diff --git a/packages/app-cli/tests/services_PluginService.ts b/packages/app-cli/tests/services_PluginService.ts index 90edcf6cc..2f27e19a7 100644 --- a/packages/app-cli/tests/services_PluginService.ts +++ b/packages/app-cli/tests/services_PluginService.ts @@ -192,7 +192,7 @@ describe('services_PluginService', function() { joplin.plugins.register({ onStart: async function() { - await joplin.plugins.registerContentScript('markdownItPlugin', 'justtesting', './markdownItTestPlugin.js'); + await joplin.contentScripts.register('markdownItPlugin', 'justtesting', './markdownItTestPlugin.js'); }, }); `); diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index 0f0a1e4e3..be19011b6 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -8,7 +8,6 @@ import NoteEditor from '../NoteEditor/NoteEditor'; import NoteContentPropertiesDialog from '../NoteContentPropertiesDialog'; import ShareNoteDialog from '../ShareNoteDialog'; import CommandService from '@joplin/lib/services/CommandService'; -import PluginService from '@joplin/lib/services/plugins/PluginService'; import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; import SideBar from '../SideBar/SideBar'; import UserWebview from '../../services/plugins/UserWebview'; @@ -30,7 +29,6 @@ import { themeStyle } from '@joplin/lib/theme'; import validateLayout from '../ResizableLayout/utils/validateLayout'; import iterateItems from '../ResizableLayout/utils/iterateItems'; import removeItem from '../ResizableLayout/utils/removeItem'; -import Logger from '@joplin/lib/Logger'; const { connect } = require('react-redux'); const { PromptDialog } = require('../PromptDialog.min.js'); @@ -39,8 +37,6 @@ const PluginManager = require('@joplin/lib/services/PluginManager'); const EncryptionService = require('@joplin/lib/services/EncryptionService'); const ipcRenderer = require('electron').ipcRenderer; -const logger = Logger.create('MainScreen'); - interface LayerModalState { visible: boolean; message: string; @@ -157,7 +153,6 @@ class MainScreenComponent extends React.Component { this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this); this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this); this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this); - this.userWebview_message = this.userWebview_message.bind(this); this.resizableLayout_resize = this.resizableLayout_resize.bind(this); this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this); this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this); @@ -567,11 +562,6 @@ class MainScreenComponent extends React.Component { } } - userWebview_message(event: any) { - logger.debug('Got message (WebView => Plugin) (2)', event); - PluginService.instance().pluginById(event.pluginId).viewController(event.viewId).emitMessage(event); - } - resizableLayout_resize(event: any) { this.updateMainLayout(event.layout); } @@ -584,6 +574,8 @@ class MainScreenComponent extends React.Component { resizableLayout_renderItem(key: string, event: any) { const eventEmitter = event.eventEmitter; + // const viewsToRemove:string[] = []; + const components: any = { sideBar: () => { return ; @@ -612,26 +604,46 @@ class MainScreenComponent extends React.Component { const viewInfo = pluginUtils.viewInfoByViewId(this.props.plugins, event.item.key); if (!viewInfo) { + // Note that it will happen when the component is rendered + // before the plugins have loaded their views, so because of + // this we need to keep the view in the layout. + // + // But it can also be a problem if the view really is invalid + // due to a faulty plugin as currently there would be no way to + // remove it. console.warn(`Could not find plugin associated with view: ${event.item.key}`); return null; + } else { + const { view, plugin } = viewInfo; + + return ; } - - const { view, plugin } = viewInfo; - - return ; + } else { + throw new Error(`Invalid layout component: ${key}`); } - throw new Error(`Invalid layout component: ${key}`); + // if (viewsToRemove.length) { + // window.requestAnimationFrame(() => { + // let newLayout = this.props.mainLayout; + // for (const itemKey of viewsToRemove) { + // newLayout = removeItem(newLayout, itemKey); + // } + + // if (newLayout !== this.props.mainLayout) { + // console.warn('Removed invalid views:', viewsToRemove); + // this.updateMainLayout(newLayout); + // } + // }); + // } } renderPluginDialogs() { @@ -650,7 +662,6 @@ class MainScreenComponent extends React.Component { html={view.html} scripts={view.scripts} pluginId={plugin.id} - onMessage={this.userWebview_message} buttons={view.buttons} />); } diff --git a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts index abca8e5f8..0437c55da 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts @@ -4,6 +4,7 @@ import contextMenu from './contextMenu'; import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index'; import { _ } from '@joplin/lib/locale'; import CommandService from '@joplin/lib/services/CommandService'; +import PostMessageService from '@joplin/lib/services/PostMessageService'; const BaseItem = require('@joplin/lib/models/BaseItem'); const BaseModel = require('@joplin/lib/BaseModel').default; const Resource = require('@joplin/lib/models/Resource.js'); @@ -95,6 +96,8 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea const commandName = arg0.name; const commandArgs = arg0.args || []; void CommandService.instance().execute(commandName, ...commandArgs); + } else if (msg === 'postMessageService.message') { + void PostMessageService.instance().postMessage(arg0); } else { bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg)); } diff --git a/packages/app-desktop/gui/NoteTextViewer.tsx b/packages/app-desktop/gui/NoteTextViewer.tsx index 8f33ea9eb..aa7aa32b0 100644 --- a/packages/app-desktop/gui/NoteTextViewer.tsx +++ b/packages/app-desktop/gui/NoteTextViewer.tsx @@ -1,3 +1,4 @@ +import PostMessageService, { MessageResponse, ResponderComponentType } from '@joplin/lib/services/PostMessageService'; import * as React from 'react'; const { connect } = require('react-redux'); const { reg } = require('@joplin/lib/registry.js'); @@ -20,6 +21,19 @@ class NoteTextViewerComponent extends React.Component { this.webviewRef_ = React.createRef(); + PostMessageService.instance().registerResponder(ResponderComponentType.NoteTextViewer, '', (message: MessageResponse) => { + if (!this.webviewRef_?.current?.contentWindow) { + reg.logger().warn('Cannot respond to message because target is gone', message); + return; + } + + this.webviewRef_.current.contentWindow.postMessage({ + target: 'webview', + name: 'postMessageService.response', + data: message, + }, '*'); + }); + this.webview_domReady = this.webview_domReady.bind(this); this.webview_ipcMessage = this.webview_ipcMessage.bind(this); this.webview_load = this.webview_load.bind(this); diff --git a/packages/app-desktop/gui/note-viewer/index.html b/packages/app-desktop/gui/note-viewer/index.html index e7cd582c3..9a5591d56 100644 --- a/packages/app-desktop/gui/note-viewer/index.html +++ b/packages/app-desktop/gui/note-viewer/index.html @@ -55,16 +55,30 @@ window.postMessage({ target: 'main', name: methodName, args: [ arg ] }, '*'); } + const webviewApiPromises_ = {}; + // This function is reserved for plugin, currently only to allow // executing a command, but more features could be added to the object // later on. const webviewApi = { - executeCommand: function (commandName, ...args) { - return ipcProxySendToHost('contentScriptExecuteCommand', { - name: commandName, - args: args, + postMessage: function(contentScriptId, message) { + const messageId = 'noteViewer_' + Date.now() + Math.random(); + + const promise = new Promise((resolve, reject) => { + webviewApiPromises_[messageId] = { resolve, reject }; }); - } + + ipcProxySendToHost('postMessageService.message', { + contentScriptId: contentScriptId, + viewId: '', + from: 'contentScript', + to: 'plugin', + id: messageId, + content: message, + }); + + return promise; + }, } let pluginAssetsAdded_ = {}; @@ -75,7 +89,7 @@ const ipc = {}; window.addEventListener('message', webviewLib.logEnabledEventHandler(event => { - // Here we only deal with messages that are sent from the main Electro process to the webview. + // Here we only deal with messages that are sent from the main Electron process to the webview. if (!event.data || event.data.target !== 'webview') return; const callName = event.data.name; @@ -367,6 +381,20 @@ ipcProxySendToHost('percentScroll', percent); })); + ipc['postMessageService.response'] = function(event) { + const promise = webviewApiPromises_[event.responseId]; + if (!promise) { + console.warn('postMessageService.response: could not find callback for message', event); + return; + } + + if (event.error) { + promise.reject(event.error); + } else { + promise.resolve(event.response); + } + } + document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => { let element = event.target; diff --git a/packages/app-desktop/services/plugins/UserWebview.tsx b/packages/app-desktop/services/plugins/UserWebview.tsx index 9384cf8d7..4de7d668a 100644 --- a/packages/app-desktop/services/plugins/UserWebview.tsx +++ b/packages/app-desktop/services/plugins/UserWebview.tsx @@ -15,7 +15,6 @@ const logger = Logger.create('UserWebview'); export interface Props { html: string; scripts: string[]; - onMessage: Function; pluginId: string; viewId: string; themeId: number; @@ -119,9 +118,9 @@ function UserWebview(props: Props, ref: any) { useWebviewToPluginMessages( frameWindow(), isReady, - props.onMessage, props.pluginId, - props.viewId + props.viewId, + postMessage ); useScriptLoader( diff --git a/packages/app-desktop/services/plugins/UserWebviewDialog.tsx b/packages/app-desktop/services/plugins/UserWebviewDialog.tsx index 5af28b4d4..bb768a3c1 100644 --- a/packages/app-desktop/services/plugins/UserWebviewDialog.tsx +++ b/packages/app-desktop/services/plugins/UserWebviewDialog.tsx @@ -102,7 +102,6 @@ export default function UserWebviewDialog(props: Props) { ref={webviewRef} html={props.html} scripts={props.scripts} - onMessage={props.onMessage} pluginId={props.pluginId} viewId={props.viewId} themeId={props.themeId} diff --git a/packages/app-desktop/services/plugins/UserWebviewIndex.js b/packages/app-desktop/services/plugins/UserWebviewIndex.js index 4562b5976..a0931d98d 100644 --- a/packages/app-desktop/services/plugins/UserWebviewIndex.js +++ b/packages/app-desktop/services/plugins/UserWebviewIndex.js @@ -1,8 +1,26 @@ // This is the API that JS files loaded from the webview can see +const webviewApiPromises_ = {}; + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars const webviewApi = { postMessage: function(message) { - window.postMessage({ target: 'plugin', message: message }, '*'); + const messageId = `userWebview_${Date.now()}${Math.random()}`; + + const promise = new Promise((resolve, reject) => { + webviewApiPromises_[messageId] = { resolve, reject }; + }); + + window.postMessage({ + target: 'postMessageService.message', + message: { + from: 'userWebview', + to: 'plugin', + id: messageId, + content: message, + }, + }); + + return promise; }, }; @@ -94,6 +112,21 @@ const webviewApi = { addScript(scriptPath); } }, + + 'postMessageService.response': (event) => { + const message = event.message; + const promise = webviewApiPromises_[message.responseId]; + if (!promise) { + console.warn('postMessageService.response: could not find callback for message', message); + return; + } + + if (message.error) { + promise.reject(message.error); + } else { + promise.resolve(message.response); + } + }, }; window.addEventListener('message', ((event) => { diff --git a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts index 53ada67a8..db59462a9 100644 --- a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts +++ b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts @@ -1,24 +1,27 @@ -import Logger from '@joplin/lib/Logger'; +import PostMessageService, { MessageResponse, ResponderComponentType } from '@joplin/lib/services/PostMessageService'; import { useEffect } from 'react'; -const logger = Logger.create('useWebviewToPluginMessages'); +export default function(frameWindow: any, isReady: boolean, pluginId: string, viewId: string, postMessage: Function) { + useEffect(() => { + PostMessageService.instance().registerResponder(ResponderComponentType.UserWebview, viewId, (message: MessageResponse) => { + postMessage('postMessageService.response', { message }); + }); + + return () => { + PostMessageService.instance().unregisterResponder(ResponderComponentType.UserWebview, viewId); + }; + }, [viewId]); -export default function(frameWindow: any, isReady: boolean, onMessage: Function, pluginId: string, viewId: string) { useEffect(() => { if (!frameWindow) return () => {}; function onMessage_(event: any) { - if (!event.data || event.data.target !== 'plugin') return; + if (!event.data || event.data.target !== 'postMessageService.message') return; - // The message is passed from one component or service to the next - // till it reaches its destination, so if something doesn't work - // follow the chain of messages searching for the string "Got message" - logger.debug('Got message (WebView => Plugin) (1)', pluginId, viewId, event.data.message); - - onMessage({ - pluginId: pluginId, - viewId: viewId, - message: event.data.message, + void PostMessageService.instance().postMessage({ + pluginId, + viewId, + ...event.data.message, }); } @@ -27,5 +30,5 @@ export default function(frameWindow: any, isReady: boolean, onMessage: Function, return () => { frameWindow.removeEventListener('message', onMessage_); }; - }, [frameWindow, onMessage, isReady, pluginId, viewId]); + }, [frameWindow, isReady, pluginId, viewId]); } diff --git a/packages/app-desktop/testPluginDemo.sh b/packages/app-desktop/testPluginDemo.sh index 5e6f7e9eb..362769322 100755 --- a/packages/app-desktop/testPluginDemo.sh +++ b/packages/app-desktop/testPluginDemo.sh @@ -4,5 +4,5 @@ # It could be used to develop plugins too. SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -PLUGIN_PATH="$SCRIPT_DIR/../app-cli/tests/support/plugins/toc" +PLUGIN_PATH="$SCRIPT_DIR/../app-cli/tests/support/plugins/post_messages" npm i --prefix="$PLUGIN_PATH" && npm start -- --dev-plugins "$PLUGIN_PATH" \ No newline at end of file diff --git a/packages/lib/services/PostMessageService.ts b/packages/lib/services/PostMessageService.ts new file mode 100644 index 000000000..b2961d949 --- /dev/null +++ b/packages/lib/services/PostMessageService.ts @@ -0,0 +1,133 @@ +// Passing messages across the various sandbox boundaries can be complex and is +// hard to unit test. This class is an attempt to clarify and track what happens +// when messages are sent. +// +// Essentially it works like this: +// +// The component that might post messages, for example from a content script to +// the plugin, and expect responses: +// +// - First it registers a responder with the PostMessageService - this is what +// will be used to send back responses. +// - Whenever it sends a message it calls PostMessageService.postMessage() and +// wait for the response +// - This class forwards the message to the relevant participant and wait for the +// response +// - Then it sends back the response to the component using the registered +// responder. +// +// There's still quite a bit of boiler plate code on the content script or +// webview side to mask the complexity of passing messages. In particular, it +// needs to create and return a promise when a message is posted. Then in +// another location, when the response is received, it resolves that promise. +// See UserWebviewIndex.js to see how it's done. + +import Logger from '../Logger'; +import PluginService from './plugins/PluginService'; + +const logger = Logger.create('PostMessageService'); + +enum MessageParticipant { + ContentScript = 'contentScript', + Plugin = 'plugin', + UserWebview = 'userWebview', +} + +export enum ResponderComponentType { + NoteTextViewer = 'noteTextViewer', + UserWebview = 'userWebview', +} + +export interface MessageResponse { + responseId: string; + response: any; + error: any; +} + +type MessageResponder = (message: MessageResponse)=> void; + +interface Message { + pluginId: string; + contentScriptId: string; + viewId: string; + from: MessageParticipant; + to: MessageParticipant; + id: string; + content: any; +} + +export default class PostMessageService { + + private static instance_: PostMessageService; + private responders_: Record = {}; + + public static instance(): PostMessageService { + if (this.instance_) return this.instance_; + this.instance_ = new PostMessageService(); + return this.instance_; + } + + public async postMessage(message: Message) { + logger.debug('postMessage:', message); + + let response = null; + let error = null; + + try { + if (message.from === MessageParticipant.ContentScript && message.to === MessageParticipant.Plugin) { + + const pluginId = PluginService.instance().pluginIdByContentScriptId(message.contentScriptId); + if (!pluginId) throw new Error(`Could not find plugin associated with content script "${message.contentScriptId}"`); + response = await PluginService.instance().pluginById(pluginId).emitContentScriptMessage(message.contentScriptId, message.content); + + } else if (message.from === MessageParticipant.UserWebview && message.to === MessageParticipant.Plugin) { + + response = await PluginService.instance().pluginById(message.pluginId).viewController(message.viewId).emitMessage({ message: message.content }); + + } else { + + throw new Error(`Unhandled message: ${JSON.stringify(message)}`); + + } + } catch (e) { + error = e; + } + + this.sendResponse(message, response, error); + } + + private sendResponse(message: Message, responseContent: any, error: any) { + logger.debug('sendResponse', message, responseContent, error); + + let responder: MessageResponder = null; + + if (message.from === MessageParticipant.ContentScript) { + responder = this.responder(ResponderComponentType.NoteTextViewer, message.viewId); + } else if (message.from === MessageParticipant.UserWebview) { + responder = this.responder(ResponderComponentType.UserWebview, message.viewId); + } + + if (!responder) { + logger.warn('Cannot respond to message because no responder was found', message); + } + + responder({ + responseId: message.id, + response: responseContent, + error, + }); + } + + private responder(type: ResponderComponentType, viewId: string): any { + return this.responders_[[type, viewId].join(':')]; + } + + public registerResponder(type: ResponderComponentType, viewId: string, responder: MessageResponder) { + this.responders_[[type, viewId].join(':')] = responder; + } + + public unregisterResponder(type: ResponderComponentType, viewId: string) { + delete this.responders_[[type, viewId].join(':')]; + } + +} diff --git a/packages/lib/services/plugins/Plugin.ts b/packages/lib/services/plugins/Plugin.ts index cf85018af..bae02c219 100644 --- a/packages/lib/services/plugins/Plugin.ts +++ b/packages/lib/services/plugins/Plugin.ts @@ -31,6 +31,8 @@ export default class Plugin { private dispatch_: Function; private eventEmitter_: any; private devMode_: boolean = false; + private messageListener_: Function = null; + private contentScriptMessageListeners_: Record = {}; constructor(baseDir: string, manifest: PluginManifest, scriptText: string, dispatch: Function) { this.baseDir_ = shim.fsDriver().resolve(baseDir); @@ -106,6 +108,17 @@ export default class Plugin { return this.contentScripts_[type] ? this.contentScripts_[type] : []; } + public contentScriptById(id: string): ContentScript { + for (const type in this.contentScripts_) { + const cs = this.contentScripts_[type]; + for (const c of cs) { + if (c.id === id) return c; + } + } + + return null; + } + public addViewController(v: ViewController) { if (this.viewControllers_[v.handle]) throw new Error(`View already added or there is already a view with this ID: ${v.handle}`); this.viewControllers_[v.handle] = v; @@ -120,4 +133,29 @@ export default class Plugin { logger.warn(`"${this.id}": DEPRECATION NOTICE: ${message} This will stop working in version ${goneInVersion}.`); } + public emitMessage(message: any) { + if (!this.messageListener_) return; + return this.messageListener_(message); + } + + public onMessage(callback: any) { + this.messageListener_ = callback; + } + + public onContentScriptMessage(id: string, callback: any) { + if (!this.contentScriptById(id)) { + // The script could potentially be registered later on, but still + // best to print a warning to notify the user of a possible bug. + logger.warn(`onContentScriptMessage: No such content script: ${id}`); + } + + this.contentScriptMessageListeners_[id] = callback; + } + + public emitContentScriptMessage(id: string, message: any) { + if (!this.contentScriptMessageListeners_[id]) return; + return this.contentScriptMessageListeners_[id](message); + } + + } diff --git a/packages/lib/services/plugins/PluginService.ts b/packages/lib/services/plugins/PluginService.ts index 732c789d0..61c2967d0 100644 --- a/packages/lib/services/plugins/PluginService.ts +++ b/packages/lib/services/plugins/PluginService.ts @@ -67,7 +67,7 @@ export default class PluginService extends BaseService { private plugins_: Plugins = {}; private runner_: BasePluginRunner = null; - initialize(appVersion: string, platformImplementation: any, runner: BasePluginRunner, store: any) { + public initialize(appVersion: string, platformImplementation: any, runner: BasePluginRunner, store: any) { this.appVersion_ = appVersion; this.store_ = store; this.runner_ = runner; @@ -115,6 +115,15 @@ export default class PluginService extends BaseService { return JSON.stringify(settings); } + public pluginIdByContentScriptId(contentScriptId: string): string { + for (const pluginId in this.plugins_) { + const plugin = this.plugins_[pluginId]; + const contentScript = plugin.contentScriptById(contentScriptId); + if (contentScript) return pluginId; + } + return null; + } + private async parsePluginJsBundle(jsBundleString: string) { const scriptText = jsBundleString; const lines = scriptText.split('\n'); diff --git a/packages/lib/services/plugins/ViewController.ts b/packages/lib/services/plugins/ViewController.ts index e69666944..1a94229df 100644 --- a/packages/lib/services/plugins/ViewController.ts +++ b/packages/lib/services/plugins/ViewController.ts @@ -1,5 +1,9 @@ import { ViewHandle } from './utils/createViewHandle'; +export interface EmitMessageEvent { + message: any; +} + export default class ViewController { private handle_: ViewHandle; @@ -36,7 +40,7 @@ export default class ViewController { throw new Error('Must be overriden'); } - public emitMessage(event: any) { + public async emitMessage(event: EmitMessageEvent): Promise { console.info('Calling ViewController.emitMessage - but not implemented', event); } diff --git a/packages/lib/services/plugins/WebviewController.ts b/packages/lib/services/plugins/WebviewController.ts index 69c93e321..c8f5ca45e 100644 --- a/packages/lib/services/plugins/WebviewController.ts +++ b/packages/lib/services/plugins/WebviewController.ts @@ -1,4 +1,4 @@ -import ViewController from './ViewController'; +import ViewController, { EmitMessageEvent } from './ViewController'; import shim from '../../shim'; import { ButtonSpec, DialogResult } from './api/types'; const { toSystemSlashes } = require('../../path-utils'); @@ -102,9 +102,9 @@ export default class WebviewController extends ViewController { }); } - public emitMessage(event: any) { + public async emitMessage(event: EmitMessageEvent): Promise { if (!this.messageListener_) return; - this.messageListener_(event.message); + return this.messageListener_(event.message); } public onMessage(callback: any) { diff --git a/packages/lib/services/plugins/api/Joplin.ts b/packages/lib/services/plugins/api/Joplin.ts index 16dd4c82d..b7b82d4a2 100644 --- a/packages/lib/services/plugins/api/Joplin.ts +++ b/packages/lib/services/plugins/api/Joplin.ts @@ -7,6 +7,7 @@ import JoplinCommands from './JoplinCommands'; import JoplinViews from './JoplinViews'; import JoplinInterop from './JoplinInterop'; import JoplinSettings from './JoplinSettings'; +import JoplinContentScripts from './JoplinContentScripts'; /** * This is the main entry point to the Joplin API. You can access various services using the provided accessors. @@ -33,6 +34,7 @@ export default class Joplin { private views_: JoplinViews = null; private interop_: JoplinInterop = null; private settings_: JoplinSettings = null; + private contentScripts_: JoplinContentScripts = null; constructor(implementation: any, plugin: Plugin, store: any) { this.data_ = new JoplinData(); @@ -43,6 +45,7 @@ export default class Joplin { this.views_ = new JoplinViews(implementation.views, plugin, store); this.interop_ = new JoplinInterop(); this.settings_ = new JoplinSettings(plugin); + this.contentScripts_ = new JoplinContentScripts(plugin); } get data(): JoplinData { @@ -57,6 +60,10 @@ export default class Joplin { return this.workspace_; } + get contentScripts(): JoplinContentScripts { + return this.contentScripts_; + } + /** * @ignore * diff --git a/packages/lib/services/plugins/api/JoplinContentScripts.ts b/packages/lib/services/plugins/api/JoplinContentScripts.ts new file mode 100644 index 000000000..ea19a0772 --- /dev/null +++ b/packages/lib/services/plugins/api/JoplinContentScripts.ts @@ -0,0 +1,52 @@ +import Plugin from '../Plugin'; +import { ContentScriptType } from './types'; + +export default class JoplinContentScripts { + + private plugin: Plugin; + + public constructor(plugin: Plugin) { + this.plugin = plugin; + } + + /** + * Registers a new content script. Unlike regular plugin code, which runs in + * a separate process, content scripts run within the main process code and + * thus allow improved performances and more customisations in specific + * cases. It can be used for example to load a Markdown or editor plugin. + * + * Note that registering a content script in itself will do nothing - it + * will only be loaded in specific cases by the relevant app modules (eg. + * the Markdown renderer or the code editor). So it is not a way to inject + * and run arbitrary code in the app, which for safety and performance + * reasons is not supported. + * + * The plugin generator provides a way to build any content script you might + * want to package as well as its dependencies. See the [Plugin Generator + * doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md) + * for more information. + * + * * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) + * * [View the editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) + * + * See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) + * + * @param type Defines how the script will be used. See the type definition for more information about each supported type. + * @param id A unique ID for the content script. + * @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`. + */ + public async register(type: ContentScriptType, id: string, scriptPath: string) { + return this.plugin.registerContentScript(type, id, scriptPath); + } + + /** + * Listens to a messages sent from the content script using postMessage(). + * See {@link ContentScriptType} for more information as well as the + * [postMessage + * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) + */ + public async onMessage(contentScriptId: string, callback: any) { + this.plugin.onContentScriptMessage(contentScriptId, callback); + } + +} diff --git a/packages/lib/services/plugins/api/JoplinPlugins.ts b/packages/lib/services/plugins/api/JoplinPlugins.ts index 8264f85ae..db1221708 100644 --- a/packages/lib/services/plugins/api/JoplinPlugins.ts +++ b/packages/lib/services/plugins/api/JoplinPlugins.ts @@ -51,30 +51,19 @@ export default class JoplinPlugins { } /** - * Registers a new content script. Unlike regular plugin code, which runs in - * a separate process, content scripts run within the main process code and - * thus allow improved performances and more customisations in specific - * cases. It can be used for example to load a Markdown or editor plugin. - * - * Note that registering a content script in itself will do nothing - it - * will only be loaded in specific cases by the relevant app modules (eg. - * the Markdown renderer or the code editor). So it is not a way to inject - * and run arbitrary code in the app, which for safety and performance - * reasons is not supported. - * - * The plugin generator provides a way to build any content script you might - * want to package as well as its dependencies. See the [Plugin Generator - * doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md) - * for more information. - * - * * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) - * * [View the editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) - * - * @param type Defines how the script will be used. See the type definition for more information about each supported type. - * @param id A unique ID for the content script. - * @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`. + * @deprecated Use joplin.contentScripts.register() */ public async registerContentScript(type: ContentScriptType, id: string, scriptPath: string) { + this.plugin.deprecationNotice('1.8', 'joplin.plugins.registerContentScript() is deprecated in favour of joplin.contentScripts.register()'); return this.plugin.registerContentScript(type, id, scriptPath); } + + // public async onMessage(callback: any) { + // this.plugin.onMessage(callback); + // } + + // public async onContentScriptMessage(id: string, callback: any) { + // this.plugin.onContentScriptMessage(id, callback); + // } + } diff --git a/packages/lib/services/plugins/api/JoplinViewsPanels.ts b/packages/lib/services/plugins/api/JoplinViewsPanels.ts index 413fde461..2acd58386 100644 --- a/packages/lib/services/plugins/api/JoplinViewsPanels.ts +++ b/packages/lib/services/plugins/api/JoplinViewsPanels.ts @@ -57,6 +57,22 @@ export default class JoplinViewsPanels { /** * Called when a message is sent from the webview (using postMessage). + * + * To post a message from the webview to the plugin use: + * + * ```javascript + * const response = await webviewApi.postMessage(message); + * ``` + * + * - `message` can be any JavaScript object, string or number + * - `response` is whatever was returned by the `onMessage` handler + * + * Using this mechanism, you can have two-way communication between the + * plugin and webview. + * + * See the [postMessage + * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details. + * */ public async onMessage(handle: ViewHandle, callback: Function) { return this.controller(handle).onMessage(callback); diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index d14c63b49..c8110fb65 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -366,9 +366,31 @@ export interface SettingSection { export type Path = string[]; // ================================================================= -// Plugins type +// Content Script types // ================================================================= +export type PostMessageHandler = (id: string, message: any)=> Promise; + +/** + * When a content script is initialised, it receives a `context` object. + */ +export interface ContentScriptContext { + /** + * The plugin ID that registered this content script + */ + pluginId: string; + + /** + * The content script ID, which may be necessary to post messages + */ + contentScriptId: string; + + /** + * Can be used by CodeMirror content scripts to post a message to the plugin + */ + postMessage: PostMessageHandler; +} + export enum ContentScriptType { /** * Registers a new Markdown-It plugin, which should follow the template @@ -394,43 +416,56 @@ export enum ContentScriptType { * * ## Exported members * - * - The `context` argument is currently unused but could be used later - * on to provide access to your own plugin so that the content script - * and plugin can communicate. + * - The `context` argument is currently unused but could be used later on + * to provide access to your own plugin so that the content script and + * plugin can communicate. * - * - The **required** `plugin` key is the actual Markdown-It plugin - - * check the [official - * doc](https://github.com/markdown-it/markdown-it) for more + * - The **required** `plugin` key is the actual Markdown-It plugin - check + * the [official doc](https://github.com/markdown-it/markdown-it) for more * information. The `options` parameter is of type * [RuleOptions](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts), - * which contains a number of options, mostly useful for Joplin's - * internal code. + * which contains a number of options, mostly useful for Joplin's internal + * code. * - * - Using the **optional** `assets` key you may specify assets such as - * JS or CSS that should be loaded in the rendered HTML document. - * Check for example the Joplin [Mermaid + * - Using the **optional** `assets` key you may specify assets such as JS + * or CSS that should be loaded in the rendered HTML document. Check for + * example the Joplin [Mermaid * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) * to see how the data should be structured. * - * ## Passing messages from the content script to your plugin + * ## Posting messages from the content script to your plugin * * The application provides the following function to allow executing * commands from the rendered HTML code: * - * `webviewApi.executeCommand(commandName, ...args)` + * ```javascript + * const response = await webviewApi.postMessage(contentScriptId, message); + * ``` * - * So you can use this mechanism to pass messages from the note viewer - * to your own plugin. To do so you would define a command, using - * `joplin.commands.register`, then you would call this command using - * the `webviewApi` object. See again [the - * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) - * to see how this can be done. + * - `contentScriptId` is the ID you've defined when you registered the + * content script. You can retrieve it from the + * {@link ContentScriptContext | context}. + * - `message` can be any basic JavaScript type (number, string, plain + * object), but it cannot be a function or class instance. + * + * When you post a message, the plugin can send back a `response` thus + * allowing two-way communication: + * + * ```javascript + * await joplin.contentScripts.onMessage(contentScriptId, (message) => { + * // Process message + * return response; // Can be any object, string or number + * }); + * ``` + * + * See {@link JoplinContentScripts.onMessage} for more details, as well as + * the [postMessage + * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages). * * ## Registering an existing Markdown-it plugin * - * To include a regular Markdown-It plugin, that doesn't make use of - * any Joplin-specific features, you would simply create a file such as - * this: + * To include a regular Markdown-It plugin, that doesn't make use of any + * Joplin-specific features, you would simply create a file such as this: * * ```javascript * module.exports = { @@ -443,6 +478,7 @@ export enum ContentScriptType { * ``` */ MarkdownItPlugin = 'markdownItPlugin', + /** * Registers a new CodeMirror plugin, which should follow the template * below. @@ -466,42 +502,65 @@ export enum ContentScriptType { * } * ``` * - * - The `context` argument is currently unused but could be used later - * on to provide access to your own plugin so that the content script - * and plugin can communicate. + * - The `context` argument is currently unused but could be used later on + * to provide access to your own plugin so that the content script and + * plugin can communicate. * * - The `plugin` key is your CodeMirror plugin. This is where you can - * register new commands with CodeMirror or interact with the - * CodeMirror instance as needed. + * register new commands with CodeMirror or interact with the CodeMirror + * instance as needed. * - * - The `codeMirrorResources` key is an array of CodeMirror resources - * that will be loaded and attached to the CodeMirror module. These - * are made up of addons, keymaps, and modes. For example, for a - * plugin that want's to enable clojure highlighting in code blocks. - * `codeMirrorResources` would be set to `['mode/clojure/clojure']`. + * - The `codeMirrorResources` key is an array of CodeMirror resources that + * will be loaded and attached to the CodeMirror module. These are made up + * of addons, keymaps, and modes. For example, for a plugin that want's to + * enable clojure highlighting in code blocks. `codeMirrorResources` would + * be set to `['mode/clojure/clojure']`. * * - The `codeMirrorOptions` key contains all the - * [CodeMirror](https://codemirror.net/doc/manual.html#config) - * options that will be set or changed by this plugin. New options - * can alse be declared via + * [CodeMirror](https://codemirror.net/doc/manual.html#config) options + * that will be set or changed by this plugin. New options can alse be + * declared via * [`CodeMirror.defineOption`](https://codemirror.net/doc/manual.html#defineOption), - * and then have their value set here. For example, a plugin that - * enables line numbers would set `codeMirrorOptions` to - * `{'lineNumbers': true}`. + * and then have their value set here. For example, a plugin that enables + * line numbers would set `codeMirrorOptions` to `{'lineNumbers': true}`. * - * - Using the **optional** `assets` key you may specify **only** CSS - * assets that should be loaded in the rendered HTML document. Check - * for example the Joplin [Mermaid + * - Using the **optional** `assets` key you may specify **only** CSS assets + * that should be loaded in the rendered HTML document. Check for example + * the Joplin [Mermaid * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) * to see how the data should be structured. * - * One of the `plugin`, `codeMirrorResources`, or `codeMirrorOptions` - * keys must be provided for the plugin to be valid. Having multiple or - * all provided is also okay. + * One of the `plugin`, `codeMirrorResources`, or `codeMirrorOptions` keys + * must be provided for the plugin to be valid. Having multiple or all + * provided is also okay. * - * See the [demo + * See also the [demo * plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) * for an example of all these keys being used in one plugin. + * + * ## Posting messages from the content script to your plugin + * + * In order to post messages to the plugin, you can use the postMessage + * function passed to the {@link ContentScriptContext | context}. + * + * ```javascript + * const response = await context.postMessage('messageFromCodeMirrorContentScript'); + * ``` + * + * When you post a message, the plugin can send back a `response` thus + * allowing two-way communication: + * + * ```javascript + * await joplin.contentScripts.onMessage(contentScriptId, (message) => { + * // Process message + * return response; // Can be any object, string or number + * }); + * ``` + * + * See {@link JoplinContentScripts.onMessage} for more details, as well as + * the [postMessage + * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages). + * */ CodeMirrorPlugin = 'codeMirrorPlugin', } diff --git a/packages/lib/services/plugins/utils/loadContentScripts.ts b/packages/lib/services/plugins/utils/loadContentScripts.ts index 1eddab048..a1bb7796b 100644 --- a/packages/lib/services/plugins/utils/loadContentScripts.ts +++ b/packages/lib/services/plugins/utils/loadContentScripts.ts @@ -1,8 +1,9 @@ import { PluginStates } from '../reducer'; -import { ContentScriptType } from '../api/types'; +import { ContentScriptType, ContentScriptContext, PostMessageHandler } from '../api/types'; import { dirname } from '@joplin/renderer/pathUtils'; import shim from '../../../shim'; import Logger from '../../../Logger'; +import PluginService from '../PluginService'; const logger = Logger.create('loadContentScripts'); @@ -12,6 +13,17 @@ export interface ExtraContentScript { assetPath: string; } +function postMessageHandler(pluginId: string, scriptType: ContentScriptType, contentScriptId: string): PostMessageHandler { + return (message: any) => { + if (scriptType === ContentScriptType.MarkdownItPlugin) { + logger.error('context.postMessage is not available to renderer content scripts'); + } else { + const plugin = PluginService.instance().pluginById(pluginId); + return plugin.emitContentScriptMessage(contentScriptId, message); + } + }; +} + export function contentScriptsToRendererRules(plugins: PluginStates): ExtraContentScript[] { return loadContentScripts(plugins, ContentScriptType.MarkdownItPlugin); } @@ -35,7 +47,14 @@ function loadContentScripts(plugins: PluginStates, scriptType: ContentScriptType const module = shim.requireDynamic(contentScript.path); if (!module.default || typeof module.default !== 'function') throw new Error(`Content script must export a function under the "default" key: Plugin: ${pluginId}: Script: ${contentScript.id}`); - const loadedModule = module.default({}); + const context: ContentScriptContext = { + pluginId, + contentScriptId: contentScript.id, + postMessage: postMessageHandler(pluginId, contentScript.id), + }; + + const loadedModule = module.default(context); + if (!loadedModule.plugin && !loadedModule.codeMirrorResources && !loadedModule.codeMirrorOptions) throw new Error(`Content script must export a "plugin" key or a list of CodeMirror assets or define a CodeMirror option: Plugin: ${pluginId}: Script: ${contentScript.id}`); output.push({