Plugin system (#6479)

* wip

* wip

* wip

* wip

* Update store.ts
This commit is contained in:
syuilo 2020-07-12 00:38:55 +09:00 committed by GitHub
parent cbdfbc1a44
commit 3144e2aff3
13 changed files with 293 additions and 17 deletions

View File

@ -523,6 +523,9 @@ themeEditor: "テーマエディター"
description: "説明" description: "説明"
author: "作者" author: "作者"
leaveConfirm: "未保存の変更があります。破棄しますか?" leaveConfirm: "未保存の変更があります。破棄しますか?"
manage: "管理"
plugins: "プラグイン"
pluginInstallWarn: "信頼できないプラグインはインストールしないでください。"
deck: "デッキ" deck: "デッキ"
undeck: "デッキ解除" undeck: "デッキ解除"

View File

@ -48,7 +48,7 @@
"@koa/multer": "3.0.0", "@koa/multer": "3.0.0",
"@koa/router": "9.3.1", "@koa/router": "9.3.1",
"@sinonjs/fake-timers": "6.0.1", "@sinonjs/fake-timers": "6.0.1",
"@syuilo/aiscript": "0.7.0", "@syuilo/aiscript": "0.7.2",
"@types/bcryptjs": "2.4.2", "@types/bcryptjs": "2.4.2",
"@types/bull": "3.14.0", "@types/bull": "3.14.0",
"@types/cbor": "5.0.0", "@types/cbor": "5.0.0",

View File

@ -89,7 +89,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { parse } from '../../mfm/parse'; import { parse } from '../../mfm/parse';
import { sum, unique } from '../../prelude/array'; import { sum, unique } from '../../prelude/array';
@ -108,7 +108,6 @@ import { url } from '../config';
import copyToClipboard from '../scripts/copy-to-clipboard'; import copyToClipboard from '../scripts/copy-to-clipboard';
export default Vue.extend({ export default Vue.extend({
components: { components: {
XSub, XSub,
XNoteHeader, XNoteHeader,
@ -145,7 +144,7 @@ export default Vue.extend({
showContent: false, showContent: false,
hideThisNote: false, hideThisNote: false,
noteBody: this.$refs.noteBody, noteBody: this.$refs.noteBody,
faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faEllipsisH faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug
}; };
}, },
@ -612,6 +611,16 @@ export default Vue.extend({
.filter(x => x !== undefined); .filter(x => x !== undefined);
} }
if (this.$store.state.noteActions.length > 0) {
menu = menu.concat([null, ...this.$store.state.noteActions.map(action => ({
icon: faPlug,
text: action.title,
action: () => {
action.handler(this.appearNote);
}
}))]);
}
this.$root.menu({ this.$root.menu({
items: menu, items: menu,
source: this.$refs.menuButton, source: this.$refs.menuButton,

View File

@ -44,6 +44,7 @@
<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><fa :icon="faEyeSlash"/></button> <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><fa :icon="faEyeSlash"/></button>
<button class="_button" @click="insertMention" v-tooltip="$t('mention')"><fa :icon="faAt"/></button> <button class="_button" @click="insertMention" v-tooltip="$t('mention')"><fa :icon="faAt"/></button>
<button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><fa :icon="faLaughSquint"/></button> <button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><fa :icon="faLaughSquint"/></button>
<button class="_button" @click="showActions" v-tooltip="$t('plugin')" v-if="$store.state.postFormActions.length > 0"><fa :icon="faPlug"/></button>
</footer> </footer>
<input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/> <input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/>
</div> </div>
@ -52,7 +53,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard } from '@fortawesome/free-solid-svg-icons'; import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz'; import { length } from 'stringz';
@ -133,7 +134,7 @@ export default Vue.extend({
draghover: false, draghover: false,
quoteId: null, quoteId: null,
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug
}; };
}, },
@ -580,6 +581,22 @@ export default Vue.extend({
vm.close(); vm.close();
}); });
}, },
showActions(ev) {
this.$root.menu({
items: this.$store.state.postFormActions.map(action => ({
text: action.title,
action: () => {
action.handler({
text: this.text
}, (key, value) => {
if (key === 'text') { this.text = value; }
});
}
})),
source: ev.currentTarget || ev.target,
});
}
} }
}); });
</script> </script>

View File

@ -4,7 +4,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons';
import XMenu from './menu.vue'; import XMenu from './menu.vue';
import copyToClipboard from '../scripts/copy-to-clipboard'; import copyToClipboard from '../scripts/copy-to-clipboard';
@ -80,6 +80,16 @@ export default Vue.extend({
}]); }]);
} }
if (this.$store.state.userActions.length > 0) {
menu = menu.concat([null, ...this.$store.state.userActions.map(action => ({
icon: faPlug,
text: action.title,
action: () => {
action.handler(this.user);
}
}))]);
}
return { return {
items: menu items: menu
}; };

View File

@ -25,6 +25,8 @@ import { isDeviceDarkmode } from './scripts/is-device-darkmode';
import createStore from './store'; import createStore from './store';
import { clientDb, get, count } from './db'; import { clientDb, get, count } from './db';
import { setI18nContexts } from './scripts/set-i18n-contexts'; import { setI18nContexts } from './scripts/set-i18n-contexts';
import { createPluginEnv } from './scripts/aiscript/api';
import { AiScript } from '@syuilo/aiscript';
Vue.use(Vuex); Vue.use(Vuex);
Vue.use(VueHotkey); Vue.use(VueHotkey);
@ -231,6 +233,35 @@ os.init(async () => {
//store.commit('instance/set', ); //store.commit('instance/set', );
}); });
for (const plugin of store.state.deviceUser.plugins) {
console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
const aiscript = new AiScript(createPluginEnv(app, {
plugin: plugin,
storageKey: 'plugins:' + plugin.id
}), {
in: (q) => {
return new Promise(ok => {
app.dialog({
title: q,
input: {}
}).then(({ canceled, result: a }) => {
ok(a);
});
});
},
out: (value) => {
console.log(value);
},
log: (type, params) => {
},
});
store.commit('initPlugin', { plugin, aiscript });
aiscript.exec(plugin.ast);
}
if (store.getters.isSignedIn) { if (store.getters.isSignedIn) {
const main = os.stream.useSharedConnection('main'); const main = os.stream.useSharedConnection('main');

View File

@ -9,6 +9,8 @@
<x-sidebar/> <x-sidebar/>
<x-plugins/>
<section class="_card"> <section class="_card">
<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div> <div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
<div class="_content"> <div class="_content">
@ -115,6 +117,7 @@ import MkRadio from '../../components/ui/radio.vue';
import MkRange from '../../components/ui/range.vue'; import MkRange from '../../components/ui/range.vue';
import XTheme from './theme.vue'; import XTheme from './theme.vue';
import XSidebar from './sidebar.vue'; import XSidebar from './sidebar.vue';
import XPlugins from './plugins.vue';
import { langs } from '../../config'; import { langs } from '../../config';
import { clientDb, set } from '../../db'; import { clientDb, set } from '../../db';
@ -146,11 +149,12 @@ export default Vue.extend({
components: { components: {
XTheme, XTheme,
XSidebar, XSidebar,
XPlugins,
MkButton, MkButton,
MkSwitch, MkSwitch,
MkSelect, MkSelect,
MkRadio, MkRadio,
MkRange MkRange,
}, },
data() { data() {

View File

@ -0,0 +1,134 @@
<template>
<section class="_card">
<div class="_title"><fa :icon="faPlug"/> {{ $t('plugins') }}</div>
<div class="_content">
<details>
<summary><fa :icon="faDownload"/> {{ $t('install') }}</summary>
<mk-info warn>{{ $t('pluginInstallWarn') }}</mk-info>
<mk-textarea v-model="script" tall>
<span>{{ $t('script') }}</span>
</mk-textarea>
<mk-button @click="install()" primary><fa :icon="faSave"/> {{ $t('install') }}</mk-button>
</details>
</div>
<div class="_content">
<details>
<summary><fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary>
<mk-select v-model="selectedPluginId">
<option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option>
</mk-select>
<template v-if="selectedPlugin">
<div class="_keyValue">
<div>{{ $t('version') }}:</div>
<div>{{ selectedPlugin.version }}</div>
</div>
<div class="_keyValue">
<div>{{ $t('author') }}:</div>
<div>{{ selectedPlugin.author }}</div>
</div>
<div class="_keyValue">
<div>{{ $t('description') }}:</div>
<div>{{ selectedPlugin.description }}</div>
</div>
<mk-button @click="uninstall()" style="margin-top: 8px;"><fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</mk-button>
</template>
</details>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload } from '@fortawesome/free-solid-svg-icons';
import MkButton from '../../components/ui/button.vue';
import MkTextarea from '../../components/ui/textarea.vue';
import MkSelect from '../../components/ui/select.vue';
import MkInfo from '../../components/ui/info.vue';
import { AiScript, parse } from '@syuilo/aiscript';
export default Vue.extend({
components: {
MkButton,
MkTextarea,
MkSelect,
MkInfo,
},
data() {
return {
script: '',
selectedPluginId: null,
faPlug, faSave, faTrashAlt, faFolderOpen, faDownload
}
},
computed: {
selectedPlugin() {
if (this.selectedPluginId == null) return null;
return this.$store.state.deviceUser.plugins.find(x => x.id === this.selectedPluginId);
},
},
methods: {
install() {
let ast;
try {
ast = parse(this.script);
} catch (e) {
this.$root.dialog({
type: 'error',
text: 'Syntax error :('
});
return;
}
const meta = AiScript.collectMetadata(ast);
console.log(meta);
if (meta == null) {
this.$root.dialog({
type: 'error',
text: 'No metadata found :('
});
return;
}
const data = meta.get(null);
if (data == null) {
this.$root.dialog({
type: 'error',
text: 'No metadata found :('
});
return;
}
const { id, name, version, author, description } = data;
if (id == null || name == null || version == null || author == null) {
this.$root.dialog({
type: 'error',
text: 'Required property not found :('
});
return;
}
this.$store.commit('deviceUser/installPlugin', {
meta: {
id, name, version, author, description
},
ast
});
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
},
uninstall() {
this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId);
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -30,7 +30,7 @@ import PrismEditor from 'vue-prism-editor';
import { AiScript, parse, utils, values } from '@syuilo/aiscript'; import { AiScript, parse, utils, values } from '@syuilo/aiscript';
import MkContainer from '../components/ui/container.vue'; import MkContainer from '../components/ui/container.vue';
import MkButton from '../components/ui/button.vue'; import MkButton from '../components/ui/button.vue';
import { createAiScriptEnv } from '../scripts/create-aiscript-env'; import { createAiScriptEnv } from '../scripts/aiscript/api';
export default Vue.extend({ export default Vue.extend({
metaInfo() { metaInfo() {

View File

@ -40,3 +40,18 @@ export function createAiScriptEnv(vm, opts) {
}), }),
}; };
} }
export function createPluginEnv(vm, opts) {
return {
...createAiScriptEnv(vm, opts),
'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler });
}),
'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => {
vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler });
}),
'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => {
vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler });
}),
};
}

View File

@ -3,7 +3,7 @@ import * as seedrandom from 'seedrandom';
import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.'; import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.';
import { version } from '../../config'; import { version } from '../../config';
import { AiScript, utils, values } from '@syuilo/aiscript'; import { AiScript, utils, values } from '@syuilo/aiscript';
import { createAiScriptEnv } from '../create-aiscript-env'; import { createAiScriptEnv } from '../aiscript/api';
import { collectPageVars } from '../collect-page-vars'; import { collectPageVars } from '../collect-page-vars';
import { initLib } from './lib'; import { initLib } from './lib';

View File

@ -3,6 +3,7 @@ import createPersistedState from 'vuex-persistedstate';
import * as nestedProperty from 'nested-property'; import * as nestedProperty from 'nested-property';
import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons'; import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
import { AiScript, utils, values } from '@syuilo/aiscript';
import { apiUrl, deckmode } from './config'; import { apiUrl, deckmode } from './config';
import { erase } from '../prelude/array'; import { erase } from '../prelude/array';
@ -43,6 +44,7 @@ export const defaultDeviceUserSettings = {
columns: [], columns: [],
layout: [], layout: [],
}, },
plugins: [],
}; };
export const defaultDeviceSettings = { export const defaultDeviceSettings = {
@ -93,7 +95,13 @@ export default () => new Vuex.Store({
state: { state: {
i: null, i: null,
pendingApiRequestsCount: 0, pendingApiRequestsCount: 0,
spinner: null spinner: null,
// Plugin
pluginContexts: new Map<string, AiScript>(),
postFormActions: [],
userActions: [],
noteActions: [],
}, },
getters: { getters: {
@ -224,8 +232,38 @@ export default () => new Vuex.Store({
state.i = x; state.i = x;
}, },
updateIKeyValue(state, x) { updateIKeyValue(state, { key, value }) {
state.i[x.key] = x.value; state.i[key] = value;
},
initPlugin(state, { plugin, aiscript }) {
state.pluginContexts.set(plugin.id, aiscript);
},
registerPostFormAction(state, { pluginId, title, handler }) {
state.postFormActions.push({
title, handler: (form, update) => {
state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
update(key.value, value.value);
})]);
}
});
},
registerUserAction(state, { pluginId, title, handler }) {
state.userActions.push({
title, handler: (user) => {
state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]);
}
});
},
registerNoteAction(state, { pluginId, title, handler }) {
state.noteActions.push({
title, handler: (note) => {
state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]);
}
});
}, },
}, },
@ -546,6 +584,21 @@ export default () => new Vuex.Store({
column = x; column = x;
}, },
//#endregion //#endregion
installPlugin(state, { meta, ast }) {
state.plugins.push({
id: meta.id,
name: meta.name,
version: meta.version,
author: meta.author,
description: meta.description,
ast: ast
});
},
uninstallPlugin(state, id) {
state.plugins = state.plugins.filter(x => x.id != id);
},
} }
}, },

View File

@ -197,10 +197,10 @@
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@sinonjs/commons" "^1.7.0"
"@syuilo/aiscript@0.7.0": "@syuilo/aiscript@0.7.2":
version "0.7.0" version "0.7.2"
resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.7.0.tgz#1394511a789891e844d32e536a203fe0d92b3039" resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.7.2.tgz#2f30adb14ffa9f1180af83c059927ab306b175a5"
integrity sha512-X4TaP/FO7RD8MpFSPDFwKAI4KX7byn8ApqmSSmf2bxcwCTcdevsbyxsLrvkbNaWclIoqTgXwtJjY+2Tc2exeXA== integrity sha512-l8HVTJTq9KLzDqGswOIGlBepkacudUp70EScrLjL7nEL2NKcti7Ui5fwZCrmxazxgGz6NrVNX5UBIOFFyrwr0A==
dependencies: dependencies:
autobind-decorator "2.4.0" autobind-decorator "2.4.0"
chalk "4.0.0" chalk "4.0.0"