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