Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
70b16db
Align plugin settings with capability parameters
matteomekhail Aug 14, 2026
485a9f3
Add safe directory operations to scoped filesystems
matteomekhail Aug 14, 2026
e3535b4
Resolve custom plugin defaults consistently
matteomekhail Aug 14, 2026
dea9ce8
Divide required and optional plugin settings
matteomekhail Aug 14, 2026
11b5294
Rework color picker dialog
matteomekhail Aug 14, 2026
feed807
Add declarative plugin-defined pages
matteomekhail Aug 14, 2026
d343ba9
fix: complete setting metadata integration
matteomekhail Aug 14, 2026
848eb8d
fix: harden sandboxed file deletion
matteomekhail Aug 14, 2026
2eff259
fix: preserve unset plugin settings
matteomekhail Aug 14, 2026
badf10c
fix: test setting groups and target deep links
matteomekhail Aug 14, 2026
5062c9d
Merge branch 'codex/issue-3-plugin-setting-parameters' into codex/iss…
matteomekhail Aug 14, 2026
d75c541
Merge branch 'codex/issue-11-custom-setting-defaults' into codex/issu…
matteomekhail Aug 14, 2026
5cc8dcc
fix: preserve color picker values and alpha
matteomekhail Aug 14, 2026
a2cb1b3
fix: generate plugin UI pages without breaking ABI
matteomekhail Aug 14, 2026
4db053c
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 14, 2026
9d7e9d5
fix: resolve generated settings safely at runtime
matteomekhail Aug 14, 2026
4a1158f
fix: preserve legacy color values and alpha
matteomekhail Aug 14, 2026
144d594
fix: preserve safe paths and isolate cache operations
matteomekhail Aug 14, 2026
206ae36
fix: retain data-class copy ABI for plugins
matteomekhail Aug 14, 2026
0eb3aaf
Merge branch 'codex/issue-3-plugin-setting-parameters' into codex/iss…
matteomekhail Aug 14, 2026
153c666
fix: preserve unchanged settings cache identity
matteomekhail Aug 14, 2026
c38bb10
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 14, 2026
5768bef
fix: retain alpha for functional color values
matteomekhail Aug 14, 2026
a9f0142
fix: handle unavailable sandbox roots safely
matteomekhail Aug 14, 2026
894a2eb
fix: round-trip functional and short alpha colors
matteomekhail Aug 14, 2026
2f15ae1
fix: apply manifest defaults to capability gates
matteomekhail Aug 14, 2026
c6cb1eb
Merge branch 'codex/issue-11-custom-setting-defaults' into codex/issu…
matteomekhail Aug 14, 2026
ffaf22e
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 14, 2026
774280b
Merge branch 'codex/issue-13-color-picker-rework' into codex/issue-4-…
matteomekhail Aug 14, 2026
e203a5c
fix: keep cache filesystem operations isolated
matteomekhail Aug 15, 2026
ce2f1a8
Merge branch 'codex/issue-10-plugin-filesystem' into codex/issue-11-c…
matteomekhail Aug 15, 2026
db3dfd2
Merge branch 'codex/issue-11-custom-setting-defaults' into codex/issu…
matteomekhail Aug 15, 2026
092c09e
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 15, 2026
f8a237d
Merge branch 'codex/issue-13-color-picker-rework' into codex/issue-4-…
matteomekhail Aug 15, 2026
a2b4a8e
Revert "Merge branch 'codex/issue-13-color-picker-rework' into codex/…
matteomekhail Aug 15, 2026
a232e9e
Revert "Merge branch 'codex/issue-13-color-picker-rework' into codex/…
matteomekhail Aug 15, 2026
7e8dd8d
Merge remote-tracking branch 'origin/dev' into codex/issue-4-plugin-d…
matteomekhail Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)…
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="plugin_settings_required">Obbligatorie</string>
<string name="plugin_settings_optional">Facoltative</string>
<string name="app_name">PluginToolkit</string>
<string name="section_runner">Runner</string>
<string name="nav_main">Dashboard</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@
<string name="plugin_settings_actions">Actions</string>
<string name="plugin_settings_custom">Custom Settings</string>
<string name="plugin_settings_global_defaults">Global Parameter Defaults</string>
<string name="plugin_settings_required">Required</string>
<string name="plugin_settings_optional">Optional</string>
<string name="plugin_settings_capability">Capability: %1$s</string>
<string name="settings_enum_unlock_message">Configure required settings to unlock options</string>
<string name="settings_locked_capability">Locked capability: %1$s</string>
Expand Down Expand Up @@ -474,4 +476,4 @@
<string name="repo_filter_label">Filter:</string>
<string name="repo_sort_label">Sort:</string>
<string name="repo_sync_all">Sync All</string>
</resources>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -287,6 +288,7 @@ class PluginLifecycleManager(
}

val decryptedStore = store.copy(settings = decryptedSettings)
.withResolvedAutogeneratedSettings(manifest?.settings.orEmpty())
_pluginSettingsState.update { it + (pkg to decryptedStore) }
return decryptedStore
}
Expand All @@ -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)
Expand All @@ -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" }
}
Expand All @@ -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<String, JsonElement>()

// 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, SettingMetadata>,
settings: Map<String, JsonElement>,
additionalValues: Map<String, JsonElement> = emptyMap()
): Map<String, JsonElement> {
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<String, SettingMetadata>
): 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() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ 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(
val settings: Map<String, JsonElement> = emptyMap(),
val globalParams: Map<String, JsonElement> = emptyMap(),
val capabilityParams: Map<String, Map<String, JsonElement>> = emptyMap()
)

fun PluginManifest.defaultCustomSettings(): Map<String, JsonElement> = 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<String, JsonElement> =
(manifest?.defaultCustomSettings() ?: emptyMap()) + settings

/** Values available to generated inputs and lock evaluation in the settings UI. */
fun PluginSettingsStore.resolveProvidedValues(manifest: PluginManifest?): Map<String, JsonElement> =
resolveCustomSettings(manifest) + globalParams
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<String, kotlinx.serialization.json.JsonElement>
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
Expand Down Expand Up @@ -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 {
Expand Down
Loading