refactor: plugin manager and execution as ts (#504)

* refactor: plugin manager and execution as ts

* chore: refactoring
This commit is contained in:
Louis 2023-11-01 09:48:28 +07:00 committed by GitHub
parent edeb82ae84
commit 37c36363d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 518 additions and 1725 deletions

View File

@ -1,116 +0,0 @@
import Ep from './ExtensionPoint'
/** @type {Ep} */
let ep
const changeListener = jest.fn()
const objectRsp = { foo: 'bar' }
const funcRsp = arr => {
arr || (arr = [])
arr.push({ foo: 'baz' })
return arr
}
beforeEach(() => {
ep = new Ep('test-ep')
ep.register('test-ext-obj', objectRsp)
ep.register('test-ext-func', funcRsp, 10)
ep.onRegister('test', changeListener)
})
it('should create a new extension point by providing a name', () => {
expect(ep.name).toEqual('test-ep')
})
it('should register extension with extension point', () => {
expect(ep._extensions).toContainEqual({
name: 'test-ext-func',
response: funcRsp,
priority: 10
})
})
it('should register extension with a default priority of 0 if not provided', () => {
expect(ep._extensions).toContainEqual({
name: 'test-ext-obj',
response: objectRsp,
priority: 0
})
})
it('should execute the change listeners on registering a new extension', () => {
changeListener.mockClear()
ep.register('test-change-listener', true)
expect(changeListener.mock.calls.length).toBeTruthy()
})
it('should unregister an extension with the provided name if it exists', () => {
ep.unregister('test-ext-obj')
expect(ep._extensions).not.toContainEqual(
expect.objectContaining({
name: 'test-ext-obj'
})
)
})
it('should not unregister any extensions if the provided name does not exist', () => {
ep.unregister('test-ext-invalid')
expect(ep._extensions.length).toBe(2)
})
it('should execute the change listeners on unregistering an extension', () => {
changeListener.mockClear()
ep.unregister('test-ext-obj')
expect(changeListener.mock.calls.length).toBeTruthy()
})
it('should empty the registry of all extensions on clearing', () => {
ep.clear()
expect(ep._extensions).toEqual([])
})
it('should execute the change listeners on clearing extensions', () => {
changeListener.mockClear()
ep.clear()
expect(changeListener.mock.calls.length).toBeTruthy()
})
it('should return the relevant extension using the get method', () => {
const ext = ep.get('test-ext-obj')
expect(ext).toEqual({ foo: 'bar' })
})
it('should return the false using the get method if the extension does not exist', () => {
const ext = ep.get('test-ext-invalid')
expect(ext).toBeUndefined()
})
it('should provide an array with all responses, including promises where necessary, using the execute method', async () => {
ep.register('test-ext-async', () => new Promise(resolve => setTimeout(resolve, 0, { foo: 'delayed' })))
const arr = ep.execute([])
const res = await Promise.all(arr)
expect(res).toContainEqual({ foo: 'bar' })
expect(res).toContainEqual([{ foo: 'baz' }])
expect(res).toContainEqual({ foo: 'delayed' })
expect(res.length).toBe(3)
})
it('should provide an array including all responses in priority order, using the executeSerial method provided with an array', async () => {
const res = await ep.executeSerial([])
expect(res).toEqual([{ "foo": "bar" }, { "foo": "baz" }])
})
it('should provide an array including the last response using the executeSerial method provided with something other than an array', async () => {
const res = await ep.executeSerial()
expect(res).toEqual([{ "foo": "baz" }])
})

View File

@ -1,22 +0,0 @@
import { setImporter } from "./import-manager"
import Plugin from './Plugin'
describe('triggerExport', () => {
it('should call the provided export on the plugin\'s main file', async () => {
// Set up mock importer with mock main plugin file
const mockExport = jest.fn()
const mockImporter = jest.fn(() => ({
lifeCycleFn: mockExport
}))
setImporter(mockImporter)
// Call triggerExport on new plugin
const plgUrl = 'main'
const plugin = new Plugin('test', plgUrl, ['ap1'], true)
await plugin.triggerExport('lifeCycleFn')
// Check results
expect(mockImporter.mock.lastCall).toEqual([plgUrl])
expect(mockExport.mock.calls.length).toBeTruthy()
})
})

View File

@ -1,307 +0,0 @@
import { setup } from './index'
import { register, trigger, remove, clear, get } from "./activation-manager";
import { add } from './extension-manager'
let mockPlugins = {}
setup({
importer(plugin) { return mockPlugins[plugin] }
})
afterEach(() => {
clear()
mockPlugins = {}
})
describe('register', () => {
it('should add a new activation point to the register when a new, valid plugin is registered',
() => {
register({
name: 'test',
url: 'testPkg',
activationPoints: ['ap1', 'ap2'],
active: true
})
expect(get()).toEqual([
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap1',
activated: false
},
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap2',
activated: false
}
])
}
)
it('should not add an activation point to the register when an existing, valid plugin is registered',
() => {
register({
name: 'test',
url: 'testPkg',
activationPoints: ['ap1', 'ap2'],
active: true
})
register({
name: 'test',
url: 'testPkg',
activationPoints: ['ap2', 'ap3'],
active: true
})
expect(get()).toEqual([
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap1',
activated: false
},
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'test',
url: 'testPkg',
activationPoint: 'ap3',
activated: false
},
])
}
)
it('should throw an error when an invalid plugin is registered',
() => {
const noActivationPoints = () => register({
name: 'test',
url: 'testPkg',
active: true
})
expect(noActivationPoints).toThrow(/does not have any activation points set up in its manifest/)
}
)
})
describe('trigger', () => {
it('should trigger all and only the activations with for the given execution point on triggering an execution, using the defined importer',
async () => {
const triggered = []
mockPlugins.plugin1 = {
ap1() { triggered.push('plugin1-ap1') }
}
mockPlugins.plugin2 = {
ap2() { triggered.push('plugin2-ap2') }
}
mockPlugins.plugin3 = {
ap1() { triggered.push('plugin3-ap1') },
ap2() { triggered.push('plugin3-ap2') }
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1'],
active: true
})
register({
name: 'plugin2',
url: 'plugin2',
activationPoints: ['ap2'],
active: true
})
register({
name: 'plugin3',
url: 'plugin3',
activationPoints: ['ap1', 'ap2'],
active: true
})
await trigger('ap1')
expect(triggered).toEqual(['plugin1-ap1', 'plugin3-ap1'])
}
)
it('should return an error if an activation point is triggered on a plugin that does not include it',
async () => {
mockPlugins.plugin1 = {
wrongAp() { }
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
await expect(() => trigger('ap1')).rejects.toThrow(/was triggered but does not exist on plugin/)
}
)
it('should provide the registered extension points to the triggered activation point if presetEPs is set to true in the setup',
async () => {
setup({
importer(plugin) { return mockPlugins[plugin] },
presetEPs: true,
})
let ap1Res
mockPlugins.plugin1 = {
ap1: eps => ap1Res = eps
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
add('ep1')
add('ep2')
await trigger('ap1')
expect(ap1Res.ep1.constructor.name).toEqual('ExtensionPoint')
expect(ap1Res.ep2.constructor.name).toEqual('ExtensionPoint')
}
)
it('should allow registration, execution and serial execution of execution points when an activation point is triggered if presetEPs is set to false in the setup',
async () => {
setup({
importer(plugin) { return mockPlugins[plugin] },
})
let ap1Res
mockPlugins.plugin1 = {
ap1: eps => ap1Res = eps
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
await trigger('ap1')
expect(typeof ap1Res.register).toBe('function')
expect(typeof ap1Res.execute).toBe('function')
expect(typeof ap1Res.executeSerial).toBe('function')
}
)
it('should not provide any reference to extension points during activation point triggering if presetEPs is set to null in the setup',
async () => {
setup({
importer(plugin) { return mockPlugins[plugin] },
presetEPs: null,
})
let ap1Res = true
mockPlugins.plugin1 = {
ap1: eps => ap1Res = eps
}
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1']
})
await trigger('ap1')
expect(ap1Res).not.toBeDefined()
}
)
})
describe('remove and clear', () => {
beforeEach(() => {
register({
name: 'plugin1',
url: 'plugin1',
activationPoints: ['ap1', 'ap2'],
active: true
})
register({
name: 'plugin2',
url: 'plugin2',
activationPoints: ['ap2', 'ap3'],
active: true
})
})
it('should remove all and only the activations for the given plugin from the register when removing activations',
() => {
remove('plugin1')
expect(get()).toEqual([
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap3',
activated: false
},
])
}
)
it('should not remove any activations from the register if no plugin name is provided',
() => {
remove()
expect(get()).toEqual([
{
plugin: 'plugin1',
url: 'plugin1',
activationPoint: 'ap1',
activated: false
},
{
plugin: 'plugin1',
url: 'plugin1',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap2',
activated: false
},
{
plugin: 'plugin2',
url: 'plugin2',
activationPoint: 'ap3',
activated: false
},
])
}
)
it('should remove all activations from the register when clearing the register',
() => {
clear()
expect(get()).toEqual([])
}
)
})

View File

@ -1,116 +0,0 @@
import { add, remove, register, get, execute, executeSerial, unregisterAll } from './extension-manager'
import ExtensionPoint from './ExtensionPoint'
beforeEach(() => {
add('ep1')
add('ep2')
})
afterEach(() => {
remove('ep1')
remove('ep2')
remove('ep3')
})
describe('get', () => {
it('should return the extension point with the given name if it exists', () => {
expect(get('ep1')).toBeInstanceOf(ExtensionPoint)
})
it('should return all extension points if no name is provided', () => {
expect(get()).toEqual(expect.objectContaining({ ep1: expect.any(ExtensionPoint) }))
expect(get()).toEqual(expect.objectContaining({ ep2: expect.any(ExtensionPoint) }))
})
})
describe('Add and remove', () => {
it('should add a new extension point with the given name using the add function', () => {
add('ep1')
expect(get('ep1')).toBeInstanceOf(ExtensionPoint)
})
it('should remove only the extension point with the given name using the remove function', () => {
remove('ep1')
expect(get()).not.toEqual(expect.objectContaining({ ep1: expect.anything() }))
expect(get()).toEqual(expect.objectContaining({ ep2: expect.any(ExtensionPoint) }))
})
it('should not remove any extension points if no name is provided using the remove function', () => {
remove()
expect(get()).toEqual(expect.objectContaining({ ep1: expect.any(ExtensionPoint) }))
expect(get()).toEqual(expect.objectContaining({ ep2: expect.any(ExtensionPoint) }))
})
})
describe('register', () => {
it('should register an extension to an existing extension point if the point has already been created', () => {
register('ep1', 'extension1', { foo: 'bar' })
expect(get('ep1')._extensions).toContainEqual(expect.objectContaining({ name: 'extension1' }))
})
it('should create an extension point and register an extension to it if the point has not yet been created', () => {
register('ep3', 'extension1', { foo: 'bar' })
expect(get('ep3')._extensions).toContainEqual(expect.objectContaining({ name: 'extension1' }))
})
})
describe('unregisterAll', () => {
it('should unregister all extension points matching the give name regex', () => {
// Register example extensions
register('ep1', 'remove1', { foo: 'bar' })
register('ep2', 'remove2', { foo: 'bar' })
register('ep1', 'keep', { foo: 'bar' })
// Remove matching extensions
unregisterAll(/remove/)
// Extract all registered extensions
const eps = Object.values(get()).map(ep => ep._extensions)
const extensions = eps.flat()
// Test extracted extensions
expect(extensions).toContainEqual(expect.objectContaining({ name: 'keep' }))
expect(extensions).not.toContainEqual(expect.objectContaining({ name: 'ep1' }))
expect(extensions).not.toContainEqual(expect.objectContaining({ name: 'ep2' }))
})
})
describe('execute', () => {
it('should execute the extensions registered to the named extension point with the provided input', () => {
const result = []
register('ep1', 'extension1', input => result.push(input + 'bar'))
register('ep1', 'extension2', input => result.push(input + 'baz'))
execute('ep1', 'foo')
expect(result).toEqual(['foobar', 'foobaz'])
})
it('should throw an error if the named extension point does not exist', () => {
register('ep1', 'extension1', { foo: 'bar' })
expect(() => execute('ep3')).toThrow(/not a valid extension point/)
})
})
describe('executeSerial', () => {
it('should execute the extensions in serial registered to the named extension point with the provided input', async () => {
register('ep1', 'extension1', input => input + 'bar')
register('ep1', 'extension2', input => input + 'baz')
const result = await executeSerial('ep1', 'foo')
expect(result).toEqual('foobarbaz')
})
it('should throw an error if the named extension point does not exist', () => {
register('ep1', 'extension1', { foo: 'bar' })
expect(() => executeSerial('ep3')).toThrow(/not a valid extension point/)
})
})

View File

@ -1,28 +0,0 @@
import { setup } from "."
import { importer, presetEPs } from "./import-manager"
describe('setup', () => {
const mockImporter = jest.fn()
it('should store the importer function', () => {
setup({ importer: mockImporter })
expect(importer).toBe(mockImporter)
})
it('should set presetEPS to false if not provided', () => {
expect(presetEPs).toBe(false)
})
it('should set presetEPS to the provided value if it is true', () => {
setup({ presetEPs: true })
expect(presetEPs).toBe(true)
})
it('should set presetEPS to the provided value if it is null', () => {
setup({ presetEPs: null })
expect(presetEPs).toBe(null)
})
})

View File

@ -1,196 +0,0 @@
jest.mock('electron', () => {
const handlers = {}
return {
ipcMain: {
handle(channel, callback) {
handlers[channel] = callback
}
},
ipcRenderer: {
invoke(channel, ...args) {
return Promise.resolve(handlers[channel].call(undefined, 'event', ...args))
}
},
webContents: {
getAllWebContents: jest.fn(() => [])
},
contextBridge: {
exposeInMainWorld(key, val) {
global.window = { [key]: val }
}
}
}
})
jest.mock('../pluginMgr/store', () => {
const setActive = jest.fn(() => true)
const uninstall = jest.fn()
const update = jest.fn(() => true)
const isUpdateAvailable = jest.fn(() => false)
class Plugin {
constructor(name) {
this.name = name
this.activationPoints = ['test']
}
setActive = setActive
uninstall = uninstall
update = update
isUpdateAvailable = isUpdateAvailable
}
return {
getPlugin: jest.fn(name => new Plugin(name)),
getActivePlugins: jest.fn(() => [new Plugin('test')]),
installPlugins: jest.fn(async plugins => plugins.map(name => new Plugin(name))),
removePlugin: jest.fn()
}
})
const { rmSync } = require('fs')
const { webContents } = require('electron')
const useFacade = require('./index')
const { getActive, install, toggleActive, uninstall, update, updatesAvailable, registerActive } = require('../execution/facade')
const { setPluginsPath, setConfirmInstall } = require('../pluginMgr/globals')
const router = require('../pluginMgr/router')
const { getPlugin, getActivePlugins, removePlugin } = require('../pluginMgr/store')
const { get: getActivations } = require('../execution/activation-manager')
const pluginsPath = './testPlugins'
const confirmInstall = jest.fn(() => true)
beforeAll(async () => {
setPluginsPath(pluginsPath)
router()
useFacade()
})
afterAll(() => {
rmSync(pluginsPath, { recursive: true })
})
describe('install', () => {
it('should return cancelled state if the confirmPlugin callback returns falsy', async () => {
setConfirmInstall(() => false)
const plugins = await install(['test-install'])
expect(plugins).toEqual(false)
})
it('should perform a security check of the install using confirmInstall if facade is used', async () => {
setConfirmInstall(confirmInstall)
await install(['test-install'])
expect(confirmInstall.mock.calls.length).toBeTruthy()
})
it('should register all installed plugins', async () => {
const pluginName = 'test-install'
await install([pluginName])
expect(getActivations()).toContainEqual(expect.objectContaining({
plugin: pluginName
}))
})
it('should return a list of plugins', async () => {
setConfirmInstall(confirmInstall)
const pluginName = 'test-install'
const plugins = await install([pluginName])
expect(plugins).toEqual([expect.objectContaining({ name: pluginName })])
})
})
describe('uninstall', () => {
it('should uninstall all plugins with the provided name, remove it from the store and refresh all renderers', async () => {
// Reset mock functions
const mockUninstall = getPlugin().uninstall
mockUninstall.mockClear()
removePlugin.mockClear()
webContents.getAllWebContents.mockClear()
getPlugin.mockClear()
// Uninstall plugins
const specs = ['test-uninstall-1', 'test-uninstall-2']
await uninstall(specs)
// Test result
expect(getPlugin.mock.calls).toEqual(specs.map(spec => [spec]))
expect(mockUninstall.mock.calls.length).toBeTruthy()
expect(removePlugin.mock.calls.length).toBeTruthy()
expect(webContents.getAllWebContents.mock.calls.length).toBeTruthy()
})
})
describe('getActive', () => {
it('should return all active plugins', async () => {
getActivePlugins.mockClear()
await getActive()
expect(getActivePlugins.mock.calls.length).toBeTruthy()
})
})
describe('registerActive', () => {
it('should register all active plugins', async () => {
await registerActive()
expect(getActivations()).toContainEqual(expect.objectContaining({
plugin: 'test'
}))
})
})
describe('update', () => {
const specs = ['test-uninstall-1', 'test-uninstall-2']
const mockUpdate = getPlugin().update
beforeAll(async () => {
// Reset mock functions
mockUpdate.mockClear()
webContents.getAllWebContents.mockClear()
getPlugin.mockClear()
// Update plugins
await update(specs)
})
it('should call the update function on all provided plugins', async () => {
// Check result
expect(getPlugin.mock.calls).toEqual(specs.map(spec => [spec]))
expect(mockUpdate.mock.calls.length).toBe(2)
})
it('should reload the renderers if reload is true', () => {
expect(webContents.getAllWebContents.mock.calls.length).toBeTruthy()
})
it('should not reload the renderer if reload is false', async () => {
webContents.getAllWebContents.mockClear()
await update(['test-uninstall'], false)
expect(webContents.getAllWebContents.mock.calls.length).toBeFalsy()
})
})
describe('toggleActive', () => {
it('call the setActive function on the plugin with the provided name, with the provided active state', async () => {
await toggleActive('test-toggleActive', true)
expect(getPlugin.mock.lastCall).toEqual(['test-toggleActive'])
const mockSetActive = getPlugin().setActive
expect(mockSetActive.mock.lastCall).toEqual([true])
})
})
describe('updatesAvailable', () => {
it('should return the new versions for the provided plugins if provided', async () => {
// Reset mock functions
const mockIsUpdAvailable = getPlugin().isUpdateAvailable
mockIsUpdAvailable.mockClear()
getPlugin.mockClear()
// Get available updates
const testPlugin1 = 'test-plugin-1'
const testPlugin2 = 'test-update-2'
const updates = await updatesAvailable([testPlugin1, testPlugin2])
expect(updates).toEqual({
[testPlugin1]: false,
[testPlugin2]: false,
})
})
})

View File

@ -1,212 +0,0 @@
import { init } from "."
import { join } from 'path'
import Plugin from "./Plugin"
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "fs"
const pluginsDir = './testPlugins'
const testPluginDir = './testPluginSrc'
const testPluginName = 'test-plugin'
const manifest = join(testPluginDir, 'package.json')
const main = 'index'
/** @type Plugin */
let plugin
beforeAll(() => {
init({
confirmInstall: () => true,
pluginsPath: pluginsDir,
})
mkdirSync(testPluginDir)
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
main,
}), 'utf8')
plugin = new Plugin(testPluginDir)
})
afterAll(() => {
rmSync(pluginsDir, { recursive: true })
rmSync(testPluginDir, { recursive: true })
})
describe('subscribe', () => {
let res = false
it('should register the provided callback', () => {
plugin.subscribe('test', () => res = true)
plugin.setActive(true)
expect(res).toBeTruthy()
})
})
describe('unsubscribe', () => {
it(`should remove the provided callback from the register
after which it should not be executed anymore when the plugin is updated`, () => {
let res = false
plugin.subscribe('test', () => res = true)
plugin.unsubscribe('test')
plugin.setActive(true)
expect(res).toBeFalsy()
})
})
describe('install', () => {
beforeAll(async () => {
await plugin._install()
})
it('should store all the relevant manifest values on the plugin', async () => {
expect(plugin).toMatchObject({
origin: testPluginDir,
installOptions: {
version: false,
fullMetadata: false,
},
name: testPluginName,
url: `plugin://${testPluginName}/${main}`,
activationPoints: []
})
})
it('should create a folder for the plugin if it does not yet exist and copy the plugin files to it', () => {
expect(existsSync(join(pluginsDir, testPluginName))).toBeTruthy()
})
it('should replace the existing plugin files in the plugin folder if it already exist', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
main: 'updated',
}), 'utf8')
await plugin._install()
const savedPkg = JSON.parse(readFileSync(join(pluginsDir, testPluginName, 'package.json')))
expect(savedPkg.main).toBe('updated')
})
it('should throw an error and the plugin should be set to inactive if no manifest could be found', async () => {
rmSync(join(testPluginDir, 'package.json'))
await expect(() => plugin._install()).rejects.toThrow(/does not contain a valid manifest/)
})
it('should throw an error and the plugin should be set to inactive if plugin does not contain any activation points', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
main,
}), 'utf8')
await expect(() => plugin._install()).rejects.toThrow('The plugin does not contain any activation points')
expect(plugin.active).toBe(false)
})
})
describe('update', () => {
let updatedPlugin
let subscription = false
let beforeUpd
beforeAll(async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
version: '0.0.1',
main,
}), 'utf8')
await plugin._install()
plugin.subscribe('test', () => subscription = true)
beforeUpd = Object.assign({}, plugin)
await plugin.update()
})
it('should not do anything if no version update is available', () => {
expect(beforeUpd).toMatchObject(plugin)
})
it('should update the plugin files to the latest version if there is a new version available for the plugin', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
version: '0.0.2',
main,
}), 'utf8')
await plugin.update()
expect(plugin).toMatchObject({
origin: testPluginDir,
installOptions: {
version: false,
fullMetadata: false,
},
name: testPluginName,
version: '0.0.2',
url: `plugin://${testPluginName}/${main}`,
activationPoints: []
})
})
it('should execute callbacks subscribed to this plugin, providing the plugin as a parameter', () => {
expect(subscription).toBeTruthy()
})
})
describe('isUpdateAvailable', () => {
it('should return false if no new version is available', async () => {
await expect(plugin.isUpdateAvailable()).resolves.toBe(false)
})
it('should return the latest version number if a new version is available', async () => {
writeFileSync(manifest, JSON.stringify({
name: testPluginName,
activationPoints: [],
version: '0.0.3',
main,
}), 'utf8')
await expect(plugin.isUpdateAvailable()).resolves.toBe('0.0.3')
})
})
describe('setActive', () => {
it('should set the plugin to be active', () => {
plugin.setActive(true)
expect(plugin.active).toBeTruthy()
})
it('should execute callbacks subscribed to this plugin, providing the plugin as a parameter', () => {
let res = false
plugin.subscribe('test', () => res = true)
plugin.setActive(true)
expect(res).toBeTruthy()
})
})
describe('uninstall', () => {
let subscription = false
beforeAll(async () => {
plugin.subscribe('test', () => subscription = true)
await plugin.uninstall()
})
it('should remove the installed plugin from the plugins folder', () => {
expect(existsSync(join(pluginsDir, testPluginName))).toBe(false)
})
it('should execute callbacks subscribed to this plugin, providing the plugin as a parameter', () => {
expect(subscription).toBeTruthy()
})
})

View File

@ -1,57 +0,0 @@
import { existsSync, mkdirSync, writeFileSync } from "fs"
import { join, resolve } from "path"
export let pluginsPath = null
/**
* @private
* Set path to plugins directory and create the directory if it does not exist.
* @param {string} plgPath path to plugins directory
*/
export function setPluginsPath(plgPath) {
// Create folder if it does not exist
let plgDir
try {
plgDir = resolve(plgPath)
if (plgDir.length < 2) throw new Error()
if (!existsSync(plgDir)) mkdirSync(plgDir)
const pluginsJson = join(plgDir, 'plugins.json')
if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, '{}', 'utf8')
pluginsPath = plgDir
} catch (error) {
throw new Error('Invalid path provided to the plugins folder')
}
}
/**
* @private
* Get the path to the plugins.json file.
* @returns location of plugins.json
*/
export function getPluginsFile() { return join(pluginsPath, 'plugins.json') }
export let confirmInstall = function () {
return new Error(
'The facade.confirmInstall callback needs to be set in when initializing Pluggable Electron in the main process.'
)
}
/**
* @private
* Set callback to use as confirmInstall.
* @param {confirmInstall} cb Callback
*/
export function setConfirmInstall(cb) { confirmInstall = cb }
/**
* This function is executed when plugins are installed to verify that the user indeed wants to install the plugin.
* @callback confirmInstall
* @param {Array.<string>} plg The specifiers used to locate the packages (from NPM or local file)
* @returns {Promise<boolean>} Whether to proceed with the plugin installation
*/

View File

@ -1,150 +0,0 @@
import { usePlugins, getStore, init } from './index'
import { installPlugins, getPlugin, getAllPlugins, getActivePlugins, addPlugin, removePlugin } from './store'
import Plugin from './Plugin'
import { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { protocol } from 'electron'
// Set up variables for test folders and test plugins.
const pluginDir = './testPlugins'
const registeredPluginName = 'registered-plugin'
const demoPlugin = {
origin: ".\\demo-plugin\\demo-plugin-1.5.0.tgz",
installOptions: {
version: false,
fullMetadata: false
},
name: "demoPlugin",
version: "1.5.0",
activationPoints: [
"init"
],
main: "index.js",
_active: true,
url: "plugin://demo-plugin/index.js"
}
describe('before setting a plugin path', () => {
describe('getStore', () => {
it('should throw an error if called without a plugin path set', () => {
expect(() => getStore()).toThrowError('The plugin path has not yet been set up. Please run usePlugins before accessing the store')
})
})
describe('usePlugins', () => {
it('should throw an error if called without a plugin path whilst no plugin path is set', () => {
expect(() => usePlugins()).toThrowError('A path to the plugins folder is required to use Pluggable Electron')
})
it('should throw an error if called with an invalid plugin path', () => {
expect(() => usePlugins('http://notsupported')).toThrowError('Invalid path provided to the plugins folder')
})
it('should create the plugin path if it does not yet exist', () => {
// Execute usePlugins with a folder that does not exist
const newPluginDir = './test-new-plugins'
usePlugins(newPluginDir)
expect(existsSync(newPluginDir)).toBe(true)
// Remove created folder to clean up
rmSync(newPluginDir, { recursive: true })
})
})
})
describe('after setting a plugin path', () => {
let pm
beforeAll(() => {
// Create folders to contain plugins
mkdirSync(pluginDir)
// Create initial
writeFileSync(join(pluginDir, 'plugins.json'), JSON.stringify({ demoPlugin }), 'utf8')
// Register a plugin before using plugins
const registeredPLugin = new Plugin(registeredPluginName)
registeredPLugin.name = registeredPluginName
addPlugin(registeredPLugin, false)
// Load plugins
pm = usePlugins(pluginDir)
})
afterAll(() => {
rmSync(pluginDir, { recursive: true })
})
describe('getStore', () => {
it('should return the plugin lifecycle functions if no plugin path is provided', () => {
expect(getStore()).toEqual({
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
})
})
})
describe('usePlugins', () => {
it('should return the plugin lifecycle functions if a plugin path is provided', () => {
expect(pm).toEqual({
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
})
})
it('should load the plugins defined in plugins.json in the provided plugins folder if a plugin path is provided', () => {
expect(getPlugin('demoPlugin')).toEqual(demoPlugin)
})
it('should unregister any registered plugins before registering the new ones if a plugin path is provided', () => {
expect(() => getPlugin(registeredPluginName)).toThrowError(`Plugin ${registeredPluginName} does not exist`)
})
})
})
describe('init', () => {
// Enabling the facade and registering the confirm install function is tested with the router.
let pm
beforeAll(() => {
// Create test plugins folder
mkdirSync(pluginDir)
// Initialize Pluggable Electron without a plugin folder
pm = init({ confirmInstall: () => true })
})
afterAll(() => {
// Remove test plugins folder
rmSync(pluginDir, { recursive: true })
})
it('should make the plugin files available through the plugin protocol', async () => {
expect(protocol.isProtocolRegistered('plugin')).toBeTruthy()
})
it('should return an empty object if no plugin path is provided', () => {
expect(pm).toEqual({})
})
it('should return the plugin lifecycle functions if a plugin path is provided', () => {
pm = init({
confirmInstall: () => true,
pluginsPath: pluginDir,
})
expect(pm).toEqual({
installPlugins,
getPlugin,
getAllPlugins,
getActivePlugins,
removePlugin,
})
})
})

View File

@ -1,91 +0,0 @@
import { ipcMain, webContents } from "electron"
import { getPlugin, getActivePlugins, installPlugins, removePlugin, getAllPlugins } from "./store"
import { pluginsPath, confirmInstall } from './globals'
// Throw an error if pluginsPath has not yet been provided by usePlugins.
const checkPluginsPath = () => {
if (!pluginsPath) throw Error('Path to plugins folder has not yet been set up.')
}
let active = false
/**
* Provide the renderer process access to the plugins.
**/
export default function () {
if (active) return
// Register IPC route to install a plugin
ipcMain.handle('pluggable:install', async (e, plugins) => {
checkPluginsPath()
// Validate install request from backend for security.
const specs = plugins.map(plg => typeof plg === 'object' ? plg.specifier : plg)
const conf = await confirmInstall(specs)
if (!conf) return { cancelled: true }
// Install and activate all provided plugins
const installed = await installPlugins(plugins)
return JSON.parse(JSON.stringify(installed))
})
// Register IPC route to uninstall a plugin
ipcMain.handle('pluggable:uninstall', async (e, plugins, reload) => {
checkPluginsPath()
// Uninstall all provided plugins
for (const plg of plugins) {
const plugin = getPlugin(plg)
await plugin.uninstall()
removePlugin(plugin.name)
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach(wc => wc.reload())
return true
})
// Register IPC route to update a plugin
ipcMain.handle('pluggable:update', (e, plugins, reload) => {
checkPluginsPath()
// Update all provided plugins
let updated = []
for (const plg of plugins) {
const plugin = getPlugin(plg)
const res = plugin.update()
if (res) updated.push(plugin)
}
// Reload all renderer pages if needed
if (updated.length && reload) webContents.getAllWebContents().forEach(wc => wc.reload())
return JSON.parse(JSON.stringify(updated))
})
// Register IPC route to check if updates are available for a plugin
ipcMain.handle('pluggable:updatesAvailable', (e, names) => {
checkPluginsPath()
const plugins = names ? names.map(name => getPlugin(name)) : getAllPlugins()
const updates = {}
for (const plugin of plugins) {
updates[plugin.name] = plugin.isUpdateAvailable()
}
return updates
})
// Register IPC route to get the list of active plugins
ipcMain.handle('pluggable:getActivePlugins', () => {
checkPluginsPath()
return JSON.parse(JSON.stringify(getActivePlugins()))
})
// Register IPC route to toggle the active state of a plugin
ipcMain.handle('pluggable:togglePluginActive', (e, plg, active) => {
checkPluginsPath()
const plugin = getPlugin(plg)
return JSON.parse(JSON.stringify(plugin.setActive(active)))
})
active = true
}

View File

@ -1,108 +0,0 @@
import { getActivePlugins, getAllPlugins, getPlugin, installPlugins } from './store'
import { init } from "."
import { join } from 'path'
import Plugin from "./Plugin"
import { mkdirSync, writeFileSync, rmSync } from "fs"
// Temporary directory to install plugins to
const pluginsDir = './testPlugins'
// Temporary directory containing the active plugin to install
const activePluginDir = './activePluginSrc'
const activePluginName = 'active-plugin'
const activeManifest = join(activePluginDir, 'package.json')
// Temporary directory containing the inactive plugin to install
const inactivePluginDir = './inactivePluginSrc'
const inactivePluginName = 'inactive-plugin'
const inactiveManifest = join(inactivePluginDir, 'package.json')
// Mock name for the entry file in the plugins
const main = 'index'
/** @type Array.<Plugin> */
let activePlugins
/** @type Array.<Plugin> */
let inactivePlugins
beforeAll(async () => {
// Initialize pluggable Electron
init({
confirmInstall: () => true,
pluginsPath: pluginsDir,
})
// Create active plugin
mkdirSync(activePluginDir)
writeFileSync(activeManifest, JSON.stringify({
name: activePluginName,
activationPoints: [],
main,
}), 'utf8')
// Create active plugin
mkdirSync(inactivePluginDir)
writeFileSync(inactiveManifest, JSON.stringify({
name: inactivePluginName,
activationPoints: [],
main,
}), 'utf8')
// Install plugins
activePlugins = await installPlugins([activePluginDir], true)
activePlugins[0].setActive(true)
inactivePlugins = await installPlugins([{
specifier: inactivePluginDir,
activate: false
}], true)
})
afterAll(() => {
// Remove all test files and folders
rmSync(pluginsDir, { recursive: true })
rmSync(activePluginDir, { recursive: true })
rmSync(inactivePluginDir, { recursive: true })
})
describe('installPlugins', () => {
it('should create a new plugin found at the given location and return it if store is false', async () => {
const res = await installPlugins([activePluginDir], false)
expect(res[0]).toBeInstanceOf(Plugin)
})
it('should create a new plugin found at the given location and register it if store is true', () => {
expect(activePlugins[0]).toBeInstanceOf(Plugin)
expect(getPlugin(activePluginName)).toBe(activePlugins[0])
})
it('should activate the installed plugin by default', () => {
expect(getPlugin(activePluginName).active).toBe(true)
})
it('should set plugin to inactive if activate is set to false in the install options', async () => {
expect(inactivePlugins[0].active).toBe(false)
})
})
describe('getPlugin', () => {
it('should return the plugin with the given name if it is registered', () => {
expect(getPlugin(activePluginName)).toBeInstanceOf(Plugin)
})
it('should return an error if the plugin with the given name is not registered', () => {
expect(() => getPlugin('wrongName')).toThrowError('Plugin wrongName does not exist')
})
})
describe('getAllPlugins', () => {
it('should return a list of all registered plugins', () => {
expect(getAllPlugins()).toEqual([activePlugins[0], inactivePlugins[0]])
})
})
describe('getActivePlugins', () => {
it('should return a list of all and only the registered plugins that are active', () => {
expect(getActivePlugins()).toEqual(activePlugins)
})
})

View File

@ -1,23 +1,23 @@
const { ipcRenderer, contextBridge } = require("electron");
function useFacade() {
export function useFacade() {
const interfaces = {
install(plugins) {
install(plugins: any[]) {
return ipcRenderer.invoke("pluggable:install", plugins);
},
uninstall(plugins, reload) {
uninstall(plugins: any[], reload: boolean) {
return ipcRenderer.invoke("pluggable:uninstall", plugins, reload);
},
getActive() {
return ipcRenderer.invoke("pluggable:getActivePlugins");
},
update(plugins, reload) {
update(plugins: any[], reload: boolean) {
return ipcRenderer.invoke("pluggable:update", plugins, reload);
},
updatesAvailable(plugin) {
updatesAvailable(plugin: any) {
return ipcRenderer.invoke("pluggable:updatesAvailable", plugin);
},
toggleActive(plugin, active) {
toggleActive(plugin: any, active: boolean) {
return ipcRenderer.invoke("pluggable:togglePluginActive", plugin, active);
},
};
@ -28,5 +28,3 @@ function useFacade() {
return interfaces;
}
module.exports = useFacade;

View File

@ -0,0 +1,36 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join, resolve } from "path";
export let pluginsPath: string | undefined = undefined;
/**
* @private
* Set path to plugins directory and create the directory if it does not exist.
* @param {string} plgPath path to plugins directory
*/
export function setPluginsPath(plgPath: string) {
// Create folder if it does not exist
let plgDir;
try {
plgDir = resolve(plgPath);
if (plgDir.length < 2) throw new Error();
if (!existsSync(plgDir)) mkdirSync(plgDir);
const pluginsJson = join(plgDir, "plugins.json");
if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, "{}", "utf8");
pluginsPath = plgDir;
} catch (error) {
throw new Error("Invalid path provided to the plugins folder");
}
}
/**
* @private
* Get the path to the plugins.json file.
* @returns location of plugins.json
*/
export function getPluginsFile() {
return join(pluginsPath ?? "", "plugins.json");
}

View File

@ -1,40 +1,52 @@
import { readFileSync } from "fs"
import { protocol } from 'electron'
import { normalize } from "path"
import { readFileSync } from "fs";
import { protocol } from "electron";
import { normalize } from "path";
import Plugin from "./Plugin"
import { getAllPlugins, removePlugin, persistPlugins, installPlugins, getPlugin, getActivePlugins, addPlugin } from "./store"
import { pluginsPath as storedPluginsPath, setPluginsPath, getPluginsFile, setConfirmInstall } from './globals'
import router from "./router"
import Plugin from "./plugin";
import {
getAllPlugins,
removePlugin,
persistPlugins,
installPlugins,
getPlugin,
getActivePlugins,
addPlugin,
} from "./store";
import {
pluginsPath as storedPluginsPath,
setPluginsPath,
getPluginsFile,
} from "./globals";
import router from "./router";
/**
* Sets up the required communication between the main and renderer processes.
* Additionally sets the plugins up using {@link usePlugins} if a pluginsPath is provided.
* @param {Object} options configuration for setting up the renderer facade.
* @param {confirmInstall} [options.confirmInstall] Function to validate that a plugin should be installed.
* @param {confirmInstall} [options.confirmInstall] Function to validate that a plugin should be installed.
* @param {Boolean} [options.useFacade=true] Whether to make a facade to the plugins available in the renderer.
* @param {string} [options.pluginsPath] Optional path to the plugins folder.
* @returns {pluginManager|Object} A set of functions used to manage the plugin lifecycle if usePlugins is provided.
* @function
*/
export function init(options) {
if (!Object.prototype.hasOwnProperty.call(options, 'useFacade') || options.useFacade) {
// Store the confirmInstall function
setConfirmInstall(options.confirmInstall)
export function init(options: any) {
if (
!Object.prototype.hasOwnProperty.call(options, "useFacade") ||
options.useFacade
) {
// Enable IPC to be used by the facade
router()
router();
}
// Create plugins protocol to serve plugins to renderer
registerPluginProtocol()
registerPluginProtocol();
// perform full setup if pluginsPath is provided
if (options.pluginsPath) {
return usePlugins(options.pluginsPath)
return usePlugins(options.pluginsPath);
}
return {}
return {};
}
/**
@ -43,11 +55,11 @@ export function init(options) {
* @returns {boolean} Whether the protocol registration was successful
*/
function registerPluginProtocol() {
return protocol.registerFileProtocol('plugin', (request, callback) => {
const entry = request.url.substr(8)
const url = normalize(storedPluginsPath + entry)
callback({ path: url })
})
return protocol.registerFileProtocol("plugin", (request, callback) => {
const entry = request.url.substr(8);
const url = normalize(storedPluginsPath + entry);
callback({ path: url });
});
}
/**
@ -56,34 +68,38 @@ function registerPluginProtocol() {
* @param {string} pluginsPath Path to the plugins folder. Required if not yet set up.
* @returns {pluginManager} A set of functions used to manage the plugin lifecycle.
*/
export function usePlugins(pluginsPath) {
if (!pluginsPath) throw Error('A path to the plugins folder is required to use Pluggable Electron')
export function usePlugins(pluginsPath: string) {
if (!pluginsPath)
throw Error(
"A path to the plugins folder is required to use Pluggable Electron"
);
// Store the path to the plugins folder
setPluginsPath(pluginsPath)
setPluginsPath(pluginsPath);
// Remove any registered plugins
for (const plugin of getAllPlugins()) {
removePlugin(plugin.name, false)
if (plugin.name) removePlugin(plugin.name, false);
}
// Read plugin list from plugins folder
const plugins = JSON.parse(readFileSync(getPluginsFile()))
const plugins = JSON.parse(readFileSync(getPluginsFile(), "utf-8"));
try {
// Create and store a Plugin instance for each plugin in list
for (const p in plugins) {
loadPlugin(plugins[p])
loadPlugin(plugins[p]);
}
persistPlugins()
persistPlugins();
} catch (error) {
// Throw meaningful error if plugin loading fails
throw new Error('Could not successfully rebuild list of installed plugins.\n'
+ error
+ '\nPlease check the plugins.json file in the plugins folder.')
throw new Error(
"Could not successfully rebuild list of installed plugins.\n" +
error +
"\nPlease check the plugins.json file in the plugins folder."
);
}
// Return the plugin lifecycle functions
return getStore()
return getStore();
}
/**
@ -92,16 +108,24 @@ export function usePlugins(pluginsPath) {
* @private
* @param {Object} plg Plugin info
*/
function loadPlugin(plg) {
function loadPlugin(plg: any) {
// Create new plugin, populate it with plg details and save it to the store
const plugin = new Plugin()
const plugin = new Plugin();
for (const key in plg) {
plugin[key] = plg[key]
if (Object.prototype.hasOwnProperty.call(plg, key)) {
// Use Object.defineProperty to set the properties as writable
Object.defineProperty(plugin, key, {
value: plg[key],
writable: true,
enumerable: true,
configurable: true,
});
}
}
addPlugin(plugin, false)
plugin.subscribe('pe-persist', persistPlugins)
addPlugin(plugin, false);
plugin.subscribe("pe-persist", persistPlugins);
}
/**
@ -110,7 +134,9 @@ function loadPlugin(plg) {
*/
export function getStore() {
if (!storedPluginsPath) {
throw new Error('The plugin path has not yet been set up. Please run usePlugins before accessing the store')
throw new Error(
"The plugin path has not yet been set up. Please run usePlugins before accessing the store"
);
}
return {
@ -119,5 +145,5 @@ export function getStore() {
getAllPlugins,
getActivePlugins,
removePlugin,
}
};
}

View File

@ -1,11 +1,11 @@
import { rmdir } from "fs/promises"
import { resolve, join } from "path"
import { manifest, extract } from "pacote"
import Arborist from '@npmcli/arborist'
import { rmdir } from "fs/promises";
import { resolve, join } from "path";
import { manifest, extract } from "pacote";
import * as Arborist from "@npmcli/arborist";
import { pluginsPath } from "./globals"
import { pluginsPath } from "./globals";
/**
/**
* An NPM package that can be used as a Pluggable Electron plugin.
* Used to hold all the information and functions necessary to handle the plugin lifecycle.
*/
@ -21,30 +21,39 @@ class Plugin {
* @property {string} description The description of plugin as defined in the manifest.
* @property {string} icon The icon of plugin as defined in the manifest.
*/
origin?: string;
installOptions: any;
name?: string;
url?: string;
version?: string;
activationPoints?: Array<string>;
main?: string;
description?: string;
icon?: string;
/** @private */
_active = false
_active = false;
/**
* @private
* @property {Object.<string, Function>} #listeners A list of callbacks to be executed when the Plugin is updated.
*/
#listeners = {}
listeners: Record<string, (obj: any) => void> = {};
/**
* Set installOptions with defaults for options that have not been provided.
* @param {string} [origin] Original specification provided to fetch the package.
* @param {Object} [options] Options provided to pacote when fetching the manifest.
*/
constructor(origin, options = {}) {
constructor(origin?: string, options = {}) {
const defaultOpts = {
version: false,
fullMetadata: false,
Arborist
}
Arborist,
};
this.origin = origin
this.installOptions = { ...defaultOpts, ...options }
this.origin = origin;
this.installOptions = { ...defaultOpts, ...options };
}
/**
@ -52,7 +61,10 @@ class Plugin {
* @type {string}
*/
get specifier() {
return this.origin + (this.installOptions.version ? '@' + this.installOptions.version : '')
return (
this.origin +
(this.installOptions.version ? "@" + this.installOptions.version : "")
);
}
/**
@ -60,31 +72,34 @@ class Plugin {
* @type {boolean}
*/
get active() {
return this._active
return this._active;
}
/**
* Set Package details based on it's manifest
* @returns {Promise.<Boolean>} Resolves to true when the action completed
* @returns {Promise.<Boolean>} Resolves to true when the action completed
*/
async #getManifest() {
async getManifest() {
// Get the package's manifest (package.json object)
try {
const mnf = await manifest(this.specifier, this.installOptions)
const mnf = await manifest(this.specifier, this.installOptions);
// set the Package properties based on the it's manifest
this.name = mnf.name
this.version = mnf.version
this.activationPoints = mnf.activationPoints || null
this.main = mnf.main
this.description = mnf.description
this.icon = mnf.icon
this.name = mnf.name;
this.version = mnf.version;
this.activationPoints = mnf.activationPoints
? (mnf.activationPoints as string[])
: undefined;
this.main = mnf.main;
this.description = mnf.description;
this.icon = mnf.icon as any;
} catch (error) {
throw new Error(`Package ${this.origin} does not contain a valid manifest: ${error}`)
throw new Error(
`Package ${this.origin} does not contain a valid manifest: ${error}`
);
}
return true
return true;
}
/**
@ -95,26 +110,29 @@ class Plugin {
async _install() {
try {
// import the manifest details
await this.#getManifest()
await this.getManifest();
// Install the package in a child folder of the given folder
await extract(this.specifier, join(pluginsPath, this.name), this.installOptions)
await extract(
this.specifier,
join(pluginsPath ?? "", this.name ?? ""),
this.installOptions
);
if (!Array.isArray(this.activationPoints))
throw new Error('The plugin does not contain any activation points')
throw new Error("The plugin does not contain any activation points");
// Set the url using the custom plugins protocol
this.url = `plugin://${this.name}/${this.main}`
this.#emitUpdate()
this.url = `plugin://${this.name}/${this.main}`;
this.emitUpdate();
} catch (err) {
// Ensure the plugin is not stored and the folder is removed if the installation fails
this.setActive(false)
throw err
this.setActive(false);
throw err;
}
return [this]
return [this];
}
/**
@ -122,24 +140,24 @@ class Plugin {
* @param {string} name name of the callback to register
* @param {callback} cb The function to execute on update
*/
subscribe(name, cb) {
this.#listeners[name] = cb
subscribe(name: string, cb: () => void) {
this.listeners[name] = cb;
}
/**
* Remove subscription
* @param {string} name name of the callback to remove
*/
unsubscribe(name) {
delete this.#listeners[name]
unsubscribe(name: string) {
delete this.listeners[name];
}
/**
* Execute listeners
*/
#emitUpdate() {
for (const cb in this.#listeners) {
this.#listeners[cb].call(null, this)
emitUpdate() {
for (const cb in this.listeners) {
this.listeners[cb].call(null, this);
}
}
@ -149,13 +167,13 @@ class Plugin {
* @returns {boolean} Whether an update was performed.
*/
async update(version = false) {
if (this.isUpdateAvailable()) {
this.installOptions.version = version
await this._install(false)
return true
if (await this.isUpdateAvailable()) {
this.installOptions.version = version;
await this._install();
return true;
}
return false
return false;
}
/**
@ -163,19 +181,21 @@ class Plugin {
* @returns the latest available version if a new version is available or false if not.
*/
async isUpdateAvailable() {
const mnf = await manifest(this.origin)
return mnf.version !== this.version ? mnf.version : false
if (this.origin) {
const mnf = await manifest(this.origin);
return mnf.version !== this.version ? mnf.version : false;
}
}
/**
* Remove plugin and refresh renderers.
* @returns {Promise}
* @returns {Promise}
*/
async uninstall() {
const plgPath = resolve(pluginsPath, this.name)
await rmdir(plgPath, { recursive: true })
const plgPath = resolve(pluginsPath ?? "", this.name ?? "");
await rmdir(plgPath, { recursive: true });
this.#emitUpdate()
this.emitUpdate();
}
/**
@ -183,11 +203,11 @@ class Plugin {
* @param {boolean} active State to set _active to
* @returns {Plugin} This plugin
*/
setActive(active) {
this._active = active
this.#emitUpdate()
return this
setActive(active: boolean) {
this._active = active;
this.emitUpdate();
return this;
}
}
export default Plugin
export default Plugin;

View File

@ -0,0 +1,97 @@
import { ipcMain, webContents } from "electron";
import {
getPlugin,
getActivePlugins,
installPlugins,
removePlugin,
getAllPlugins,
} from "./store";
import { pluginsPath } from "./globals";
import Plugin from "./plugin";
// Throw an error if pluginsPath has not yet been provided by usePlugins.
const checkPluginsPath = () => {
if (!pluginsPath)
throw Error("Path to plugins folder has not yet been set up.");
};
let active = false;
/**
* Provide the renderer process access to the plugins.
**/
export default function () {
if (active) return;
// Register IPC route to install a plugin
ipcMain.handle("pluggable:install", async (e, plugins) => {
checkPluginsPath();
// Install and activate all provided plugins
const installed = await installPlugins(plugins);
return JSON.parse(JSON.stringify(installed));
});
// Register IPC route to uninstall a plugin
ipcMain.handle("pluggable:uninstall", async (e, plugins, reload) => {
checkPluginsPath();
// Uninstall all provided plugins
for (const plg of plugins) {
const plugin = getPlugin(plg);
await plugin.uninstall();
if (plugin.name) removePlugin(plugin.name);
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach((wc) => wc.reload());
return true;
});
// Register IPC route to update a plugin
ipcMain.handle("pluggable:update", async (e, plugins, reload) => {
checkPluginsPath();
// Update all provided plugins
const updated: Plugin[] = [];
for (const plg of plugins) {
const plugin = getPlugin(plg);
const res = await plugin.update();
if (res) updated.push(plugin);
}
// Reload all renderer pages if needed
if (updated.length && reload)
webContents.getAllWebContents().forEach((wc) => wc.reload());
return JSON.parse(JSON.stringify(updated));
});
// Register IPC route to check if updates are available for a plugin
ipcMain.handle("pluggable:updatesAvailable", (e, names) => {
checkPluginsPath();
const plugins = names
? names.map((name: string) => getPlugin(name))
: getAllPlugins();
const updates: Record<string, Plugin> = {};
for (const plugin of plugins) {
updates[plugin.name] = plugin.isUpdateAvailable();
}
return updates;
});
// Register IPC route to get the list of active plugins
ipcMain.handle("pluggable:getActivePlugins", () => {
checkPluginsPath();
return JSON.parse(JSON.stringify(getActivePlugins()));
});
// Register IPC route to toggle the active state of a plugin
ipcMain.handle("pluggable:togglePluginActive", (e, plg, active) => {
checkPluginsPath();
const plugin = getPlugin(plg);
return JSON.parse(JSON.stringify(plugin.setActive(active)));
});
active = true;
}

View File

@ -8,9 +8,9 @@
* @prop {removePlugin} removePlugin
*/
import { writeFileSync } from "fs"
import Plugin from "./Plugin"
import { getPluginsFile } from './globals'
import { writeFileSync } from "fs";
import Plugin from "./plugin";
import { getPluginsFile } from "./globals";
/**
* @module store
@ -21,7 +21,7 @@ import { getPluginsFile } from './globals'
* Register of installed plugins
* @type {Object.<string, Plugin>} plugin - List of installed plugins
*/
const plugins = {}
const plugins: Record<string, Plugin> = {};
/**
* Get a plugin from the stored plugins.
@ -29,12 +29,12 @@ const plugins = {}
* @returns {Plugin} Retrieved plugin
* @alias pluginManager.getPlugin
*/
export function getPlugin(name) {
export function getPlugin(name: string) {
if (!Object.prototype.hasOwnProperty.call(plugins, name)) {
throw new Error(`Plugin ${name} does not exist`)
throw new Error(`Plugin ${name} does not exist`);
}
return plugins[name]
return plugins[name];
}
/**
@ -42,7 +42,9 @@ export function getPlugin(name) {
* @returns {Array.<Plugin>} All plugin objects
* @alias pluginManager.getAllPlugins
*/
export function getAllPlugins() { return Object.values(plugins) }
export function getAllPlugins() {
return Object.values(plugins);
}
/**
* Get list of active plugin objects.
@ -50,7 +52,7 @@ export function getAllPlugins() { return Object.values(plugins) }
* @alias pluginManager.getActivePlugins
*/
export function getActivePlugins() {
return Object.values(plugins).filter(plugin => plugin.active)
return Object.values(plugins).filter((plugin) => plugin.active);
}
/**
@ -60,10 +62,10 @@ export function getActivePlugins() {
* @returns {boolean} Whether the delete was successful
* @alias pluginManager.removePlugin
*/
export function removePlugin(name, persist = true) {
const del = delete plugins[name]
if (persist) persistPlugins()
return del
export function removePlugin(name: string, persist = true) {
const del = delete plugins[name];
if (persist) persistPlugins();
return del;
}
/**
@ -72,11 +74,11 @@ export function removePlugin(name, persist = true) {
* @param {boolean} persist Whether to save the changes to plugins to file
* @returns {void}
*/
export function addPlugin(plugin, persist = true) {
plugins[plugin.name] = plugin
export function addPlugin(plugin: Plugin, persist = true) {
if (plugin.name) plugins[plugin.name] = plugin;
if (persist) {
persistPlugins()
plugin.subscribe('pe-persist', persistPlugins)
persistPlugins();
plugin.subscribe("pe-persist", persistPlugins);
}
}
@ -85,11 +87,11 @@ export function addPlugin(plugin, persist = true) {
* @returns {void}
*/
export function persistPlugins() {
const persistData = {}
const persistData: Record<string, Plugin> = {};
for (const name in plugins) {
persistData[name] = plugins[name]
persistData[name] = plugins[name];
}
writeFileSync(getPluginsFile(), JSON.stringify(persistData), 'utf8')
writeFileSync(getPluginsFile(), JSON.stringify(persistData), "utf8");
}
/**
@ -99,26 +101,26 @@ export function persistPlugins() {
* @returns {Promise.<Array.<Plugin>>} New plugin
* @alias pluginManager.installPlugins
*/
export async function installPlugins(plugins, store = true) {
const installed = []
export async function installPlugins(plugins: any, store = true) {
const installed: Plugin[] = [];
for (const plg of plugins) {
// Set install options and activation based on input type
const isObject = typeof plg === 'object'
const spec = isObject ? [plg.specifier, plg] : [plg]
const activate = isObject ? plg.activate !== false : true
const isObject = typeof plg === "object";
const spec = isObject ? [plg.specifier, plg] : [plg];
const activate = isObject ? plg.activate !== false : true;
// Install and possibly activate plugin
const plugin = new Plugin(...spec)
await plugin._install()
if (activate) plugin.setActive(true)
const plugin = new Plugin(...spec);
await plugin._install();
if (activate) plugin.setActive(true);
// Add plugin to store if needed
if (store) addPlugin(plugin)
installed.push(plugin)
if (store) addPlugin(plugin);
installed.push(plugin);
}
// Return list of all installed plugins
return installed
return installed;
}
/**
@ -126,4 +128,4 @@ export async function installPlugins(plugins, store = true) {
* options used to install the plugin with some extra options.
* @param {string} specifier the NPM specifier that identifies the package.
* @param {boolean} [activate] Whether this plugin should be activated after installation. Defaults to true.
*/
*/

View File

@ -9,7 +9,7 @@ import {
import { readdirSync, writeFileSync } from "fs";
import { resolve, join, extname } from "path";
import { rmdir, unlink, createWriteStream } from "fs";
import { init } from "./core/plugin-manager/pluginMgr";
import { init } from "./core/plugin/index";
import { setupMenu } from "./utils/menu";
import { dispose } from "./utils/disposable";

View File

@ -74,6 +74,8 @@
"devDependencies": {
"@electron/notarize": "^2.1.0",
"@playwright/test": "^1.38.1",
"@types/npmcli__arborist": "^5.6.4",
"@types/pacote": "^11.1.7",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"electron": "26.2.1",

View File

@ -1,6 +1,5 @@
// Make Pluggable Electron's facade available to the renderer on window.plugins
//@ts-ignore
const useFacade = require("../core/plugin-manager/facade");
import { useFacade } from "./core/plugin/facade";
useFacade();
//@ts-ignore
const { contextBridge, ipcRenderer } = require("electron");

View File

@ -1,9 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
plugins,
extensionPoints,
} from '@/../../electron/core/plugin-manager/execution/index'
import { plugins, extensionPoints } from '@plugin'
import {
ChartPieIcon,
CommandLineIcon,
@ -13,7 +10,7 @@ import {
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import { DataService, PluginService, preferences } from '@janhq/core'
import { execute } from '../../../electron/core/plugin-manager/execution/extension-manager'
import { execute } from '@plugin/extension-manager'
import LoadingIndicator from './LoadingIndicator'
import { executeSerial } from '@services/pluginService'
@ -33,7 +30,7 @@ export const Preferences = () => {
* Loads the plugin catalog module from a CDN and sets it as the plugin catalog state.
*/
useEffect(() => {
executeSerial(DataService.GetPluginManifest).then((data) => {
executeSerial(DataService.GetPluginManifest).then((data: any) => {
setPluginCatalog(data)
})
}, [])
@ -52,7 +49,7 @@ export const Preferences = () => {
if (extensionPoints.get('experimentComponent')) {
const components = await Promise.all(
extensionPoints.execute('experimentComponent')
extensionPoints.execute('experimentComponent', {})
)
if (components.length > 0) {
setIsTestAvailable(true)
@ -67,7 +64,7 @@ export const Preferences = () => {
if (extensionPoints.get('PluginPreferences')) {
const data = await Promise.all(
extensionPoints.execute('PluginPreferences')
extensionPoints.execute('PluginPreferences', {})
)
setPreferenceItems(Array.isArray(data) ? data : [])
Promise.all(
@ -149,7 +146,7 @@ export const Preferences = () => {
}
if (extensionPoints.get(PluginService.OnPreferencesUpdate))
timeout = setTimeout(
() => execute(PluginService.OnPreferencesUpdate),
() => execute(PluginService.OnPreferencesUpdate, {}),
100
)
}

View File

@ -7,15 +7,14 @@ import JotaiWrapper from '@helpers/JotaiWrapper'
import { ModalWrapper } from '@helpers/ModalWrapper'
import { useEffect, useState } from 'react'
import CompactLogo from '@containers/Logo/CompactLogo'
import {
setup,
plugins,
activationPoints,
extensionPoints,
} from '../../../electron/core/plugin-manager/execution/index'
import { setup, plugins, activationPoints, extensionPoints } from '@plugin'
import EventListenerWrapper from '@helpers/EventListenerWrapper'
import { setupCoreServices } from '@services/coreService'
import { executeSerial, isCorePluginInstalled, setupBasePlugins } from '@services/pluginService'
import {
executeSerial,
isCorePluginInstalled,
setupBasePlugins,
} from '@services/pluginService'
const Providers = (props: PropsWithChildren) => {
const [setupCore, setSetupCore] = useState(false)

View File

@ -9,12 +9,15 @@ import {
updateConversationAtom,
updateConversationWaitingForResponseAtom,
} from './atoms/Conversation.atom'
import { executeSerial } from '../../electron/core/plugin-manager/execution/extension-manager'
import { executeSerial } from '@plugin/extension-manager'
import { debounce } from 'lodash'
import { setDownloadStateAtom, setDownloadStateSuccessAtom } from "./atoms/DownloadState.atom";
import { downloadedModelAtom } from "./atoms/DownloadedModel.atom";
import { ModelManagementService } from "@janhq/core";
import { getDownloadedModels } from "../hooks/useGetDownloadedModels";
import {
setDownloadStateAtom,
setDownloadStateSuccessAtom,
} from './atoms/DownloadState.atom'
import { downloadedModelAtom } from './atoms/DownloadedModel.atom'
import { ModelManagementService } from '@janhq/core'
import { getDownloadedModels } from '../hooks/useGetDownloadedModels'
let currentConversation: Conversation | undefined = undefined
@ -25,7 +28,6 @@ const debouncedUpdateConversation = debounce(
1000
)
export default function EventHandler({ children }: { children: ReactNode }) {
const addNewMessage = useSetAtom(addNewMessageAtom)
const updateMessage = useSetAtom(updateMessageAtom)
@ -34,9 +36,9 @@ export default function EventHandler({ children }: { children: ReactNode }) {
const { getConversationById } = useGetUserConversations()
const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom)
const setDownloadState = useSetAtom(setDownloadStateAtom);
const setDownloadStateSuccess = useSetAtom(setDownloadStateSuccessAtom);
const setDownloadedModels = useSetAtom(downloadedModelAtom);
const setDownloadState = useSetAtom(setDownloadStateAtom)
const setDownloadStateSuccess = useSetAtom(setDownloadStateSuccessAtom)
const setDownloadedModels = useSetAtom(downloadedModelAtom)
async function handleNewMessageResponse(message: NewMessageResponse) {
if (message.conversationId) {
@ -97,18 +99,21 @@ export default function EventHandler({ children }: { children: ReactNode }) {
}
function handleDownloadUpdate(state: any) {
if (!state) return;
setDownloadState(state);
if (!state) return
setDownloadState(state)
}
function handleDownloadSuccess(state: any) {
if (state && state.fileName && state.success === true) {
setDownloadStateSuccess(state.fileName);
executeSerial(ModelManagementService.UpdateFinishedDownloadAt, state.fileName).then(() => {
setDownloadStateSuccess(state.fileName)
executeSerial(
ModelManagementService.UpdateFinishedDownloadAt,
state.fileName
).then(() => {
getDownloadedModels().then((models) => {
setDownloadedModels(models);
});
});
setDownloadedModels(models)
})
})
}
}
@ -117,12 +122,12 @@ export default function EventHandler({ children }: { children: ReactNode }) {
events.on(EventName.OnNewMessageResponse, handleNewMessageResponse)
events.on(EventName.OnMessageResponseUpdate, handleMessageResponseUpdate)
events.on(
"OnMessageResponseFinished",
'OnMessageResponseFinished',
// EventName.OnMessageResponseFinished,
handleMessageResponseFinished
)
events.on(EventName.OnDownloadUpdate, handleDownloadUpdate);
events.on(EventName.OnDownloadSuccess, handleDownloadSuccess);
events.on(EventName.OnDownloadUpdate, handleDownloadUpdate)
events.on(EventName.OnDownloadSuccess, handleDownloadSuccess)
}
}, [])
@ -131,12 +136,12 @@ export default function EventHandler({ children }: { children: ReactNode }) {
events.off(EventName.OnNewMessageResponse, handleNewMessageResponse)
events.off(EventName.OnMessageResponseUpdate, handleMessageResponseUpdate)
events.off(
"OnMessageResponseFinished",
'OnMessageResponseFinished',
// EventName.OnMessageResponseFinished,
handleMessageResponseFinished
)
events.off(EventName.OnDownloadUpdate, handleDownloadUpdate);
events.off(EventName.OnDownloadSuccess, handleDownloadSuccess);
events.off(EventName.OnDownloadUpdate, handleDownloadUpdate)
events.off(EventName.OnDownloadSuccess, handleDownloadSuccess)
}
}, [])
return <>{children}</>

View File

@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { ModelManagementService } from '@janhq/core'
import { useAtom } from 'jotai'
import { downloadedModelAtom } from '@helpers/atoms/DownloadedModel.atom'
import { extensionPoints } from '../../electron/core/plugin-manager/execution'
import { extensionPoints } from '@plugin'
import { executeSerial } from '@services/pluginService'
export function useGetDownloadedModels() {

View File

@ -1,5 +1,5 @@
import { ModelManagementService } from '@janhq/core'
import { executeSerial } from '../../electron/core/plugin-manager/execution/extension-manager'
import { executeSerial } from '@plugin/extension-manager'
export default function useGetModelById() {
const getModelById = async (

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { extensionPoints } from '../../electron/core/plugin-manager/execution'
import { extensionPoints } from '@plugin'
import { SystemMonitoringService } from '@janhq/core'
import { useSetAtom } from 'jotai'
import { totalRamAtom } from '@helpers/atoms/SystemBar.atom'

View File

@ -1,4 +1,4 @@
import { callExport } from "./import-manager.js"
import { callExport } from "./import-manager"
class Activation {
/** @type {string} Name of the registered plugin. */
@ -13,7 +13,7 @@ class Activation {
/** @type {boolean} Whether the activation has been activated. */
activated
constructor(plugin, activationPoint, url) {
constructor(plugin: string, activationPoint: string, url: string) {
this.plugin = plugin
this.activationPoint = activationPoint
this.url = url

View File

@ -18,29 +18,29 @@ class ExtensionPoint {
* @type {Array.<Extension>} The list of all extensions registered with this extension point.
* @private
*/
_extensions = []
_extensions: any[] = []
/**
* @type {Array.<Object>} A list of functions to be executed when the list of extensions changes.
* @private
*/
#changeListeners = []
changeListeners: any[] = []
constructor(name) {
constructor(name: string) {
this.name = name
}
/**
* Register new extension with this extension point.
* The registered response will be executed (if callback) or returned (if object)
* The registered response will be executed (if callback) or returned (if object)
* when the extension point is executed (see below).
* @param {string} name Unique name for the extension.
* @param {Object|Callback} response Object to be returned or function to be called by the extension point.
* @param {number} [priority] Order priority for execution used for executing in serial.
* @returns {void}
*/
register(name, response, priority = 0) {
const index = this._extensions.findIndex(p => p.priority > priority)
register(name: string, response: any, priority: number = 0) {
const index = this._extensions.findIndex((p) => p.priority > priority)
const newExt = { name, response, priority }
if (index > -1) {
this._extensions.splice(index, 0, newExt)
@ -48,7 +48,7 @@ class ExtensionPoint {
this._extensions.push(newExt)
}
this.#emitChange()
this.emitChange()
}
/**
@ -56,11 +56,11 @@ class ExtensionPoint {
* @param {RegExp } name Matcher for the name of the extension to remove.
* @returns {void}
*/
unregister(name) {
const index = this._extensions.findIndex(ext => ext.name.match(name))
unregister(name: string) {
const index = this._extensions.findIndex((ext) => ext.name.match(name))
if (index > -1) this._extensions.splice(index, 1)
this.#emitChange()
this.emitChange()
}
/**
@ -69,7 +69,7 @@ class ExtensionPoint {
*/
clear() {
this._extensions = []
this.#emitChange()
this.emitChange()
}
/**
@ -77,8 +77,8 @@ class ExtensionPoint {
* @param {string} name Name of the extension to return
* @returns {Object|Callback|undefined} The response of the extension. If this is a function the function is returned, not its response.
*/
get(name) {
const ep = this._extensions.find(ext => ext.name === name)
get(name: string) {
const ep = this._extensions.find((ext) => ext.name === name)
return ep && ep.response
}
@ -88,8 +88,8 @@ class ExtensionPoint {
* @param {*} input Input to be provided as a parameter to each response if response is a callback.
* @returns {Array} List of responses from the extensions.
*/
execute(input) {
return this._extensions.map(p => {
execute(input: any) {
return this._extensions.map((p) => {
if (typeof p.response === 'function') {
return p.response(input)
} else {
@ -105,7 +105,7 @@ class ExtensionPoint {
* @param {*} input Input to be provided as a parameter to the 1st callback
* @returns {Promise.<*>} Result of the last extension that was called
*/
async executeSerial(input) {
async executeSerial(input: any) {
return await this._extensions.reduce(async (throughput, p) => {
let tp = await throughput
if (typeof p.response === 'function') {
@ -122,24 +122,25 @@ class ExtensionPoint {
* @param {string} name Name of the listener needed if it is to be removed.
* @param {Function} callback The callback function to trigger on a change.
*/
onRegister(name, callback) {
if (typeof callback === 'function') this.#changeListeners.push({ name, callback })
onRegister(name: string, callback: any) {
if (typeof callback === 'function')
this.changeListeners.push({ name, callback })
}
/**
* Unregister a callback from the extension list changes.
* @param {string} name The name of the listener to remove.
*/
offRegister(name) {
const index = this.#changeListeners.findIndex(l => l.name === name)
if (index > -1) this.#changeListeners.splice(index, 1)
offRegister(name: string) {
const index = this.changeListeners.findIndex((l) => l.name === name)
if (index > -1) this.changeListeners.splice(index, 1)
}
#emitChange() {
for (const l of this.#changeListeners) {
emitChange() {
for (const l of this.changeListeners) {
l.callback(this)
}
}
}
export default ExtensionPoint
export default ExtensionPoint

View File

@ -1,4 +1,4 @@
import { callExport } from "./import-manager"
import { callExport } from './import-manager'
/**
* A slimmed down representation of a plugin for the renderer.
@ -25,7 +25,15 @@ class Plugin {
/** @type {string} Plugin's logo. */
icon
constructor(name, url, activationPoints, active, description, version, icon) {
constructor(
name?: string,
url?: string,
activationPoints?: any[],
active?: boolean,
description?: string,
version?: string,
icon?: string
) {
this.name = name
this.url = url
this.activationPoints = activationPoints
@ -39,9 +47,9 @@ class Plugin {
* Trigger an exported callback on the plugin's main file.
* @param {string} exp exported callback to trigger.
*/
triggerExport(exp) {
callExport(this.url, exp, this.name)
triggerExport(exp: string) {
if (this.url && this.name) callExport(this.url, exp, this.name)
}
}
export default Plugin
export default Plugin

View File

@ -1,16 +1,16 @@
import Activation from "./Activation.js"
import Activation from './Activation'
import Plugin from './Plugin'
/**
* This object contains a register of plugin registrations to an activation points, and the means to work with them.
* @namespace activationPoints
*/
/**
/**
* @constant {Array.<Activation>} activationRegister
* @private
* Store of activations used by the consumer
*/
const activationRegister = []
const activationRegister: any[] = []
/**
* Register a plugin with its activation points (as defined in its manifest).
@ -18,18 +18,22 @@ const activationRegister = []
* @returns {void}
* @alias activationPoints.register
*/
export function register(plugin) {
if (!Array.isArray(plugin.activationPoints)) throw new Error(
`Plugin ${plugin.name || 'without name'} does not have any activation points set up in its manifest.`
)
export function register(plugin: Plugin) {
if (!Array.isArray(plugin.activationPoints))
throw new Error(
`Plugin ${
plugin.name || 'without name'
} does not have any activation points set up in its manifest.`
)
for (const ap of plugin.activationPoints) {
// Ensure plugin is not already registered to activation point
const duplicate = activationRegister.findIndex(act =>
act.plugin === plugin.name && act.activationPoint === ap
const duplicate = activationRegister.findIndex(
(act) => act.plugin === plugin.name && act.activationPoint === ap
)
// Create new activation and add it to the register
if (duplicate < 0) activationRegister.push(new Activation(plugin.name, ap, plugin.url))
if (duplicate < 0 && plugin.name && plugin.url)
activationRegister.push(new Activation(plugin.name, ap, plugin.url))
}
}
@ -37,10 +41,10 @@ export function register(plugin) {
* Trigger all activations registered to the given activation point. See {@link Plugin}.
* This will call the function with the same name as the activation point on the path specified in the plugin.
* @param {string} activationPoint Name of the activation to trigger
* @returns {Promise.<Boolean>} Resolves to true when the activations are complete.
* @returns {Promise.<Boolean>} Resolves to true when the activations are complete.
* @alias activationPoints.trigger
*/
export async function trigger(activationPoint) {
export async function trigger(activationPoint: string) {
// Make sure all triggers are complete before returning
await Promise.all(
// Trigger each relevant activation point from the register and return an array of trigger promises
@ -60,7 +64,7 @@ export async function trigger(activationPoint) {
* @returns {void}
* @alias activationPoints.remove
*/
export function remove(plugin) {
export function remove(plugin: any) {
let i = activationRegister.length
while (i--) {
if (activationRegister[i].plugin === plugin) {
@ -85,4 +89,4 @@ export function clear() {
*/
export function get() {
return [...activationRegister]
}
}

View File

@ -3,14 +3,14 @@
* @namespace extensionPoints
*/
import ExtensionPoint from "./ExtensionPoint.js"
import ExtensionPoint from './ExtensionPoint'
/**
/**
* @constant {Object.<string, ExtensionPoint>} extensionPoints
* @private
* Register of extension points created by the consumer
*/
const _extensionPoints = {}
const _extensionPoints: Record<string, any> = {}
/**
* Create new extension point and add it to the registry.
@ -18,7 +18,7 @@ const _extensionPoints = {}
* @returns {void}
* @alias extensionPoints.add
*/
export function add(name) {
export function add(name: string) {
_extensionPoints[name] = new ExtensionPoint(name)
}
@ -28,7 +28,7 @@ export function add(name) {
* @returns {void}
* @alias extensionPoints.remove
*/
export function remove(name) {
export function remove(name: string) {
delete _extensionPoints[name]
}
@ -41,7 +41,7 @@ export function remove(name) {
* @returns {void}
* @alias extensionPoints.register
*/
export function register(ep, extension, response, priority) {
export function register(ep: string, extension: string, response: any, priority: number) {
if (!_extensionPoints[ep]) add(ep)
if (_extensionPoints[ep].register) {
_extensionPoints[ep].register(extension, response, priority)
@ -53,7 +53,7 @@ export function register(ep, extension, response, priority) {
* @param {RegExp} name Matcher for the name of the extension to remove.
* @alias extensionPoints.unregisterAll
*/
export function unregisterAll(name) {
export function unregisterAll(name: string) {
for (const ep in _extensionPoints) _extensionPoints[ep].unregister(name)
}
@ -63,8 +63,8 @@ export function unregisterAll(name) {
* @returns {Object.<ExtensionPoint> | ExtensionPoint} Found extension points
* @alias extensionPoints.get
*/
export function get(ep) {
return (ep ? _extensionPoints[ep] : { ..._extensionPoints })
export function get(ep?: string) {
return ep ? _extensionPoints[ep] : { ..._extensionPoints }
}
/**
@ -75,10 +75,11 @@ export function get(ep) {
* @returns {Array} Result of Promise.all or Promise.allSettled depending on exitOnError
* @alias extensionPoints.execute
*/
export function execute(name, input) {
if (!_extensionPoints[name] || !_extensionPoints[name].execute) throw new Error(
`The extension point "${name}" is not a valid extension point`
)
export function execute(name: string, input: any) {
if (!_extensionPoints[name] || !_extensionPoints[name].execute)
throw new Error(
`The extension point "${name}" is not a valid extension point`
)
return _extensionPoints[name].execute(input)
}
@ -90,9 +91,10 @@ export function execute(name, input) {
* @returns {Promise.<*>} Result of the last extension that was called
* @alias extensionPoints.executeSerial
*/
export function executeSerial(name, input) {
if (!_extensionPoints[name] || !_extensionPoints[name].executeSerial) throw new Error(
`The extension point "${name}" is not a valid extension point`
)
export function executeSerial(name: string, input: any) {
if (!_extensionPoints[name] || !_extensionPoints[name].executeSerial)
throw new Error(
`The extension point "${name}" is not a valid extension point`
)
return _extensionPoints[name].executeSerial(input)
}

View File

@ -5,8 +5,8 @@
* @namespace plugins
*/
import Plugin from "./Plugin";
import { register } from "./activation-manager";
import Plugin from './Plugin'
import { register } from './activation-manager'
/**
* @typedef {Object.<string, any>} installOptions The {@link https://www.npmjs.com/package/pacote|pacote options}
@ -21,23 +21,23 @@ import { register } from "./activation-manager";
* @returns {Promise.<Array.<Plugin> | false>} plugin as defined by the main process. Has property cancelled set to true if installation was cancelled in the main process.
* @alias plugins.install
*/
export async function install(plugins) {
if (typeof window === "undefined") {
return;
export async function install(plugins: any[]) {
if (typeof window === 'undefined') {
return
}
// eslint-disable-next-line no-undef
const plgList = await window.pluggableElectronIpc.install(plugins);
if (plgList.cancelled) return false;
return plgList.map((plg) => {
const plgList = await window.pluggableElectronIpc.install(plugins)
if (plgList.cancelled) return false
return plgList.map((plg: any) => {
const plugin = new Plugin(
plg.name,
plg.url,
plg.activationPoints,
plg.active
);
register(plugin);
return plugin;
});
)
register(plugin)
return plugin
})
}
/**
@ -47,12 +47,12 @@ export async function install(plugins) {
* @returns {Promise.<boolean>} Whether uninstalling the plugins was successful.
* @alias plugins.uninstall
*/
export function uninstall(plugins, reload = true) {
if (typeof window === "undefined") {
return;
export function uninstall(plugins: string[], reload = true) {
if (typeof window === 'undefined') {
return
}
// eslint-disable-next-line no-undef
return window.pluggableElectronIpc.uninstall(plugins, reload);
return window.pluggableElectronIpc.uninstall(plugins, reload)
}
/**
@ -61,17 +61,18 @@ export function uninstall(plugins, reload = true) {
* @alias plugins.getActive
*/
export async function getActive() {
if (typeof window === "undefined") {
return;
if (typeof window === 'undefined') {
return []
}
// eslint-disable-next-line no-undef
const plgList = await window.pluggableElectronIpc?.getActive() ??
await import(
// eslint-disable-next-line no-undef
/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`
).then((data) => data.default.filter((e) => e.supportCloudNative));
const plgList =
(await window.pluggableElectronIpc?.getActive()) ??
(await import(
// eslint-disable-next-line no-undef
/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`
).then((data) => data.default.filter((e: any) => e.supportCloudNative)))
return plgList.map(
(plugin) =>
(plugin: any) =>
new Plugin(
plugin.name,
plugin.url,
@ -81,7 +82,7 @@ export async function getActive() {
plugin.version,
plugin.icon
)
);
)
}
/**
@ -90,12 +91,12 @@ export async function getActive() {
* @alias plugins.registerActive
*/
export async function registerActive() {
if (typeof window === "undefined") {
return;
if (typeof window === 'undefined') {
return
}
// eslint-disable-next-line no-undef
const plgList = await getActive()
plgList.forEach((plugin) =>
plgList.forEach((plugin: Plugin) =>
register(
new Plugin(
plugin.name,
@ -104,7 +105,7 @@ export async function registerActive() {
plugin.active
)
)
);
)
}
/**
@ -114,21 +115,21 @@ export async function registerActive() {
* @returns {Promise.<Array.<Plugin>>} Updated plugin as defined by the main process.
* @alias plugins.update
*/
export async function update(plugins, reload = true) {
if (typeof window === "undefined") {
return;
export async function update(plugins: Plugin[], reload = true) {
if (typeof window === 'undefined') {
return
}
// eslint-disable-next-line no-undef
const plgList = await window.pluggableElectronIpc.update(plugins, reload);
const plgList = await window.pluggableElectronIpc.update(plugins, reload)
return plgList.map(
(plugin) =>
(plugin: any) =>
new Plugin(
plugin.name,
plugin.url,
plugin.activationPoints,
plugin.active
)
);
)
}
/**
@ -137,12 +138,12 @@ export async function update(plugins, reload = true) {
* @returns {Object.<string | false>} Object with plugins as keys and new version if update is available or false as values.
* @alias plugins.updatesAvailable
*/
export function updatesAvailable(plugin) {
if (typeof window === "undefined") {
return;
export function updatesAvailable(plugin: Plugin) {
if (typeof window === 'undefined') {
return
}
// eslint-disable-next-line no-undef
return window.pluggableElectronIpc.updatesAvailable(plugin);
return window.pluggableElectronIpc.updatesAvailable(plugin)
}
/**
@ -152,11 +153,11 @@ export function updatesAvailable(plugin) {
* @returns {Promise.<Plugin>} Updated plugin as defined by the main process.
* @alias plugins.toggleActive
*/
export async function toggleActive(plugin, active) {
if (typeof window === "undefined") {
return;
export async function toggleActive(plugin: Plugin, active: boolean) {
if (typeof window === 'undefined') {
return
}
// eslint-disable-next-line no-undef
const plg = await window.pluggableElectronIpc.toggleActive(plugin, active);
return new Plugin(plg.name, plg.url, plg.activationPoints, plg.active);
const plg = await window.pluggableElectronIpc.toggleActive(plugin, active)
return new Plugin(plg.name, plg.url, plg.activationPoints, plg.active)
}

View File

@ -3,7 +3,7 @@ import {
register,
execute,
executeSerial,
} from "./extension-manager.js";
} from "./extension-manager";
/**
* Used to import a plugin entry point.
* Ensure your bundler does no try to resolve this import as the plugins are not known at build time.
@ -16,14 +16,14 @@ import {
* @private
* @type {importer}
*/
export let importer;
export let importer: any;
/**
* @private
* Set the plugin importer function.
* @param {importer} callback Callback to import plugins.
*/
export function setImporter(callback) {
export function setImporter(callback: any) {
importer = callback;
}
@ -31,14 +31,14 @@ export function setImporter(callback) {
* @private
* @type {Boolean|null}
*/
export let presetEPs;
export let presetEPs: boolean|null;
/**
* @private
* Define how extension points are accessed.
* @param {Boolean|null} peps Whether extension points are predefined.
*/
export function definePresetEps(peps) {
export function definePresetEps(peps: boolean|null) {
presetEPs = peps === null || peps === true ? peps : false;
}
@ -49,7 +49,7 @@ export function definePresetEps(peps) {
* @param {string} exp Export to call
* @param {string} [plugin] @see Activation
*/
export async function callExport(url, exp, plugin) {
export async function callExport(url: string, exp: string, plugin: string) {
if (!importer) throw new Error("Importer callback has not been set");
const main = await importer(url);

View File

@ -1,9 +1,9 @@
import { definePresetEps, setImporter } from "./import-manager.js";
import { definePresetEps, setImporter } from "./import-manager";
export * as extensionPoints from "./extension-manager.js";
export * as activationPoints from "./activation-manager.js";
export * as plugins from "./facade.js";
export { default as ExtensionPoint } from "./ExtensionPoint.js";
export * as extensionPoints from "./extension-manager";
export * as activationPoints from "./activation-manager";
export * as plugins from "./facade";
export { default as ExtensionPoint } from "./ExtensionPoint";
// eslint-disable-next-line no-undef
if (typeof window !== "undefined" && !window.pluggableElectronIpc)
@ -19,7 +19,7 @@ if (typeof window !== "undefined" && !window.pluggableElectronIpc)
* can be created on the fly(false) or should not be provided through the input at all (null).
* @returns {void}
*/
export function setup(options) {
export function setup(options: any) {
setImporter(options.importer);
definePresetEps(options.presetEPs);
}

View File

@ -5,10 +5,7 @@ import { Button, Switch } from '@uikit'
import Loader from '@containers/Loader'
import { formatPluginsName } from '@utils/converter'
import {
plugins,
extensionPoints,
} from '@/../../electron/core/plugin-manager/execution/index'
import { plugins, extensionPoints } from '@plugin'
import { executeSerial } from '@services/pluginService'
import { DataService } from '@janhq/core'
import useGetAppVersion from '@hooks/useGetAppVersion'
@ -27,19 +24,19 @@ const PluginCatalog = () => {
*/
useEffect(() => {
if (!window.electronAPI) {
return;
return
}
if (!version) return
// Load plugin manifest from plugin if any
if (extensionPoints.get(DataService.GetPluginManifest)) {
executeSerial(DataService.GetPluginManifest).then((data) => {
executeSerial(DataService.GetPluginManifest).then((data: any) => {
setPluginCatalog(
data.filter(
(e: any) =>
!e.requiredVersion ||
e.requiredVersion.replace(/[.^]/g, '') <=
version.replaceAll('.', '')
version.replaceAll('.', '')
)
)
})
@ -53,7 +50,7 @@ const PluginCatalog = () => {
(e: any) =>
!e.requiredVersion ||
e.requiredVersion.replace(/[.^]/g, '') <=
version.replaceAll('.', '')
version.replaceAll('.', '')
)
)
)
@ -74,7 +71,7 @@ const PluginCatalog = () => {
if (extensionPoints.get('experimentComponent')) {
const components = await Promise.all(
extensionPoints.execute('experimentComponent')
extensionPoints.execute('experimentComponent', {})
)
components.forEach((e) => {
if (experimentRef.current) {

View File

@ -1,5 +1,5 @@
import React from 'react'
import { execute } from '../../../../../electron/core/plugin-manager/execution/extension-manager'
import { execute } from '@plugin/extension-manager'
type Props = {
pluginName: string
@ -22,7 +22,10 @@ const PreferencePlugins = (props: Props) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => execute(PluginService.OnPreferencesUpdate), 100)
timeout = setTimeout(
() => execute(PluginService.OnPreferencesUpdate, {}),
100
)
}
return (

View File

@ -13,10 +13,7 @@ import { twMerge } from 'tailwind-merge'
import { formatPluginsName } from '@utils/converter'
import {
plugins,
extensionPoints,
} from '@/../../electron/core/plugin-manager/execution/index'
import { extensionPoints } from '@plugin'
const staticMenu = ['Appearance']
@ -40,7 +37,7 @@ const SettingsScreen = () => {
const getActivePluginPreferences = async () => {
if (extensionPoints.get('PluginPreferences')) {
const data = await Promise.all(
extensionPoints.execute('PluginPreferences')
extensionPoints.execute('PluginPreferences', {})
)
setPreferenceItems(Array.isArray(data) ? data : [])
Promise.all(

View File

@ -2,10 +2,9 @@
import {
extensionPoints,
plugins,
} from '../../electron/core/plugin-manager/execution/index'
} from '@plugin'
import {
CoreService,
DataService,
InferenceService,
ModelManagementService,
StoreService,

View File

@ -29,7 +29,9 @@
"@models/*": ["./models/*"],
"@hooks/*": ["./hooks/*"],
"@containers/*": ["./containers/*"],
"@screens/*": ["./screens/*"]
"@screens/*": ["./screens/*"],
"@plugin/*": ["./plugin/*"],
"@plugin": ["./plugin"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

View File

@ -7,5 +7,6 @@ declare global {
electronAPI?: any | undefined;
corePlugin?: any | undefined;
coreAPI?: any | undefined;
pluggableElectronIpc?: any | undefined;
}
}