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:
		
							
								
								
									
										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> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { defineAsyncComponent, nextTick, ref } from 'vue'; | ||||
| import { compareVersions } from 'compare-versions'; | ||||
| import { Interpreter, Parser, utils } from '@syuilo/aiscript'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { nextTick, ref } from 'vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import FormInfo from '@/components/MkInfo.vue'; | ||||
| 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 { i18n } from '@/i18n.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
|  | ||||
| const parser = new Parser(); | ||||
| 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; | ||||
| 	} | ||||
| } | ||||
| const code = ref<string | null>(null); | ||||
|  | ||||
| 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 { | ||||
| 		ast = parser.parse(code.value); | ||||
| 		await installPlugin(code.value); | ||||
| 		os.success(); | ||||
|  | ||||
| 		nextTick(() => { | ||||
| 			unisonReload(); | ||||
| 		}); | ||||
| 	} catch (err) { | ||||
| 		os.alert({ | ||||
| 			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(() => []); | ||||
|   | ||||
| @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</MkTextarea> | ||||
|  | ||||
| 	<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> | ||||
| 	</div> | ||||
| </div> | ||||
| @@ -18,60 +18,41 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import JSON5 from 'json5'; | ||||
| import MkTextarea from '@/components/MkTextarea.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 { addTheme, getThemes } from '@/theme-store'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
|  | ||||
| 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> { | ||||
| 	const theme = parseThemeCode(code); | ||||
| 	if (!theme) return; | ||||
| 	await addTheme(theme); | ||||
| 	os.alert({ | ||||
| 		type: 'success', | ||||
| 		text: i18n.t('_theme.installed', { name: theme.name }), | ||||
| 	}); | ||||
| 	try { | ||||
| 		const theme = parseThemeCode(code); | ||||
| 		await installTheme(code); | ||||
| 		os.alert({ | ||||
| 			type: 'success', | ||||
| 			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(() => []); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 かっこかり
					かっこかり