1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Plugins: Added support for bi-directional messages in content scripts and webview scripts using postMessage

This commit is contained in:
Laurent Cozic 2021-01-11 23:33:10 +00:00
parent cbad3b1190
commit 2489409abb
23 changed files with 574 additions and 128 deletions

View File

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

15
.gitignore vendored
View File

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

View File

@ -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');
},
});
`);

View File

@ -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<Props, State> {
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<Props, State> {
}
}
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<Props, State> {
resizableLayout_renderItem(key: string, event: any) {
const eventEmitter = event.eventEmitter;
// const viewsToRemove:string[] = [];
const components: any = {
sideBar: () => {
return <SideBar key={key} />;
@ -612,26 +604,46 @@ class MainScreenComponent extends React.Component<Props, State> {
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 <UserWebview
key={view.id}
viewId={view.id}
themeId={this.props.themeId}
html={view.html}
scripts={view.scripts}
pluginId={plugin.id}
borderBottom={true}
fitToContent={false}
/>;
}
const { view, plugin } = viewInfo;
return <UserWebview
key={view.id}
viewId={view.id}
themeId={this.props.themeId}
html={view.html}
scripts={view.scripts}
pluginId={plugin.id}
onMessage={this.userWebview_message}
borderBottom={true}
fitToContent={false}
/>;
} 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<Props, State> {
html={view.html}
scripts={view.scripts}
pluginId={plugin.id}
onMessage={this.userWebview_message}
buttons={view.buttons}
/>);
}

View File

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

View File

@ -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<Props, any> {
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);

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

@ -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]);
}

View File

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

View File

@ -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<string, MessageResponder> = {};
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(':')];
}
}

View File

@ -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<string, Function> = {};
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);
}
}

View File

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

View File

@ -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<any> {
console.info('Calling ViewController.emitMessage - but not implemented', event);
}

View File

@ -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<any> {
if (!this.messageListener_) return;
this.messageListener_(event.message);
return this.messageListener_(event.message);
}
public onMessage(callback: any) {

View File

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

View File

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

View File

@ -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);
// }
}

View File

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

View File

@ -366,9 +366,31 @@ export interface SettingSection {
export type Path = string[];
// =================================================================
// Plugins type
// Content Script types
// =================================================================
export type PostMessageHandler = (id: string, message: any)=> Promise<any>;
/**
* 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',
}

View File

@ -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({