mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
|
/**
|
||
|
* The SafeBrowser component implementation is responsible for executing the safeBrowser component
|
||
|
* of a plugin.
|
||
|
*
|
||
|
* A plugin's safeBrowser component is made of one main entry point (the javascript files declares
|
||
|
* in the manifest), html files and any ressources included by the html files (css, scripts, images
|
||
|
* ...). The main script is the main entry point which uses the Grist API to render the views,
|
||
|
* communicate with them en dispose them.
|
||
|
*
|
||
|
* The main script is executed within a WebWorker, and the html files are rendered within webviews
|
||
|
* if run within electron, or iframe in case of the browser.
|
||
|
*
|
||
|
* Communication between the main process and the views are handle with rpc.
|
||
|
*
|
||
|
* If the plugins includes as well an unsafeNode component or a safePython component and if one of
|
||
|
* them registers a function using the Grist Api, this function can then be called from within the
|
||
|
* safeBrowser main script using the Grist API, as described in `app/plugin/Grist.ts`.
|
||
|
*
|
||
|
* The grist API available to safeBrowser components is implemented in `app/plugin/PluginImpl.ts`.
|
||
|
*
|
||
|
* All the safeBrowser's component ressources, including the main script, the html files and any
|
||
|
* other ressources needed by the views, should be placed within one plugins' subfolder, and Grist
|
||
|
* should serve only this folder. However, this is not yet implemented and is left as a TODO, as of
|
||
|
* now the whole plugin's folder is served.
|
||
|
*
|
||
|
*/
|
||
|
// Todo: plugin ressources should not be made available on the server by default, but only after
|
||
|
// activation.
|
||
|
|
||
|
// tslint:disable:max-classes-per-file
|
||
|
|
||
|
import { ClientScope } from 'app/client/components/ClientScope';
|
||
|
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
|
||
|
import * as dom from 'app/client/lib/dom';
|
||
|
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||
|
import { ActionRouter } from 'app/common/ActionRouter';
|
||
|
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
|
||
|
import { tbind } from 'app/common/tbind';
|
||
|
import { getOriginUrl } from 'app/common/urlUtils';
|
||
|
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
|
||
|
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
|
||
|
import { checkers } from 'app/plugin/TypeCheckers';
|
||
|
import { IpcMessageEvent, WebviewTag } from 'electron';
|
||
|
import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc';
|
||
|
import { Disposable } from './dispose';
|
||
|
const G = getBrowserGlobals('document', 'window');
|
||
|
|
||
|
/**
|
||
|
* The SafeBrowser component implementation. Responsible for running the script, rendering the
|
||
|
* views, settings up communication channel.
|
||
|
*/
|
||
|
// todo: it is unfortunate that SafeBrowser had to expose both `renderImpl` and `disposeImpl` which
|
||
|
// really have no business outside of this module. What could be done, is to have an internal class
|
||
|
// ProcessManager which will be created by SafeBrowser as a private field. It will manage the
|
||
|
// client processes and among other thing will expose both renderImpl and
|
||
|
// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
|
||
|
export class SafeBrowser extends BaseComponent {
|
||
|
|
||
|
/**
|
||
|
* Create a webview ClientProcess to render safe browser process in electron.
|
||
|
*/
|
||
|
public static createWorker(safeBrowser: SafeBrowser, rpc: Rpc, src: string): WorkerProcess {
|
||
|
return new WorkerProcess(safeBrowser, rpc, src);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create either an iframe or a webview ClientProcess depending on wether running electron or not.
|
||
|
*/
|
||
|
public static createView(safeBrowser: SafeBrowser, rpc: Rpc, src: string): ViewProcess {
|
||
|
return G.window.isRunningUnderElectron ?
|
||
|
new WebviewProcess(safeBrowser, rpc, src) :
|
||
|
new IframeProcess(safeBrowser, rpc, src);
|
||
|
}
|
||
|
|
||
|
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
|
||
|
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
|
||
|
// events to all processes (such as doc actions which will need soon).
|
||
|
private _viewProcesses: Map<number, ClientProcess> = new Map();
|
||
|
private _pluginId: string;
|
||
|
private _pluginRpc: Rpc;
|
||
|
private _mainProcess: WorkerProcess|undefined;
|
||
|
private _viewCount: number = 0;
|
||
|
|
||
|
constructor(
|
||
|
private _plugin: PluginInstance,
|
||
|
private _clientScope: ClientScope,
|
||
|
private _untrustedContentOrigin: string,
|
||
|
private _mainPath: string = "",
|
||
|
private _baseLogger: BaseLogger = console,
|
||
|
rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`),
|
||
|
) {
|
||
|
super(_plugin.definition.manifest, rpcLogger);
|
||
|
this._pluginId = _plugin.definition.id;
|
||
|
this._pluginRpc = _plugin.rpc;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Render the file at path in an iframe or webview and returns its ViewProcess.
|
||
|
*/
|
||
|
public createViewProcess(path: string): ViewProcess {
|
||
|
return this._createViewProcess(path)[0];
|
||
|
}
|
||
|
/**
|
||
|
* `receiveAction` handles an action received from the server by forwarding it to the view processes.
|
||
|
*/
|
||
|
public receiveAction(action: any[]) {
|
||
|
for (const view of this._viewProcesses.values()) {
|
||
|
view.receiveAction(action);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Renders the file at path and returns its proc id. This is the SafeBrowser implementation for
|
||
|
* the GristAPI's render(...) method, more details can be found at app/plugin/GristAPI.ts.
|
||
|
*/
|
||
|
public async renderImpl(path: string, target: RenderTarget, options: RenderOptions): Promise<number> {
|
||
|
const [proc, viewId] = this._createViewProcess(path);
|
||
|
const renderFunc = this._plugin.getRenderTarget(target, options);
|
||
|
renderFunc(proc.element);
|
||
|
if (this._mainProcess) {
|
||
|
// Disposing the web worker should dispose all view processes that created using the
|
||
|
// gristAPI. There is a flaw here: please read [1].
|
||
|
this._mainProcess.autoDispose(proc);
|
||
|
}
|
||
|
return viewId;
|
||
|
// [1]: When a process, which is not owned by the mainProcess (ie: a process which was created
|
||
|
// using `public createViewProcess(...)'), creates a view process using the gristAPI, the
|
||
|
// rendered view will be owned by the main process. This is not correct and could cause views to
|
||
|
// suddently disappear from the screen. This is pretty nasty. But for following reason I think
|
||
|
// it's ok to leave it for now: (1) fixing this would require (yet) another refactoring of
|
||
|
// SafeBrowser and (2) at this point it is not sure wether we want to keep `render()` in the
|
||
|
// future (we could as well directly register contribution using files directly in the
|
||
|
// manifest), and (3) plugins are only developped by us, we only have to remember that using
|
||
|
// `render()` is only supported from within the main process (which cover all our use cases so
|
||
|
// far).
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Dispose the process using it's proc id. This is the SafeBrowser implementation for the
|
||
|
* GristAPI's dispose(...) method, more details can be found at app/plugin/GristAPI.ts.
|
||
|
*/
|
||
|
public async disposeImpl(procId: number): Promise<void> {
|
||
|
const proc = this._viewProcesses.get(procId);
|
||
|
if (proc) {
|
||
|
this._viewProcesses.delete(procId);
|
||
|
proc.dispose();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
protected doForwardCall(c: IMsgRpcCall): Promise<any> {
|
||
|
if (this._mainProcess) {
|
||
|
return this._mainProcess.rpc.forwardCall(c);
|
||
|
}
|
||
|
// should not happen.
|
||
|
throw new Error("Using SafeBrowser as an IForwarder requires a main script");
|
||
|
}
|
||
|
|
||
|
protected doForwardMessage(c: IMsgCustom): Promise<any> {
|
||
|
if (this._mainProcess) {
|
||
|
return this._mainProcess.rpc.forwardMessage(c);
|
||
|
}
|
||
|
// should not happen.
|
||
|
throw new Error("Using SafeBrowser as an IForwarder requires a main script");
|
||
|
}
|
||
|
|
||
|
protected async activateImplementation(): Promise<void> {
|
||
|
if (this._mainPath) {
|
||
|
const rpc = this._createRpc(this._mainPath);
|
||
|
const src = `plugins/${this._pluginId}/${this._mainPath}`;
|
||
|
// This SafeBrowser object is registered with _pluginRpc as _mainPath forwarder, and
|
||
|
// forwards calls to _mainProcess in doForward* methods (called from BaseComponent.forward*
|
||
|
// methods). Note that those calls are what triggers component activation.
|
||
|
this._mainProcess = SafeBrowser.createWorker(this, rpc, src);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
protected async deactivateImplementation(): Promise<void> {
|
||
|
if (this._mainProcess) {
|
||
|
this._mainProcess.dispose();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Creates an iframe or a webview embedding the file at path. And adds it to `this._viewProcesses`
|
||
|
* using `viewId` as key, and registers it as forwarder to the `pluginRpc` using name
|
||
|
* `path`. Unregister both on disposal.
|
||
|
*/
|
||
|
private _createViewProcess(path: string): [ViewProcess, number] {
|
||
|
const rpc = this._createRpc(path);
|
||
|
const url = `${this._untrustedContentOrigin}/plugins/${this._plugin.definition.id}/${path}`
|
||
|
+ `?host=${G.window.location.origin}`;
|
||
|
const viewId = this._viewCount++;
|
||
|
const process = SafeBrowser.createView(this, rpc, url);
|
||
|
this._viewProcesses.set(viewId, process);
|
||
|
this._pluginRpc.registerForwarder(path, rpc);
|
||
|
process.autoDisposeCallback(() => {
|
||
|
this._pluginRpc.unregisterForwarder(path);
|
||
|
this._viewProcesses.delete(viewId);
|
||
|
});
|
||
|
return [process, viewId];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create an rpc instance and set it up for communicating with a ClientProcess:
|
||
|
* - won't send any message before receiving a ready message
|
||
|
* - has the '*' forwarder set to the plugin's instance rpc
|
||
|
* - has registered an implementation of the gristAPI.
|
||
|
* Returns the rpc instance.
|
||
|
*/
|
||
|
private _createRpc(path: string): Rpc {
|
||
|
const rpc = new Rpc({logger: createRpcLogger(this._baseLogger, `PLUGIN ${this._pluginId}/${path} SafeBrowser:`) });
|
||
|
rpc.queueOutgoingUntilReadyMessage();
|
||
|
warnIfNotReady(rpc, 3000, "Plugin isn't ready; be sure to call grist.ready() from plugin");
|
||
|
rpc.registerForwarder('*', this._pluginRpc);
|
||
|
// TODO: we should be able to stop serving plugins, it looks like there are some resources
|
||
|
// required that should be disposed on component deactivation.
|
||
|
this._clientScope.servePlugin(this._pluginId, rpc);
|
||
|
return rpc;
|
||
|
}
|
||
|
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Base class for any client process. `onDispose` allows to register a callback that will be
|
||
|
* triggered when dispose() is called. This is for internally use.
|
||
|
*/
|
||
|
export class ClientProcess extends Disposable {
|
||
|
public rpc: Rpc;
|
||
|
|
||
|
private _safeBrowser: SafeBrowser;
|
||
|
private _src: string;
|
||
|
private _actionRouter: ActionRouter;
|
||
|
|
||
|
public create(...args: any[]): void;
|
||
|
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
||
|
this.rpc = rpc;
|
||
|
this._safeBrowser = safeBrowser;
|
||
|
this._src = src;
|
||
|
this._actionRouter = new ActionRouter(this.rpc);
|
||
|
const gristAPI: GristAPI = {
|
||
|
subscribe: tbind(this._actionRouter.subscribeTable, this._actionRouter),
|
||
|
unsubscribe: tbind(this._actionRouter.unsubscribeTable, this._actionRouter),
|
||
|
render: tbind(this._safeBrowser.renderImpl, this._safeBrowser),
|
||
|
dispose: tbind(this._safeBrowser.disposeImpl, this._safeBrowser),
|
||
|
};
|
||
|
rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, gristAPI, checkers.GristAPI);
|
||
|
this.autoDisposeCallback(() => {
|
||
|
this.rpc.unregisterImpl(RPC_GRISTAPI_INTERFACE);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
public receiveAction(action: any[]) {
|
||
|
this._actionRouter.process(action)
|
||
|
// tslint:disable:no-console
|
||
|
.catch((err: any) => console.warn("ClientProcess[%s] receiveAction: failed with %s", this._src, err));
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* The web worker client process, used to execute safe browser main script.
|
||
|
*/
|
||
|
class WorkerProcess extends ClientProcess {
|
||
|
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
||
|
super.create(safeBrowser, rpc, src);
|
||
|
// Serve web worker script from same host as current page
|
||
|
const worker = new Worker(getOriginUrl(`/${src}`));
|
||
|
worker.addEventListener("message", (e: MessageEvent) => this.rpc.receiveMessage(e.data));
|
||
|
this.rpc.setSendMessage(worker.postMessage.bind(worker));
|
||
|
this.autoDisposeCallback(() => worker.terminate());
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export class ViewProcess extends ClientProcess {
|
||
|
public element: HTMLElement;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* The Iframe ClientProcess used to render safe browser content in the browser.
|
||
|
*/
|
||
|
class IframeProcess extends ViewProcess {
|
||
|
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
||
|
super.create(safeBrowser, rpc, src);
|
||
|
const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`,
|
||
|
{ src }));
|
||
|
const listener = (event: MessageEvent) => {
|
||
|
if (event.source === iframe.contentWindow) {
|
||
|
this.rpc.receiveMessage(event.data);
|
||
|
}
|
||
|
};
|
||
|
G.window.addEventListener('message', listener);
|
||
|
this.autoDisposeCallback(() => {
|
||
|
G.window.removeEventListener('message', listener);
|
||
|
});
|
||
|
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* The webview ClientProcess to render safe browser process in electron.
|
||
|
*/
|
||
|
class WebviewProcess extends ViewProcess {
|
||
|
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
||
|
super.create(safeBrowser, rpc, src);
|
||
|
const webview: WebviewTag = this.element = this.autoDispose(dom('webview.safe_browser_process.clipboard_focus', {
|
||
|
src,
|
||
|
allowpopups: '',
|
||
|
// Requests with this partition get an extra header (see main.js) to get access to plugin content.
|
||
|
partition: 'plugins',
|
||
|
}));
|
||
|
// Temporaily disable "mousetrap" keyboard stealing for the duration of this webview.
|
||
|
// This is acceptable since webviews are currently full-screen modals.
|
||
|
// TODO: find a way for keyboard events to play nice when webviews are non-modal.
|
||
|
Mousetrap.setPaused(true);
|
||
|
this.autoDisposeCallback(() => Mousetrap.setPaused(false));
|
||
|
webview.addEventListener('ipc-message', (event: IpcMessageEvent) => {
|
||
|
// The event object passed to the listener is missing proper documentation. In the examples
|
||
|
// listed in https://electronjs.org/docs/api/ipc-main the arguments should be passed to the
|
||
|
// listener after the event object, but this is not happening here. Only we know it is a
|
||
|
// DOMEvent with some extra porperties including a `channel` property of type `string` and an
|
||
|
// `args` property of type `any[]`.
|
||
|
if (event.channel === 'grist') {
|
||
|
rpc.receiveMessage(event.args[0]);
|
||
|
}
|
||
|
});
|
||
|
this.rpc.setSendMessage(msg => webview.send('grist', msg));
|
||
|
}
|
||
|
}
|