diff --git a/README.md b/README.md index c22e02b0..a2c7aad3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ This is a Kotlin Multiplatform project targeting Desktop (JVM). +## Plugin-defined pages + +Plugins can organize capabilities into pages rendered by the host, without bundling Compose UI binaries: + +```kotlin +@PluginUiPage( + id = "convert", + title = "Convert media", + description = "Choose an operation to begin.", + capabilityNames = ["Convert image", "Convert video"] +) +@PluginInfo(/* ... */) +class MediaPlugin +``` + +Unknown capability names are ignored. The declarative contract stays usable across host UI upgrades and +can also be interpreted by future web or command-line front ends. + * [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders: - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. @@ -33,4 +51,4 @@ The internal job execution engine (`FlowEngine` and `JobWorker`) enforces strict - **Recursion Depth Limits**: Deep subflow execution limits the stack frame depth to 50 iterations. Attempting to create an infinitely recursive subflow safely fails before hitting a JVM StackOverflow. - **Configurable Capabilities Policies**: Transient network execution failures in plugins automatically back off and retry up to `maxRetries` (configurable in app settings). Executions are also bound by a strict `pluginTimeoutMs` to prevent hung plugins. -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… diff --git a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt index 3bb804ac..05b6b876 100644 --- a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt +++ b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt @@ -23,6 +23,7 @@ import org.wip.plugintoolkit.api.annotations.CapabilityParam import org.wip.plugintoolkit.api.annotations.CapabilityResult import org.wip.plugintoolkit.api.annotations.PluginAction import org.wip.plugintoolkit.api.annotations.PluginInfo +import org.wip.plugintoolkit.api.annotations.PluginUiPage import org.wip.plugintoolkit.api.annotations.PluginLoad import org.wip.plugintoolkit.api.annotations.PluginSetting import org.wip.plugintoolkit.api.annotations.PluginSetup @@ -39,7 +40,9 @@ import java.io.File data class CompleteExampleSettings( @PluginSetting( description = "Public configuration value example", - defaultValue = "default_api_key" + defaultValue = "default_api_key", + minLength = 8, + semanticTypes = ["text/plain"] ) val apiKey: String? = "default_api_key", @PluginSetting( @@ -117,6 +120,17 @@ enum class FeatureMode { description = "Complete showcase of plugin API features including settings, validation, signals, storage, file system, lifecycle hooks, and flow contexts.", supportedOs = [OS.WINDOWS, OS.LINUX, OS.MACOS] ) +@PluginUiPage( + id = "essentials", + title = "Essential capabilities", + description = "Common storage and file operations.", + capabilityNames = ["capabilityWithFileAccess", "capabilityWithDataStorage"] +) +@PluginUiPage( + id = "advanced", + title = "Advanced capabilities", + capabilityNames = ["capabilityWithPauseResume", "capabilityWithComplexObjectsAndSemanticTypes"] +) class CompleteExamplePlugin(val settings: CompleteExampleSettings) { @PluginLoad diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 9303dcb6..ab0288cc 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1,5 +1,7 @@ + Obbligatorie + Facoltative PluginToolkit Runner Dashboard diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ba373128..29bb810d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -191,6 +191,8 @@ Actions Custom Settings Global Parameter Defaults + Required + Optional Capability: %1$s Configure required settings to unlock options Locked capability: %1$s @@ -474,4 +476,4 @@ Filter: Sort: Sync All - \ No newline at end of file + diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt index 6119f11e..4a76a74e 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt @@ -21,6 +21,7 @@ import org.wip.plugintoolkit.core.utils.FileSystem import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore +import org.wip.plugintoolkit.features.plugin.model.resolveCustomSettings import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import org.wip.plugintoolkit.features.settings.model.PluginUnplugBehavior import org.wip.plugintoolkit.features.plugin.utils.PluginCompatibilityUtils @@ -287,6 +288,7 @@ class PluginLifecycleManager( } val decryptedStore = store.copy(settings = decryptedSettings) + .withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) _pluginSettingsState.update { it + (pkg to decryptedStore) } return decryptedStore } @@ -296,7 +298,8 @@ class PluginLifecycleManager( val settingsFile = "${plugin.installPath}/settings.json" val manifest = getManifest(pkg) - val encryptedSettings = store.settings.mapValues { (key, value) -> + val resolvedStore = store.withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) + val encryptedSettings = resolvedStore.settings.mapValues { (key, value) -> val isSecret = manifest?.settings?.get(key)?.secret == true if (isSecret && value is kotlinx.serialization.json.JsonPrimitive && value.isString) { val encrypted = org.wip.plugintoolkit.core.utils.SecureStorage.encrypt(value.content) @@ -305,12 +308,12 @@ class PluginLifecycleManager( value } } - val storeToSave = store.copy(settings = encryptedSettings) + val storeToSave = resolvedStore.copy(settings = encryptedSettings) try { fileSystem.writeFile(settingsFile, json.encodeToString(storeToSave)) // Update cache with the decrypted store - _pluginSettingsState.update { it + (pkg to store) } + _pluginSettingsState.update { it + (pkg to resolvedStore) } } catch (t: Throwable) { Logger.e(t) { "Failed to save settings for $pkg" } } @@ -330,17 +333,10 @@ class PluginLifecycleManager( val installPath = plugin?.installPath ?: "" val jarFullPath = plugin?.let { "${it.installPath}/${it.jarFileName}" } - val storedSettings = overriddenSettings ?: loadPluginSettings(pkg) val actualManifest = manifest ?: getManifest(pkg) - val mergedSettings = mutableMapOf() - - // 1. Manifest defaults - actualManifest?.settings?.forEach { (key, meta) -> - meta.defaultValue?.let { mergedSettings[key] = it } - } - - // 2. User overrides - mergedSettings.putAll(storedSettings.settings) + val storedSettings = (overriddenSettings ?: loadPluginSettings(pkg)) + .withResolvedAutogeneratedSettings(actualManifest?.settings.orEmpty()) + val mergedSettings = storedSettings.resolveCustomSettings(actualManifest) val pluginLogger = jobManager.getPluginLogger(pkg, jobId) val progressReporter = object : ProgressReporter { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt new file mode 100644 index 00000000..4c5a413b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt @@ -0,0 +1,63 @@ +package org.wip.plugintoolkit.features.plugin.logic + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata +import org.wip.plugintoolkit.features.flows.logic.PathPatternResolver +import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore +import org.wip.plugintoolkit.features.plugin.utils.SettingsUtils + +internal fun resolveAutogeneratedSettings( + metadata: Map, + settings: Map, + additionalValues: Map = emptyMap() +): Map { + val resolvedSettings = settings.toMutableMap() + val defaults = metadata.mapNotNull { (key, value) -> value.defaultValue?.let { key to it } }.toMap() + + repeat(metadata.size.coerceAtLeast(1)) { + var changed = false + metadata.forEach { (key, settingMetadata) -> + val pattern = settingMetadata.autogeneratedPattern?.takeIf { it.isNotBlank() } ?: return@forEach + val availableValues = defaults + additionalValues + resolvedSettings + val stringValues = availableValues.mapValues { (valueKey, value) -> + val valueType = metadata[valueKey]?.type + if (valueType != null) SettingsUtils.jsonToString(value, valueType) else value.toString().trim('"') + } + val generated = runCatching { PathPatternResolver.tryResolve(pattern, stringValues) }.getOrNull() + ?: return@forEach + val generatedValue = generatedSettingValue(generated, settingMetadata.type) ?: return@forEach + if (resolvedSettings[key] != generatedValue) { + resolvedSettings[key] = generatedValue + changed = true + } + } + if (!changed) return resolvedSettings + } + + return resolvedSettings +} + +internal fun PluginSettingsStore.withResolvedAutogeneratedSettings( + metadata: Map +): PluginSettingsStore { + val resolved = resolveAutogeneratedSettings(metadata, settings, globalParams) + return if (resolved == settings) this else copy(settings = resolved) +} + +private fun generatedSettingValue(value: String, type: DataType): JsonElement? = when (type) { + is DataType.Primitive -> when (type.primitiveType) { + PrimitiveType.STRING, PrimitiveType.ANY, PrimitiveType.UNKNOWN -> JsonPrimitive(value) + PrimitiveType.BOOLEAN -> value.toBooleanStrictOrNull()?.let(::JsonPrimitive) + PrimitiveType.INT -> value.toIntOrNull()?.let(::JsonPrimitive) + PrimitiveType.LONG -> value.toLongOrNull()?.let(::JsonPrimitive) + PrimitiveType.SHORT -> value.toShortOrNull()?.let { JsonPrimitive(it.toInt()) } + PrimitiveType.BYTE -> value.toByteOrNull()?.let { JsonPrimitive(it.toInt()) } + PrimitiveType.DOUBLE -> value.toDoubleOrNull()?.let(::JsonPrimitive) + PrimitiveType.FLOAT -> value.toFloatOrNull()?.let { JsonPrimitive(it.toDouble()) } + PrimitiveType.UNIT -> null + } + else -> value.takeIf { it.isNotBlank() }?.let { runCatching { SettingsUtils.stringToJson(it, type) }.getOrNull() } +} diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt index 472b0a24..8ed2f01a 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt @@ -2,6 +2,7 @@ package org.wip.plugintoolkit.features.plugin.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import org.wip.plugintoolkit.api.PluginManifest @Serializable data class PluginSettingsStore( @@ -9,3 +10,15 @@ data class PluginSettingsStore( val globalParams: Map = emptyMap(), val capabilityParams: Map> = emptyMap() ) + +fun PluginManifest.defaultCustomSettings(): Map = settings.orEmpty().mapNotNull { (key, metadata) -> + metadata.defaultValue?.let { key to it } +}.toMap() + +/** Manifest defaults with persisted user values taking precedence. */ +fun PluginSettingsStore.resolveCustomSettings(manifest: PluginManifest?): Map = + (manifest?.defaultCustomSettings() ?: emptyMap()) + settings + +/** Values available to generated inputs and lock evaluation in the settings UI. */ +fun PluginSettingsStore.resolveProvidedValues(manifest: PluginManifest?): Map = + resolveCustomSettings(manifest) + globalParams diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt index e8d0fd4b..cc9a0b2a 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -40,15 +41,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import kotlinx.serialization.json.JsonPrimitive -import org.wip.plugintoolkit.api.DataType -import org.wip.plugintoolkit.api.PrimitiveType import org.jetbrains.compose.resources.stringResource import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.ParameterRole import org.wip.plugintoolkit.api.PluginManifest import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobStatus +import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.features.navigation.model.Screen import org.wip.plugintoolkit.features.plugin.viewmodel.PluginViewModel import org.wip.plugintoolkit.shared.components.plugin.JobResultCard @@ -119,19 +118,24 @@ fun PluginContent( emptyMap() } } - val providedSettings = remember(pluginId, pluginSettingsState) { + val selectedManifest = remember(pluginId, viewModel.selectedPlugin) { + viewModel.selectedPlugin?.getManifest()?.getOrNull() + } + val providedSettings = remember(pluginId, pluginSettingsState, selectedManifest) { val store = if (pluginId != null) pluginSettingsState[pluginId] ?: pluginManager.loadPluginSettings(pluginId) else null - val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() - val manifestDefaults = (manifest?.settings?.mapValues { (_, meta) -> - meta.defaultValue ?: if (meta.type is DataType.Primitive && (meta.type as DataType.Primitive).primitiveType == PrimitiveType.BOOLEAN) { - JsonPrimitive(false) - } else null - }?.filterValues { it != null } ?: emptyMap()) as Map - manifestDefaults + (store?.settings ?: emptyMap()) + (store?.globalParams ?: emptyMap()) + (store ?: org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore()) + .resolveProvidedValues(selectedManifest) } if (selectedCapability == null) { - EmptyState(stringResource(Res.string.plugin_select_capability_hint)) + if (selectedManifest != null && selectedManifest.uiPages.isNotEmpty()) { + PluginDefinedPages( + manifest = selectedManifest, + onCapabilitySelected = viewModel::selectCapability + ) + } else { + EmptyState(stringResource(Res.string.plugin_select_capability_hint)) + } } else { Column( modifier = Modifier @@ -203,6 +207,51 @@ fun PluginContent( } } +@Composable +private fun PluginDefinedPages( + manifest: PluginManifest, + onCapabilitySelected: (Capability) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(ToolkitTheme.spacing.extraLarge), + verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.extraLarge) + ) { + manifest.uiPages.forEach { page -> + key(page.id) { + val capabilities = page.capabilityNames.mapNotNull { name -> + manifest.capabilities.firstOrNull { it.name == name } + } + Column(verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small)) { + Text(page.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + if (page.description.isNotBlank()) { + Text( + page.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + capabilities.forEach { capability -> + OutlinedButton( + onClick = { onCapabilitySelected(capability) }, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(capability.name, style = MaterialTheme.typography.titleMedium) + capability.description?.takeIf { it.isNotBlank() }?.let { description -> + Text(description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } + } +} + @Composable fun PluginHeader(manifest: PluginManifest) { Column { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt index e723fa67..99c0dcfb 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt @@ -51,6 +53,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -72,8 +75,10 @@ import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.ParameterMetadata import org.wip.plugintoolkit.api.PluginAction import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme +import org.wip.plugintoolkit.features.plugin.model.resolveCustomSettings import org.wip.plugintoolkit.features.plugin.utils.SettingsUtils import org.wip.plugintoolkit.features.plugin.viewmodel.PluginSettingsViewModel import org.wip.plugintoolkit.shared.components.ToolkitChip @@ -95,6 +100,8 @@ import plugintoolkit.composeapp.generated.resources.plugin_settings_by_section import plugintoolkit.composeapp.generated.resources.plugin_settings_capability import plugintoolkit.composeapp.generated.resources.plugin_settings_custom import plugintoolkit.composeapp.generated.resources.plugin_settings_global_defaults +import plugintoolkit.composeapp.generated.resources.plugin_settings_optional +import plugintoolkit.composeapp.generated.resources.plugin_settings_required import plugintoolkit.composeapp.generated.resources.settings import plugintoolkit.composeapp.generated.resources.settings_locked_capability import plugintoolkit.composeapp.generated.resources.settings_no_results @@ -102,6 +109,11 @@ import plugintoolkit.composeapp.generated.resources.settings_search_placeholder import org.wip.plugintoolkit.shared.components.verticalFadingEdges +internal fun partitionSettings( + settings: Map +): Pair, Map> = + settings.filterValues { it.required } to settings.filterValues { !it.required } + @Composable fun PluginSettingsContent( pkg: String, @@ -125,6 +137,8 @@ fun PluginSettingsContent( val actionsTitle = stringResource(Res.string.plugin_settings_actions) val customTitle = stringResource(Res.string.plugin_settings_custom) val globalTitle = stringResource(Res.string.plugin_settings_global_defaults) + val requiredTitle = stringResource(Res.string.plugin_settings_required) + val optionalTitle = stringResource(Res.string.plugin_settings_optional) val capabilityTitles = manifest.capabilities.associate { it.name to stringResource(Res.string.plugin_settings_capability, it.name) @@ -173,6 +187,10 @@ fun PluginSettingsContent( val hasGlobalParams = globalParams.isNotEmpty() val hasCapabilities = capabilities.isNotEmpty() val hasAnyResults = hasActions || hasCustomSettings || hasGlobalParams || hasCapabilities + val (requiredSettings, optionalSettings) = remember(customSettings) { partitionSettings(customSettings) } + val customSettingRequesters = remember(customSettings.keys) { + customSettings.keys.associateWith { BringIntoViewRequester() } + } val lockedEnumOptions = remember(manifest) { val result = mutableMapOf>() @@ -246,7 +264,8 @@ fun PluginSettingsContent( // Auto-scroll to requested setting or section LaunchedEffect(scrollToSetting, sectionIndices, customSettings) { if (scrollToSetting != null) { - val targetKey = if (customSettings.containsKey(scrollToSetting)) { + val isCustomSetting = customSettings.containsKey(scrollToSetting) + val targetKey = if (isCustomSetting) { "section_custom" } else if (capabilities.any { it.parameters?.containsKey(scrollToSetting) == true }) { val cap = capabilities.first { it.parameters?.containsKey(scrollToSetting) == true } @@ -260,6 +279,10 @@ fun PluginSettingsContent( val targetIndex = targetKey?.let { sectionIndices[it] } if (targetIndex != null) { lazyListState.animateScrollToItem(targetIndex) + if (isCustomSetting) { + withFrameNanos { } + customSettingRequesters[scrollToSetting]?.bringIntoView() + } } } } @@ -410,15 +433,8 @@ fun PluginSettingsContent( ) } } else { - val manifestDefaults = remember(manifest) { - (manifest.settings?.mapValues { (_, meta) -> - meta.defaultValue ?: if (meta.type is DataType.Primitive && (meta.type as DataType.Primitive).primitiveType == PrimitiveType.BOOLEAN) { - JsonPrimitive(false) - } else null - }?.filterValues { it != null } ?: emptyMap()) as Map - } - val providedSettings = remember(manifestDefaults, store.settings) { - manifestDefaults + store.settings + val providedSettings = remember(manifest, store.settings) { + store.resolveCustomSettings(manifest) } LazyColumn( @@ -472,75 +488,91 @@ fun PluginSettingsContent( modifier = Modifier.fillMaxWidth().padding(top = ToolkitTheme.spacing.small), verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.mediumSmall) ) { - customSettings.forEach { (key, meta) -> - Column(modifier = Modifier.fillMaxWidth()) { - val value = store.settings[key] ?: meta.defaultValue - DynamicParameterInput( - name = key, - metadata = ParameterMetadata( - description = meta.description, - type = meta.type, - defaultValue = meta.defaultValue, - required = meta.required, - secret = meta.secret - ), - value = SettingsUtils.jsonToString(value, meta.type), - onValueChange = { - viewModel.updateSetting( - key, - SettingsUtils.stringToJson(it, meta.type) - ) - }, - enabled = !isBusy, - providedSettings = providedSettings, - providedLocks = locks - ) - - val lockedOptionsForSetting = lockedEnumOptions[key]?.distinct() ?: emptyList() - - if (meta.requiredByCapabilities.isNotEmpty() || lockedOptionsForSetting.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = ToolkitTheme.spacing.medium, - bottom = ToolkitTheme.spacing.mediumSmall, - end = ToolkitTheme.spacing.medium - ) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small) - ) { - meta.requiredByCapabilities.forEach { capName -> - ToolkitChip( - text = stringResource( - Res.string.settings_locked_capability, - capName - ), - icon = { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) - ) - }, - style = ToolkitChipStyle.Tinted - ) - } - if (lockedOptionsForSetting.isNotEmpty()) { - ToolkitChip( - text = "Unlocks Enum Options", - modifier = Modifier.tooltip( - text = "Unlocks values:\n" + lockedOptionsForSetting.joinToString("\n"), - ), - icon = { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) - ) - }, - style = ToolkitChipStyle.Outlined + listOf( + requiredTitle to requiredSettings, + optionalTitle to optionalSettings + ).forEach { (groupTitle, groupSettings) -> + if (groupSettings.isNotEmpty()) { + PluginSettingGroupHeader(groupTitle, groupSettings.size) + } + groupSettings.forEach { (key, meta) -> + Column( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(customSettingRequesters.getValue(key)) + ) { + val value = store.settings[key] ?: meta.defaultValue + DynamicParameterInput( + name = key, + metadata = ParameterMetadata( + description = meta.description, + type = meta.type, + defaultValue = meta.defaultValue, + constraints = meta.constraints, + required = meta.required, + secret = meta.secret, + semanticTypes = meta.semanticTypes, + autogeneratedPattern = meta.autogeneratedPattern + ), + value = SettingsUtils.jsonToString(value, meta.type), + onValueChange = { + viewModel.updateSetting( + key, + SettingsUtils.stringToJson(it, meta.type) ) + }, + enabled = !isBusy && meta.autogeneratedPattern == null, + isAutoGenerated = meta.autogeneratedPattern != null, + providedSettings = providedSettings, + providedLocks = locks + ) + + val lockedOptionsForSetting = lockedEnumOptions[key]?.distinct() ?: emptyList() + + if (meta.requiredByCapabilities.isNotEmpty() || lockedOptionsForSetting.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = ToolkitTheme.spacing.medium, + bottom = ToolkitTheme.spacing.mediumSmall, + end = ToolkitTheme.spacing.medium + ) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small) + ) { + meta.requiredByCapabilities.forEach { capName -> + ToolkitChip( + text = stringResource( + Res.string.settings_locked_capability, + capName + ), + icon = { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) + ) + }, + style = ToolkitChipStyle.Tinted + ) + } + if (lockedOptionsForSetting.isNotEmpty()) { + ToolkitChip( + text = "Unlocks Enum Options", + modifier = Modifier.tooltip( + text = "Unlocks values:\n" + lockedOptionsForSetting.joinToString("\n"), + ), + icon = { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) + ) + }, + style = ToolkitChipStyle.Outlined + ) + } } } } @@ -672,6 +704,21 @@ private fun PluginSectionHeader(title: String) { ) } +@Composable +private fun PluginSettingGroupHeader(title: String, count: Int) { + Text( + text = "$title ($count)", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = ToolkitTheme.spacing.medium, + top = ToolkitTheme.spacing.small, + bottom = ToolkitTheme.spacing.extraSmall + ) + ) +} + @Composable private fun ActionParametersDialog( action: PluginAction, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt index 6094902a..3b614b0f 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt @@ -10,22 +10,29 @@ import kotlinx.serialization.json.JsonElement import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.plugin.logic.PluginManager +import org.wip.plugintoolkit.features.plugin.logic.withResolvedAutogeneratedSettings +import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore class PluginSettingsViewModel( val pkg: String, private val pluginManager: PluginManager, private val jobManager: JobManager, ) : ViewModel() { - private val _store = MutableStateFlow(pluginManager.loadPluginSettings(pkg)) + val manifest = pluginManager.getManifest(pkg) + private val initialStore = pluginManager.loadPluginSettings(pkg) + private val _store = MutableStateFlow(initialStore.withAutogeneratedSettings()) val store = _store.asStateFlow() private val _isBusy = MutableStateFlow(false) val isBusy = _isBusy.asStateFlow() - val manifest = pluginManager.getManifest(pkg) - val locks = MutableStateFlow>(emptyMap()) + private fun PluginSettingsStore.withAutogeneratedSettings(): PluginSettingsStore { + val settingMetadata = manifest?.settings ?: return this + return withResolvedAutogeneratedSettings(settingMetadata) + } + init { viewModelScope.launch { pluginManager.refreshLocks(pkg) @@ -45,7 +52,7 @@ class PluginSettingsViewModel( fun updateSetting(key: String, value: JsonElement) { _store.update { current -> - val updated = current.copy(settings = current.settings + (key to value)) + val updated = current.copy(settings = current.settings + (key to value)).withAutogeneratedSettings() viewModelScope.launch { val newLocks = pluginManager.refreshLocks(pkg, updated) locks.value = newLocks @@ -56,7 +63,7 @@ class PluginSettingsViewModel( fun updateGlobalParam(key: String, value: JsonElement) { _store.update { current -> - val updated = current.copy(globalParams = current.globalParams + (key to value)) + val updated = current.copy(globalParams = current.globalParams + (key to value)).withAutogeneratedSettings() viewModelScope.launch { val newLocks = pluginManager.refreshLocks(pkg, updated) locks.value = newLocks diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt index acf03ddb..eced3f26 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt @@ -7,9 +7,16 @@ import org.wip.plugintoolkit.core.utils.FileSystem import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.plugin.model.InstalledPlugin import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore +import org.wip.plugintoolkit.features.plugin.model.resolveCustomSettings import org.wip.plugintoolkit.features.settings.logic.SettingsPersistence import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import org.wip.plugintoolkit.features.settings.model.AppSettings +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PluginInfo +import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.Requirements +import org.wip.plugintoolkit.api.SettingMetadata import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotSame @@ -48,6 +55,58 @@ class PluginLifecycleManagerTest { override fun openLatestLog() {} } + @Test + fun testPluginContextReceivesExactlyResolvedCustomSettings() = runTest { + val fileSystem = FakeFileSystem() + val settingsRepo = SettingsRepository(FakeSettingsPersistence(), backgroundScope) + val registry = PluginRegistry( + settingsRepo, + backgroundScope, + loomDispatcher, + io.mockk.mockk(relaxed = true) + ) + val lifecycleManager = PluginLifecycleManager( + registry, + JobManager(backgroundScope, settingsRepo), + settingsRepo, + fileSystem + ) + val pkg = "test.context.defaults" + registry.addOrUpdatePlugin( + InstalledPlugin(pkg, "Test", "1.0.0", "/tmp/test.context.defaults") + ) + val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo(pkg, "Test", "1.0.0", "Test plugin"), + requirements = Requirements(128, 10), + settings = mapOf( + "endpoint" to SettingMetadata( + defaultValue = JsonPrimitive("https://default.test"), + description = "Endpoint", + type = DataType.Primitive(PrimitiveType.STRING) + ), + "optionalFlag" to SettingMetadata( + description = "Optional flag", + type = DataType.Primitive(PrimitiveType.BOOLEAN) + ) + ) + ) + val store = PluginSettingsStore( + settings = mapOf("endpoint" to JsonPrimitive("https://custom.test")), + globalParams = mapOf("region" to JsonPrimitive("eu")) + ) + + val context = lifecycleManager.createPluginContext( + pkg = pkg, + manifest = manifest, + overriddenSettings = store + ) + + assertEquals(store.resolveCustomSettings(manifest), context.settings) + kotlin.test.assertFalse(context.settings.containsKey("optionalFlag")) + kotlin.test.assertFalse(context.settings.containsKey("region")) + } + @Test fun testSettingsCaching() = runTest { val fileSystem = FakeFileSystem() diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt new file mode 100644 index 00000000..6b645980 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt @@ -0,0 +1,65 @@ +package org.wip.plugintoolkit.features.plugin.model + +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PluginInfo +import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.Requirements +import org.wip.plugintoolkit.api.SettingMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PluginSettingDefaultsTest { + private val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1.0", "Example plugin"), + requirements = Requirements(128, 10), + settings = mapOf( + "endpoint" to SettingMetadata( + defaultValue = JsonPrimitive("https://example.test"), + description = "Endpoint", + type = DataType.Primitive(PrimitiveType.STRING) + ), + "enabled" to SettingMetadata( + description = "Enabled", + type = DataType.Primitive(PrimitiveType.BOOLEAN) + ) + ) + ) + + @Test + fun `manifest defaults are available before a user saves settings`() { + val resolved = PluginSettingsStore().resolveCustomSettings(manifest) + + assertEquals(JsonPrimitive("https://example.test"), resolved["endpoint"]) + assertFalse(resolved.containsKey("enabled")) + } + + @Test + fun `user values override defaults and global values are exposed separately`() { + val store = PluginSettingsStore( + settings = mapOf("endpoint" to JsonPrimitive("https://custom.test")), + globalParams = mapOf("region" to JsonPrimitive("eu")) + ) + + val custom = store.resolveCustomSettings(manifest) + val provided = store.resolveProvidedValues(manifest) + + assertEquals(JsonPrimitive("https://custom.test"), custom["endpoint"]) + assertFalse(custom.containsKey("enabled")) + assertEquals(JsonPrimitive("eu"), provided["region"]) + } + + @Test + fun `global parameters cannot shadow custom settings in custom setting resolution`() { + val store = PluginSettingsStore( + settings = mapOf("endpoint" to JsonPrimitive("https://custom.test")), + globalParams = mapOf("endpoint" to JsonPrimitive("global-collision")) + ) + + assertEquals(JsonPrimitive("https://custom.test"), store.resolveCustomSettings(manifest)["endpoint"]) + assertEquals(JsonPrimitive("global-collision"), store.resolveProvidedValues(manifest)["endpoint"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt new file mode 100644 index 00000000..93d238d9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt @@ -0,0 +1,40 @@ +package org.wip.plugintoolkit.features.plugin.ui + +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PluginSettingPartitionTest { + @Test + fun `partitions required and optional settings while preserving order`() { + val settings = linkedMapOf( + "optionalFirst" to setting(required = false), + "requiredFirst" to setting(required = true), + "requiredSecond" to setting(required = true), + "optionalSecond" to setting(required = false) + ) + + val (required, optional) = partitionSettings(settings) + + assertEquals(listOf("requiredFirst", "requiredSecond"), required.keys.toList()) + assertEquals(listOf("optionalFirst", "optionalSecond"), optional.keys.toList()) + assertEquals(settings.keys, (required.keys + optional.keys).toSet()) + } + + @Test + fun `empty settings produce two empty groups`() { + val (required, optional) = partitionSettings(emptyMap()) + + assertTrue(required.isEmpty()) + assertTrue(optional.isEmpty()) + } + + private fun setting(required: Boolean) = SettingMetadata( + description = "Setting", + type = DataType.Primitive(PrimitiveType.STRING), + required = required + ) +} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt new file mode 100644 index 00000000..2bdeab26 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt @@ -0,0 +1,61 @@ +package org.wip.plugintoolkit.features.plugin.viewmodel + +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata +import org.wip.plugintoolkit.features.plugin.logic.resolveAutogeneratedSettings +import kotlin.test.Test +import kotlin.test.assertEquals + +class PluginSettingsAutogenerationTest { + private val stringType = DataType.Primitive(PrimitiveType.STRING) + private val metadata = mapOf( + "input" to SettingMetadata(description = "Input", type = stringType), + "output" to SettingMetadata( + description = "Output", + type = stringType, + autogeneratedPattern = "{input.dir}/{input.nameWithoutExtension}.result" + ) + ) + + @Test + fun `derived setting follows its dependency`() { + val result = resolveAutogeneratedSettings( + metadata = metadata, + settings = mapOf("input" to JsonPrimitive("work/photo.png")) + ) + + assertEquals(JsonPrimitive("work/photo.result"), result["output"]) + } + + @Test + fun `derived setting preserves the last explicit value when dependency is missing`() { + val result = resolveAutogeneratedSettings( + metadata = metadata, + settings = mapOf("output" to JsonPrimitive("stale.result")) + ) + + assertEquals(JsonPrimitive("stale.result"), result["output"]) + } + + @Test + fun `invalid generated value does not replace a valid persisted value`() { + val numberType = DataType.Primitive(PrimitiveType.INT) + val result = resolveAutogeneratedSettings( + metadata = metadata + ( + "count" to SettingMetadata( + description = "Count", + type = numberType, + autogeneratedPattern = "{input.nameWithoutExtension}" + ) + ), + settings = mapOf( + "input" to JsonPrimitive("photo.png"), + "count" to JsonPrimitive(42) + ) + ) + + assertEquals(JsonPrimitive(42), result["count"]) + } +} diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index d69b5025..d08fb3ed 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -37,7 +37,8 @@ The `@PluginSetting` annotation supports identical validation constraints to tho data class MyAdvancedSettings( @PluginSetting( description = "Service Endpoint", - regex = "^https?://.*" + regex = "^https?://.*", + semanticTypes = ["text/uri"] ) val endpoint: String, @PluginSetting( @@ -48,6 +49,8 @@ data class MyAdvancedSettings( ) ``` +Settings also accept `semanticTypes` and `pathTemplate`, matching capability parameters. Semantic types select specialized controls such as color or file inputs; a path template derives a value from other configured fields. + ### 2. Capabilities A plugin provides one or more **Capabilities**. These are functions annotated with `@Capability`. Each capability becomes a task that the host application can execute. diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt index d82c0c3f..d8c897c2 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt @@ -251,8 +251,67 @@ data class SettingMetadata( * This allows UI to show which capabilities are locked behind this setting * without making the setting globally required for the plugin to load. */ - val requiredByCapabilities: List = emptyList() -) + val requiredByCapabilities: List = emptyList(), + // Keep new fields after the original constructor fields so component7 retains + // its pre-existing requiredByCapabilities meaning for old compiled callers. + val semanticTypes: List = emptyList(), + val autogeneratedPattern: String? = null +) { + /** + * Retains the JVM constructor used by plugins compiled before semantic hints and + * autogenerated patterns were added. The host classloader deliberately shares + * plugin-api classes, so removing this descriptor would break installed plugins + * before their compatibility metadata can be inspected. + */ + @Deprecated("Binary compatibility constructor", level = DeprecationLevel.HIDDEN) + constructor( + defaultValue: JsonElement? = null, + description: String, + type: DataType, + required: Boolean = false, + secret: Boolean = false, + constraints: ParameterConstraints? = null, + requiredByCapabilities: List = emptyList() + ) : this( + defaultValue = defaultValue, + description = description, + type = type, + required = required, + secret = secret, + constraints = constraints, + requiredByCapabilities = requiredByCapabilities, + semanticTypes = emptyList(), + autogeneratedPattern = null + ) + + @Deprecated("Binary compatibility copy", level = DeprecationLevel.HIDDEN) + fun copy( + defaultValue: JsonElement?, description: String, type: DataType, required: Boolean, + secret: Boolean, constraints: ParameterConstraints?, requiredByCapabilities: List + ): SettingMetadata = SettingMetadata( + defaultValue, description, type, required, secret, constraints, requiredByCapabilities, + semanticTypes, autogeneratedPattern + ) + + companion object { + @JvmStatic + @Deprecated("Binary compatibility copy bridge", level = DeprecationLevel.HIDDEN) + fun `copy$default`( + self: SettingMetadata, + defaultValue: JsonElement?, description: String?, type: DataType?, required: Boolean, + secret: Boolean, constraints: ParameterConstraints?, requiredByCapabilities: List?, + mask: Int, marker: Any? + ): SettingMetadata = self.copy( + if (mask and 0x01 != 0) self.defaultValue else defaultValue, + if (mask and 0x02 != 0) self.description else requireNotNull(description), + if (mask and 0x04 != 0) self.type else requireNotNull(type), + if (mask and 0x08 != 0) self.required else required, + if (mask and 0x10 != 0) self.secret else secret, + if (mask and 0x20 != 0) self.constraints else constraints, + if (mask and 0x40 != 0) self.requiredByCapabilities else requireNotNull(requiredByCapabilities) + ) + } +} /** * The complete manifest of a plugin, describing its capabilities and requirements. @@ -271,7 +330,85 @@ data class PluginManifest( val changelog: Changelog? = null, val hasUpdateHandler: Boolean = false, val hasSetupHandler: Boolean = false, - val hasMigrations: Boolean = false + val hasMigrations: Boolean = false, + /** Optional declarative pages rendered by the host. Unknown capability names are ignored. */ + val uiPages: List = emptyList() +) { + @Deprecated("Binary compatibility constructor", level = DeprecationLevel.HIDDEN) + constructor( + manifestVersion: String, + plugin: PluginInfo, + requirements: Requirements, + defaultParameters: Map? = null, + capabilities: List = emptyList(), + actions: List = emptyList(), + settings: Map? = null, + changelog: Changelog? = null, + hasUpdateHandler: Boolean = false, + hasSetupHandler: Boolean = false, + hasMigrations: Boolean = false + ) : this( + manifestVersion = manifestVersion, + plugin = plugin, + requirements = requirements, + defaultParameters = defaultParameters, + capabilities = capabilities, + actions = actions, + settings = settings, + changelog = changelog, + hasUpdateHandler = hasUpdateHandler, + hasSetupHandler = hasSetupHandler, + hasMigrations = hasMigrations, + uiPages = emptyList() + ) + + @Deprecated("Binary compatibility copy", level = DeprecationLevel.HIDDEN) + fun copy( + manifestVersion: String, plugin: PluginInfo, requirements: Requirements, + defaultParameters: Map?, capabilities: List, + actions: List, settings: Map?, changelog: Changelog?, + hasUpdateHandler: Boolean, hasSetupHandler: Boolean, hasMigrations: Boolean + ): PluginManifest = PluginManifest( + manifestVersion, plugin, requirements, defaultParameters, capabilities, actions, settings, changelog, + hasUpdateHandler, hasSetupHandler, hasMigrations, uiPages + ) + + companion object { + @JvmStatic + @Deprecated("Binary compatibility copy bridge", level = DeprecationLevel.HIDDEN) + fun `copy$default`( + self: PluginManifest, + manifestVersion: String?, plugin: PluginInfo?, requirements: Requirements?, + defaultParameters: Map?, capabilities: List?, + actions: List?, settings: Map?, changelog: Changelog?, + hasUpdateHandler: Boolean, hasSetupHandler: Boolean, hasMigrations: Boolean, + mask: Int, marker: Any? + ): PluginManifest = self.copy( + if (mask and 0x001 != 0) self.manifestVersion else requireNotNull(manifestVersion), + if (mask and 0x002 != 0) self.plugin else requireNotNull(plugin), + if (mask and 0x004 != 0) self.requirements else requireNotNull(requirements), + if (mask and 0x008 != 0) self.defaultParameters else defaultParameters, + if (mask and 0x010 != 0) self.capabilities else requireNotNull(capabilities), + if (mask and 0x020 != 0) self.actions else requireNotNull(actions), + if (mask and 0x040 != 0) self.settings else settings, + if (mask and 0x080 != 0) self.changelog else changelog, + if (mask and 0x100 != 0) self.hasUpdateHandler else hasUpdateHandler, + if (mask and 0x200 != 0) self.hasSetupHandler else hasSetupHandler, + if (mask and 0x400 != 0) self.hasMigrations else hasMigrations + ) + } +} + +/** + * A host-rendered plugin page. Keeping this declarative avoids coupling plugin JARs to a + * particular Compose version while still allowing plugins to shape their user experience. + */ +@Serializable +data class PluginUiPage( + val id: String, + val title: String, + val description: String = "", + val capabilityNames: List = emptyList() ) @Serializable @@ -632,4 +769,3 @@ data class PluginAction( val functionName: String, val parameters: Map? = null ) - diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt index b82f97a5..705ec25c 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt @@ -14,6 +14,17 @@ annotation class PluginInfo( val supportedOs: Array = [] ) +/** Declares a host-rendered page grouping capabilities without bundling UI code. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +@Repeatable +annotation class PluginUiPage( + val id: String, + val title: String, + val description: String = "", + val capabilityNames: Array = [] +) + /** * Provides metadata for a capability result. * Can be applied to a single-return capability function or to properties of a custom data class return type. @@ -169,6 +180,8 @@ annotation class CapabilityOutput( * @property defaultValue The default value for the setting (as a string). * @property required Whether the setting is mandatory for the plugin to function. * @property secret Whether the setting contains sensitive information (e.g., API keys). + * @property semanticTypes Semantic hints used to select a specialized editor (for example `color/rgb`). + * @property pathTemplate Optional template used to derive this setting from other values. */ @Target(AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.SOURCE) @@ -184,7 +197,9 @@ annotation class PluginSetting( val regex: String = "", val multiSelect: Boolean = false, val minChoices: Int = -1, - val maxChoices: Int = -1 + val maxChoices: Int = -1, + val semanticTypes: Array = [], + val pathTemplate: String = "" ) /** @@ -271,4 +286,4 @@ annotation class ComplexObject( val id: String = "", val description: String = "", val version: Int = 1 -) \ No newline at end of file +) diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt index a7e8ba13..f3930871 100644 --- a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt @@ -1,10 +1,30 @@ package org.wip.plugintoolkit.api import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals class ManifestModelsTest { + @Test + fun `setting metadata preserves capability-style input hints`() { + val metadata = SettingMetadata( + defaultValue = JsonPrimitive("#112233"), + description = "Brand color", + type = DataType.Primitive(PrimitiveType.STRING), + constraints = ParameterConstraints(regex = "#[0-9A-Fa-f]{6}"), + semanticTypes = parseSemanticTypes("color/rgb"), + autogeneratedPattern = "{theme}/brand.hex" + ) + val json = Json { encodeDefaults = true } + + val decoded = json.decodeFromString(json.encodeToString(metadata)) + + assertEquals(metadata.constraints, decoded.constraints) + assertEquals(metadata.semanticTypes, decoded.semanticTypes) + assertEquals(metadata.autogeneratedPattern, decoded.autogeneratedPattern) + } + @Test fun testCapabilityDeserialization() { val jsonString = """{ diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt new file mode 100644 index 00000000..c29cebca --- /dev/null +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt @@ -0,0 +1,25 @@ +package org.wip.plugintoolkit.api + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class PluginUiPageTest { + @Test + fun `plugin pages round trip through the manifest`() { + val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1", "Example"), + requirements = Requirements(64, 10), + uiPages = listOf( + PluginUiPage("home", "Home", "Common actions", listOf("convert")) + ) + ) + + val json = Json.encodeToString(manifest) + val restored = Json.decodeFromString(json) + + assertEquals(manifest.uiPages, restored.uiPages) + } +} diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt index 3bf562ee..4b68d921 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt @@ -10,6 +10,7 @@ import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.ksp.toTypeName import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.PluginUiPage import org.wip.plugintoolkit.api.SemanticType import org.wip.plugintoolkit.api.parseSemanticTypes @@ -109,6 +110,40 @@ object GeneratorUtils { return this.annotationType.resolve().declaration.qualifiedName?.asString() == name } + fun extractUiPages( + classDeclaration: KSClassDeclaration, + reportError: (String) -> Unit = {} + ): List = + classDeclaration.annotations + .filter { it.hasQualifiedName(ProcessorConstants.PLUGIN_UI_PAGE_ANNOTATION) } + .mapNotNull { annotation -> + val id = annotation.arguments.find { it.name?.asString() == "id" }?.value as? String + val title = annotation.arguments.find { it.name?.asString() == "title" }?.value as? String + if (id == null || title == null) { + reportError("@PluginUiPage requires string 'id' and 'title' arguments") + return@mapNotNull null + } + val rawCapabilities = annotation.arguments + .find { it.name?.asString() == "capabilityNames" } + ?.value + if (rawCapabilities != null && rawCapabilities !is List<*>) { + reportError("@PluginUiPage.capabilityNames must be a string array") + return@mapNotNull null + } + val capabilityNames = (rawCapabilities as? List<*>)?.filterIsInstance().orEmpty() + if ((rawCapabilities as? List<*>)?.size != capabilityNames.size) { + reportError("@PluginUiPage.capabilityNames must contain only strings") + return@mapNotNull null + } + PluginUiPage( + id = id, + title = title, + description = annotation.arguments.find { it.name?.asString() == "description" }?.value as? String ?: "", + capabilityNames = capabilityNames + ) + } + .toList() + fun generateDataTypeCode(dataType: DataType): com.squareup.kotlinpoet.CodeBlock { val cnDataType = com.squareup.kotlinpoet.ClassName("org.wip.plugintoolkit.api", "DataType") val cnPrimitiveType = com.squareup.kotlinpoet.ClassName("org.wip.plugintoolkit.api", "PrimitiveType") diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt index 2b4e20b1..642dbfb2 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt @@ -50,7 +50,7 @@ object KotlinGenerator { it.getAllProperties() .filter { p -> p.annotations.any { a -> a.hasQualifiedName(org.wip.plugintoolkit.api.processor.ProcessorConstants.PLUGIN_SETTING_ANNOTATION) } } }.toList(), - actions, updateFunction != null, setupFunction != null + actions, updateFunction != null, setupFunction != null, classDeclaration ) fileSpec.addType(manifestType) diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt index 06505316..9aea47b4 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt @@ -279,6 +279,12 @@ object ManifestJsonGenerator { val multiSelect = ann.arguments.find { it.name?.asString() == "multiSelect" }?.value as? Boolean ?: false val minChoices = ann.arguments.find { it.name?.asString() == "minChoices" }?.value as? Int ?: -1 val maxChoices = ann.arguments.find { it.name?.asString() == "maxChoices" }?.value as? Int ?: -1 + val semanticTypes = + (ann.arguments.find { it.name?.asString() == "semanticTypes" }?.value as? List<*>) + ?.filterIsInstance() + ?.flatMap { parseSemanticTypes(it) } + ?: emptyList() + val pathTemplate = ann.arguments.find { it.name?.asString() == "pathTemplate" }?.value as? String ?: "" val hasConstraints = !minValue.isNaN() || !maxValue.isNaN() || minLength != -1 || maxLength != -1 || regex.isNotEmpty() || multiSelect || minChoices != -1 || maxChoices != -1 @@ -303,6 +309,8 @@ object ManifestJsonGenerator { required = required, secret = secret, constraints = constraints, + semanticTypes = semanticTypes, + autogeneratedPattern = pathTemplate.ifBlank { null }, requiredByCapabilities = requiredBy ) } @@ -372,7 +380,8 @@ object ManifestJsonGenerator { changelog = changelogObj, hasUpdateHandler = updateFunction != null, hasSetupHandler = setupFunction != null, - hasMigrations = hasMigrations + hasMigrations = hasMigrations, + uiPages = GeneratorUtils.extractUiPages(classDeclaration) ) val json = Json { diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt index 52c538ed..8eb3e213 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt @@ -176,6 +176,25 @@ class ManifestProcessor( it.annotations.any { ann -> ann.hasQualifiedName(PLUGIN_ACTION_ANNOTATION) } }.toList() + val uiPages = org.wip.plugintoolkit.api.processor.GeneratorUtils.extractUiPages(classDeclaration) { message -> + logger.error(message, classDeclaration) + } + uiPages.filter { it.id.isBlank() }.forEach { + logger.error("@PluginUiPage.id must not be blank", classDeclaration) + } + uiPages.groupBy { it.id }.filterValues { it.size > 1 }.keys.forEach { duplicateId -> + logger.error("Duplicate @PluginUiPage id '$duplicateId'", classDeclaration) + } + val capabilityNames = functions.map { function -> + val annotation = function.annotations.first { it.hasQualifiedName(CAPABILITY_ANNOTATION) } + annotation.arguments.first { it.name?.asString() == "name" }.value as String + }.toSet() + uiPages.flatMap { page -> page.capabilityNames.map { page.id to it } } + .filter { (_, capabilityName) -> capabilityName !in capabilityNames } + .forEach { (pageId, capabilityName) -> + logger.warn("@PluginUiPage '$pageId' references unknown capability '$capabilityName'", classDeclaration) + } + // 1. Parse Changelog var changelogObj: Changelog? = null val sourceFile = classDeclaration.containingFile diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt index b509c422..199780a3 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt @@ -20,6 +20,7 @@ import org.wip.plugintoolkit.api.PluginFileSystem import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.api.PluginLogger import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PluginUiPage import org.wip.plugintoolkit.api.PluginModuleProvider import org.wip.plugintoolkit.api.PluginRequest import org.wip.plugintoolkit.api.PluginResponse @@ -34,6 +35,7 @@ object ProcessorConstants { // Annotations const val PLUGIN_INFO_ANNOTATION = "$ANNOTATION_PACKAGE.PluginInfo" + const val PLUGIN_UI_PAGE_ANNOTATION = "$ANNOTATION_PACKAGE.PluginUiPage" const val CAPABILITY_ANNOTATION = "$ANNOTATION_PACKAGE.Capability" const val CAPABILITY_PARAM_ANNOTATION = "$ANNOTATION_PACKAGE.CapabilityParam" const val CAPABILITY_INPUT_ANNOTATION = "$ANNOTATION_PACKAGE.CapabilityInput" @@ -53,6 +55,7 @@ object ProcessorConstants { // API Classes val CN_PLUGIN_MANIFEST = PluginManifest::class.asClassName() + val CN_PLUGIN_UI_PAGE = PluginUiPage::class.asClassName() val CN_PLUGIN_INFO = PluginInfo::class.asClassName() val CN_REQUIREMENTS = Requirements::class.asClassName() val CN_CAPABILITY = Capability::class.asClassName() diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt index ff0682ea..a35b7a71 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt @@ -1,6 +1,7 @@ package org.wip.plugintoolkit.api.processor.generators import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -62,7 +63,8 @@ object ManifestGenerator { settingsProperties: List, actions: List, hasUpdateHandler: Boolean, - hasSetupHandler: Boolean + hasSetupHandler: Boolean, + classDeclaration: KSClassDeclaration ): TypeSpec { val manifestType = TypeSpec.objectBuilder(manifestName) @@ -355,6 +357,30 @@ object ManifestGenerator { val secret = ann.arguments.find { it.name?.asString() == "secret" }?.value as? Boolean ?: false val propName = prop.simpleName.asString() val propType = prop.type.resolve().toTypeName() + val minValue = ann.arguments.find { it.name?.asString() == "minValue" }?.value as? Double ?: Double.NaN + val maxValue = ann.arguments.find { it.name?.asString() == "maxValue" }?.value as? Double ?: Double.NaN + val minLength = ann.arguments.find { it.name?.asString() == "minLength" }?.value as? Int ?: -1 + val maxLength = ann.arguments.find { it.name?.asString() == "maxLength" }?.value as? Int ?: -1 + val regex = ann.arguments.find { it.name?.asString() == "regex" }?.value as? String ?: "" + val multiSelect = ann.arguments.find { it.name?.asString() == "multiSelect" }?.value as? Boolean ?: false + val minChoices = ann.arguments.find { it.name?.asString() == "minChoices" }?.value as? Int ?: -1 + val maxChoices = ann.arguments.find { it.name?.asString() == "maxChoices" }?.value as? Int ?: -1 + val semanticTypeValues = + (ann.arguments.find { it.name?.asString() == "semanticTypes" }?.value as? List<*>) + ?.filterIsInstance() + ?: emptyList() + val pathTemplate = ann.arguments.find { it.name?.asString() == "pathTemplate" }?.value as? String ?: "" + val requiredByCapabilities = functions.mapNotNull { function -> + val capabilityAnnotation = function.annotations.find { + it.hasQualifiedName(CAPABILITY_ANNOTATION) + } ?: return@mapNotNull null + val requiredSettings = + (capabilityAnnotation.arguments.find { it.name?.asString() == "requiresSettings" }?.value as? List<*>) + ?.filterIsInstance() + ?: emptyList() + if (propName !in requiredSettings) return@mapNotNull null + capabilityAnnotation.arguments.find { it.name?.asString() == "name" }?.value as? String + } val defaultValueCode = if (defaultVal.isNotEmpty()) { try { kotlinx.serialization.json.Json.parseToJsonElement(defaultVal) @@ -366,17 +392,52 @@ object ManifestGenerator { CodeBlock.of("null") } + val hasConstraints = + !minValue.isNaN() || !maxValue.isNaN() || minLength != -1 || maxLength != -1 || + regex.isNotEmpty() || multiSelect || minChoices != -1 || maxChoices != -1 + val constraintsCode = if (hasConstraints) { + CodeBlock.of( + "%T(minValue = %L, maxValue = %L, minLength = %L, maxLength = %L, regex = %L, multiSelect = %L, minChoices = %L, maxChoices = %L)", + CN_PARAMETER_CONSTRAINTS, + if (!minValue.isNaN()) minValue else "null", + if (!maxValue.isNaN()) maxValue else "null", + if (minLength != -1) minLength else "null", + if (maxLength != -1) maxLength else "null", + if (regex.isNotEmpty()) CodeBlock.of("%S", regex) else CodeBlock.of("null"), + if (multiSelect) "true" else "null", + if (minChoices != -1) minChoices else "null", + if (maxChoices != -1) maxChoices else "null" + ) + } else CodeBlock.of("null") + val semanticTypesCode = generateSemanticTypesCode( + semanticTypeValues.flatMap { org.wip.plugintoolkit.api.parseSemanticTypes(it) } + ) + val autogeneratedPatternCode = + if (pathTemplate.isBlank()) CodeBlock.of("null") else CodeBlock.of("%S", pathTemplate) + val requiredByCapabilitiesCode = if (requiredByCapabilities.isEmpty()) { + CodeBlock.of("emptyList()") + } else { + CodeBlock.of( + "listOf(%L)", + requiredByCapabilities.joinToString { "\"$it\"" } + ) + } + settingsCode.add( - "%S to %T(defaultValue = %L, description = %S, type = %M<%T>(), required = %L, secret = %L)", + "%S to %T(defaultValue = %L, description = %S, type = %M<%T>(), constraints = %L, required = %L, secret = %L, semanticTypes = %L, autogeneratedPattern = %L, requiredByCapabilities = %L)", propName, CN_SETTING_METADATA, defaultValueCode, desc, MN_GET_DATA_TYPE, propType, + constraintsCode, required, - secret + secret, + semanticTypesCode, + autogeneratedPatternCode, + requiredByCapabilitiesCode ) if (index < settingsProperties.size - 1) settingsCode.add(",\n") else settingsCode.add("\n") } @@ -451,6 +512,27 @@ object ManifestGenerator { } supportedOsCode.add(")") + val uiPagesCode = CodeBlock.builder().add("listOf(\n").indent() + val uiPages = GeneratorUtils.extractUiPages(classDeclaration) + uiPages.forEachIndexed { index, page -> + val capabilityNamesCode = CodeBlock.builder().add("listOf(") + page.capabilityNames.forEachIndexed { capabilityIndex, capabilityName -> + capabilityNamesCode.add("%S", capabilityName) + if (capabilityIndex < page.capabilityNames.lastIndex) capabilityNamesCode.add(", ") + } + capabilityNamesCode.add(")") + uiPagesCode.add( + "%T(id = %S, title = %S, description = %S, capabilityNames = %L)", + ProcessorConstants.CN_PLUGIN_UI_PAGE, + page.id, + page.title, + page.description, + capabilityNamesCode.build() + ) + if (index < uiPages.lastIndex) uiPagesCode.add(",\n") else uiPagesCode.add("\n") + } + uiPagesCode.unindent().add(")") + manifestType.addProperty( PropertySpec.builder("manifest", CN_PLUGIN_MANIFEST) .initializer( @@ -479,7 +561,8 @@ object ManifestGenerator { .add(actionsCode.build()) .add(",\nsettings = ") .add(settingsCode.build()) - .add(",\nhasUpdateHandler = %L,\nhasSetupHandler = %L\n", hasUpdateHandler, hasSetupHandler) + .add(",\nhasUpdateHandler = %L,\nhasSetupHandler = %L,\n", hasUpdateHandler, hasSetupHandler) + .add("uiPages = %L\n", uiPagesCode.build()) .unindent() .add(")") .build() diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt new file mode 100644 index 00000000..3e441aaf --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt @@ -0,0 +1,42 @@ +package org.wip.plugintoolkit.api + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PluginManifestBinaryCompatibilityTest { + @Test + fun `retains constructors used before plugin UI pages`() { + val constructors = PluginManifest::class.java.declaredConstructors.map { constructor -> + constructor.parameterTypes.toList() + } + + assertTrue(constructors.any { it.size == 11 && it.last() == Boolean::class.javaPrimitiveType }) + assertTrue(constructors.any { + it.size == 13 && + it[it.lastIndex - 1] == Int::class.javaPrimitiveType && + it.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" + }) + } + + @Test + fun `retains copy bridges used before plugin UI pages`() { + val methods = PluginManifest::class.java.declaredMethods + val oldCopy = methods.single { it.name == "copy" && it.parameterCount == 11 } + val oldDefaultCopy = methods.single { it.name == "copy\$default" && it.parameterCount == 14 } + val original = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("id", "name", "1", "description"), + requirements = Requirements(1, 1), + uiPages = listOf(PluginUiPage("page", "Page")) + ) + + val copied = oldDefaultCopy.invoke( + null, original, null, null, null, null, null, null, null, null, + false, false, false, 0x7FF, null + ) as PluginManifest + + assertEquals(original, copied) + assertEquals(PluginManifest::class.java, oldCopy.returnType) + } +} diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt new file mode 100644 index 00000000..fbce25af --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt @@ -0,0 +1,50 @@ +package org.wip.plugintoolkit.api + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SettingMetadataBinaryCompatibilityTest { + @Test + fun `retains pre-hints JVM constructor`() { + val oldParameterTypes = listOf( + JsonElement::class.java, + String::class.java, + DataType::class.java, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + ParameterConstraints::class.java, + List::class.java + ) + + assertTrue( + SettingMetadata::class.java.declaredConstructors.any { constructor -> + constructor.parameterTypes.toList() == oldParameterTypes + }, + "SettingMetadata must keep the constructor used by plugins compiled against the previous API" + ) + } + + @Test + fun `retains pre-hints copy bridges`() { + val methods = SettingMetadata::class.java.declaredMethods + val oldCopy = methods.single { it.name == "copy" && it.parameterCount == 7 } + val oldDefaultCopy = methods.single { it.name == "copy\$default" && it.parameterCount == 10 } + val original = SettingMetadata( + defaultValue = JsonPrimitive("value"), + description = "description", + type = DataType.Primitive(PrimitiveType.STRING), + semanticTypes = listOf(SemanticType(null, "text", null)), + autogeneratedPattern = "{input}" + ) + + val copied = oldDefaultCopy.invoke( + null, original, null, null, null, false, false, null, null, 0x7F, null + ) as SettingMetadata + + assertEquals(original, copied) + assertEquals(SettingMetadata::class.java, oldCopy.returnType) + } +}