add sync plugin and storage sync

This commit is contained in:
Fairy-Phy 2023-11-11 02:42:33 +09:00
parent f395324e8b
commit 8eb5f4957c
No known key found for this signature in database
GPG Key ID: 53E58673D5961DB5
13 changed files with 273 additions and 30 deletions

View File

@ -19,6 +19,8 @@ import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js
import { mainRouter } from '@/router.js';
import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { getPluginList } from '@/plugin.js';
import { unisonReload } from '@/scripts/unison-reload.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
@ -57,7 +59,11 @@ export async function mainBoot() {
}
});
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
let plugins = ColdDeviceStorage.get('plugins').filter(p => p.active);
const accountPlugins = Object.values(await getPluginList()).filter(p => p.active);
plugins.push(...accountPlugins);
for (const plugin of plugins) {
import('@/plugin.js').then(async ({ install }) => {
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
await new Promise(r => setTimeout(r, 0));
@ -274,6 +280,13 @@ export async function mainBoot() {
main.on('myTokenRegenerated', () => {
signout();
});
// プラグインに変更が入ったら自動でリロードする
stream.useChannel('main').on('registryUpdated', ({ scope, key }: { scope: string[], key: string, value: any }) => {
if (scope[0] === 'client' && key === 'plugins') {
unisonReload();
}
});
}
// shortcut

View File

@ -0,0 +1,76 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
:withOkButton="true"
:okButtonDisabled="false"
:canClose="false"
@close="dialog.close()"
@closed="$emit('closed')"
@ok="ok()"
>
<template #header>{{ 'プラグインの共有設定' }}</template>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<div>
<MkInfo>{{ 'このプラグインをローカルのみにインストールするか他端末と同期するかを選択できます。' }}</MkInfo>
</div>
<div v-if="isExistsFromAccount && localOrAccount === 'account'">
<MkInfo warn>{{ 'このプラグインは同期されているプラグインと重複しています。' }}</MkInfo>
<MkSwitch v-model="pluginOnlyOverride" :class="$style.switch">{{ 'コードのみを上書きする' }}</MkSwitch>
</div>
<div>
<MkSelect v-model="localOrAccount">
<template #label>{{ '共有設定' }}</template>
<option value="local">{{ 'ローカルのみ' }}</option>
<option value="account">{{ '他端末で同期' }}</option>
</MkSelect>
</div>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import MkSwitch from './MkSwitch.vue';
import MkSelect from './MkSelect.vue';
import MkInfo from './MkInfo.vue';
import MkModalWindow from './MkModalWindow.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
isExistsFromAccount: boolean;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
(ev: 'done', result: { name: string | null, permissions: string[] }): void;
}>();
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
let localOrAccount = $ref(props.isExistsFromAccount ? 'account' : 'local');
let pluginOnlyOverride = $ref(false);
function ok(): void {
emit('done', {
isLocal: localOrAccount === 'local',
pluginOnlyOverride,
});
dialog.close();
}
</script>
<style lang="scss" module>
.switch {
margin-top: 10px;
}
</style>

View File

@ -145,7 +145,10 @@ async function run() {
aiscript = new Interpreter({
...createAiScriptEnv({
storageKey: 'flash:' + flash.id,
scriptData: {
type: 'flash',
id: flash.id,
},
}),
...registerAsUiLib(components, (_root) => {
root.value = _root.value;

View File

@ -79,8 +79,10 @@ async function run() {
logs.value = [];
aiscript = new Interpreter(({
...createAiScriptEnv({
storageKey: 'widget',
token: $i?.token,
scriptData: {
type: 'widget',
},
}),
...registerAsUiLib(components, (_root) => {
root.value = _root.value;

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_m" style="padding: 20px;">
<div class="_gaps_s">
<span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
<span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span v-if="plugin.fromAccount" :class="$style.fromAccount">{{ '同期' }}</span><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
<MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
</div>
@ -74,11 +74,21 @@ import { ColdDeviceStorage } from '@/store.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { getPluginList } from '@/plugin.js';
const plugins = ref(ColdDeviceStorage.get('plugins'));
let plugins = Object.values(await getPluginList());
plugins.push(...ColdDeviceStorage.get('plugins'));
plugins = ref(plugins);
async function uninstall(plugin) {
ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
if (plugin.fromAccount) {
let plugins = await getPluginList();
delete plugins[plugin.id];
await os.api('i/registry/remove-all-keys-in-scope', { scope: ['client', 'aiscript', 'plugins', plugin.id] });
await os.api('i/registry/set', { scope: ['client'], key: 'plugins', value: plugins });
} else {
ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
}
await os.apiWithDialog('i/revoke-token', {
token: plugin.token,
});
@ -102,9 +112,15 @@ async function config(plugin) {
const { canceled, result } = await os.form(plugin.name, config);
if (canceled) return;
const coldPlugins = ColdDeviceStorage.get('plugins');
coldPlugins.find(p => p.id === plugin.id)!.configData = result;
ColdDeviceStorage.set('plugins', coldPlugins);
if (plugin.fromAccount) {
let plugins = await getPluginList();
plugins[plugin.id].configData = result;
await os.api('i/registry/set', { scope: ['client'], key: 'plugins', value: plugins });
} else {
const coldPlugins = ColdDeviceStorage.get('plugins');
coldPlugins.find(p => p.id === plugin.id)!.configData = result;
ColdDeviceStorage.set('plugins', coldPlugins);
}
nextTick(() => {
location.reload();
@ -130,3 +146,15 @@ definePageMetadata({
icon: 'ti ti-plug',
});
</script>
<style lang="scss" module>
.fromAccount {
margin-left: 0.7em;
font-size: 65%;
padding: 2px 3px;
color: var(--accent);
border: solid 1px var(--accent);
border-radius: 4px;
vertical-align: top;
}
</style>

View File

@ -5,19 +5,30 @@
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import { createAiScriptEnv } from '@/scripts/aiscript/api.js';
import { inputText } from '@/os.js';
import { inputText, api } from '@/os.js';
import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js';
import { $i } from './account.js';
const parser = new Parser();
const pluginContexts = new Map<string, Interpreter>();
export async function getPluginList(): Promise<Record<string, Plugin>> {
if ($i == null) return {};
try {
return await api('i/registry/get', { scope: ['client'], key: 'plugins' });
} catch (err) {
if (err.code === 'NO_SUCH_KEY') return {};
throw err;
}
}
export async function install(plugin: Plugin): Promise<void> {
// 後方互換性のため
if (plugin.src == null) return;
const aiscript = new Interpreter(createPluginEnv({
plugin: plugin,
storageKey: 'plugins:' + plugin.id,
}), {
in: (q): Promise<string> => {
return new Promise(ok => {
@ -51,14 +62,14 @@ export async function install(plugin: Plugin): Promise<void> {
console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
}
function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
function createPluginEnv(opts: { plugin: Plugin; }): Record<string, values.Value> {
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 }),
...createAiScriptEnv({ token: opts.plugin.token, scriptData: { type: 'plugins', id: opts.plugin.id, fromAccount: opts.plugin.fromAccount } }),
//#region Deprecated
'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
utils.assertString(title);

View File

@ -6,12 +6,12 @@
import { utils, values } from '@syuilo/aiscript';
import * as os from '@/os.js';
import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { url, lang } from '@/config.js';
import { nyaize } from '@/scripts/nyaize.js';
import { ScriptData, loadScriptStorage, saveScriptStorage } from './storage.js';
export function createAiScriptEnv(opts) {
export function createAiScriptEnv(opts: { token: string; scriptData: ScriptData; }) {
return {
USER_ID: $i ? values.STR($i.id) : values.NULL,
USER_NAME: $i ? values.STR($i.name) : values.NULL,
@ -60,14 +60,23 @@ export function createAiScriptEnv(opts) {
return values.ERROR('request_failed', utils.jsToVal(err));
});
}),
'Mk:save': values.FN_NATIVE(([key, value]) => {
'Mk:save': values.FN_NATIVE(async ([key, value, toAccount]) => {
utils.assertString(key);
miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value)));
return values.NULL;
const saveToAccount = toAccount ? toAccount.value : false;
return saveScriptStorage(saveToAccount, opts.scriptData, key.value, utils.valToJs(value)).then(() => {
return values.NULL;
}, err => {
return values.ERROR('request_failed', utils.jsToVal(err));
});
}),
'Mk:load': values.FN_NATIVE(([key]) => {
'Mk:load': values.FN_NATIVE(async ([key, toAccount]) => {
utils.assertString(key);
return utils.jsToVal(JSON.parse(miLocalStorage.getItem(`aiscript:${opts.storageKey}:${key.value}`)));
const loadToAccount = toAccount ? toAccount.value : false;
return loadScriptStorage(loadToAccount, opts.scriptData, key.value).then(res => {
return utils.jsToVal(res);
}, err => {
return values.ERROR('request_failed', utils.jsToVal(err));
});
}),
'Mk:url': values.FN_NATIVE(() => {
return values.STR(window.location.href);

View File

@ -0,0 +1,35 @@
import { api } from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { $i } from '@/account.js';
type ScriptType = 'widget' | 'plugins' | 'flash';
export type ScriptData = { type: ScriptType; id?: string; fromAccount?: boolean };
export async function loadScriptStorage(toAccount: boolean, scriptData: ScriptData, key: string) {
let value: string | null;
if ($i && toAccount && (scriptData.type !== 'plugins' || (scriptData.type === 'plugins' && scriptData.fromAccount))) {
if (scriptData.type === 'widget') {
value = await api('i/registry/get', { scope: ['client', 'aiscript', scriptData.type], key: key });
} else {
value = await api('i/registry/get', { scope: ['client', 'aiscript', scriptData.type, scriptData.id!], key: key });
}
} else {
value = miLocalStorage.getItem(`aiscript:${scriptData.type}:${key}`);
}
if (value === null) return null;
return JSON.parse(value);
}
export async function saveScriptStorage(toAccount: boolean, scriptData: ScriptData, key: string, value: any) {
const jsonValue = JSON.stringify(value);
if ($i && toAccount && (scriptData.type !== 'plugins' || (scriptData.type === 'plugins' && scriptData.fromAccount))) {
if (scriptData.type === 'widget') {
await api('i/registry/set', { scope: ['client', 'aiscript', scriptData.type], key: key, value: jsonValue });
} else {
await api('i/registry/set', { scope: ['client', 'aiscript', scriptData.type, scriptData.id!], key: key, value: jsonValue });
}
} else {
miLocalStorage.setItem(`aiscript:${scriptData.type}:${key}`, jsonValue);
}
}

View File

@ -6,11 +6,13 @@
import { defineAsyncComponent } from 'vue';
import { compareVersions } from 'compare-versions';
import { v4 as uuid } from 'uuid';
import xxhash from 'xxhash-wasm';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
import type { Plugin } from '@/store.js';
import { ColdDeviceStorage } from '@/store.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { getPluginList } from '@/plugin.js';
export type AiScriptPluginMeta = {
name: string;
@ -23,6 +25,11 @@ export type AiScriptPluginMeta = {
const parser = new Parser();
async function toHash(name: string, author: string) {
const { h32ToString } = await xxhash();
return h32ToString(author + name);
}
export function savePlugin({ id, meta, src, token }: {
id: string;
meta: AiScriptPluginMeta;
@ -36,9 +43,37 @@ export function savePlugin({ id, meta, src, token }: {
configData: {},
token: token,
src: src,
fromAccount: false,
} as Plugin));
}
async function savePluginToAccount(pluginOnlyOverride: boolean, { hash, meta, src, token }: {
hash: string;
meta: AiScriptPluginMeta;
src: string;
token: string;
}) {
let plugins = await getPluginList();
// pluginOnlyOverrideがtrueになっているということはすでに重複していることが確定している
const configData = pluginOnlyOverride ? plugins[hash].configData : {};
const pluginToken = pluginOnlyOverride ? plugins[hash].token : token;
plugins[hash] = {
...meta,
id: hash,
active: true,
configData,
token: pluginToken,
src: src,
fromAccount: true,
} as Plugin;
if (!pluginOnlyOverride) {
await os.api('i/registry/remove-all-keys-in-scope', { scope: ['client', 'aiscript', 'plugins', hash] });
}
await os.api('i/registry/set', { scope: ['client'], key: 'plugins', value: plugins });
}
export function isSupportedAiScriptVersion(version: string): boolean {
try {
return (compareVersions(version, '0.12.0') >= 0);
@ -101,7 +136,20 @@ export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
realMeta = meta;
}
const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => {
const plugins = Object.keys(await getPluginList());
const pluginHash = await toHash(realMeta.name, realMeta.author);
const { isLocal, pluginOnlyOverride } = (await new Promise((res, rej) => {
os.popup(defineAsyncComponent(() => import('@/components/MkPluginSelectSaveWindow.vue')), {
isExistsFromAccount: plugins.some(v => v === pluginHash)
}, {
done: result => {
res(result);
},
}, 'closed');
}));
const token = realMeta.permissions == null || realMeta.permissions.length === 0 || pluginOnlyOverride ? null : await new Promise((res, rej) => {
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
@ -120,10 +168,21 @@ export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
}, 'closed');
});
savePlugin({
id: uuid(),
meta: realMeta,
token,
src: code,
});
if (isLocal) {
savePlugin({
id: uuid(),
meta: realMeta,
token,
src: code,
});
}
else {
await savePluginToAccount(pluginOnlyOverride, {
hash: pluginHash,
meta: realMeta,
token,
src: code,
});
}
}

View File

@ -426,6 +426,7 @@ export type Plugin = {
author?: string;
description?: string;
permissions?: string[];
fromAccount: boolean;
};
interface Watcher {

View File

@ -66,8 +66,10 @@ const logs = ref<{
const run = async () => {
logs.value = [];
const aiscript = new Interpreter(createAiScriptEnv({
storageKey: 'widget',
token: $i?.token,
scriptData: {
type: 'widget',
},
}), {
in: (q) => {
return new Promise(ok => {

View File

@ -57,8 +57,10 @@ const components: Ref<AsUiComponent>[] = $ref([]);
async function run() {
const aiscript = new Interpreter({
...createAiScriptEnv({
storageKey: 'widget',
token: $i?.token,
scriptData: {
type: 'widget',
},
}),
...registerAsUiLib(components, (_root) => {
root.value = _root.value;

View File

@ -53,8 +53,10 @@ const parser = new Parser();
const run = async () => {
const aiscript = new Interpreter(createAiScriptEnv({
storageKey: 'widget',
token: $i?.token,
scriptData: {
type: 'widget',
},
}), {
in: (q) => {
return new Promise(ok => {