Feat: 外部サイトからテーマ・プラグインのインストールができるように (#12034)
* Feat: 外部サイトからテーマ・プラグインのインストールができるように * Update Changelog * Change Changelog * Remove unnecessary imports * Update fetch-external-resources.ts * Update CHANGELOG.md * Update CHANGELOG.md
This commit is contained in:
parent
722584bf72
commit
f51bca41c5
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -12,6 +12,19 @@
|
||||||
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## 2023.x.x (unreleased)
|
||||||
|
|
||||||
|
### General
|
||||||
|
-
|
||||||
|
|
||||||
|
## Client
|
||||||
|
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
|
||||||
|
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
|
||||||
|
https://misskey-hub.net/docs/advanced/publish-on-your-website.html
|
||||||
|
|
||||||
|
### Server
|
||||||
|
-
|
||||||
|
|
||||||
## 2023.10.2
|
## 2023.10.2
|
||||||
|
|
||||||
### General
|
### General
|
||||||
|
|
55
locales/index.d.ts
vendored
55
locales/index.d.ts
vendored
|
@ -2313,6 +2313,61 @@ export interface Locale {
|
||||||
"attachedNotes": string;
|
"attachedNotes": string;
|
||||||
"thisPageCanBeSeenFromTheAuthor": string;
|
"thisPageCanBeSeenFromTheAuthor": string;
|
||||||
};
|
};
|
||||||
|
"_externalResourceInstaller": {
|
||||||
|
"title": string;
|
||||||
|
"checkVendorBeforeInstall": string;
|
||||||
|
"_plugin": {
|
||||||
|
"title": string;
|
||||||
|
"metaTitle": string;
|
||||||
|
};
|
||||||
|
"_theme": {
|
||||||
|
"title": string;
|
||||||
|
"metaTitle": string;
|
||||||
|
};
|
||||||
|
"_meta": {
|
||||||
|
"base": string;
|
||||||
|
};
|
||||||
|
"_vendorInfo": {
|
||||||
|
"title": string;
|
||||||
|
"endpoint": string;
|
||||||
|
"hashVerify": string;
|
||||||
|
};
|
||||||
|
"_errors": {
|
||||||
|
"_invalidParams": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
"_resourceTypeNotSupported": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
"_failedToFetch": {
|
||||||
|
"title": string;
|
||||||
|
"fetchErrorDescription": string;
|
||||||
|
"parseErrorDescription": string;
|
||||||
|
};
|
||||||
|
"_hashUnmatched": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
"_pluginParseFailed": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
"_pluginInstallFailed": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
"_themeParseFailed": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
"_themeInstallFailed": {
|
||||||
|
"title": string;
|
||||||
|
"description": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -2225,3 +2225,45 @@ _fileViewer:
|
||||||
uploadedAt: "追加日"
|
uploadedAt: "追加日"
|
||||||
attachedNotes: "添付されているノート"
|
attachedNotes: "添付されているノート"
|
||||||
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"
|
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"
|
||||||
|
|
||||||
|
_externalResourceInstaller:
|
||||||
|
title: "外部サイトからインストール"
|
||||||
|
checkVendorBeforeInstall: "配布元が信頼できるかを確認した上でインストールしてください。"
|
||||||
|
_plugin:
|
||||||
|
title: "このプラグインをインストールしますか?"
|
||||||
|
metaTitle: "プラグイン情報"
|
||||||
|
_theme:
|
||||||
|
title: "このテーマをインストールしますか?"
|
||||||
|
metaTitle: "テーマ情報"
|
||||||
|
_meta:
|
||||||
|
base: "基本のカラースキーム"
|
||||||
|
_vendorInfo:
|
||||||
|
title: "配布元情報"
|
||||||
|
endpoint: "参照したエンドポイント"
|
||||||
|
hashVerify: "ファイル整合性の確認"
|
||||||
|
_errors:
|
||||||
|
_invalidParams:
|
||||||
|
title: "パラメータが不足しています"
|
||||||
|
description: "外部サイトからデータを取得するために必要な情報が不足しています。URLをお確かめください。"
|
||||||
|
_resourceTypeNotSupported:
|
||||||
|
title: "この外部リソースには対応していません"
|
||||||
|
description: "この外部サイトから取得したリソースの種別には対応していません。サイト管理者にお問い合わせください。"
|
||||||
|
_failedToFetch:
|
||||||
|
title: "データの取得に失敗しました"
|
||||||
|
fetchErrorDescription: "外部サイトとの通信に失敗しました。もう一度試しても改善しない場合、サイト管理者にお問い合わせください。"
|
||||||
|
parseErrorDescription: "外部サイトから取得したデータが読み取れませんでした。サイト管理者にお問い合わせください。"
|
||||||
|
_hashUnmatched:
|
||||||
|
title: "正しいデータが取得できませんでした"
|
||||||
|
description: "提供されたデータの整合性の確認に失敗しました。セキュリティ上、インストールは続行できません。サイト管理者にお問い合わせください。"
|
||||||
|
_pluginParseFailed:
|
||||||
|
title: "AiScript エラー"
|
||||||
|
description: "データは取得できたものの、AiScriptの解析時にエラーがあったため読み込めませんでした。プラグインの作者にお問い合わせください。エラーの詳細はJavascriptコンソールをご確認ください。"
|
||||||
|
_pluginInstallFailed:
|
||||||
|
title: "プラグインのインストールに失敗しました"
|
||||||
|
description: "プラグインのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。"
|
||||||
|
_themeParseFailed:
|
||||||
|
title: "テーマ解析エラー"
|
||||||
|
description: "データは取得できたものの、テーマファイルの解析時にエラーがあったため読み込めませんでした。テーマの作者にお問い合わせください。エラーの詳細はJavascriptコンソールをご確認ください。"
|
||||||
|
_themeInstallFailed:
|
||||||
|
title: "テーマのインストールに失敗しました"
|
||||||
|
description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。"
|
||||||
|
|
|
@ -357,6 +357,7 @@ import * as ep___users_show from './endpoints/users/show.js';
|
||||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||||
|
import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js';
|
||||||
import * as ep___retention from './endpoints/retention.js';
|
import * as ep___retention from './endpoints/retention.js';
|
||||||
import { GetterService } from './GetterService.js';
|
import { GetterService } from './GetterService.js';
|
||||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||||
|
@ -713,6 +714,7 @@ const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_s
|
||||||
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
|
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
|
||||||
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
|
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
|
||||||
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
|
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
|
||||||
|
const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default };
|
||||||
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
|
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
@ -1073,6 +1075,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$users_achievements,
|
$users_achievements,
|
||||||
$users_updateMemo,
|
$users_updateMemo,
|
||||||
$fetchRss,
|
$fetchRss,
|
||||||
|
$fetchExternalResources,
|
||||||
$retention,
|
$retention,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
@ -1424,6 +1427,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$users_achievements,
|
$users_achievements,
|
||||||
$users_updateMemo,
|
$users_updateMemo,
|
||||||
$fetchRss,
|
$fetchRss,
|
||||||
|
$fetchExternalResources,
|
||||||
$retention,
|
$retention,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -357,6 +357,7 @@ import * as ep___users_show from './endpoints/users/show.js';
|
||||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||||
|
import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js';
|
||||||
import * as ep___retention from './endpoints/retention.js';
|
import * as ep___retention from './endpoints/retention.js';
|
||||||
|
|
||||||
const eps = [
|
const eps = [
|
||||||
|
@ -711,6 +712,7 @@ const eps = [
|
||||||
['users/achievements', ep___users_achievements],
|
['users/achievements', ep___users_achievements],
|
||||||
['users/update-memo', ep___users_updateMemo],
|
['users/update-memo', ep___users_updateMemo],
|
||||||
['fetch-rss', ep___fetchRss],
|
['fetch-rss', ep___fetchRss],
|
||||||
|
['fetch-external-resources', ep___fetchExternalResources],
|
||||||
['retention', ep___retention],
|
['retention', ep___retention],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import ms from 'ms';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
|
import { ApiError } from '../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['meta'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
limit: {
|
||||||
|
duration: ms('1hour'),
|
||||||
|
max: 50,
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
invalidSchema: {
|
||||||
|
message: 'External resource returned invalid schema.',
|
||||||
|
code: 'EXT_RESOURCE_RETURNED_INVALID_SCHEMA',
|
||||||
|
id: 'bb774091-7a15-4a70-9dc5-6ac8cf125856',
|
||||||
|
},
|
||||||
|
hashUnmached: {
|
||||||
|
message: 'Hash did not match.',
|
||||||
|
code: 'EXT_RESOURCE_HASH_DIDNT_MATCH',
|
||||||
|
id: '693ba8ba-b486-40df-a174-72f8279b56a4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string' },
|
||||||
|
hash: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['url', 'hash'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private httpRequestService: HttpRequestService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps) => {
|
||||||
|
const res = await this.httpRequestService.getJson<{
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
}>(ps.url);
|
||||||
|
|
||||||
|
if (!res.data || !res.type) {
|
||||||
|
throw new ApiError(meta.errors.invalidSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resHash = createHash('sha512').update(res.data.replace(/\r\n/g, '\n')).digest('hex');
|
||||||
|
if (resHash !== ps.hash) {
|
||||||
|
throw new ApiError(meta.errors.hashUnmached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: res.type,
|
||||||
|
data: res.data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,23 +31,28 @@ import * as os from '@/os.js';
|
||||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||||
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
|
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
url: string;
|
url: string;
|
||||||
rel?: string;
|
rel?: string;
|
||||||
}>();
|
showUrlPreview?: boolean;
|
||||||
|
}>(), {
|
||||||
|
showUrlPreview: true,
|
||||||
|
});
|
||||||
|
|
||||||
const self = props.url.startsWith(local);
|
const self = props.url.startsWith(local);
|
||||||
const url = new URL(props.url);
|
const url = new URL(props.url);
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
|
||||||
const el = ref();
|
const el = ref();
|
||||||
|
|
||||||
useTooltip(el, (showing) => {
|
if (props.showUrlPreview) {
|
||||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
useTooltip(el, (showing) => {
|
||||||
showing,
|
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||||
url: props.url,
|
showing,
|
||||||
source: el.value,
|
url: props.url,
|
||||||
}, {}, 'closed');
|
source: el.value,
|
||||||
});
|
}, {}, 'closed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const schema = url.protocol;
|
const schema = url.protocol;
|
||||||
const hostname = decodePunycode(url.hostname);
|
const hostname = decodePunycode(url.hostname);
|
||||||
|
|
354
packages/frontend/src/pages/install-extentions.vue
Normal file
354
packages/frontend/src/pages/install-extentions.vue
Normal file
|
@ -0,0 +1,354 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
|
<MkSpacer :contentMax="500">
|
||||||
|
<MkLoading v-if="uiPhase === 'fetching'"/>
|
||||||
|
<div v-else-if="uiPhase === 'confirm' && data" class="_gaps_m" :class="$style.extInstallerRoot">
|
||||||
|
<div :class="$style.extInstallerIconWrapper">
|
||||||
|
<i v-if="data.type === 'plugin'" class="ti ti-plug"></i>
|
||||||
|
<i v-else-if="data.type === 'theme'" class="ti ti-palette"></i>
|
||||||
|
<i v-else class="ti ti-download"></i>
|
||||||
|
</div>
|
||||||
|
<h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${data.type}`].title }}</h2>
|
||||||
|
<div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div>
|
||||||
|
<MkInfo v-if="data.type === 'plugin'" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo>
|
||||||
|
<FormSection>
|
||||||
|
<template #label>{{ i18n.ts._externalResourceInstaller[`_${data.type}`].metaTitle }}</template>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<FormSplit>
|
||||||
|
<MkKeyValue>
|
||||||
|
<template #key>{{ i18n.ts.name }}</template>
|
||||||
|
<template #value>{{ data.meta?.name }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkKeyValue>
|
||||||
|
<template #key>{{ i18n.ts.author }}</template>
|
||||||
|
<template #value>{{ data.meta?.author }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
</FormSplit>
|
||||||
|
<MkKeyValue v-if="data.type === 'plugin'">
|
||||||
|
<template #key>{{ i18n.ts.description }}</template>
|
||||||
|
<template #value>{{ data.meta?.description }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkKeyValue v-if="data.type === 'plugin'">
|
||||||
|
<template #key>{{ i18n.ts.version }}</template>
|
||||||
|
<template #value>{{ data.meta?.version }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkKeyValue v-if="data.type === 'plugin'">
|
||||||
|
<template #key>{{ i18n.ts.permission }}</template>
|
||||||
|
<template #value>
|
||||||
|
<ul :class="$style.extInstallerKVList">
|
||||||
|
<li v-for="permission in data.meta?.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkKeyValue v-if="data.type === 'theme' && data.meta?.base">
|
||||||
|
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
|
||||||
|
<template #value>{{ i18n.ts[data.meta.base] }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkFolder>
|
||||||
|
<template #icon><i class="ti ti-code"></i></template>
|
||||||
|
<template #label>{{ i18n.ts._plugin.viewSource }}</template>
|
||||||
|
|
||||||
|
<MkCode :code="data.raw ?? ''"/>
|
||||||
|
</MkFolder>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection>
|
||||||
|
<template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkKeyValue>
|
||||||
|
<template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template>
|
||||||
|
<template #value><MkUrl :url="url ?? ''" :showUrlPreview="false"></MkUrl></template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkKeyValue>
|
||||||
|
<template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.hashVerify }}</template>
|
||||||
|
<template #value>
|
||||||
|
<!--この画面が出ている時点でハッシュの検証には成功している-->
|
||||||
|
<i class="ti ti-check" style="color: var(--accent)"></i>
|
||||||
|
</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
<div class="_buttonsCenter">
|
||||||
|
<MkButton primary @click="install()"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="uiPhase === 'error'" class="_gaps_m" :class="[$style.extInstallerRoot, $style.error]">
|
||||||
|
<div :class="$style.extInstallerIconWrapper">
|
||||||
|
<i class="ti ti-circle-x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 :class="$style.extInstallerTitle">{{ errorKV?.title }}</h2>
|
||||||
|
<div :class="$style.extInstallerNormDesc">{{ errorKV?.description }}</div>
|
||||||
|
<div class="_buttonsCenter">
|
||||||
|
<MkButton @click="goBack()">{{ i18n.ts.goBack }}</MkButton>
|
||||||
|
<MkButton @click="goToMisskey()">{{ i18n.ts.goToMisskey }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||||
|
import MkLoading from '@/components/global/MkLoading.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import FormSection from '@/components/form/section.vue';
|
||||||
|
import FormSplit from '@/components/form/split.vue';
|
||||||
|
import MkCode from '@/components/MkCode.vue';
|
||||||
|
import MkUrl from '@/components/global/MkUrl.vue';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js';
|
||||||
|
import { parseThemeCode, installTheme } from '@/scripts/install-theme.js';
|
||||||
|
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
|
const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching');
|
||||||
|
const errorKV = ref<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}>({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const url = urlParams.get('url');
|
||||||
|
const hash = urlParams.get('hash');
|
||||||
|
|
||||||
|
const data = ref<{
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
raw: string;
|
||||||
|
meta?: {
|
||||||
|
// Plugin & Theme Common
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
|
||||||
|
// Plugin
|
||||||
|
description?: string;
|
||||||
|
version?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
config?: Record<string, any>;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
base?: 'light' | 'dark';
|
||||||
|
};
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToMisskey(): void {
|
||||||
|
location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
if (!url || !hash) {
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._invalidParams.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._invalidParams.description,
|
||||||
|
};
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await os.api('fetch-external-resources', {
|
||||||
|
url,
|
||||||
|
hash,
|
||||||
|
}).catch((err) => {
|
||||||
|
switch (err.id) {
|
||||||
|
case 'bb774091-7a15-4a70-9dc5-6ac8cf125856':
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.parseErrorDescription,
|
||||||
|
};
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
break;
|
||||||
|
case '693ba8ba-b486-40df-a174-72f8279b56a4':
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._hashUnmatched.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._hashUnmatched.description,
|
||||||
|
};
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription,
|
||||||
|
};
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw new Error(err.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._failedToFetch.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._failedToFetch.fetchErrorDescription,
|
||||||
|
};
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (res.type) {
|
||||||
|
case 'plugin':
|
||||||
|
try {
|
||||||
|
const meta = await parsePluginMeta(res.data);
|
||||||
|
data.value = {
|
||||||
|
type: 'plugin',
|
||||||
|
meta,
|
||||||
|
raw: res.data,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._pluginParseFailed.description,
|
||||||
|
};
|
||||||
|
console.error(err);
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'theme':
|
||||||
|
try {
|
||||||
|
const metaRaw = parseThemeCode(res.data);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { id, props, desc: description, ...meta } = metaRaw;
|
||||||
|
data.value = {
|
||||||
|
type: 'theme',
|
||||||
|
meta: {
|
||||||
|
description,
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
raw: res.data,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
switch (err.message.toLowerCase()) {
|
||||||
|
case 'this theme is already installed':
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
|
||||||
|
description: i18n.ts._theme.alreadyInstalled,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._themeParseFailed.description,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._resourceTypeNotSupported.description,
|
||||||
|
};
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiPhase.value = 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install() {
|
||||||
|
if (!data.value) return;
|
||||||
|
|
||||||
|
switch (data.value.type) {
|
||||||
|
case 'plugin':
|
||||||
|
if (!data.value.meta) return;
|
||||||
|
try {
|
||||||
|
await installPlugin(data.value.raw, data.value.meta as AiScriptPluginMeta);
|
||||||
|
os.success();
|
||||||
|
nextTick(() => {
|
||||||
|
unisonReload('/');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
errorKV.value = {
|
||||||
|
title: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.title,
|
||||||
|
description: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.description,
|
||||||
|
};
|
||||||
|
console.error(err);
|
||||||
|
uiPhase.value = 'error';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'theme':
|
||||||
|
if (!data.value.meta) return;
|
||||||
|
await installTheme(data.value.raw);
|
||||||
|
os.success();
|
||||||
|
nextTick(() => {
|
||||||
|
location.href = '/settings/theme';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerActions = computed(() => []);
|
||||||
|
|
||||||
|
const headerTabs = computed(() => []);
|
||||||
|
|
||||||
|
definePageMetadata({
|
||||||
|
title: i18n.ts._externalResourceInstaller.title,
|
||||||
|
icon: 'ti ti-download',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.extInstallerRoot {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extInstallerIconWrapper {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 48px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
background-color: var(--accentedBg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error .extInstallerIconWrapper {
|
||||||
|
background-color: rgba(255, 42, 42, .15);
|
||||||
|
color: #ff2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extInstallerTitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extInstallerNormDesc {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extInstallerKVList {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -18,130 +18,35 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, nextTick, ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import { compareVersions } from 'compare-versions';
|
|
||||||
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import MkTextarea from '@/components/MkTextarea.vue';
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import FormInfo from '@/components/MkInfo.vue';
|
import FormInfo from '@/components/MkInfo.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { ColdDeviceStorage } from '@/store.js';
|
import { installPlugin } from '@/scripts/install-plugin.js';
|
||||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
const parser = new Parser();
|
const code = ref<string | null>(null);
|
||||||
const code = ref(null);
|
|
||||||
|
|
||||||
function installPlugin({ id, meta, src, token }) {
|
|
||||||
ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
|
|
||||||
...meta,
|
|
||||||
id,
|
|
||||||
active: true,
|
|
||||||
configData: {},
|
|
||||||
token: token,
|
|
||||||
src: src,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSupportedAiScriptVersion(version: string): boolean {
|
|
||||||
try {
|
|
||||||
return (compareVersions(version, '0.12.0') >= 0);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function install() {
|
async function install() {
|
||||||
if (code.value == null) return;
|
if (!code.value) return;
|
||||||
|
|
||||||
const lv = utils.getLangVersion(code.value);
|
|
||||||
if (lv == null) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: 'No language version annotation found :(',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else if (!isSupportedAiScriptVersion(lv)) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: `aiscript version '${lv}' is not supported :(`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ast;
|
|
||||||
try {
|
try {
|
||||||
ast = parser.parse(code.value);
|
await installPlugin(code.value);
|
||||||
|
os.success();
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
unisonReload();
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: 'Syntax error :(',
|
title: 'Install failed',
|
||||||
|
text: err.toString() ?? null,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = Interpreter.collectMetadata(ast);
|
|
||||||
if (meta == null) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: 'No metadata found :(',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = meta.get(null);
|
|
||||||
if (metadata == null) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: 'No metadata found :(',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, version, author, description, permissions, config } = metadata;
|
|
||||||
if (name == null || version == null || author == null) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: 'Required property not found :(',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
|
|
||||||
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
|
|
||||||
title: i18n.ts.tokenRequested,
|
|
||||||
information: i18n.ts.pluginTokenRequestedDescription,
|
|
||||||
initialName: name,
|
|
||||||
initialPermissions: permissions,
|
|
||||||
}, {
|
|
||||||
done: async result => {
|
|
||||||
const { name, permissions } = result;
|
|
||||||
const { token } = await os.api('miauth/gen-token', {
|
|
||||||
session: null,
|
|
||||||
name: name,
|
|
||||||
permission: permissions,
|
|
||||||
});
|
|
||||||
res(token);
|
|
||||||
},
|
|
||||||
}, 'closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
installPlugin({
|
|
||||||
id: uuid(),
|
|
||||||
meta: {
|
|
||||||
name, version, author, description, permissions, config,
|
|
||||||
},
|
|
||||||
token,
|
|
||||||
src: code.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
os.success();
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
unisonReload();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions = $computed(() => []);
|
const headerActions = $computed(() => []);
|
||||||
|
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
<div class="_buttons">
|
<div class="_buttons">
|
||||||
<MkButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
|
<MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
|
||||||
<MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
|
<MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,60 +18,41 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { } from 'vue';
|
import { } from 'vue';
|
||||||
import JSON5 from 'json5';
|
|
||||||
import MkTextarea from '@/components/MkTextarea.vue';
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { applyTheme, validateTheme } from '@/scripts/theme.js';
|
import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { addTheme, getThemes } from '@/theme-store';
|
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
let installThemeCode = $ref(null);
|
let installThemeCode = $ref(null);
|
||||||
|
|
||||||
function parseThemeCode(code: string) {
|
|
||||||
let theme;
|
|
||||||
|
|
||||||
try {
|
|
||||||
theme = JSON5.parse(code);
|
|
||||||
} catch (err) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: i18n.ts._theme.invalid,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!validateTheme(theme)) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: i18n.ts._theme.invalid,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (getThemes().some(t => t.id === theme.id)) {
|
|
||||||
os.alert({
|
|
||||||
type: 'info',
|
|
||||||
text: i18n.ts._theme.alreadyInstalled,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function preview(code: string): void {
|
|
||||||
const theme = parseThemeCode(code);
|
|
||||||
if (theme) applyTheme(theme, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function install(code: string): Promise<void> {
|
async function install(code: string): Promise<void> {
|
||||||
const theme = parseThemeCode(code);
|
try {
|
||||||
if (!theme) return;
|
const theme = parseThemeCode(code);
|
||||||
await addTheme(theme);
|
await installTheme(code);
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
text: i18n.t('_theme.installed', { name: theme.name }),
|
text: i18n.t('_theme.installed', { name: theme.name }),
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
switch (err.message.toLowerCase()) {
|
||||||
|
case 'this theme is already installed':
|
||||||
|
os.alert({
|
||||||
|
type: 'info',
|
||||||
|
text: i18n.ts._theme.alreadyInstalled,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: i18n.ts._theme.invalid,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions = $computed(() => []);
|
const headerActions = $computed(() => []);
|
||||||
|
|
|
@ -322,6 +322,10 @@ export const routes = [{
|
||||||
}, {
|
}, {
|
||||||
path: '/registry',
|
path: '/registry',
|
||||||
component: page(() => import('./pages/registry.vue')),
|
component: page(() => import('./pages/registry.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/install-extentions',
|
||||||
|
component: page(() => import('./pages/install-extentions.vue')),
|
||||||
|
loginRequired: true,
|
||||||
}, {
|
}, {
|
||||||
path: '/admin/user/:userId',
|
path: '/admin/user/:userId',
|
||||||
component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')),
|
component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')),
|
||||||
|
|
129
packages/frontend/src/scripts/install-plugin.ts
Normal file
129
packages/frontend/src/scripts/install-plugin.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
import { compareVersions } from 'compare-versions';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type AiScriptPluginMeta = {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
config?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parser = new Parser();
|
||||||
|
|
||||||
|
export function savePlugin({ id, meta, src, token }: {
|
||||||
|
id: string;
|
||||||
|
meta: AiScriptPluginMeta;
|
||||||
|
src: string;
|
||||||
|
token: string;
|
||||||
|
}) {
|
||||||
|
ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
|
||||||
|
...meta,
|
||||||
|
id,
|
||||||
|
active: true,
|
||||||
|
configData: {},
|
||||||
|
token: token,
|
||||||
|
src: src,
|
||||||
|
} as Plugin));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 installPlugin(code: string, meta?: AiScriptPluginMeta) {
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
let realMeta: AiScriptPluginMeta;
|
||||||
|
if (!meta) {
|
||||||
|
realMeta = await parsePluginMeta(code);
|
||||||
|
} else {
|
||||||
|
realMeta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => {
|
||||||
|
os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
|
||||||
|
title: i18n.ts.tokenRequested,
|
||||||
|
information: i18n.ts.pluginTokenRequestedDescription,
|
||||||
|
initialName: realMeta.name,
|
||||||
|
initialPermissions: realMeta.permissions,
|
||||||
|
}, {
|
||||||
|
done: async result => {
|
||||||
|
const { name, permissions } = result;
|
||||||
|
const { token } = await os.api('miauth/gen-token', {
|
||||||
|
session: null,
|
||||||
|
name: name,
|
||||||
|
permission: permissions,
|
||||||
|
});
|
||||||
|
res(token);
|
||||||
|
},
|
||||||
|
}, 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
savePlugin({
|
||||||
|
id: uuid(),
|
||||||
|
meta: realMeta,
|
||||||
|
token,
|
||||||
|
src: code,
|
||||||
|
});
|
||||||
|
}
|
37
packages/frontend/src/scripts/install-theme.ts
Normal file
37
packages/frontend/src/scripts/install-theme.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import JSON5 from 'json5';
|
||||||
|
import { addTheme, getThemes } from '@/theme-store.js';
|
||||||
|
import { Theme, applyTheme, validateTheme } from '@/scripts/theme.js';
|
||||||
|
|
||||||
|
export function parseThemeCode(code: string): Theme {
|
||||||
|
let theme;
|
||||||
|
|
||||||
|
try {
|
||||||
|
theme = JSON5.parse(code);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('Failed to parse theme json');
|
||||||
|
}
|
||||||
|
if (!validateTheme(theme)) {
|
||||||
|
throw new Error('This theme is invaild');
|
||||||
|
}
|
||||||
|
if (getThemes().some(t => t.id === theme.id)) {
|
||||||
|
throw new Error('This theme is already installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewTheme(code: string): void {
|
||||||
|
const theme = parseThemeCode(code);
|
||||||
|
if (theme) applyTheme(theme, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTheme(code: string): Promise<void> {
|
||||||
|
const theme = parseThemeCode(code);
|
||||||
|
if (!theme) return;
|
||||||
|
await addTheme(theme);
|
||||||
|
}
|
|
@ -2229,6 +2229,22 @@ export type Endpoints = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
'fetch-rss': {
|
||||||
|
req: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
res: TODO;
|
||||||
|
};
|
||||||
|
'fetch-external-resources': {
|
||||||
|
req: {
|
||||||
|
url: string;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
declare namespace entities {
|
declare namespace entities {
|
||||||
|
|
|
@ -639,4 +639,11 @@ export type Endpoints = {
|
||||||
$default: UserDetailed;
|
$default: UserDetailed;
|
||||||
};
|
};
|
||||||
}; };
|
}; };
|
||||||
|
|
||||||
|
// fetching external data
|
||||||
|
'fetch-rss': { req: { url: string; }; res: TODO; };
|
||||||
|
'fetch-external-resources': {
|
||||||
|
req: { url: string; hash: string; };
|
||||||
|
res: { type: string; data: string; };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue