Implement type-safe config for plugins in BE and FE

This commit is contained in:
grimsi
2025-06-03 17:51:17 +02:00
parent 6e390df900
commit 0050ab1f74
30 changed files with 372 additions and 259 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ dependencies {
implementation("jakarta.validation:jakarta.validation-api:3.1.0") implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// Kotlin extensions // Kotlin extensions
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation(kotlin("reflect"))
// Reactive // Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-webflux")
@@ -1,4 +1,4 @@
import React, {useEffect} from "react"; import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage"; import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
@@ -11,16 +11,12 @@ import LibraryCreationModal from "Frontend/components/general/modals/LibraryCrea
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) { function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure(); const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState); const state = useSnapshot(libraryState);
useEffect(() => {
initializeLibraryState();
}, []);
async function updateLibrary(library: LibraryUpdateDto) { async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library); await LibraryEndpoint.updateLibrary(library);
addToast({ addToast({
@@ -52,9 +52,11 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<div className="flex flex-row"> <div className="flex flex-row">
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")} <ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")} <div className="flex flex-row flex-1 justify-center gap-2">
isDisabled={!formik.values.sso.oidc.enabled || <ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
!formik.values.sso.oidc["auto-register-new-users"]}/> isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
</div> </div>
<Section title="SSO provider configuration"/> <Section title="SSO provider configuration"/>
@@ -23,9 +23,9 @@ import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import PluginConfigValidationResult import PluginConfigValidationResult
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult"; from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/config/PluginConfigValidationResult";
import PluginConfigValidationResultType import PluginConfigValidationResultType
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResultType"; from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/config/PluginConfigValidationResultType";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) { export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginDetailsModal = useDisclosure(); const pluginDetailsModal = useDisclosure();
@@ -0,0 +1,58 @@
import SelectInput from "Frontend/components/general/input/SelectInput";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import PluginConfigMetadataDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginConfigMetadataDto";
export default function PluginConfigFormField({pluginConfigMetadata, ...props}: any) {
function inputElement(metadata: PluginConfigMetadataDto) {
if (metadata.allowedValues != null && metadata.allowedValues.length > 0) {
return (
<SelectInput label={metadata.label}
name={metadata.key}
values={metadata.allowedValues}
{...props}/>
);
}
switch (metadata.type) {
case "Boolean":
return (
<CheckboxInput label={metadata.label}
name={metadata.key}
{...props}/>
);
case "String":
return (
<Input label={metadata.label}
name={metadata.key}
type={metadata.secret ? "password" : "text"}
isRequired={metadata.required}
{...props}/>
);
case "Float":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="0.1"
{...props}/>
);
case "Int":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="1"
{...props}/>
);
default:
return <pre>Unsupported type: {metadata.type} for key {metadata.key}</pre>;
}
}
return inputElement(pluginConfigMetadata!);
}
@@ -6,23 +6,19 @@ const SelectInput = ({label, values, ...props}) => {
// @ts-ignore // @ts-ignore
const [field] = useField(props); const [field] = useField(props);
const items = values.map((v: string) => ({key: v, label: v}));
return ( return (
<div className="flex flex-row flex-1 justify-center gap-2"> <Select
<Select {...field}
{...field} {...props}
{...props} label={label}
id={field.name} items={items}
label={label} selectedKeys={[field.value]}
selectedKeys={[field.value]} disallowEmptySelection
disallowEmptySelection >
> {(item: { key: string, label: string }) => <SelectItem>{item.label}</SelectItem>}
{values.map((value: string) => ( </Select>
<SelectItem key={value}>
{value}
</SelectItem>
))}
</Select>
</div>
); );
} }
@@ -1,14 +1,14 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react"; import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
import Input from "Frontend/components/general/input/Input";
import PluginLogo from "Frontend/components/general/plugin/PluginLogo"; import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react"; import {ArrowClockwise} from "@phosphor-icons/react";
import PluginConfigMetadataDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/input/PluginConfigFormField";
interface PluginDetailsModalProps { interface PluginDetailsModalProps {
plugin: PluginDto; plugin: PluginDto;
@@ -35,11 +35,28 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
}); });
} }
function getEffectiveConfig(): Record<string, string> {
const effectiveConfig: Record<string, string> = {};
if (!plugin.configMetadata) return effectiveConfig;
for (const meta of plugin.configMetadata) {
const key = meta.key;
let value = plugin.config?.[key]?.toString();
if (value == null && meta.default != null) {
value = meta.default.toString();
}
if (value) {
effectiveConfig[key] = value;
}
}
return effectiveConfig;
}
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg"> <Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<Formik initialValues={plugin.config} <Formik initialValues={getEffectiveConfig()}
initialErrors={plugin.configValidation?.errors} initialErrors={plugin.configValidation?.errors}
enableReinitialize={true} enableReinitialize={true}
onSubmit={async (values: any) => { onSubmit={async (values: any) => {
@@ -138,10 +155,11 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
</>} </>}
</div> </div>
{(plugin.configMetadata && plugin.configMetadata.length > 0) ? {(plugin.configMetadata && plugin.configMetadata.length > 0) ?
plugin.configMetadata.map((entry: PluginConfigElement) => ( plugin.configMetadata.map((entry: PluginConfigMetadataDto) => (
<Input key={entry.key} name={entry.key} label={entry.name} <PluginConfigFormField
showErrorUntouched={true} key={entry.key}
type={entry.secret ? "password" : "text"}/> pluginConfigMetadata={entry}
showErrorUntouched={true}/>
)) : "This plugin has no configuration options." )) : "This plugin has no configuration options."
} }
</ModalBody> </ModalBody>
+1 -1
View File
@@ -1,7 +1,7 @@
import {createRoot} from 'react-dom/client'; import {createRoot} from 'react-dom/client';
import {StrictMode} from "react"; import {StrictMode} from "react";
import {RouterProvider} from "react-router"; import {RouterProvider} from "react-router";
import router from "./routes"; import {router} from './routes';
const container = document.getElementById('outlet')!; const container = document.getElementById('outlet')!;
const root = createRoot(container); const root = createRoot(container);
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.core.plugins
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.users.util.isAdmin import de.grimsi.gameyfin.users.util.isAdmin
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -3,16 +3,16 @@ package de.grimsi.gameyfin.core.plugins
import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntry import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntry
import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntryKey import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntryKey
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.core.plugins.dto.PluginConfigMetadataDto
import de.grimsi.gameyfin.core.plugins.dto.PluginDto import de.grimsi.gameyfin.core.plugins.dto.PluginDto
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginDescriptor import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginDescriptor
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.plugins.management.PluginManagementRepository import de.grimsi.gameyfin.core.plugins.management.PluginManagementRepository
import de.grimsi.gameyfin.pluginapi.core.Configurable import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.wrapper.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.ExtensionPoint import org.pf4j.ExtensionPoint
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
@@ -96,11 +96,24 @@ class PluginService(
return plugin.getLogo() return plugin.getLogo()
} }
fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigElement> { fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigMetadataDto>? {
log.debug { "Getting config metadata for plugin ${pluginWrapper.pluginId}" } log.debug { "Getting config metadata for plugin ${pluginWrapper.pluginId}" }
val plugin = pluginWrapper.plugin val plugin = pluginWrapper.plugin
if (plugin !is Configurable) return emptyList()
return plugin.configMetadata if (plugin !is Configurable) return null
return plugin.configMetadata.map { meta ->
PluginConfigMetadataDto(
key = meta.key,
type = meta.type.simpleName ?: "Unknown",
label = meta.label,
description = meta.description,
default = meta.default,
isSecret = meta.isSecret,
isRequired = meta.isRequired,
allowedValues = meta.allowedValues?.map { it.toString() }
)
}
} }
fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> { fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> {
@@ -0,0 +1,16 @@
package de.grimsi.gameyfin.core.plugins.dto
import com.fasterxml.jackson.annotation.JsonInclude
import java.io.Serializable
@JsonInclude(JsonInclude.Include.ALWAYS)
class PluginConfigMetadataDto(
val key: String,
val type: String,
val label: String,
val description: String,
val default: Serializable?,
val isSecret: Boolean,
val isRequired: Boolean,
val allowedValues: List<String>?
)
@@ -1,8 +1,7 @@
package de.grimsi.gameyfin.core.plugins.dto package de.grimsi.gameyfin.core.plugins.dto
import de.grimsi.gameyfin.core.plugins.management.PluginTrustLevel import de.grimsi.gameyfin.core.plugins.management.PluginTrustLevel
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import org.pf4j.PluginState import org.pf4j.PluginState
data class PluginDto( data class PluginDto(
@@ -17,7 +16,7 @@ data class PluginDto(
val url: String? = null, val url: String? = null,
val hasLogo: Boolean, val hasLogo: Boolean,
val state: PluginState, val state: PluginState,
val configMetadata: List<PluginConfigElement>? = null, val configMetadata: List<PluginConfigMetadataDto>? = null,
val config: Map<String, String?>? = null, val config: Map<String, String?>? = null,
val configValidation: PluginConfigValidationResult? = null, val configValidation: PluginConfigValidationResult? = null,
val priority: Int, val priority: Int,
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.core.plugins.dto package de.grimsi.gameyfin.core.plugins.dto
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.pf4j.PluginState import org.pf4j.PluginState
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@@ -1,9 +1,9 @@
package de.grimsi.gameyfin.core.plugins.management package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.pluginapi.core.Configurable import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResultType import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResultType
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.* import org.pf4j.*
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -164,7 +164,7 @@ class GameyfinPluginManager(
fun restart(pluginId: String) { fun restart(pluginId: String) {
val plugin = getPlugin(pluginId)?.plugin ?: return val plugin = getPlugin(pluginId)?.plugin ?: return
stopPlugin(pluginId) stopPlugin(pluginId)
if (plugin is Configurable) plugin.config = getConfig(pluginId) if (plugin is Configurable) plugin.loadConfig(getConfig(pluginId))
startPlugin(pluginId) startPlugin(pluginId)
} }
@@ -213,7 +213,7 @@ class GameyfinPluginManager(
val plugin = pluginWrapper.plugin val plugin = pluginWrapper.plugin
if (plugin is Configurable) { if (plugin is Configurable) {
val config = getConfig(pluginWrapper.pluginId) val config = getConfig(pluginWrapper.pluginId)
plugin.config = config plugin.loadConfig(config)
} }
} }
+15 -95
View File
@@ -16,20 +16,19 @@ import settings from './build/vaadin-dev-server-settings.json';
import { import {
AssetInfo, AssetInfo,
ChunkInfo, ChunkInfo,
build,
defineConfig, defineConfig,
mergeConfig, mergeConfig,
OutputOptions, OutputOptions,
PluginOption, PluginOption,
InlineConfig,
UserConfigFn UserConfigFn
} from 'vite'; } from 'vite';
import { getManifest, type ManifestTransform } from 'workbox-build';
import * as rollup from 'rollup'; import * as rollup from 'rollup';
import brotli from 'rollup-plugin-brotli'; import brotli from 'rollup-plugin-brotli';
import checker from 'vite-plugin-checker'; import checker from 'vite-plugin-checker';
import postcssLit from './build/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js'; import postcssLit from './build/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
import vaadinI18n from './build/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js';
import serviceWorkerPlugin from './build/plugins/vite-plugin-service-worker';
import { createRequire } from 'module'; import { createRequire } from 'module';
@@ -41,8 +40,6 @@ import vitePluginFileSystemRouter from '@vaadin/hilla-file-router/vite-plugin.js
// Make `require` compatible with ES modules // Make `require` compatible with ES modules
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const appShellUrl = '.';
const frontendFolder = path.resolve(__dirname, settings.frontendFolder); const frontendFolder = path.resolve(__dirname, settings.frontendFolder);
const themeFolder = path.resolve(frontendFolder, settings.themeFolder); const themeFolder = path.resolve(frontendFolder, settings.themeFolder);
const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput); const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput);
@@ -56,6 +53,7 @@ const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder;
const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput); const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput);
const statsFile = path.resolve(statsFolder, 'stats.json'); const statsFile = path.resolve(statsFolder, 'stats.json');
const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html'); const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html');
const i18nFolder = path.resolve(__dirname, settings.i18nOutput);
const nodeModulesFolder = path.resolve(__dirname, 'node_modules'); const nodeModulesFolder = path.resolve(__dirname, 'node_modules');
const webComponentTags = ''; const webComponentTags = '';
@@ -91,94 +89,6 @@ const target = ['safari15', 'es2022'];
console.trace = () => {}; console.trace = () => {};
console.debug = () => {}; console.debug = () => {};
function injectManifestToSWPlugin(): rollup.Plugin {
const rewriteManifestIndexHtmlUrl: ManifestTransform = (manifest) => {
const indexEntry = manifest.find((entry) => entry.url === 'index.html');
if (indexEntry) {
indexEntry.url = appShellUrl;
}
return { manifest, warnings: [] };
};
return {
name: 'vaadin:inject-manifest-to-sw',
async transform(code, id) {
if (/sw\.(ts|js)$/.test(id)) {
const { manifestEntries } = await getManifest({
globDirectory: buildOutputFolder,
globPatterns: ['**/*'],
globIgnores: ['**/*.br', 'pwa-icons/**'],
manifestTransforms: [rewriteManifestIndexHtmlUrl],
maximumFileSizeToCacheInBytes: 100 * 1024 * 1024 // 100mb,
});
return code.replace('self.__WB_MANIFEST', JSON.stringify(manifestEntries));
}
}
};
}
function buildSWPlugin(opts: { devMode: boolean }): PluginOption {
let buildConfig: InlineConfig;
let buildOutput: rollup.RollupOutput;
const devMode = opts.devMode;
return {
name: 'vaadin:build-sw',
enforce: 'post',
async configResolved(viteConfig) {
buildConfig = {
base: viteConfig.base,
root: viteConfig.root,
mode: viteConfig.mode,
resolve: viteConfig.resolve,
define: {
...viteConfig.define,
'process.env.NODE_ENV': JSON.stringify(viteConfig.mode),
},
build: {
write: !devMode,
minify: viteConfig.build.minify,
outDir: viteConfig.build.outDir,
target,
sourcemap: viteConfig.command === 'serve' || viteConfig.build.sourcemap,
emptyOutDir: false,
modulePreload: false,
rollupOptions: {
input: {
sw: settings.clientServiceWorkerSource
},
output: {
exports: 'none',
entryFileNames: 'sw.js',
inlineDynamicImports: true,
},
},
},
};
},
async buildStart() {
if (devMode) {
buildOutput = await build(buildConfig) as rollup.RollupOutput;
}
},
async load(id) {
if (id.endsWith('sw.js')) {
return buildOutput.output[0].code;
}
},
async closeBundle() {
if (!devMode) {
await build({
...buildConfig,
plugins: [injectManifestToSWPlugin(), brotli()]
});
}
},
};
}
function statsExtracterPlugin(): PluginOption { function statsExtracterPlugin(): PluginOption {
function collectThemeJsonsInFrontend(themeJsonContents: Record<string, string>, themeName: string) { function collectThemeJsonsInFrontend(themeJsonContents: Record<string, string>, themeName: string) {
const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json'); const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json');
@@ -276,7 +186,7 @@ function statsExtracterPlugin(): PluginOption {
const frontendFiles: Record<string, string> = {}; const frontendFiles: Record<string, string> = {};
frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map', '.']; const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
const isThemeComponentsResource = (id: string) => const isThemeComponentsResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
@@ -753,7 +663,9 @@ export const vaadinConfig: UserConfigFn = (env) => {
productionMode && brotli(), productionMode && brotli(),
devMode && vaadinBundlesPlugin(), devMode && vaadinBundlesPlugin(),
devMode && showRecompileReason(), devMode && showRecompileReason(),
settings.offlineEnabled && buildSWPlugin({ devMode }), settings.offlineEnabled && serviceWorkerPlugin({
srcPath: settings.clientServiceWorkerSource,
}),
!devMode && statsExtracterPlugin(), !devMode && statsExtracterPlugin(),
!productionMode && preserveUsageStats(), !productionMode && preserveUsageStats(),
themePlugin({ devMode }), themePlugin({ devMode }),
@@ -795,6 +707,14 @@ export const vaadinConfig: UserConfigFn = (env) => {
].filter(Boolean) ].filter(Boolean)
} }
}), }),
productionMode && vaadinI18n({
cwd: __dirname,
meta: {
output: {
dir: i18nFolder,
},
},
}),
{ {
name: 'vaadin:force-remove-html-middleware', name: 'vaadin:force-remove-html-middleware',
configureServer(server) { configureServer(server) {
-9
View File
@@ -20,13 +20,4 @@ publishing {
dependencies { dependencies {
// PF4J (shared) // PF4J (shared)
api("org.pf4j:pf4j:${rootProject.extra["pf4jVersion"]}") api("org.pf4j:pf4j:${rootProject.extra["pf4jVersion"]}")
implementation(kotlin("stdlib"))
// Test dependencies
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
} }
@@ -1,9 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
interface Configurable {
val configMetadata: List<PluginConfigElement>
var config: Map<String, String?>
fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
}
@@ -1,17 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
import org.pf4j.PluginWrapper
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
companion object {
lateinit var plugin: ConfigurableGameyfinPlugin
private set
}
init {
plugin = this
}
override var config: Map<String, String?> = emptyMap()
}
@@ -1,8 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
data class PluginConfigElement(
val key: String,
val name: String,
val description: String,
val isSecret: Boolean = false
)
@@ -0,0 +1,21 @@
package de.grimsi.gameyfin.pluginapi.core.config
import java.io.Serializable
typealias PluginConfigMetadata = List<ConfigMetadata<*>>
data class ConfigMetadata<T : Serializable>(
val key: String,
val type: Class<T>,
val label: String,
val description: String,
val default: T? = null,
val isSecret: Boolean = false,
val isRequired: Boolean = true,
) {
var allowedValues: List<T>? = null
init {
allowedValues = type.enumConstants?.toList()
}
}
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.pluginapi.core.config
import java.io.Serializable
interface Configurable {
val configMetadata: PluginConfigMetadata
fun loadConfig(config: Map<String, String?>)
fun validateConfig(): PluginConfigValidationResult
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
fun <T : Serializable> config(key: String): T
fun <T : Serializable> optionalConfig(key: String): T?
}
@@ -1,3 +1,3 @@
package de.grimsi.gameyfin.pluginapi.core package de.grimsi.gameyfin.pluginapi.core.config
class PluginConfigError(message: String) : RuntimeException(message) class PluginConfigError(message: String) : RuntimeException(message)
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.pluginapi.core package de.grimsi.gameyfin.pluginapi.core.config
data class PluginConfigValidationResult( data class PluginConfigValidationResult(
val result: PluginConfigValidationResultType, val result: PluginConfigValidationResultType,
@@ -0,0 +1,92 @@
package de.grimsi.gameyfin.pluginapi.core.wrapper
import de.grimsi.gameyfin.pluginapi.core.config.ConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigError
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.pf4j.PluginWrapper
import java.io.Serializable
@Suppress("UNCHECKED_CAST")
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
private var config: Map<String, String?> = emptyMap()
override fun loadConfig(config: Map<String, String?>) {
this.config = config
}
override fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val errors = mutableMapOf<String, String>()
for (meta in configMetadata) {
val value = resolveValue(meta.key, config)
if (meta.isRequired && value == null) {
errors[meta.key] = "${meta.label} is required"
continue
}
if (value != null) {
try {
castConfigValue(meta, value)
} catch (e: PluginConfigError) {
errors[meta.key] = e.message ?: "Invalid value"
}
}
}
return if (errors.isEmpty()) {
PluginConfigValidationResult.VALID
} else {
PluginConfigValidationResult.INVALID(errors)
}
}
override fun <T : Serializable> optionalConfig(key: String): T? {
val meta = resolveMetadata(key)
val value = resolveValue(key)
if (value == null) return null
return try {
castConfigValue(meta, value) as T
} catch (e: Exception) {
throw PluginConfigError("Failed to cast value for key '$key' to type ${meta.type.simpleName}: ${e.message}")
}
}
private fun castConfigValue(meta: ConfigMetadata<*>, value: Any): Any? {
val expectedType = meta.type
return if (expectedType.isEnum) {
try {
java.lang.Enum.valueOf(expectedType as Class<out Enum<*>>, value.toString())
} catch (_: IllegalArgumentException) {
throw PluginConfigError("Invalid value '${value}', must be one of ${meta.allowedValues!!.joinToString(", ")}")
}
} else {
if (!expectedType.isInstance(value)) {
throw PluginConfigError("Value for key '${meta.key}' is not of type ${expectedType.simpleName}")
}
value
}
}
override fun <T : Serializable> config(key: String): T {
val value = optionalConfig<T>(key)
if (value == null) {
throw PluginConfigError("Required configuration key '$key' is missing or has no value")
}
return value
}
private fun resolveMetadata(key: String): ConfigMetadata<*> {
return configMetadata.find { it.key == key }
?: throw PluginConfigError("Unknown configuration key: $key")
}
private fun resolveValue(key: String, configOverride: Map<String, Serializable?>? = null): Serializable? {
val meta = resolveMetadata(key)
val conf = configOverride ?: config
return conf[key] ?: meta.default
}
}
@@ -1,8 +1,9 @@
package de.grimsi.gameyfin.pluginapi.core package de.grimsi.gameyfin.pluginapi.core.wrapper
import org.pf4j.Plugin import org.pf4j.Plugin
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
@Suppress("DEPRECATION")
abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
companion object { companion object {
@@ -0,0 +1,18 @@
package de.grimsi.gameyfin.plugins.directdownload
import de.grimsi.gameyfin.plugins.directdownload.CompressionMode.*
import java.util.zip.Deflater
enum class CompressionMode {
NONE,
FAST,
BEST;
}
fun CompressionMode.deflaterLevel(): Int {
return when (this) {
NONE -> Deflater.NO_COMPRESSION
FAST -> Deflater.BEST_SPEED
BEST -> Deflater.BEST_COMPRESSION
}
}
@@ -1,8 +1,8 @@
package de.grimsi.gameyfin.plugins.directdownload package de.grimsi.gameyfin.plugins.directdownload
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.config.ConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.download.Download import de.grimsi.gameyfin.pluginapi.download.Download
import de.grimsi.gameyfin.pluginapi.download.DownloadProvider import de.grimsi.gameyfin.pluginapi.download.DownloadProvider
import de.grimsi.gameyfin.pluginapi.download.FileDownload import de.grimsi.gameyfin.pluginapi.download.FileDownload
@@ -14,7 +14,6 @@ import java.io.PipedInputStream
import java.io.PipedOutputStream import java.io.PipedOutputStream
import java.nio.file.* import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -24,31 +23,25 @@ import kotlin.io.path.isDirectory
class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) { class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
override val configMetadata: List<PluginConfigElement> = listOf( companion object {
PluginConfigElement( lateinit var plugin: DirectDownloadPlugin
private set
}
init {
plugin = this
}
override val configMetadata: PluginConfigMetadata = listOf(
ConfigMetadata(
key = "compressionMode", key = "compressionMode",
name = "Compression mode (\"none\" = default, \"fast\", \"best\")", type = CompressionMode::class.java,
label = "Compression mode",
description = "Higher compression modes are more resource intensive, but save bandwidth", description = "Higher compression modes are more resource intensive, but save bandwidth",
default = CompressionMode.NONE
) )
) )
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val compressionMode = config["compressionMode"]
if (compressionMode != null) {
return try {
CompressionMode.valueOf(compressionMode.uppercase())
PluginConfigValidationResult.VALID
} catch (_: IllegalArgumentException) {
PluginConfigValidationResult.INVALID(
mapOf("compressionMode" to "Invalid compression mode: $compressionMode (must be \"none\", \"fast\", or \"best\")")
)
}
}
return PluginConfigValidationResult.VALID
}
@Extension @Extension
class DirectDownloadProvider : DownloadProvider { class DirectDownloadProvider : DownloadProvider {
override fun download(path: Path): Download { override fun download(path: Path): Download {
@@ -95,9 +88,8 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
try { try {
ZipOutputStream(pipeOut).use { zos -> ZipOutputStream(pipeOut).use { zos ->
zos.setLevel(CompressionMode.toDeflaterLevel(plugin.config["compressionMode"]?.let { val compressionMode = plugin.config<CompressionMode>("compressionMode")
CompressionMode.valueOf(it.uppercase()) zos.setLevel(compressionMode.deflaterLevel())
} ?: CompressionMode.NONE))
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() { Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
@@ -125,20 +117,3 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
} }
} }
} }
enum class CompressionMode {
NONE,
FAST,
BEST;
companion object {
fun toDeflaterLevel(mode: CompressionMode): Int {
return when (mode) {
NONE -> Deflater.NO_COMPRESSION
FAST -> Deflater.BEST_SPEED
BEST -> Deflater.BEST_COMPRESSION
}
}
}
}
@@ -5,7 +5,8 @@ import com.api.igdb.exceptions.RequestException
import com.api.igdb.request.IGDBWrapper import com.api.igdb.request.IGDBWrapper
import com.api.igdb.request.TwitchAuthenticator import com.api.igdb.request.TwitchAuthenticator
import com.api.igdb.request.games import com.api.igdb.request.games
import de.grimsi.gameyfin.pluginapi.core.* import de.grimsi.gameyfin.pluginapi.core.config.*
import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
@@ -16,26 +17,35 @@ import proto.Game
import java.time.Instant import java.time.Instant
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable { class IgdbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wrapper) {
override val configMetadata = listOf( override val configMetadata: PluginConfigMetadata = listOf(
PluginConfigElement( ConfigMetadata(
key = "clientId", key = "clientId",
name = "Twitch client ID", type = String::class.java,
label = "Twitch client ID",
description = "Your Twitch Client ID" description = "Your Twitch Client ID"
), ),
PluginConfigElement( ConfigMetadata(
key = "clientSecret", key = "clientSecret",
name = "Twitch client secret", type = String::class.java,
label = "Twitch client secret",
description = "Your Twitch Client Secret", description = "Your Twitch Client Secret",
isSecret = true isSecret = true
) )
) )
override var config: Map<String, String?> = emptyMap()
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult { override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val pluginConfigValidationResult = super.validateConfig(config)
if (pluginConfigValidationResult.result == PluginConfigValidationResultType.INVALID) {
return pluginConfigValidationResult
}
try { try {
authenticate(config["clientId"], config["clientSecret"]) val clientIdToValidate = config["clientId"]
val clientSecretToValidate = config["clientSecret"]
authenticate(clientIdToValidate, clientSecretToValidate)
return PluginConfigValidationResult.VALID return PluginConfigValidationResult.VALID
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
@@ -50,7 +60,7 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable
override fun start() { override fun start() {
try { try {
authenticate(config["clientId"], config["clientSecret"]) authenticate(config("clientId"), config("clientSecret"))
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
} }
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.plugins.steam package de.grimsi.gameyfin.plugins.steam
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.wrapper.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.plugins.steam.dto.SteamDetailsResultWrapper import de.grimsi.gameyfin.plugins.steam.dto.SteamDetailsResultWrapper
@@ -1,9 +1,7 @@
package de.grimsi.gameyfin.plugins.steamgriddb package de.grimsi.gameyfin.plugins.steamgriddb
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.config.*
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.wrapper.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigError
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.plugins.steamgriddb.api.SteamGridDbApiClient import de.grimsi.gameyfin.plugins.steamgriddb.api.SteamGridDbApiClient
@@ -20,18 +18,26 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
private var client: SteamGridDbApiClient? = null private var client: SteamGridDbApiClient? = null
} }
override val configMetadata: List<PluginConfigElement> = listOf( override val configMetadata: PluginConfigMetadata = listOf(
PluginConfigElement( ConfigMetadata(
key = "apiKey", key = "apiKey",
name = "SteamGridDB API key", type = String::class.java,
description = "Your SteamGridDB API key", label = "SteamGridDB API key",
description = "The API key can be obtained from your SteamGridDB account preferences",
isSecret = true isSecret = true
) )
) )
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult { override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val pluginConfigValidationResult = super.validateConfig(config)
if (pluginConfigValidationResult.result == PluginConfigValidationResultType.INVALID) {
return pluginConfigValidationResult
}
try { try {
runBlocking { authenticate(config["apiKey"]) } val apiKeyToValidate = config["apiKey"]
runBlocking { authenticate(apiKeyToValidate) }
return PluginConfigValidationResult.VALID return PluginConfigValidationResult.VALID
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
@@ -43,7 +49,7 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
override fun start() { override fun start() {
try { try {
runBlocking { authenticate(config["apiKey"]) } runBlocking { authenticate(config("apiKey")) }
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
} }