You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-06-30 23:44:55 +02:00
Desktop: Added support for plugins packaged as JS bundles
This commit is contained in:
@ -2,7 +2,7 @@ import PluginRunner from '../app/services/plugins/PluginRunner';
|
|||||||
import PluginService from 'lib/services/plugins/PluginService';
|
import PluginService from 'lib/services/plugins/PluginService';
|
||||||
|
|
||||||
require('app-module-path').addPath(__dirname);
|
require('app-module-path').addPath(__dirname);
|
||||||
const { asyncTest, setupDatabaseAndSynchronizer, switchClient } = require('test-utils.js');
|
const { asyncTest, setupDatabaseAndSynchronizer, switchClient, expectThrow } = require('test-utils.js');
|
||||||
const Note = require('lib/models/Note');
|
const Note = require('lib/models/Note');
|
||||||
const Folder = require('lib/models/Folder');
|
const Folder = require('lib/models/Folder');
|
||||||
|
|
||||||
@ -40,8 +40,7 @@ describe('services_PluginService', function() {
|
|||||||
|
|
||||||
it('should load and run a simple plugin', asyncTest(async () => {
|
it('should load and run a simple plugin', asyncTest(async () => {
|
||||||
const service = newPluginService();
|
const service = newPluginService();
|
||||||
const plugin = await service.loadPlugin(`${testPluginDir}/simple`);
|
await service.loadAndRunPlugins([`${testPluginDir}/simple`]);
|
||||||
await service.runPlugin(plugin);
|
|
||||||
|
|
||||||
const allFolders = await Folder.all();
|
const allFolders = await Folder.all();
|
||||||
expect(allFolders.length).toBe(1);
|
expect(allFolders.length).toBe(1);
|
||||||
@ -55,9 +54,9 @@ describe('services_PluginService', function() {
|
|||||||
|
|
||||||
it('should load and run a plugin that uses external packages', asyncTest(async () => {
|
it('should load and run a plugin that uses external packages', asyncTest(async () => {
|
||||||
const service = newPluginService();
|
const service = newPluginService();
|
||||||
const plugin = await service.loadPlugin(`${testPluginDir}/withExternalModules`);
|
await service.loadAndRunPlugins([`${testPluginDir}/withExternalModules`]);
|
||||||
|
const plugin = service.pluginById('withexternalmodules');
|
||||||
expect(plugin.id).toBe('withexternalmodules');
|
expect(plugin.id).toBe('withexternalmodules');
|
||||||
await service.runPlugin(plugin);
|
|
||||||
|
|
||||||
const allFolders = await Folder.all();
|
const allFolders = await Folder.all();
|
||||||
expect(allFolders.length).toBe(1);
|
expect(allFolders.length).toBe(1);
|
||||||
@ -78,4 +77,75 @@ describe('services_PluginService', function() {
|
|||||||
expect(allFolders.map((f:any) => f.title).sort().join(', ')).toBe('multi - simple1, multi - simple2');
|
expect(allFolders.map((f:any) => f.title).sort().join(', ')).toBe('multi - simple1, multi - simple2');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should load plugins from JS bundles', asyncTest(async () => {
|
||||||
|
const service = newPluginService();
|
||||||
|
|
||||||
|
const plugin = await service.loadPluginFromString('example', '/tmp', `
|
||||||
|
/* joplin-manifest:
|
||||||
|
{
|
||||||
|
"manifest_version": 1,
|
||||||
|
"name": "JS Bundle test",
|
||||||
|
"description": "JS Bundle Test plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Laurent Cozic",
|
||||||
|
"homepage_url": "https://joplinapp.org"
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
joplin.plugins.register({
|
||||||
|
onStart: async function() {
|
||||||
|
await joplin.data.post(['folders'], null, { title: "my plugin folder" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
|
||||||
|
await service.runPlugin(plugin);
|
||||||
|
|
||||||
|
expect(plugin.manifest.manifest_version).toBe(1);
|
||||||
|
expect(plugin.manifest.name).toBe('JS Bundle test');
|
||||||
|
|
||||||
|
const allFolders = await Folder.all();
|
||||||
|
expect(allFolders.length).toBe(1);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should load plugins from JS bundle files', asyncTest(async () => {
|
||||||
|
const service = newPluginService();
|
||||||
|
await service.loadAndRunPlugins(`${testPluginDir}/jsbundles`);
|
||||||
|
expect(!!service.pluginById('example')).toBe(true);
|
||||||
|
expect((await Folder.all()).length).toBe(1);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should validate JS bundles', asyncTest(async () => {
|
||||||
|
const invalidJsBundles = [
|
||||||
|
`
|
||||||
|
/* joplin-manifest:
|
||||||
|
{
|
||||||
|
"not_a_valid_manifest_at_all": 1
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
joplin.plugins.register({
|
||||||
|
onStart: async function() {},
|
||||||
|
});
|
||||||
|
`, `
|
||||||
|
/* joplin-manifest:
|
||||||
|
*/
|
||||||
|
|
||||||
|
joplin.plugins.register({
|
||||||
|
onStart: async function() {},
|
||||||
|
});
|
||||||
|
`, `
|
||||||
|
joplin.plugins.register({
|
||||||
|
onStart: async function() {},
|
||||||
|
});
|
||||||
|
`, '',
|
||||||
|
];
|
||||||
|
|
||||||
|
const service = newPluginService();
|
||||||
|
|
||||||
|
for (const jsBundle of invalidJsBundles) {
|
||||||
|
await expectThrow(async () => await service.loadPluginFromString('example', '/tmp', jsBundle));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
16
CliClient/tests/support/plugins/jsbundles/example.js
Normal file
16
CliClient/tests/support/plugins/jsbundles/example.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/* joplin-manifest:
|
||||||
|
{
|
||||||
|
"manifest_version": 1,
|
||||||
|
"name": "JS Bundle test",
|
||||||
|
"description": "JS Bundle Test plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Laurent Cozic",
|
||||||
|
"homepage_url": "https://joplinapp.org"
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
joplin.plugins.register({
|
||||||
|
onStart: async function() {
|
||||||
|
await joplin.data.post(['folders'], null, { title: "my plugin folder" });
|
||||||
|
},
|
||||||
|
});
|
@ -4,7 +4,7 @@ import Global from 'lib/services/plugins/api/Global';
|
|||||||
import BasePluginRunner from 'lib/services/plugins/BasePluginRunner';
|
import BasePluginRunner from 'lib/services/plugins/BasePluginRunner';
|
||||||
import BaseService from '../BaseService';
|
import BaseService from '../BaseService';
|
||||||
import shim from 'lib/shim';
|
import shim from 'lib/shim';
|
||||||
const { filename } = require('lib/path-utils');
|
const { filename, dirname } = require('lib/path-utils');
|
||||||
const nodeSlug = require('slug');
|
const nodeSlug = require('slug');
|
||||||
|
|
||||||
interface Plugins {
|
interface Plugins {
|
||||||
@ -49,9 +49,52 @@ export default class PluginService extends BaseService {
|
|||||||
return this.plugins_[id];
|
return this.plugins_[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPlugin(path:string):Promise<Plugin> {
|
private async parsePluginJsBundle(jsBundleString:string) {
|
||||||
|
const scriptText = jsBundleString;
|
||||||
|
const lines = scriptText.split('\n');
|
||||||
|
const manifestText:string[] = [];
|
||||||
|
|
||||||
|
const StateStarted = 1;
|
||||||
|
const StateInManifest = 2;
|
||||||
|
let state:number = StateStarted;
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
line = line.trim();
|
||||||
|
|
||||||
|
if (state !== StateInManifest) {
|
||||||
|
if (line === '/* joplin-manifest:') {
|
||||||
|
state = StateInManifest;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === StateInManifest) {
|
||||||
|
if (line.indexOf('*/') === 0) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
manifestText.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifestText.length) throw new Error('Could not find manifest');
|
||||||
|
|
||||||
|
return {
|
||||||
|
scriptText: scriptText,
|
||||||
|
manifestText: manifestText.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadPluginFromString(pluginId:string, baseDir:string, jsBundleString:string):Promise<Plugin> {
|
||||||
|
const r = await this.parsePluginJsBundle(jsBundleString);
|
||||||
|
return this.loadPlugin(pluginId, baseDir, r.manifestText, r.scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPluginFromPath(path:string):Promise<Plugin> {
|
||||||
const fsDriver = shim.fsDriver();
|
const fsDriver = shim.fsDriver();
|
||||||
|
|
||||||
|
if (path.toLowerCase().endsWith('.js')) return this.loadPluginFromString(filename(path), dirname(path), await fsDriver.readFile(path));
|
||||||
|
|
||||||
let distPath = path;
|
let distPath = path;
|
||||||
if (!(await fsDriver.exists(`${distPath}/manifest.json`))) {
|
if (!(await fsDriver.exists(`${distPath}/manifest.json`))) {
|
||||||
distPath = `${path}/dist`;
|
distPath = `${path}/dist`;
|
||||||
@ -59,19 +102,22 @@ export default class PluginService extends BaseService {
|
|||||||
|
|
||||||
this.logger().info(`PluginService: Loading plugin from ${path}`);
|
this.logger().info(`PluginService: Loading plugin from ${path}`);
|
||||||
|
|
||||||
const manifestPath = `${distPath}/manifest.json`;
|
const scriptText = await fsDriver.readFile(`${distPath}/index.js`);
|
||||||
const indexPath = `${distPath}/index.js`;
|
const manifestText = await fsDriver.readFile(`${distPath}/manifest.json`);
|
||||||
const manifestContent = await fsDriver.readFile(manifestPath);
|
|
||||||
const manifest = manifestFromObject(JSON.parse(manifestContent));
|
|
||||||
const scriptText = await fsDriver.readFile(indexPath);
|
|
||||||
const pluginId = makePluginId(filename(path));
|
const pluginId = makePluginId(filename(path));
|
||||||
|
|
||||||
|
return this.loadPlugin(pluginId, distPath, manifestText, scriptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPlugin(pluginId:string, baseDir:string, manifestText:string, scriptText:string):Promise<Plugin> {
|
||||||
|
const manifest = manifestFromObject(JSON.parse(manifestText));
|
||||||
|
|
||||||
// After transforming the plugin path to an ID, multiple plugins might end up with the same ID. For
|
// After transforming the plugin path to an ID, multiple plugins might end up with the same ID. For
|
||||||
// example "MyPlugin" and "myplugin" would have the same ID. Technically it's possible to have two
|
// example "MyPlugin" and "myplugin" would have the same ID. Technically it's possible to have two
|
||||||
// such folders but to keep things sane we disallow it.
|
// such folders but to keep things sane we disallow it.
|
||||||
if (this.plugins_[pluginId]) throw new Error(`There is already a plugin with this ID: ${pluginId}`);
|
if (this.plugins_[pluginId]) throw new Error(`There is already a plugin with this ID: ${pluginId}`);
|
||||||
|
|
||||||
const plugin = new Plugin(pluginId, distPath, manifest, scriptText, this.logger());
|
const plugin = new Plugin(pluginId, baseDir, manifest, scriptText, this.logger());
|
||||||
|
|
||||||
this.store_.dispatch({
|
this.store_.dispatch({
|
||||||
type: 'PLUGIN_ADD',
|
type: 'PLUGIN_ADD',
|
||||||
@ -84,14 +130,14 @@ export default class PluginService extends BaseService {
|
|||||||
return plugin;
|
return plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAndRunPlugins(pluginDirOrPaths:string | string[]) {
|
public async loadAndRunPlugins(pluginDirOrPaths:string | string[]) {
|
||||||
let pluginPaths = [];
|
let pluginPaths = [];
|
||||||
|
|
||||||
if (Array.isArray(pluginDirOrPaths)) {
|
if (Array.isArray(pluginDirOrPaths)) {
|
||||||
pluginPaths = pluginDirOrPaths;
|
pluginPaths = pluginDirOrPaths;
|
||||||
} else {
|
} else {
|
||||||
pluginPaths = (await shim.fsDriver().readDirStats(pluginDirOrPaths))
|
pluginPaths = (await shim.fsDriver().readDirStats(pluginDirOrPaths))
|
||||||
.filter((stat:any) => stat.isDirectory())
|
.filter((stat:any) => (stat.isDirectory() || stat.path.toLowerCase().endsWith('.js')))
|
||||||
.map((stat:any) => `${pluginDirOrPaths}/${stat.path}`);
|
.map((stat:any) => `${pluginDirOrPaths}/${stat.path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +148,7 @@ export default class PluginService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plugin = await this.loadPlugin(pluginPath);
|
const plugin = await this.loadPluginFromPath(pluginPath);
|
||||||
await this.runPlugin(plugin);
|
await this.runPlugin(plugin);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger().error(`PluginService: Could not load plugin: ${pluginPath}`, error);
|
this.logger().error(`PluginService: Could not load plugin: ${pluginPath}`, error);
|
||||||
@ -110,7 +156,7 @@ export default class PluginService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async runPlugin(plugin:Plugin) {
|
public async runPlugin(plugin:Plugin) {
|
||||||
this.plugins_[plugin.id] = plugin;
|
this.plugins_[plugin.id] = plugin;
|
||||||
const pluginApi = new Global(this.logger(), this.platformImplementation_, plugin, this.store_);
|
const pluginApi = new Global(this.logger(), this.platformImplementation_, plugin, this.store_);
|
||||||
return this.runner_.run(plugin, pluginApi);
|
return this.runner_.run(plugin, pluginApi);
|
||||||
|
Reference in New Issue
Block a user