diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-05-07 16:33:18 +0000 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-05-07 16:33:18 +0000 |
| commit | d39a56c1b7d74dd07cc78b4c82a6fb6e51036252 (patch) | |
| tree | 24f9c6baa07fadc11c791f1a59bee2c3149cbf56 /packages/frontend/src/plugin.ts | |
| parent | merge: Add BunnyCDN Edge Storage support (!952) (diff) | |
| parent | isNotUserHome > isUserHome (diff) | |
| download | sharkey-d39a56c1b7d74dd07cc78b4c82a6fb6e51036252.tar.gz sharkey-d39a56c1b7d74dd07cc78b4c82a6fb6e51036252.tar.bz2 sharkey-d39a56c1b7d74dd07cc78b4c82a6fb6e51036252.zip | |
merge: Merge upstream 2025.4.1 (!955)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/955
Closes #638, #1037, #734, and #766
Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
Diffstat (limited to 'packages/frontend/src/plugin.ts')
| -rw-r--r-- | packages/frontend/src/plugin.ts | 492 |
1 files changed, 374 insertions, 118 deletions
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 27bb34da36..b482d30471 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -3,179 +3,435 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref } from 'vue'; +import { ref, defineAsyncComponent } from 'vue'; import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js'; -import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; +import { compareVersions } from 'compare-versions'; +import { v4 as uuid } from 'uuid'; +import * as Misskey from 'misskey-js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { store } from '@/store.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { warningExternalWebsite } from '@/utility/warning-external-website.js'; + +export type Plugin = { + installId: string; + name: string; + active: boolean; + config?: Record<string, { default: any }>; + configData: Record<string, any>; + src: string | null; + version: string; + author?: string; + description?: string; + permissions?: string[]; +}; + +export type AiScriptPluginMeta = { + name: string; + version: string; + author: string; + description?: string; + permissions?: string[]; + config?: Record<string, any>; +}; const parser = new Parser(); -const pluginContexts = new Map<string, Interpreter>(); -export const pluginLogs = ref(new Map<string, string[]>()); -export async function install(plugin: Plugin): Promise<void> { +export function isSupportedAiScriptVersion(version: string): boolean { + try { + return (compareVersions(version, '0.12.0') >= 0); + } catch (err) { + return false; + } +} + +export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> { + if (!code) { + throw new Error('code is required'); + } + + const lv = utils.getLangVersion(code); + if (lv == null) { + throw new Error('No language version annotation found'); + } else if (!isSupportedAiScriptVersion(lv)) { + throw new Error(`Aiscript version '${lv}' is not supported`); + } + + let ast; + try { + ast = parser.parse(code); + } catch (err) { + if (err instanceof Error) { + throw new Error(`Aiscript syntax error\n${(err as Error).message}`); + } else { + throw new Error('Aiscript syntax error'); + } + } + + const meta = Interpreter.collectMetadata(ast); + if (meta == null) { + throw new Error('Meta block not found'); + } + + const metadata = meta.get(null); + if (metadata == null) { + throw new Error('Metadata not found'); + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + throw new Error('Required property not found'); + } + + return { + name, + version, + author, + description, + permissions, + config, + }; +} + +export async function authorizePlugin(plugin: Plugin) { + if (plugin.permissions == null || plugin.permissions.length === 0) return; + if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) return; + + const token = await new Promise<string>((res, rej) => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.ts.pluginTokenRequestedDescription, + initialName: plugin.name, + initialPermissions: plugin.permissions, + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await misskeyApi('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + res(token); + }, + closed: () => dispose(), + }); + }); + + store.set('pluginTokens', { + ...store.s.pluginTokens, + [plugin.installId]: token, + }); +} + +export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { + if (!code) return; + + let realMeta: AiScriptPluginMeta; + if (!meta) { + realMeta = await parsePluginMeta(code); + } else { + realMeta = meta; + } + + if (prefer.s.plugins.some(x => x.name === realMeta.name)) { + throw new Error('Plugin already installed'); + } + + const installId = uuid(); + + const plugin = { + ...realMeta, + installId, + active: true, + configData: {}, + src: code, + }; + + prefer.commit('plugins', prefer.s.plugins.concat(plugin)); + + await authorizePlugin(plugin); + + await launchPlugin(installId); +} + +export async function uninstallPlugin(plugin: Plugin) { + abortPlugin(plugin); + prefer.commit('plugins', prefer.s.plugins.filter(x => x.installId !== plugin.installId)); + if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) { + await os.apiWithDialog('i/revoke-token', { + token: store.s.pluginTokens[plugin.installId], + }); + const pluginTokens = { ...store.s.pluginTokens }; + delete pluginTokens[plugin.installId]; + store.set('pluginTokens', pluginTokens); + } +} + +const pluginContexts = new Map<Plugin['installId'], Interpreter>(); + +export const pluginLogs = ref(new Map<Plugin['installId'], { + at: number; + message: string; + isSystem?: boolean; + isError?: boolean; +}[]>()); + +type HandlerDef = { + post_form_action: { + title: string, + handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void; + }; + user_action: { + title: string, + handler: (user: Misskey.entities.UserDetailed) => void; + }; + note_action: { + title: string, + handler: (note: Misskey.entities.Note) => void; + }; + note_view_interruptor: { + handler: (note: Misskey.entities.Note) => unknown; + }; + note_post_interruptor: { + handler: (note: FIXME) => unknown; + }; + page_view_interruptor: { + handler: (page: Misskey.entities.Page) => unknown; + }; +}; + +type PluginHandler<K extends keyof HandlerDef> = { + pluginInstallId: string; + type: K; + ctx: HandlerDef[K]; +}; + +let pluginHandlers: PluginHandler<keyof HandlerDef>[] = []; + +function addPluginHandler<K extends keyof HandlerDef>(installId: Plugin['installId'], type: K, ctx: PluginHandler<K>['ctx']) { + pluginLogs.value.get(installId)!.push({ + at: Date.now(), + isSystem: true, + message: `Handler registered: ${type}`, + }); + pluginHandlers.push({ pluginInstallId: installId, type, ctx }); +} + +export function launchPlugins() { + for (const plugin of prefer.s.plugins) { + if (plugin.active) { + launchPlugin(plugin.installId); + } + } +} + +async function launchPlugin(id: Plugin['installId']): Promise<void> { + const plugin = prefer.s.plugins.find(x => x.installId === id); + if (!plugin) return; + // 後方互換性のため if (plugin.src == null) return; + pluginLogs.value.set(plugin.installId, []); + + function systemLog(message: string, isError = false): void { + pluginLogs.value.get(plugin.installId)?.push({ + at: Date.now(), + isSystem: true, + message, + isError, + }); + } + + systemLog('Starting plugin...'); + + await authorizePlugin(plugin); + const aiscript = new Interpreter(createPluginEnv({ plugin: plugin, - storageKey: 'plugins:' + plugin.id, + storageKey: 'plugins:' + plugin.installId, }), { in: aiScriptReadline, out: (value): void => { - console.log(value); - pluginLogs.value.get(plugin.id).push(utils.reprValue(value)); + pluginLogs.value.get(plugin.installId)!.push({ + at: Date.now(), + message: utils.reprValue(value), + }); }, log: (): void => { }, err: (err): void => { - pluginLogs.value.get(plugin.id).push(`${err}`); + pluginLogs.value.get(plugin.installId)!.push({ + at: Date.now(), + message: `${err}`, + isError: true, + }); throw err; // install時のtry-catchに反応させる }, }); - initPlugin({ plugin, aiscript }); + pluginContexts.set(plugin.installId, aiscript); aiscript.exec(parser.parse(plugin.src)).then( () => { console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + systemLog('Plugin started'); }, (err) => { console.error('Plugin install failed:', plugin.name, 'v' + plugin.version); + systemLog(`${err}`, true); throw err; }, ); } +export function abortPlugin(plugin: Plugin): void { + const pluginContext = pluginContexts.get(plugin.installId); + if (!pluginContext) return; + + pluginContext.abort(); + pluginContexts.delete(plugin.installId); + pluginLogs.value.delete(plugin.installId); + pluginHandlers = pluginHandlers.filter(x => x.pluginInstallId !== plugin.installId); +} + +export function reloadPlugin(plugin: Plugin): void { + abortPlugin(plugin); + launchPlugin(plugin.installId); +} + +export async function configPlugin(plugin: Plugin) { + if (plugin.config == null) { + throw new Error('This plugin does not have a config'); + } + + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + prefer.commit('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, configData: result } : x)); + + reloadPlugin(plugin); +} + +export function changePluginActive(plugin: Plugin, active: boolean) { + prefer.commit('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, active } : x)); + + if (active) { + launchPlugin(plugin.installId); + } else { + abortPlugin(plugin); + } +} + function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> { + const id = opts.plugin.installId; + const config = new Map<string, values.Value>(); for (const [k, v] of Object.entries(opts.plugin.config ?? {})) { config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); } - return { - ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), - //#region Deprecated - 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - //#endregion - 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + function withContext<T>(fn: (ctx: Interpreter) => T): T { + const ctx = pluginContexts.get(id); + if (!ctx) throw new Error('Plugin context not found'); + return fn(ctx); + } + + const env: Record<string, values.Value> = { + ...createAiScriptEnv({ ...opts, token: store.s.pluginTokens[id] }), + + 'Plugin:register:post_form_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'post_form_action', { + title: title.value, + handler: withContext(ctx => (form, update) => { + ctx.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + if (!key || !value) { + return; + } + update(utils.valToJs(key), utils.valToJs(value)); + })]); + }), + }); }), - 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + + 'Plugin:register:user_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'user_action', { + title: title.value, + handler: withContext(ctx => (user) => { + ctx.execFn(handler, [utils.jsToVal(user)]); + }), + }); }), - 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + + 'Plugin:register:note_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'note_action', { + title: title.value, + handler: withContext(ctx => (note) => { + ctx.execFn(handler, [utils.jsToVal(note)]); + }), + }); }), - 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { - registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:note_view_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'note_view_interruptor', { + handler: withContext(ctx => async (note) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + }), + }); }), - 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { - registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:note_post_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'note_post_interruptor', { + handler: withContext(ctx => async (note) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + }), + }); }), - 'Plugin:register_page_view_interruptor': values.FN_NATIVE(([handler]) => { - registerPageViewInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:page_view_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'page_view_interruptor', { + handler: withContext(ctx => async (page) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(page)])); + }), + }); }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { utils.assertString(url); warningExternalWebsite(url.value); }), + 'Plugin:config': values.OBJ(config), }; -} - -function initPlugin({ plugin, aiscript }): void { - pluginContexts.set(plugin.id, aiscript); - pluginLogs.value.set(plugin.id, []); -} - -function registerPostFormAction({ pluginId, title, handler }): void { - postFormActions.push({ - title, handler: (form, update) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { - if (!key || !value) { - return; - } - update(utils.valToJs(key), utils.valToJs(value)); - })]); - }, - }); -} - -function registerUserAction({ pluginId, title, handler }): void { - userActions.push({ - title, handler: (user) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(user)]); - }, - }); -} - -function registerNoteAction({ pluginId, title, handler }): void { - noteActions.push({ - title, handler: (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(note)]); - }, - }); -} -function registerNoteViewInterruptor({ pluginId, handler }): void { - noteViewInterruptors.push({ - handler: async (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); - }, - }); -} + // 後方互換性のため + env['Plugin:register_post_form_action'] = env['Plugin:register:post_form_action']; + env['Plugin:register_user_action'] = env['Plugin:register:user_action']; + env['Plugin:register_note_action'] = env['Plugin:register:note_action']; + env['Plugin:register_note_view_interruptor'] = env['Plugin:register:note_view_interruptor']; + env['Plugin:register_note_post_interruptor'] = env['Plugin:register:note_post_interruptor']; + env['Plugin:register_page_view_interruptor'] = env['Plugin:register:page_view_interruptor']; -function registerNotePostInterruptor({ pluginId, handler }): void { - notePostInterruptors.push({ - handler: async (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); - }, - }); + return env; } -function registerPageViewInterruptor({ pluginId, handler }): void { - pageViewInterruptors.push({ - handler: async (page) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(page)])); - }, - }); +export function getPluginHandlers<K extends keyof HandlerDef>(type: K): HandlerDef[K][] { + return pluginHandlers.filter((x): x is PluginHandler<K> => x.type === type).map(x => x.ctx); } |