WIP plugins: hook on client side

pull/1987/head
Chocobozzz 2019-07-08 15:54:08 +02:00 committed by Chocobozzz
parent 2c0539420d
commit 18a6f04c07
11 changed files with 215 additions and 3 deletions

View File

@ -3,6 +3,10 @@
"target": "http://localhost:9000",
"secure": false
},
"/plugins": {
"target": "http://localhost:9000",
"secure": false
},
"/static": {
"target": "http://localhost:9000",
"secure": false

View File

@ -9,6 +9,7 @@ import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { fromEvent } from 'rxjs'
import { ViewportScroller } from '@angular/common'
import { PluginService } from '@app/core/plugins/plugin.service'
@Component({
selector: 'my-app',
@ -27,6 +28,7 @@ export class AppComponent implements OnInit {
private router: Router,
private authService: AuthService,
private serverService: ServerService,
private pluginService: PluginService,
private domSanitizer: DomSanitizer,
private redirectService: RedirectService,
private screenService: ScreenService,
@ -69,6 +71,8 @@ export class AppComponent implements OnInit {
this.serverService.loadVideoPrivacies()
this.serverService.loadVideoPlaylistPrivacies()
this.loadPlugins()
// Do not display menu on small screens
if (this.screenService.isInSmallView()) {
this.isMenuDisplayed = false
@ -196,6 +200,14 @@ export class AppComponent implements OnInit {
})
}
private async loadPlugins () {
this.pluginService.initializePlugins()
await this.pluginService.loadPluginsByScope('common')
this.pluginService.runHook('action:application.loaded')
}
private initHotkeys () {
this.hotkeysService.add([
new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => {

View File

@ -21,6 +21,7 @@ import { MessageService } from 'primeng/api'
import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
import { ServerConfigResolver } from './routing/server-config-resolver.service'
import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
import { PluginService } from '@app/core/plugins/plugin.service'
@NgModule({
imports: [
@ -61,6 +62,8 @@ import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
UserRightGuard,
UnloggedGuard,
PluginService,
RedirectService,
Notifier,
MessageService,

View File

@ -0,0 +1,137 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { ServerConfigPlugin } from '@shared/models'
import { ServerService } from '@app/core/server/server.service'
import { ClientScript } from '@shared/models/plugins/plugin-package-json.model'
import { PluginScope } from '@shared/models/plugins/plugin-scope.type'
import { environment } from '../../../environments/environment'
import { RegisterHookOptions } from '@shared/models/plugins/register.model'
import { ReplaySubject } from 'rxjs'
import { first } from 'rxjs/operators'
interface HookStructValue extends RegisterHookOptions {
plugin: ServerConfigPlugin
clientScript: ClientScript
}
@Injectable()
export class PluginService {
pluginsLoaded = new ReplaySubject<boolean>(1)
private plugins: ServerConfigPlugin[] = []
private scopes: { [ scopeName: string ]: { plugin: ServerConfigPlugin, clientScript: ClientScript }[] } = {}
private loadedScripts: { [ script: string ]: boolean } = {}
private hooks: { [ name: string ]: HookStructValue[] } = {}
constructor (
private router: Router,
private server: ServerService
) {
}
initializePlugins () {
this.server.configLoaded
.subscribe(() => {
this.plugins = this.server.getConfig().plugins
this.buildScopeStruct()
this.pluginsLoaded.next(true)
})
}
ensurePluginsAreLoaded () {
return this.pluginsLoaded.asObservable()
.pipe(first())
.toPromise()
}
async loadPluginsByScope (scope: PluginScope) {
try {
await this.ensurePluginsAreLoaded()
const toLoad = this.scopes[ scope ]
if (!Array.isArray(toLoad)) return
const promises: Promise<any>[] = []
for (const { plugin, clientScript } of toLoad) {
if (this.loadedScripts[ clientScript.script ]) continue
promises.push(this.loadPlugin(plugin, clientScript))
this.loadedScripts[ clientScript.script ] = true
}
return Promise.all(promises)
} catch (err) {
console.error('Cannot load plugins by scope %s.', scope, err)
}
}
async runHook (hookName: string, param?: any) {
let result = param
const wait = hookName.startsWith('static:')
for (const hook of this.hooks[hookName]) {
try {
if (wait) result = await hook.handler(param)
else result = hook.handler()
} catch (err) {
console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.plugin, hook.clientScript, err)
}
}
return result
}
private loadPlugin (plugin: ServerConfigPlugin, clientScript: ClientScript) {
const registerHook = (options: RegisterHookOptions) => {
if (!this.hooks[options.target]) this.hooks[options.target] = []
this.hooks[options.target].push({
plugin,
clientScript,
target: options.target,
handler: options.handler,
priority: options.priority || 0
})
}
console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
const url = environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`
return import(/* webpackIgnore: true */ url)
.then(script => script.register({ registerHook }))
.then(() => this.sortHooksByPriority())
}
private buildScopeStruct () {
for (const plugin of this.plugins) {
for (const key of Object.keys(plugin.clientScripts)) {
const clientScript = plugin.clientScripts[key]
for (const scope of clientScript.scopes) {
if (!this.scopes[scope]) this.scopes[scope] = []
this.scopes[scope].push({
plugin,
clientScript
})
this.loadedScripts[clientScript.script] = false
}
}
}
}
private sortHooksByPriority () {
for (const hookName of Object.keys(this.hooks)) {
this.hooks[hookName].sort((a, b) => {
return b.priority - a.priority
})
}
}
}

View File

@ -42,6 +42,7 @@ export class ServerService {
css: ''
}
},
plugins: [],
email: {
enabled: false
},

View File

@ -32,6 +32,7 @@ import { Video } from '@app/shared/video/video.model'
import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
import { PluginService } from '@app/core/plugins/plugin.service'
@Component({
selector: 'my-video-watch',
@ -85,6 +86,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private serverService: ServerService,
private restExtractor: RestExtractor,
private notifier: Notifier,
private pluginService: PluginService,
private markdownService: MarkdownService,
private zone: NgZone,
private redirectService: RedirectService,
@ -98,7 +100,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.authService.getUser()
}
ngOnInit () {
async ngOnInit () {
await this.pluginService.loadPluginsByScope('video-watch')
this.configSub = this.serverService.configLoaded
.subscribe(() => {
if (
@ -126,6 +130,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.initHotkeys()
this.theaterEnabled = getStoredTheater()
this.pluginService.runHook('action:video-watch.loaded')
}
ngOnDestroy () {

View File

@ -1,6 +1,6 @@
import * as express from 'express'
import { snakeCase } from 'lodash'
import { ServerConfig, UserRight } from '../../../shared'
import { ServerConfig, ServerConfigPlugin, UserRight } from '../../../shared'
import { About } from '../../../shared/models/server/about.model'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
@ -15,6 +15,8 @@ import { Emailer } from '../../lib/emailer'
import { isNumeric } from 'validator'
import { objectConverter } from '../../helpers/core-utils'
import { CONFIG, reloadConfig } from '../../initializers/config'
import { PluginManager } from '../../lib/plugins/plugin-manager'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
const packageJSON = require('../../../../package.json')
const configRouter = express.Router()
@ -54,6 +56,20 @@ async function getConfig (req: express.Request, res: express.Response) {
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10))
const plugins: ServerConfigPlugin[] = []
const registeredPlugins = PluginManager.Instance.getRegisteredPlugins()
for (const pluginName of Object.keys(registeredPlugins)) {
const plugin = registeredPlugins[ pluginName ]
if (plugin.type !== PluginType.PLUGIN) continue
plugins.push({
name: plugin.name,
version: plugin.version,
description: plugin.description,
clientScripts: plugin.clientScripts
})
}
const json: ServerConfig = {
instance: {
name: CONFIG.INSTANCE.NAME,
@ -66,6 +82,7 @@ async function getConfig (req: express.Request, res: express.Response) {
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
plugins,
email: {
enabled: Emailer.isEnabled()
},

View File

@ -75,6 +75,27 @@ export class PluginManager {
return registered
}
getRegisteredPlugins () {
return this.registeredPlugins
}
async runHook (hookName: string, param?: any) {
let result = param
const wait = hookName.startsWith('static:')
for (const hook of this.hooks[hookName]) {
try {
if (wait) result = await hook.handler(param)
else result = hook.handler()
} catch (err) {
logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err })
}
}
return result
}
async unregister (name: string) {
const plugin = this.getRegisteredPlugin(name)

View File

@ -0,0 +1 @@
export type PluginScope = 'common' | 'video-watch'

View File

@ -1,4 +1,4 @@
export type RegisterHookOptions = {
export interface RegisterHookOptions {
target: string
handler: Function
priority?: number

View File

@ -1,4 +1,12 @@
import { NSFWPolicyType } from '../videos/nsfw-policy.type'
import { ClientScript } from '../plugins/plugin-package-json.model'
export type ServerConfigPlugin = {
name: string
version: string
description: string
clientScripts: { [name: string]: ClientScript }
}
export interface ServerConfig {
serverVersion: string
@ -16,6 +24,8 @@ export interface ServerConfig {
}
}
plugins: ServerConfigPlugin[]
email: {
enabled: boolean
}