From 70b16db3805d1fcd16c6f5ded9ca3e1fc8172ac5 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:41:29 +1000 Subject: [PATCH 01/21] Align plugin settings with capability parameters --- .../org/wip/complete/CompleteExamplePlugin.kt | 4 +- .../plugin/ui/PluginSettingsContent.kt | 5 ++- docs/PluginDevelopment.md | 5 ++- .../wip/plugintoolkit/api/ManifestModels.kt | 3 +- .../api/annotations/Annotations.kt | 8 +++- .../plugintoolkit/api/ManifestModelsTest.kt | 20 +++++++++ .../api/processor/ManifestJsonGenerator.kt | 8 ++++ .../processor/generators/ManifestGenerator.kt | 43 ++++++++++++++++++- 8 files changed, 88 insertions(+), 8 deletions(-) diff --git a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt index 3bb804ac..fcb26363 100644 --- a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt +++ b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt @@ -39,7 +39,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( 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 a05b5883..334451c2 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 @@ -483,8 +483,11 @@ fun PluginSettingsContent( description = meta.description, type = meta.type, defaultValue = meta.defaultValue, + constraints = meta.constraints, required = meta.required, - secret = meta.secret + secret = meta.secret, + semanticTypes = meta.semanticTypes, + autogeneratedPattern = meta.autogeneratedPattern ), value = SettingsUtils.jsonToString(value, meta.type), onValueChange = { 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..0da84ce9 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 @@ -246,6 +246,8 @@ data class SettingMetadata( val required: Boolean = false, val secret: Boolean = false, val constraints: ParameterConstraints? = null, + val semanticTypes: List = emptyList(), + val autogeneratedPattern: String? = null, /** * List of capability names that require this setting. * This allows UI to show which capabilities are locked behind this setting @@ -632,4 +634,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..77edcbdf 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 @@ -169,6 +169,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 +186,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 +275,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/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt index 06505316..7334907d 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 ) } 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..6668ef16 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 @@ -355,6 +355,19 @@ 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 defaultValueCode = if (defaultVal.isNotEmpty()) { try { kotlinx.serialization.json.Json.parseToJsonElement(defaultVal) @@ -366,17 +379,43 @@ 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) + 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)", propName, CN_SETTING_METADATA, defaultValueCode, desc, MN_GET_DATA_TYPE, propType, + constraintsCode, required, - secret + secret, + semanticTypesCode, + autogeneratedPatternCode ) if (index < settingsProperties.size - 1) settingsCode.add(",\n") else settingsCode.add("\n") } From 485a9f3bb715b56987281180ae1857dcb2a0f196 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:48:15 +1000 Subject: [PATCH 02/21] Add safe directory operations to scoped filesystems --- .../logic/DefaultExecutionFileSystem.kt | 23 +++++++++++++ .../plugin/logic/DefaultPluginFileSystem.kt | 33 +++++++++++++++++++ .../logic/DefaultExecutionFileSystemTest.kt | 18 ++++++++++ docs/PluginDevelopment.md | 1 + .../org/wip/plugintoolkit/api/Interfaces.kt | 4 +++ 5 files changed, 79 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt index fea6db80..3d842b4d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt @@ -97,5 +97,28 @@ class DefaultExecutionFileSystem( } } + override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { + SystemFileSystem.createDirectories(resolvePath(relativePath)) + } + + override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { + require(relativePath != RelativePath.ROOT) { "The execution sandbox root cannot be deleted" } + deleteDirectory(resolvePath(relativePath), recursive) + } + + private fun deleteDirectory(path: Path, recursive: Boolean) { + if (!SystemFileSystem.exists(path)) return + require(SystemFileSystem.metadataOrNull(path)?.isDirectory == true) { "Path is not a directory: $path" } + val children = SystemFileSystem.list(path) + require(recursive || children.isEmpty()) { "Directory is not empty: $path" } + if (recursive) { + children.forEach { child -> + if (SystemFileSystem.metadataOrNull(child)?.isDirectory == true) deleteDirectory(child, true) + else SystemFileSystem.delete(child) + } + } + SystemFileSystem.delete(path) + } + override fun getBasePath(): String = sandboxPath } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index e32f5a16..8f9a3997 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -93,6 +93,15 @@ class DefaultPluginFileSystem( } } + override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { + SystemFileSystem.createDirectories(resolvePath(relativePath)) + } + + override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { + require(relativePath != RelativePath.ROOT) { "The plugin files root cannot be deleted" } + deleteDirectory(resolvePath(relativePath), recursive) + } + override suspend fun extractResource(resourcePath: String, targetRelativePath: RelativePath): Result { if (resourcePath.contains("..") || resourcePath.startsWith("/") || resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) { return Result.failure(SecurityException("Invalid resource path: $resourcePath")) @@ -138,11 +147,35 @@ class DefaultPluginFileSystem( override suspend fun deleteFile(relativePath: RelativePath): Result = fs.deleteFromCache(relativePath) + + override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { + SystemFileSystem.createDirectories(fs.resolveCachePath(relativePath)) + } + + override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = + runCatching { + require(relativePath != RelativePath.ROOT) { "The plugin cache root cannot be deleted" } + fs.deleteDirectory(fs.resolveCachePath(relativePath), recursive) + } } } } } + private fun deleteDirectory(path: Path, recursive: Boolean) { + if (!SystemFileSystem.exists(path)) return + require(SystemFileSystem.metadataOrNull(path)?.isDirectory == true) { "Path is not a directory: $path" } + val children = SystemFileSystem.list(path) + require(recursive || children.isEmpty()) { "Directory is not empty: $path" } + if (recursive) { + children.forEach { child -> + if (SystemFileSystem.metadataOrNull(child)?.isDirectory == true) deleteDirectory(child, true) + else SystemFileSystem.delete(child) + } + } + SystemFileSystem.delete(path) + } + private fun resolveCachePath(relativePath: RelativePath): Path { val resolved = Path(cachePath, relativePath.value) val normalized = resolved.toString().replace('\\', '/') diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt index 51da2951..406a0b05 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt @@ -84,6 +84,24 @@ class DefaultExecutionFileSystemTest { assertTrue(files.contains("file2.txt")) } + @Test + fun testCreateAndDeleteDirectory() = runTest { + val directory = RelativePath.from("models/nested").getOrThrow() + assertTrue(fileSystem.createDirectory(directory).isSuccess) + assertTrue(fileSystem.exists(directory)) + + fileSystem.writeTextFile(RelativePath.from("models/nested/model.txt").getOrThrow(), "model") + assertTrue(fileSystem.deleteDirectory(RelativePath.from("models").getOrThrow()).isFailure) + assertTrue(fileSystem.deleteDirectory(RelativePath.from("models").getOrThrow(), recursive = true).isSuccess) + assertFalse(fileSystem.exists(RelativePath.from("models").getOrThrow())) + } + + @Test + fun testCannotDeleteSandboxRoot() = runTest { + assertTrue(fileSystem.deleteDirectory(RelativePath.ROOT, recursive = true).isFailure) + assertTrue(SystemFileSystem.exists(Path(sandboxPath))) + } + @Test fun testPathTraversalPrevention() = runTest { // Attempt to create a path outside the sandbox using ../ diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index d69b5025..bfa1153a 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -317,6 +317,7 @@ While you can set a plugin to "not support cancellation" the host app can force- The `PluginContext` (and focused interfaces like `PluginLogger`, `PluginFileSystem`, `ExecutionFileSystem`, `HostFileSystem`) provide access to host services: - **Logger**: `PluginLogger` (e.g. `logger.info("Message")`) - **Plugin File System**: `PluginFileSystem` (Persistent, isolated storage for the plugin. Preserved across executions. e.g. `fileSystem.getBasePath()`) +- **Directory operations**: scoped file systems support `createDirectory` and guarded `deleteDirectory`; recursive deletion must be requested explicitly and the sandbox root can never be deleted. - **Execution File System**: `ExecutionFileSystem` (Temporary, isolated sandbox storage for the current execution. Cleared automatically after the flow finishes.) - **Host File System**: `HostFileSystem` (External file access. Restricted to paths explicitly granted by the user via file input/output parameters: `@CapabilityInput` and `@CapabilityOutput`.) - **Plugin Storage**: `PluginStorage` (`context.storage`) provides a persistent, internal key-value store (`get`, `put`, `getAll`, `remove`) for saving plugin-internal state without polluting user settings. diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt index 5b4702b9..3b86f7c0 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt @@ -102,6 +102,10 @@ interface ScopedFileSystem { suspend fun exists(relativePath: RelativePath): Boolean suspend fun listFiles(relativePath: RelativePath = RelativePath.ROOT): List suspend fun deleteFile(relativePath: RelativePath): Result + suspend fun createDirectory(relativePath: RelativePath): Result = + Result.failure(UnsupportedOperationException("Directory creation is not supported by this host")) + suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean = false): Result = + Result.failure(UnsupportedOperationException("Directory deletion is not supported by this host")) /** * Get the absolute base path of the managed file area. From e3535b4b13278e888e4a8ad2eb6deaed8315545b Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:52:11 +1000 Subject: [PATCH 03/21] Resolve custom plugin defaults consistently --- .../plugin/logic/PluginLifecycleManager.kt | 11 +--- .../plugin/model/PluginSettingsStore.kt | 20 +++++++ .../features/plugin/ui/PluginContent.kt | 11 +--- .../plugin/ui/PluginSettingsContent.kt | 12 ++--- .../plugin/model/PluginSettingDefaultsTest.kt | 53 +++++++++++++++++++ 5 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt 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..ca55a892 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 @@ -332,15 +333,7 @@ class PluginLifecycleManager( 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 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/model/PluginSettingsStore.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt index 472b0a24..a2418e3e 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,10 @@ package org.wip.plugintoolkit.features.plugin.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PrimitiveType @Serializable data class PluginSettingsStore( @@ -9,3 +13,19 @@ data class PluginSettingsStore( val globalParams: Map = emptyMap(), val capabilityParams: Map> = emptyMap() ) + +fun PluginManifest.defaultCustomSettings(): Map = settings.orEmpty().mapNotNull { (key, metadata) -> + val type = metadata.type + val value = metadata.defaultValue ?: if ( + type is DataType.Primitive && type.primitiveType == PrimitiveType.BOOLEAN + ) JsonPrimitive(false) else null + value?.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 62632836..a68aa328 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 @@ -40,15 +40,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.plugin.viewmodel.PluginViewModel import org.wip.plugintoolkit.shared.components.plugin.JobResultCard import plugintoolkit.composeapp.generated.resources.Res @@ -121,12 +119,7 @@ fun PluginContent( val providedSettings = remember(pluginId, pluginSettingsState) { 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?.resolveProvidedValues(manifest) ?: emptyMap() } if (selectedCapability == null) { 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 a05b5883..d2b98758 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 @@ -74,6 +74,7 @@ import org.wip.plugintoolkit.api.PluginAction import org.wip.plugintoolkit.api.PrimitiveType import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme +import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.features.plugin.utils.SettingsUtils import org.wip.plugintoolkit.features.plugin.viewmodel.PluginSettingsViewModel import org.wip.plugintoolkit.shared.components.ToolkitChip @@ -413,15 +414,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.globalParams) { + store.resolveProvidedValues(manifest) } LazyColumn( 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..5d5cecef --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt @@ -0,0 +1,53 @@ +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 + +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"]) + assertEquals(JsonPrimitive(false), resolved["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"]) + assertEquals(JsonPrimitive(false), custom["enabled"]) + assertEquals(JsonPrimitive("eu"), provided["region"]) + } +} From dea9ce8bfb8927a61fd73757a421e8987e58a1a1 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:54:35 +1000 Subject: [PATCH 04/21] Divide required and optional plugin settings --- .../composeResources/values-it/strings.xml | 2 + .../composeResources/values/strings.xml | 4 +- .../plugin/ui/PluginSettingsContent.kt | 163 ++++++++++-------- 3 files changed, 100 insertions(+), 69 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 6c6bccf3..06c715fe 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1,4 +1,6 @@ + Obbligatorie + Facoltative PluginToolkit Runner Dashboard diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 48aa407f..69076655 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 @@ -427,4 +429,4 @@ Warning: In-Place Settings Opening settings in-place may cause some components to not update their unlocked states until reloaded. Are you sure you want to enable this mode? By Section - \ No newline at end of file + 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 a05b5883..398c8017 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 @@ -95,6 +95,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 @@ -125,6 +127,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) @@ -474,75 +478,83 @@ 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 customSettings.filterValues { it.required }, + optionalTitle to customSettings.filterValues { !it.required } + ).forEach { (groupTitle, groupSettings) -> + if (groupSettings.isNotEmpty()) { + PluginSettingGroupHeader(groupTitle, groupSettings.size) + } + groupSettings.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 + ) + } } } } @@ -674,6 +686,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, From 11b529432fcc4313102d06ebb8b6e4e9c1f94c0e Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:56:36 +1000 Subject: [PATCH 05/21] Rework color picker dialog --- .../features/colorpicker/ui/ColorPicker.kt | 3 +- .../colorpicker/ui/ColorPickerDialog.kt | 180 ++++++++---------- .../ui/pickers/ClassicColorPicker.kt | 41 +++- .../features/colorpicker/utils/ColorExt.kt | 8 + .../settings/ui/AccentColorControl.kt | 3 +- .../colorpicker/utils/ColorExtTest.kt | 20 ++ 6 files changed, 141 insertions(+), 114 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt index 48900e2c..4faf3523 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPicker.kt @@ -21,12 +21,14 @@ import org.wip.plugintoolkit.features.colorpicker.ui.pickers.SimpleRingColorPick fun ColorPicker( modifier: Modifier = Modifier, type: ColorPickerType = ColorPickerType.Classic(), + initialColor: Color = Color.White, onPickedColor: (Color) -> Unit ) { Box(modifier = modifier) { when (type) { is ColorPickerType.Classic -> ClassicColorPicker( showAlphaBar = type.showAlphaBar, + initialColor = initialColor, onPickedColor = onPickedColor, ) @@ -62,4 +64,3 @@ fun ColorPicker( private fun ColorPickerPreview() { ColorPicker(onPickedColor = {}) } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt index 7d09ca4c..d06c74ac 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt @@ -9,12 +9,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,119 +27,95 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.model.ColorPickerType -import org.wip.plugintoolkit.features.colorpicker.utils.toCMYK -import org.wip.plugintoolkit.features.colorpicker.utils.toHSL +import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor import org.wip.plugintoolkit.features.colorpicker.utils.toHex -import org.wip.plugintoolkit.features.colorpicker.utils.toRGB import org.wip.plugintoolkit.features.colorpicker.utils.transparentBackground -import org.wip.plugintoolkit.shared.components.SelectedButtonGroup -import org.wip.plugintoolkit.core.theme.ToolkitTheme -/** - * Color picker wrapped in a dialog. - * - * @param show Whether the dialog is visible. - * @param onDismissRequest Called when the user tries to dismiss the dialog. - * @param initialType The picker style — defaults to [ColorPickerType.Classic]. - * @param onPickedColor Callback invoked when the user confirms a color selection. - */ +/** A focused, editable color picker dialog with explicit cancel/apply actions. */ @Composable fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, - initialType: ColorPickerType = ColorPickerType.Classic(), + initialColor: Color = Color.White, onPickedColor: (Color) -> Unit ) { - var showDialog by remember(show) { mutableStateOf(show) } - var color by remember { mutableStateOf(Color.White) } - var selectedFormat by remember { mutableStateOf("HEX") } - var type by remember { mutableStateOf(initialType) } + if (!show) return - if (showDialog) { - Dialog( - onDismissRequest = { - onDismissRequest() - showDialog = false - }) { - val includeAlpha = when (type) { - is ColorPickerType.Circle -> (type as ColorPickerType.Circle).showAlphaBar - is ColorPickerType.Classic -> (type as ColorPickerType.Classic).showAlphaBar - is ColorPickerType.Ring -> (type as ColorPickerType.Ring).showAlphaBar - else -> false - } + var color by remember(initialColor) { mutableStateOf(initialColor) } + var hexInput by remember(initialColor) { + mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = false).uppercase()) + } + val parsedHex = remember(hexInput) { parseHexColor(hexInput) } + + Dialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = Modifier.widthIn(max = ToolkitTheme.dimensions.minWidthMedium), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = ToolkitTheme.dimensions.elevationHighMedium + ) { + Column( + modifier = Modifier.padding(ToolkitTheme.spacing.extraLarge), + verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) + ) { + Text( + text = "Choose a color", + style = MaterialTheme.typography.headlineSmall + ) - val colorCode = remember(color, selectedFormat) { - when (selectedFormat) { - "HEX" -> color.toHex(hexPrefix = true, includeAlpha = includeAlpha) - "RGB" -> color.toRGB(rgbPrefix = true, includeAlpha = includeAlpha) - "HSL" -> color.toHSL(hslPrefix = true, includeAlpha = includeAlpha) - "CMYK" -> color.toCMYK(cmykPrefix = true, includeAlpha = includeAlpha) - else -> color.toHex(hexPrefix = true, includeAlpha = includeAlpha) + ColorPicker( + type = ColorPickerType.Classic(showAlphaBar = false), + initialColor = initialColor, + onPickedColor = { + color = it + hexInput = it.toHex(hexPrefix = true, includeAlpha = false).uppercase() + } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) + ) { + Box( + modifier = Modifier + .size(ToolkitTheme.dimensions.heightMediumLarge) + .clip(RoundedCornerShape(ToolkitTheme.spacing.small)) + .transparentBackground(verticalBoxesAmount = 4) + .background(parsedHex ?: color) + ) + OutlinedTextField( + value = hexInput, + onValueChange = { input -> + hexInput = input.take(9) + parseHexColor(hexInput)?.let { color = it } + }, + modifier = Modifier.weight(1f), + label = { Text("Hex") }, + supportingText = if (parsedHex == null) { + { Text("Use #RRGGBB") } + } else null, + isError = parsedHex == null, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace) + ) } - } - Surface( - modifier = Modifier.widthIn(max = ToolkitTheme.dimensions.minWidthMedium), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = ToolkitTheme.dimensions.elevationHighMedium - ) { - Box(modifier = Modifier.padding(ToolkitTheme.spacing.extraLarge)) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismissRequest) { + Text("Cancel") + } + Button( + onClick = { parsedHex?.let(onPickedColor) }, + enabled = parsedHex != null ) { - SelectedButtonGroup( - buttons = listOf("HEX", "RGB", "HSL", "CMYK"), - startingIndex = 0, - onButtonSelected = { selectedFormat = it } - ) - SelectedButtonGroup( - buttons = listOf("Classic", "Circle", "Ring", "Simple"), - startingIndex = 0, - onButtonSelected = { - type = when (it) { - "Classic" -> ColorPickerType.Classic() - "Circle" -> ColorPickerType.Circle() - "Ring" -> ColorPickerType.Ring() - "Simple" -> ColorPickerType.SimpleRing() - else -> ColorPickerType.Classic() - } - } - ) - ColorPicker(type = type, onPickedColor = { color = it }) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) - ) { - Box( - modifier = Modifier - .size(ToolkitTheme.dimensions.containerWidthMediumLarge, ToolkitTheme.dimensions.heightMediumLarge) - .clip(RoundedCornerShape(50)) - .transparentBackground(verticalBoxesAmount = 4) - .background(color) - ) - Text( - text = colorCode, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 14.sp, - fontFamily = FontFamily.Monospace, - ) - } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - onPickedColor(color) - showDialog = false - }, - shape = CircleShape - ) { - Text(text = "Select") - } + Text("Apply") } } } @@ -150,11 +127,6 @@ fun ColorPickerDialog( @Composable private fun ColorPickerDialogPreview() { MaterialTheme { - ColorPickerDialog( - show = true, - onDismissRequest = {}, - onPickedColor = {} - ) + ColorPickerDialog(show = true, onDismissRequest = {}, onPickedColor = {}) } } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt index 2739e6d4..a9ca2628 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt @@ -35,6 +35,9 @@ import org.wip.plugintoolkit.features.colorpicker.utils.fromHueProgress import org.wip.plugintoolkit.features.colorpicker.utils.green import org.wip.plugintoolkit.features.colorpicker.utils.lighten import org.wip.plugintoolkit.features.colorpicker.utils.red +import org.wip.plugintoolkit.features.colorpicker.utils.toHueProgress +import kotlin.math.max +import kotlin.math.min import kotlin.math.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -45,18 +48,33 @@ import org.wip.plugintoolkit.core.theme.ToolkitTheme internal fun ClassicColorPicker( modifier: Modifier = Modifier, showAlphaBar: Boolean, + initialColor: Color = Color.White, onPickedColor: (Color) -> Unit ) { + val initialSaturationAndValue = remember(initialColor) { initialColor.saturationAndValue() } + val initialHue = remember(initialColor) { initialColor.toHueProgress() } var pickerLocation by remember { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - var alpha by remember { mutableStateOf(1f) } - var rangeColor by remember { mutableStateOf(Color.White) } - var hueSlider by remember { mutableStateOf(0f) } + var pickerInitialized by remember { mutableStateOf(false) } + var alpha by remember(initialColor) { mutableStateOf(initialColor.alpha) } + var rangeColor by remember(initialColor) { mutableStateOf(Color.fromHueProgress(initialHue)) } + var hueSlider by remember(initialColor) { mutableStateOf(initialHue) } - var color by remember { mutableStateOf(Color.White) } + var color by remember(initialColor) { mutableStateOf(initialColor) } - LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha) { - if (colorPickerSize.width > 0 && colorPickerSize.height > 0) { + LaunchedEffect(colorPickerSize, initialColor) { + if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { + val (saturation, value) = initialSaturationAndValue + pickerLocation = Offset( + x = (1f - saturation) * colorPickerSize.width, + y = (1f - value) * colorPickerSize.height + ) + pickerInitialized = true + } + } + + LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha, pickerInitialized) { + if (pickerInitialized && colorPickerSize.width > 0 && colorPickerSize.height > 0) { val xProgress = if (colorPickerSize.width > 0) { (1 - (pickerLocation.x / colorPickerSize.width)).coerceIn(0f, 1f) } else 0f @@ -135,6 +153,16 @@ internal fun ClassicColorPicker( } } +private fun Color.saturationAndValue(): Pair { + val red = red() / 255f + val green = green() / 255f + val blue = blue() / 255f + val maximum = max(red, max(green, blue)) + val minimum = min(red, min(green, blue)) + val saturation = if (maximum == 0f) 0f else (maximum - minimum) / maximum + return saturation to maximum +} + @Composable @Preview private fun ClassicColorPickerPreview() { @@ -142,4 +170,3 @@ private fun ClassicColorPickerPreview() { ClassicColorPicker(showAlphaBar = true, onPickedColor = {}) } } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt index 16c6e6a6..0da6d1a8 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt @@ -8,6 +8,14 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +/** Parses #RRGGBB or #AARRGGBB using the same ARGB order emitted by [toHex]. */ +fun parseHexColor(value: String): Color? { + val digits = value.trim().removePrefix("#") + if (digits.length != 6 && digits.length != 8) return null + val argb = (if (digits.length == 6) "FF$digits" else digits).toLongOrNull(16) ?: return null + return Color(argb.toInt()) +} + /** * Returns an integer array for all color channels value. */ diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt index 0d55211a..75b37a8d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp -import org.wip.plugintoolkit.features.colorpicker.model.ColorPickerType import org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog import org.wip.plugintoolkit.features.settings.model.AppSettings import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -28,7 +27,7 @@ fun AccentColorControl(settings: AppSettings, onUpdate: (AppSettings) -> Unit) { ColorPickerDialog( show = showColorPicker, - initialType = ColorPickerType.Classic(), + initialColor = Color(settings.appearance.accentColor), onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> onUpdate( diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt new file mode 100644 index 00000000..4484ee52 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt @@ -0,0 +1,20 @@ +package org.wip.plugintoolkit.features.colorpicker.utils + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ColorExtTest { + @Test + fun `hex parser accepts rgb and argb values`() { + assertEquals(Color(0xFF336699.toInt()), parseHexColor("#336699")) + assertEquals(Color(0x80336699.toInt()), parseHexColor("80336699")) + } + + @Test + fun `hex parser rejects malformed values`() { + assertNull(parseHexColor("#12345")) + assertNull(parseHexColor("#GG3366")) + } +} From d343ba973e6d61b4e9abc3c756c901a7d458a070 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:38:39 +1000 Subject: [PATCH 06/21] fix: complete setting metadata integration --- .../plugin/ui/PluginSettingsContent.kt | 3 +- .../viewmodel/PluginSettingsViewModel.kt | 57 +++++++++++++++++-- .../PluginSettingsAutogenerationTest.kt | 40 +++++++++++++ .../wip/plugintoolkit/api/ManifestModels.kt | 37 ++++++++++-- .../processor/generators/ManifestGenerator.kt | 24 +++++++- .../SettingMetadataBinaryCompatibilityTest.kt | 27 +++++++++ 6 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt create mode 100644 plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt 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 334451c2..d818e734 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 @@ -496,7 +496,8 @@ fun PluginSettingsContent( SettingsUtils.stringToJson(it, meta.type) ) }, - enabled = !isBusy, + enabled = !isBusy && meta.autogeneratedPattern == null, + isAutoGenerated = meta.autogeneratedPattern != null, providedSettings = providedSettings, providedLocks = locks ) 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..6be18748 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 @@ -7,25 +7,72 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import org.wip.plugintoolkit.api.SettingMetadata +import org.wip.plugintoolkit.features.flows.logic.PathPatternResolver 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.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() + .orEmpty() + val generatedValue = SettingsUtils.stringToJson(generated, settingMetadata.type) + if (resolvedSettings[key] != generatedValue) { + resolvedSettings[key] = generatedValue + changed = true + } + } + if (!changed) return resolvedSettings + } + + return resolvedSettings +} 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 copy( + settings = resolveAutogeneratedSettings( + metadata = settingMetadata, + settings = settings, + additionalValues = globalParams + ) + ) + } + init { viewModelScope.launch { pluginManager.refreshLocks(pkg) @@ -45,7 +92,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 +103,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/viewmodel/PluginSettingsAutogenerationTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt new file mode 100644 index 00000000..37a6191b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt @@ -0,0 +1,40 @@ +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 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 is cleared when dependency is missing`() { + val result = resolveAutogeneratedSettings( + metadata = metadata, + settings = mapOf("output" to JsonPrimitive("stale.result")) + ) + + assertEquals(JsonPrimitive(""), result["output"]) + } +} 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 0da84ce9..52509212 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 @@ -246,15 +246,44 @@ data class SettingMetadata( val required: Boolean = false, val secret: Boolean = false, val constraints: ParameterConstraints? = null, - val semanticTypes: List = emptyList(), - val autogeneratedPattern: String? = null, /** * List of capability names that require this setting. * 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 + ) +} /** * The complete manifest of a plugin, describing its capabilities and requirements. 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 6668ef16..2c0f770c 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 @@ -368,6 +368,17 @@ object ManifestGenerator { ?.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) @@ -401,10 +412,18 @@ object ManifestGenerator { ) 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>(), constraints = %L, required = %L, secret = %L, semanticTypes = %L, autogeneratedPattern = %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, @@ -415,7 +434,8 @@ object ManifestGenerator { required, secret, semanticTypesCode, - autogeneratedPatternCode + autogeneratedPatternCode, + requiredByCapabilitiesCode ) if (index < settingsProperties.size - 1) settingsCode.add(",\n") else settingsCode.add("\n") } 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..e8ba24ab --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt @@ -0,0 +1,27 @@ +package org.wip.plugintoolkit.api + +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +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" + ) + } +} From 848eb8ddb7274d73adc909c4184da60854dfbfad Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:49:37 +1000 Subject: [PATCH 07/21] fix: harden sandboxed file deletion --- .../logic/DefaultExecutionFileSystem.kt | 39 +------ .../plugin/logic/DefaultPluginFileSystem.kt | 43 ++------ .../plugin/logic/SandboxFileOperations.kt | 62 +++++++++++ .../logic/SandboxFileSystemSecurityTest.kt | 104 ++++++++++++++++++ .../org/wip/plugintoolkit/api/RelativePath.kt | 15 +-- .../wip/plugintoolkit/api/RelativePathTest.kt | 4 + 6 files changed, 190 insertions(+), 77 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt index 3d842b4d..818461ff 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt @@ -17,25 +17,10 @@ class DefaultExecutionFileSystem( SystemFileSystem.createDirectories(Path(sandboxPath)) } - private fun resolvePath(relativePath: RelativePath): Path { - val resolved = Path(sandboxPath, relativePath.value) - val file = java.io.File(resolved.toString()) - val normalized = try { - file.canonicalPath - } catch (e: Exception) { - throw SecurityException("Failed to resolve canonical path for '${relativePath.value}': ${e.message}") - } - val baseFile = java.io.File(sandboxPath) - val baseCanonical = try { - baseFile.canonicalPath - } catch (e: Exception) { - throw SecurityException("Failed to resolve base canonical path for '$sandboxPath': ${e.message}") - } + private val sandboxOperations = SandboxFileOperations(sandboxPath) - if (normalized != baseCanonical && !normalized.startsWith(baseCanonical + java.io.File.separator)) { - throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the sandbox.") - } - return resolved + private fun resolvePath(relativePath: RelativePath): Path { + return sandboxOperations.resolve(relativePath) } override suspend fun readFile(relativePath: RelativePath): ByteArray? { @@ -102,22 +87,8 @@ class DefaultExecutionFileSystem( } override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { - require(relativePath != RelativePath.ROOT) { "The execution sandbox root cannot be deleted" } - deleteDirectory(resolvePath(relativePath), recursive) - } - - private fun deleteDirectory(path: Path, recursive: Boolean) { - if (!SystemFileSystem.exists(path)) return - require(SystemFileSystem.metadataOrNull(path)?.isDirectory == true) { "Path is not a directory: $path" } - val children = SystemFileSystem.list(path) - require(recursive || children.isEmpty()) { "Directory is not empty: $path" } - if (recursive) { - children.forEach { child -> - if (SystemFileSystem.metadataOrNull(child)?.isDirectory == true) deleteDirectory(child, true) - else SystemFileSystem.delete(child) - } - } - SystemFileSystem.delete(path) + require(relativePath.value.isNotEmpty()) { "The execution sandbox root cannot be deleted" } + sandboxOperations.deleteDirectory(resolvePath(relativePath), recursive) } override fun getBasePath(): String = sandboxPath diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index 8f9a3997..100ddb77 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -23,15 +23,11 @@ class DefaultPluginFileSystem( SystemFileSystem.createDirectories(Path(cachePath)) } - private fun resolvePath(relativePath: RelativePath): Path { - val resolved = Path(basePath, relativePath.value) - val normalized = resolved.toString().replace('\\', '/') - val baseCanonical = Path(basePath).toString().replace('\\', '/') + private val filesOperations = SandboxFileOperations(basePath) + private val cacheOperations = SandboxFileOperations(cachePath) - if (normalized != baseCanonical && !normalized.startsWith(if (baseCanonical.endsWith("/")) baseCanonical else "$baseCanonical/")) { - throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the plugin files directory.") - } - return resolved + private fun resolvePath(relativePath: RelativePath): Path { + return filesOperations.resolve(relativePath) } override suspend fun readFile(relativePath: RelativePath): ByteArray? { @@ -98,8 +94,8 @@ class DefaultPluginFileSystem( } override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { - require(relativePath != RelativePath.ROOT) { "The plugin files root cannot be deleted" } - deleteDirectory(resolvePath(relativePath), recursive) + require(relativePath.value.isNotEmpty()) { "The plugin files root cannot be deleted" } + filesOperations.deleteDirectory(resolvePath(relativePath), recursive) } override suspend fun extractResource(resourcePath: String, targetRelativePath: RelativePath): Result { @@ -154,37 +150,16 @@ class DefaultPluginFileSystem( override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { - require(relativePath != RelativePath.ROOT) { "The plugin cache root cannot be deleted" } - fs.deleteDirectory(fs.resolveCachePath(relativePath), recursive) + require(relativePath.value.isNotEmpty()) { "The plugin cache root cannot be deleted" } + fs.cacheOperations.deleteDirectory(fs.resolveCachePath(relativePath), recursive) } } } } } - private fun deleteDirectory(path: Path, recursive: Boolean) { - if (!SystemFileSystem.exists(path)) return - require(SystemFileSystem.metadataOrNull(path)?.isDirectory == true) { "Path is not a directory: $path" } - val children = SystemFileSystem.list(path) - require(recursive || children.isEmpty()) { "Directory is not empty: $path" } - if (recursive) { - children.forEach { child -> - if (SystemFileSystem.metadataOrNull(child)?.isDirectory == true) deleteDirectory(child, true) - else SystemFileSystem.delete(child) - } - } - SystemFileSystem.delete(path) - } - private fun resolveCachePath(relativePath: RelativePath): Path { - val resolved = Path(cachePath, relativePath.value) - val normalized = resolved.toString().replace('\\', '/') - val baseCanonical = Path(cachePath).toString().replace('\\', '/') - - if (normalized != baseCanonical && !normalized.startsWith(if (baseCanonical.endsWith("/")) baseCanonical else "$baseCanonical/")) { - throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the plugin cache directory.") - } - return resolved + return cacheOperations.resolve(relativePath) } private suspend fun readFromCache(relativePath: RelativePath): ByteArray? { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt new file mode 100644 index 00000000..7503baa1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt @@ -0,0 +1,62 @@ +package org.wip.plugintoolkit.features.plugin.logic + +import kotlinx.io.files.Path +import org.wip.plugintoolkit.api.RelativePath +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path as NioPath +import java.nio.file.Paths +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +internal class SandboxFileOperations(root: String) { + private val base = Paths.get(root).toAbsolutePath().normalize() + private val realBase = base.toRealPath() + + fun resolve(relativePath: RelativePath): Path { + if (base.toRealPath() != realBase) { + throw SecurityException("The sandbox root changed after it was initialized") + } + val candidate = base.resolve(relativePath.value).normalize() + if (!candidate.startsWith(base)) { + throw SecurityException("Access to path '${relativePath.value}' is outside the sandbox") + } + + var current = base + base.relativize(candidate).forEach { segment -> + current = current.resolve(segment) + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + val realCurrent = current.toRealPath() + if (!realCurrent.startsWith(realBase)) { + throw SecurityException("Access to path '${relativePath.value}' escapes the sandbox through a symbolic link") + } + } + } + return Path(candidate.toString()) + } + + fun deleteDirectory(path: Path, recursive: Boolean) { + val nioPath = Paths.get(path.toString()) + if (!Files.exists(nioPath, LinkOption.NOFOLLOW_LINKS)) return + require(Files.isDirectory(nioPath, LinkOption.NOFOLLOW_LINKS)) { "Path is not a directory: $path" } + + if (!recursive) { + Files.delete(nioPath) + return + } + + Files.walkFileTree(nioPath, object : SimpleFileVisitor() { + override fun visitFile(file: NioPath, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: NioPath, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt new file mode 100644 index 00000000..5a88da3e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt @@ -0,0 +1,104 @@ +package org.wip.plugintoolkit.features.plugin.logic + +import kotlinx.coroutines.test.runTest +import org.wip.plugintoolkit.api.RelativePath +import org.wip.plugintoolkit.api.ScopedFileSystem +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SandboxFileSystemSecurityTest { + private lateinit var testRoot: Path + + @BeforeTest + fun setUp() { + testRoot = Files.createTempDirectory("plugin-toolkit-sandbox-") + } + + @AfterTest + fun tearDown() { + if (!Files.exists(testRoot)) return + Files.walkFileTree(testRoot, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE + } + }) + } + + @Test + fun rootAliasesCannotDeleteAnySandboxRoot() = runTest { + val alias = RelativePath.from("././").getOrThrow() + val execution = DefaultExecutionFileSystem(testRoot.resolve("execution").toString()) + val pluginInstall = testRoot.resolve("plugin").toString() + val plugin = DefaultPluginFileSystem(pluginInstall) + val cache = DefaultPluginFileSystem.createCacheOnly(pluginInstall) + + assertTrue(execution.deleteDirectory(alias, recursive = true).isFailure) + assertTrue(plugin.deleteDirectory(alias, recursive = true).isFailure) + assertTrue(cache.deleteDirectory(alias, recursive = true).isFailure) + assertTrue(Files.isDirectory(testRoot.resolve("execution"))) + assertTrue(Files.isDirectory(testRoot.resolve("plugin/files"))) + assertTrue(Files.isDirectory(testRoot.resolve("plugin/cache"))) + } + + @Test + fun executionSandboxCannotReadThroughSymlinkAndDoesNotFollowItOnDelete() = runTest { + val sandbox = testRoot.resolve("execution") + val outside = createOutsideSecret() + val fileSystem = DefaultExecutionFileSystem(sandbox.toString()) + + verifySymlinkIsContained(fileSystem, sandbox, outside) + } + + @Test + fun pluginFilesCannotReadThroughSymlinkAndDoNotFollowItOnDelete() = runTest { + val install = testRoot.resolve("plugin") + val outside = createOutsideSecret() + val fileSystem = DefaultPluginFileSystem(install.toString()) + + verifySymlinkIsContained(fileSystem, install.resolve("files"), outside) + } + + @Test + fun pluginCacheCannotReadThroughSymlinkAndDoesNotFollowItOnDelete() = runTest { + val install = testRoot.resolve("plugin") + val outside = createOutsideSecret() + val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString()) + + verifySymlinkIsContained(fileSystem, install.resolve("cache"), outside) + } + + private fun createOutsideSecret(): Path { + val outside = Files.createDirectories(testRoot.resolve("outside")) + Files.writeString(outside.resolve("secret.txt"), "must survive") + return outside + } + + private suspend fun verifySymlinkIsContained( + fileSystem: ScopedFileSystem, + sandbox: Path, + outside: Path + ) { + val nested = Files.createDirectories(sandbox.resolve("nested")) + Files.createSymbolicLink(nested.resolve("escape"), outside) + val escapedFile = RelativePath.from("nested/escape/secret.txt").getOrThrow() + + assertFailsWith { fileSystem.readTextFile(escapedFile) } + assertTrue(fileSystem.deleteDirectory(RelativePath.from("nested").getOrThrow(), recursive = true).isSuccess) + assertTrue(Files.readString(outside.resolve("secret.txt")) == "must survive") + } +} diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt index 7940117a..650ae950 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt @@ -13,7 +13,6 @@ value class RelativePath private constructor(val value: String) { companion object { val ROOT = RelativePath("") private val NULL_BYTE_REGEX = Regex("\u0000") - private val TRAVERSAL_REGEX = Regex("""(?:^|/|\\|\.|\u2024|\uFF0E|\u3002)(?:\.\.|\u2024\u2024|\uFF0E\uFF0E|%2e%2e|%2E%2E|%252e%252e)(?:/|\\|${'$'}|\.)""", RegexOption.IGNORE_CASE) private val ENCODED_SLASH_REGEX = Regex("""%2f|%5c""", RegexOption.IGNORE_CASE) /** @@ -38,25 +37,23 @@ value class RelativePath private constructor(val value: String) { return Result.failure(SecurityException("Path must be relative, but contains drive letter: $normalized")) } - // Check for encoded slashes or traversal sequences - if (normalized.contains(ENCODED_SLASH_REGEX) || normalized.contains(TRAVERSAL_REGEX)) { + if (normalized.contains(ENCODED_SLASH_REGEX)) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - // Normalized path checks: convert unicode dot variants to regular dot for safety check val sanitized = normalized .replace("\u2024", ".") .replace("\uFF0E", ".") .replace("\u3002", ".") .replace("%2e", ".", ignoreCase = true) - .replace("%2f", "/", ignoreCase = true) - .replace("%5c", "\\", ignoreCase = true) + .replace('\\', '/') - if (sanitized.contains(TRAVERSAL_REGEX) || sanitized.contains("../") || sanitized.contains("..\\")) { + val segments = sanitized.split('/').filter { it.isNotEmpty() && it != "." } + if (segments.any { it == ".." || it.contains(Regex("%25(?:2e|2f|5c)", RegexOption.IGNORE_CASE)) }) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - - return Result.success(RelativePath(normalized)) + + return Result.success(RelativePath(segments.joinToString("/"))) } } } diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt index 96ae01bd..400f17d5 100644 --- a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt @@ -52,5 +52,9 @@ class RelativePathTest { val path = " foo/bar.txt ".toRelativePath() assertTrue(path.isSuccess) assertEquals("foo/bar.txt", path.getOrNull()?.value, "Should trim whitespace") + assertEquals(RelativePath.ROOT, ".".toRelativePath().getOrThrow()) + assertEquals(RelativePath.ROOT, "././".toRelativePath().getOrThrow()) + assertEquals("foo/bar", "foo/./bar".toRelativePath().getOrThrow().value) + assertEquals("foo/bar", "foo\\bar".toRelativePath().getOrThrow().value) } } From 2eff25943c54354c0a244de2650d76c83fbaa011 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:51:06 +1000 Subject: [PATCH 08/21] fix: preserve unset plugin settings --- .../plugin/model/PluginSettingsStore.kt | 9 +-- .../features/plugin/ui/PluginContent.kt | 3 +- .../plugin/ui/PluginSettingsContent.kt | 6 +- .../logic/PluginLifecycleManagerTest.kt | 59 +++++++++++++++++++ .../plugin/model/PluginSettingDefaultsTest.kt | 16 ++++- 5 files changed, 79 insertions(+), 14 deletions(-) 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 a2418e3e..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,10 +2,7 @@ package org.wip.plugintoolkit.features.plugin.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.PluginManifest -import org.wip.plugintoolkit.api.PrimitiveType @Serializable data class PluginSettingsStore( @@ -15,11 +12,7 @@ data class PluginSettingsStore( ) fun PluginManifest.defaultCustomSettings(): Map = settings.orEmpty().mapNotNull { (key, metadata) -> - val type = metadata.type - val value = metadata.defaultValue ?: if ( - type is DataType.Primitive && type.primitiveType == PrimitiveType.BOOLEAN - ) JsonPrimitive(false) else null - value?.let { key to it } + metadata.defaultValue?.let { key to it } }.toMap() /** Manifest defaults with persisted user values taking precedence. */ 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 a68aa328..897b845d 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 @@ -119,7 +119,8 @@ fun PluginContent( val providedSettings = remember(pluginId, pluginSettingsState) { val store = if (pluginId != null) pluginSettingsState[pluginId] ?: pluginManager.loadPluginSettings(pluginId) else null val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() - store?.resolveProvidedValues(manifest) ?: emptyMap() + (store ?: org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore()) + .resolveProvidedValues(manifest) } if (selectedCapability == null) { 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 d2b98758..153830b8 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 @@ -74,7 +74,7 @@ import org.wip.plugintoolkit.api.PluginAction import org.wip.plugintoolkit.api.PrimitiveType import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme -import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues +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 @@ -414,8 +414,8 @@ fun PluginSettingsContent( ) } } else { - val providedSettings = remember(manifest, store.settings, store.globalParams) { - store.resolveProvidedValues(manifest) + val providedSettings = remember(manifest, store.settings) { + store.resolveCustomSettings(manifest) } LazyColumn( 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 index 5d5cecef..6b645980 100644 --- 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 @@ -9,6 +9,7 @@ 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( @@ -33,7 +34,7 @@ class PluginSettingDefaultsTest { val resolved = PluginSettingsStore().resolveCustomSettings(manifest) assertEquals(JsonPrimitive("https://example.test"), resolved["endpoint"]) - assertEquals(JsonPrimitive(false), resolved["enabled"]) + assertFalse(resolved.containsKey("enabled")) } @Test @@ -47,7 +48,18 @@ class PluginSettingDefaultsTest { val provided = store.resolveProvidedValues(manifest) assertEquals(JsonPrimitive("https://custom.test"), custom["endpoint"]) - assertEquals(JsonPrimitive(false), custom["enabled"]) + 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"]) + } } From badf10c3b6b813fe8372f7271c936703428eff19 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:52:53 +1000 Subject: [PATCH 09/21] fix: test setting groups and target deep links --- .../plugin/ui/PluginSettingsContent.kt | 30 ++++++++++++-- .../plugin/ui/PluginSettingPartitionTest.kt | 40 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt 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 398c8017..e9372f81 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,6 +75,7 @@ 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.utils.SettingsUtils @@ -104,6 +108,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, @@ -177,6 +186,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>() @@ -250,7 +263,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 } @@ -264,6 +278,10 @@ fun PluginSettingsContent( val targetIndex = targetKey?.let { sectionIndices[it] } if (targetIndex != null) { lazyListState.animateScrollToItem(targetIndex) + if (isCustomSetting) { + withFrameNanos { } + customSettingRequesters[scrollToSetting]?.bringIntoView() + } } } } @@ -479,14 +497,18 @@ fun PluginSettingsContent( verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.mediumSmall) ) { listOf( - requiredTitle to customSettings.filterValues { it.required }, - optionalTitle to customSettings.filterValues { !it.required } + requiredTitle to requiredSettings, + optionalTitle to optionalSettings ).forEach { (groupTitle, groupSettings) -> if (groupSettings.isNotEmpty()) { PluginSettingGroupHeader(groupTitle, groupSettings.size) } groupSettings.forEach { (key, meta) -> - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(customSettingRequesters.getValue(key)) + ) { val value = store.settings[key] ?: meta.defaultValue DynamicParameterInput( name = key, 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 + ) +} From 5cc8dccf77645714e8a9abb03703a1ebea2efd18 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:57:21 +1000 Subject: [PATCH 10/21] fix: preserve color picker values and alpha --- .../composeResources/values-it/strings.xml | 4 +++ .../composeResources/values/strings.xml | 6 +++- .../colorpicker/ui/ColorPickerDialog.kt | 26 +++++++++------ .../ui/pickers/ClassicColorPicker.kt | 19 +++-------- .../features/colorpicker/utils/ColorExt.kt | 12 ++++++- .../features/flows/ui/NodeDialogs.kt | 5 +-- .../features/flows/ui/NodeHelpers.kt | 8 ++--- .../components/plugin/inputs/ColorInput.kt | 13 ++++---- .../colorpicker/utils/ColorExtTest.kt | 32 +++++++++++++++++++ .../features/flows/ui/NodeColorParsingTest.kt | 13 ++++++++ 10 files changed, 100 insertions(+), 38 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 6c6bccf3..f78b6015 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -167,6 +167,10 @@ Pausa Salva Annulla + Scegli un colore + Esadecimale + Usa #RRGGBB o #AARRGGBB + Applica Espandi Comprimi diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 48aa407f..24b8091f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -180,6 +180,10 @@ Pause Save Cancel + Choose a color + Hex + Use #RRGGBB or #AARRGGBB + Apply Expand Collapse Settings: %1$s @@ -427,4 +431,4 @@ Warning: In-Place Settings Opening settings in-place may cause some components to not update their unlocked states until reloaded. Are you sure you want to enable this mode? By Section - \ No newline at end of file + diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt index d06c74ac..94bc7fda 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/ColorPickerDialog.kt @@ -28,11 +28,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog +import org.jetbrains.compose.resources.stringResource import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.model.ColorPickerType import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor import org.wip.plugintoolkit.features.colorpicker.utils.toHex import org.wip.plugintoolkit.features.colorpicker.utils.transparentBackground +import plugintoolkit.composeapp.generated.resources.Res +import plugintoolkit.composeapp.generated.resources.action_cancel +import plugintoolkit.composeapp.generated.resources.color_picker_apply +import plugintoolkit.composeapp.generated.resources.color_picker_hex +import plugintoolkit.composeapp.generated.resources.color_picker_hex_hint +import plugintoolkit.composeapp.generated.resources.color_picker_title /** A focused, editable color picker dialog with explicit cancel/apply actions. */ @Composable @@ -40,13 +47,14 @@ fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, initialColor: Color = Color.White, + showAlpha: Boolean = false, onPickedColor: (Color) -> Unit ) { if (!show) return var color by remember(initialColor) { mutableStateOf(initialColor) } - var hexInput by remember(initialColor) { - mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = false).uppercase()) + var hexInput by remember(initialColor, showAlpha) { + mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase()) } val parsedHex = remember(hexInput) { parseHexColor(hexInput) } @@ -62,16 +70,16 @@ fun ColorPickerDialog( verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) ) { Text( - text = "Choose a color", + text = stringResource(Res.string.color_picker_title), style = MaterialTheme.typography.headlineSmall ) ColorPicker( - type = ColorPickerType.Classic(showAlphaBar = false), + type = ColorPickerType.Classic(showAlphaBar = showAlpha), initialColor = initialColor, onPickedColor = { color = it - hexInput = it.toHex(hexPrefix = true, includeAlpha = false).uppercase() + hexInput = it.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase() } ) @@ -94,9 +102,9 @@ fun ColorPickerDialog( parseHexColor(hexInput)?.let { color = it } }, modifier = Modifier.weight(1f), - label = { Text("Hex") }, + label = { Text(stringResource(Res.string.color_picker_hex)) }, supportingText = if (parsedHex == null) { - { Text("Use #RRGGBB") } + { Text(stringResource(Res.string.color_picker_hex_hint)) } } else null, isError = parsedHex == null, singleLine = true, @@ -109,13 +117,13 @@ fun ColorPickerDialog( horizontalArrangement = Arrangement.End ) { TextButton(onClick = onDismissRequest) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } Button( onClick = { parsedHex?.let(onPickedColor) }, enabled = parsedHex != null ) { - Text("Apply") + Text(stringResource(Res.string.color_picker_apply)) } } } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt index a9ca2628..ade2963b 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/ui/pickers/ClassicColorPicker.kt @@ -35,9 +35,8 @@ import org.wip.plugintoolkit.features.colorpicker.utils.fromHueProgress import org.wip.plugintoolkit.features.colorpicker.utils.green import org.wip.plugintoolkit.features.colorpicker.utils.lighten import org.wip.plugintoolkit.features.colorpicker.utils.red +import org.wip.plugintoolkit.features.colorpicker.utils.saturationAndValue import org.wip.plugintoolkit.features.colorpicker.utils.toHueProgress -import kotlin.math.max -import kotlin.math.min import kotlin.math.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -53,9 +52,9 @@ internal fun ClassicColorPicker( ) { val initialSaturationAndValue = remember(initialColor) { initialColor.saturationAndValue() } val initialHue = remember(initialColor) { initialColor.toHueProgress() } - var pickerLocation by remember { mutableStateOf(Offset.Zero) } + var pickerLocation by remember(initialColor) { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - var pickerInitialized by remember { mutableStateOf(false) } + var pickerInitialized by remember(initialColor) { mutableStateOf(false) } var alpha by remember(initialColor) { mutableStateOf(initialColor.alpha) } var rangeColor by remember(initialColor) { mutableStateOf(Color.fromHueProgress(initialHue)) } var hueSlider by remember(initialColor) { mutableStateOf(initialHue) } @@ -66,7 +65,7 @@ internal fun ClassicColorPicker( if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { val (saturation, value) = initialSaturationAndValue pickerLocation = Offset( - x = (1f - saturation) * colorPickerSize.width, + x = saturation * colorPickerSize.width, y = (1f - value) * colorPickerSize.height ) pickerInitialized = true @@ -153,16 +152,6 @@ internal fun ClassicColorPicker( } } -private fun Color.saturationAndValue(): Pair { - val red = red() / 255f - val green = green() / 255f - val blue = blue() / 255f - val maximum = max(red, max(green, blue)) - val minimum = min(red, min(green, blue)) - val saturation = if (maximum == 0f) 0f else (maximum - minimum) / maximum - return saturation to maximum -} - @Composable @Preview private fun ClassicColorPickerPreview() { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt index 0da6d1a8..d5a518a7 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt @@ -244,5 +244,15 @@ internal fun Color.toHueProgress(): Float { hue *= 60 if (hue < 0) hue += 360 - return hue + return hue / 360f +} + +internal fun Color.saturationAndValue(): Pair { + val red = red() / 255f + val green = green() / 255f + val blue = blue() / 255f + val maximum = max(red, max(green, blue)) + val minimum = min(red, min(green, blue)) + val saturation = if (maximum == 0f) 0f else (maximum - minimum) / maximum + return saturation to maximum } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt index e567dc53..e96e1969 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt @@ -307,8 +307,11 @@ fun NodeDialogs( val input = node.inputs.firstOrNull { it.id == activeColorInputId } val inferredSem = input?.let { inferredSemanticTypes[Pair(node.id, it.id)] ?: it.semanticTypes } ?: emptyList() val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } + val existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, + initialColor = parseColorString(existingValue), + showAlpha = hasAlpha, onDismissRequest = onDismissColorPicker, onPickedColor = { color -> activeColorInputId.let { inputId -> @@ -323,8 +326,6 @@ fun NodeDialogs( color.toHex(hexPrefix = true, includeAlpha = hasAlpha) } val isArray = input?.dataType is DataType.Array - val existingValue = - input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" val newValue = appendPickedValue(existingValue, formatted, isArray) onUpdateValue(node.id, inputId, newValue) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt index 0c651456..3fd01128 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt @@ -184,10 +184,10 @@ fun parseColorString(colorStr: String): Color { } 8 -> { - val r = hex.substring(0, 2).toInt(16) / 255f - val g = hex.substring(2, 4).toInt(16) / 255f - val b = hex.substring(4, 6).toInt(16) / 255f - val a = hex.substring(6, 8).toInt(16) / 255f + val a = hex.substring(0, 2).toInt(16) / 255f + val r = hex.substring(2, 4).toInt(16) / 255f + val g = hex.substring(4, 6).toInt(16) / 255f + val b = hex.substring(6, 8).toInt(16) / 255f Color(r, g, b, a) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt index 0b7da0b7..933167ef 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt @@ -52,6 +52,8 @@ fun ColorInput( var showColorPicker by remember { mutableStateOf(false) } val parsedColor = remember(value) { parseColorString(value) } val isArray = metadata.type is DataType.Array + val isRgba = metadata.semanticTypes.any { it.canonicalId.contains("rgba", ignoreCase = true) } + val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier .fillMaxWidth() @@ -101,17 +103,16 @@ fun ColorInput( if (showColorPicker && enabled) { ColorPickerDialog( show = showColorPicker, + initialColor = parsedColor, + showAlpha = isRgba, onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> showColorPicker = false - val formatted = if (metadata.semanticTypes.any { - it.canonicalId.contains("rgb", ignoreCase = true) - } - ) { - color.toRGB() + val formatted = if (isRgb) { + color.toRGB(rgbPrefix = true, includeAlpha = isRgba) } else { - color.toHex() + color.toHex(hexPrefix = true, includeAlpha = isRgba) } onValueChange(formatted) } diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt index 4484ee52..c37d427b 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt @@ -1,6 +1,7 @@ package org.wip.plugintoolkit.features.colorpicker.utils import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -17,4 +18,35 @@ class ColorExtTest { assertNull(parseHexColor("#12345")) assertNull(parseHexColor("#GG3366")) } + + @Test + fun `hue progress is normalized`() { + assertEquals(0f, Color.Red.toHueProgress(), absoluteTolerance = 0.0001f) + assertEquals(1f / 3f, Color.Green.toHueProgress(), absoluteTolerance = 0.0001f) + assertEquals(2f / 3f, Color.Blue.toHueProgress(), absoluteTolerance = 0.0001f) + } + + @Test + fun `picker coordinates reconstruct the initial color including alpha`() { + listOf( + Color(0xFFFF0000.toInt()), + Color(0xFF336699.toInt()), + Color(0xFF00FF00.toInt()), + Color(0xFFFFFFFF.toInt()), + Color(0x80336699.toInt()) + ).forEach { expected -> + assertEquals(expected.toArgb(), reconstructPickerColor(expected).toArgb(), "Failed for ${expected.toHex(true)}") + } + } + + private fun reconstructPickerColor(color: Color): Color { + val (saturation, value) = color.saturationAndValue() + val hueColor = Color.fromHueProgress(color.toHueProgress()) + return Color( + hueColor.red().lighten(1f - saturation).darken(1f - value), + hueColor.green().lighten(1f - saturation).darken(1f - value), + hueColor.blue().lighten(1f - saturation).darken(1f - value), + color.alpha() + ) + } } diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt new file mode 100644 index 00000000..2fe27d10 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt @@ -0,0 +1,13 @@ +package org.wip.plugintoolkit.features.flows.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import kotlin.test.Test +import kotlin.test.assertEquals + +class NodeColorParsingTest { + @Test + fun `parses the ARGB order emitted by the color formatter`() { + assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("#80336699").toArgb()) + } +} From 9d7e9d54cfed5cace6cdc21c3a98ac10a2c6c9db Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:05:00 +1000 Subject: [PATCH 11/21] fix: resolve generated settings safely at runtime --- .../plugin/logic/PluginLifecycleManager.kt | 11 ++-- .../plugin/logic/PluginSettingsResolver.kt | 62 +++++++++++++++++++ .../viewmodel/PluginSettingsViewModel.kt | 44 +------------ .../PluginSettingsAutogenerationTest.kt | 25 +++++++- 4 files changed, 94 insertions(+), 48 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt 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..b93ce0c1 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 @@ -287,6 +287,7 @@ class PluginLifecycleManager( } val decryptedStore = store.copy(settings = decryptedSettings) + .withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) _pluginSettingsState.update { it + (pkg to decryptedStore) } return decryptedStore } @@ -296,7 +297,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 +307,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,8 +332,9 @@ 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 storedSettings = (overriddenSettings ?: loadPluginSettings(pkg)) + .withResolvedAutogeneratedSettings(actualManifest?.settings.orEmpty()) val mergedSettings = mutableMapOf() // 1. Manifest defaults 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..4caebe43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt @@ -0,0 +1,62 @@ +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 = copy( + settings = resolveAutogeneratedSettings(metadata, settings, globalParams) +) + +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/viewmodel/PluginSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt index 6be18748..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 @@ -7,45 +7,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement -import org.wip.plugintoolkit.api.SettingMetadata -import org.wip.plugintoolkit.features.flows.logic.PathPatternResolver 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 -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() - .orEmpty() - val generatedValue = SettingsUtils.stringToJson(generated, settingMetadata.type) - if (resolvedSettings[key] != generatedValue) { - resolvedSettings[key] = generatedValue - changed = true - } - } - if (!changed) return resolvedSettings - } - - return resolvedSettings -} class PluginSettingsViewModel( val pkg: String, @@ -64,13 +30,7 @@ class PluginSettingsViewModel( private fun PluginSettingsStore.withAutogeneratedSettings(): PluginSettingsStore { val settingMetadata = manifest?.settings ?: return this - return copy( - settings = resolveAutogeneratedSettings( - metadata = settingMetadata, - settings = settings, - additionalValues = globalParams - ) - ) + return withResolvedAutogeneratedSettings(settingMetadata) } init { 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 index 37a6191b..2bdeab26 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -29,12 +30,32 @@ class PluginSettingsAutogenerationTest { } @Test - fun `derived setting is cleared when dependency is missing`() { + 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(""), result["output"]) + 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"]) } } From 4a1158fcc5027c036a38a5f386588b777e31b912 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:06:44 +1000 Subject: [PATCH 12/21] fix: preserve legacy color values and alpha --- .../features/colorpicker/utils/ColorExt.kt | 9 +++++++++ .../plugintoolkit/features/flows/ui/NodeDialogs.kt | 4 +++- .../plugintoolkit/features/flows/ui/NodeHelpers.kt | 2 ++ .../shared/components/plugin/inputs/ColorInput.kt | 6 +++++- .../features/colorpicker/utils/ColorExtTest.kt | 11 +++++++++++ .../features/flows/ui/NodeColorParsingTest.kt | 1 + 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt index d5a518a7..dd8a0d65 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt @@ -16,6 +16,15 @@ fun parseHexColor(value: String): Color? { return Color(argb.toInt()) } +/** Detects alpha-bearing legacy and current color representations without changing their format. */ +fun colorStringHasAlpha(value: String): Boolean { + val candidate = value.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim().orEmpty() + val digits = candidate.removePrefix("#") + return candidate.startsWith("rgba", ignoreCase = true) || + (candidate.startsWith("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || + (digits.length == 8 && digits.toLongOrNull(16) != null) +} + /** * Returns an integer array for all color channels value. */ diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt index e96e1969..20141f82 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeDialogs.kt @@ -40,6 +40,7 @@ import org.wip.plugintoolkit.api.parseSemanticTypes import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.utils.toHex import org.wip.plugintoolkit.features.colorpicker.utils.toRGB +import org.wip.plugintoolkit.features.colorpicker.utils.colorStringHasAlpha import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.features.flows.model.PortConstraints import org.wip.plugintoolkit.shared.components.ToolkitTextField @@ -306,8 +307,9 @@ fun NodeDialogs( if (showColorPicker && activeColorInputId != null) { val input = node.inputs.firstOrNull { it.id == activeColorInputId } val inferredSem = input?.let { inferredSemanticTypes[Pair(node.id, it.id)] ?: it.semanticTypes } ?: emptyList() - val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } val existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" + val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } || + colorStringHasAlpha(existingValue) org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, initialColor = parseColorString(existingValue), diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt index 3fd01128..776c5a1e 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.booleanOrNull import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.core.theme.ToolkitTheme +import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor @Composable fun PortCircle( @@ -157,6 +158,7 @@ fun parseColorString(colorStr: String): Color { val lastColor = colorStr.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim() ?: colorStr val trimmed = lastColor.trim() if (trimmed.isEmpty()) return Color.Transparent + parseHexColor(trimmed)?.let { return it } if (trimmed.startsWith("#")) { return try { val hex = trimmed.substring(1) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt index 933167ef..f355fa46 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/inputs/ColorInput.kt @@ -32,6 +32,8 @@ import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.ParameterMetadata import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog +import org.wip.plugintoolkit.features.colorpicker.utils.colorStringHasAlpha +import org.wip.plugintoolkit.features.colorpicker.utils.parseHexColor import org.wip.plugintoolkit.features.colorpicker.utils.toHex import org.wip.plugintoolkit.features.colorpicker.utils.toRGB import org.wip.plugintoolkit.shared.components.plugin.StandardTextField @@ -52,7 +54,8 @@ fun ColorInput( var showColorPicker by remember { mutableStateOf(false) } val parsedColor = remember(value) { parseColorString(value) } val isArray = metadata.type is DataType.Array - val isRgba = metadata.semanticTypes.any { it.canonicalId.contains("rgba", ignoreCase = true) } + val isRgba = metadata.semanticTypes.any { it.canonicalId.contains("rgba", ignoreCase = true) } || + colorStringHasAlpha(value) val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier @@ -126,6 +129,7 @@ fun parseColorString(colorStr: String): Color { if (trimmed.isEmpty()) return Color.Transparent try { + parseHexColor(trimmed)?.let { return it } if (trimmed.startsWith("#")) { val hex = trimmed.substring(1) when (hex.length) { diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt index c37d427b..d8fc2d51 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt @@ -10,7 +10,18 @@ class ColorExtTest { @Test fun `hex parser accepts rgb and argb values`() { assertEquals(Color(0xFF336699.toInt()), parseHexColor("#336699")) + assertEquals(Color(0xFF336699.toInt()), parseHexColor("336699")) assertEquals(Color(0x80336699.toInt()), parseHexColor("80336699")) + assertEquals(true, colorStringHasAlpha("#80336699")) + assertEquals(true, colorStringHasAlpha("80336699")) + } + + @Test + fun `ARGB values round trip without losing alpha`() { + val original = "80336699" + val parsed = parseHexColor(original)!! + + assertEquals(original.lowercase(), parsed.toHex(includeAlpha = colorStringHasAlpha(original))) } @Test diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt index 2fe27d10..2870e54f 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt @@ -9,5 +9,6 @@ class NodeColorParsingTest { @Test fun `parses the ARGB order emitted by the color formatter`() { assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("#80336699").toArgb()) + assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("80336699").toArgb()) } } From 144d594989a7b26c6444f737300bc59dffb4d8e4 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:08:04 +1000 Subject: [PATCH 13/21] fix: preserve safe paths and isolate cache operations --- .../plugin/logic/DefaultPluginFileSystem.kt | 34 +++++++++++++++++-- .../plugin/logic/PluginLifecycleManager.kt | 2 +- .../logic/SandboxFileSystemSecurityTest.kt | 22 ++++++++++++ .../org/wip/plugintoolkit/api/RelativePath.kt | 22 +++++++----- .../wip/plugintoolkit/api/RelativePathTest.kt | 2 ++ 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index 100ddb77..a68b6753 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -121,8 +121,8 @@ class DefaultPluginFileSystem( override fun getBasePath(): String = basePath companion object { - fun createCacheOnly(pluginInstallPath: String): PluginFileSystem { - return DefaultPluginFileSystem(pluginInstallPath).let { fs -> + fun createCacheOnly(pluginInstallPath: String, jarPath: String? = null): PluginFileSystem { + return DefaultPluginFileSystem(pluginInstallPath, jarPath).let { fs -> // Create a variant that uses cachePath as basePath object : PluginFileSystem by fs { override fun getBasePath(): String = fs.cachePath @@ -141,6 +141,13 @@ class DefaultPluginFileSystem( override suspend fun exists(relativePath: RelativePath): Boolean = SystemFileSystem.exists(fs.resolveCachePath(relativePath)) + override suspend fun listFiles(relativePath: RelativePath): List { + val path = fs.resolveCachePath(relativePath) + if (!SystemFileSystem.exists(path)) return emptyList() + if (SystemFileSystem.metadataOrNull(path)?.isDirectory != true) return emptyList() + return SystemFileSystem.list(path).map { it.name } + } + override suspend fun deleteFile(relativePath: RelativePath): Result = fs.deleteFromCache(relativePath) @@ -153,6 +160,11 @@ class DefaultPluginFileSystem( require(relativePath.value.isNotEmpty()) { "The plugin cache root cannot be deleted" } fs.cacheOperations.deleteDirectory(fs.resolveCachePath(relativePath), recursive) } + + override suspend fun extractResource( + resourcePath: String, + targetRelativePath: RelativePath + ): Result = fs.extractResourceToCache(resourcePath, targetRelativePath) } } } @@ -207,4 +219,22 @@ class DefaultPluginFileSystem( Result.failure(e) } } + + private suspend fun extractResourceToCache( + resourcePath: String, + targetRelativePath: RelativePath + ): Result { + if (resourcePath.contains("..") || resourcePath.startsWith("/") || + resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) { + return Result.failure(SecurityException("Invalid resource path: $resourcePath")) + } + return runCatching { + withContext(loomDispatcher) { + val jar = jarPath ?: error("No JAR path configured for resource extraction") + val data = org.wip.plugintoolkit.core.utils.PlatformUtils.readBytesFromZip(jar, resourcePath) + ?: error("Resource not found in JAR: $resourcePath") + writeToCache(targetRelativePath, data).getOrThrow() + } + } + } } 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..bd8d897d 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 @@ -359,7 +359,7 @@ class PluginLifecycleManager( logger = pluginLogger, progress = progressReporter, fileSystem = DefaultPluginFileSystem(installPath, jarFullPath), - cacheFileSystem = DefaultPluginFileSystem.createCacheOnly(installPath), + cacheFileSystem = DefaultPluginFileSystem.createCacheOnly(installPath, jarFullPath), executionFileSystem = executionFileSystem ?: DefaultExecutionFileSystem("${installPath}/temp_execution"), hostFileSystem = HostFileSystemImpl(allowedPaths, isDestructiveAllowed), settings = mergedSettings, diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt index 5a88da3e..55b1f401 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt @@ -8,10 +8,13 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith +import kotlin.test.assertEquals import kotlin.test.assertTrue class SandboxFileSystemSecurityTest { @@ -82,6 +85,25 @@ class SandboxFileSystemSecurityTest { verifySymlinkIsContained(fileSystem, install.resolve("cache"), outside) } + @Test + fun cacheOnlyVariantListsAndExtractsResourcesInsideCache() = runTest { + val install = testRoot.resolve("plugin") + Files.createDirectories(install) + val jar = install.resolve("plugin.jar") + JarOutputStream(Files.newOutputStream(jar)).use { output -> + output.putNextEntry(JarEntry("assets/example.txt")) + output.write("resource".encodeToByteArray()) + output.closeEntry() + } + val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString(), jar.toString()) + val target = RelativePath.from("nested/example.txt").getOrThrow() + + assertTrue(fileSystem.extractResource("assets/example.txt", target).isSuccess) + assertEquals("resource", fileSystem.readTextFile(target)) + assertEquals(listOf("example.txt"), fileSystem.listFiles(RelativePath.from("nested").getOrThrow())) + assertTrue(Files.notExists(install.resolve("files/nested/example.txt"))) + } + private fun createOutsideSecret(): Path { val outside = Files.createDirectories(testRoot.resolve("outside")) Files.writeString(outside.resolve("secret.txt"), "must survive") diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt index 650ae950..a9b1104c 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt @@ -41,18 +41,22 @@ value class RelativePath private constructor(val value: String) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - val sanitized = normalized - .replace("\u2024", ".") - .replace("\uFF0E", ".") - .replace("\u3002", ".") - .replace("%2e", ".", ignoreCase = true) - .replace('\\', '/') - - val segments = sanitized.split('/').filter { it.isNotEmpty() && it != "." } - if (segments.any { it == ".." || it.contains(Regex("%25(?:2e|2f|5c)", RegexOption.IGNORE_CASE)) }) { + val segments = normalized.replace('\\', '/').split('/').filter { it.isNotEmpty() && it != "." } + val validationSegments = segments.map { segment -> + segment + .replace("\u2024", ".") + .replace("\uFF0E", ".") + .replace("\u3002", ".") + .replace("%2e", ".", ignoreCase = true) + } + if (validationSegments.any { + it == ".." || it.contains(Regex("%25(?:2e|2f|5c)", RegexOption.IGNORE_CASE)) + } + ) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } + // Validation uses a security-normalized view, but the filename itself is not decoded or rewritten. return Result.success(RelativePath(segments.joinToString("/"))) } } diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt index 400f17d5..b328f843 100644 --- a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt @@ -56,5 +56,7 @@ class RelativePathTest { assertEquals(RelativePath.ROOT, "././".toRelativePath().getOrThrow()) assertEquals("foo/bar", "foo/./bar".toRelativePath().getOrThrow().value) assertEquals("foo/bar", "foo\\bar".toRelativePath().getOrThrow().value) + assertEquals("file%2ename.txt", "file%2ename.txt".toRelativePath().getOrThrow().value) + assertEquals("file\u2024txt", "file\u2024txt".toRelativePath().getOrThrow().value) } } From 153c666de6ab313329402f8dfb4e828310a63ed7 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:12:22 +1000 Subject: [PATCH 14/21] fix: preserve unchanged settings cache identity --- .../features/plugin/logic/PluginSettingsResolver.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 4caebe43..4c5a413b 100644 --- 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 @@ -42,9 +42,10 @@ internal fun resolveAutogeneratedSettings( internal fun PluginSettingsStore.withResolvedAutogeneratedSettings( metadata: Map -): PluginSettingsStore = copy( - settings = resolveAutogeneratedSettings(metadata, settings, globalParams) -) +): 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) { From 5768befdff4c35226f7b632e43b7d600d0c23f02 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:40:31 +1000 Subject: [PATCH 15/21] fix: retain alpha for functional color values --- .../plugintoolkit/features/colorpicker/utils/ColorExt.kt | 7 +++++-- .../features/colorpicker/utils/ColorExtTest.kt | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt index dd8a0d65..b8c01b64 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt @@ -18,10 +18,13 @@ fun parseHexColor(value: String): Color? { /** Detects alpha-bearing legacy and current color representations without changing their format. */ fun colorStringHasAlpha(value: String): Boolean { + val trimmed = value.trim() + if (trimmed.startsWith("rgba(", ignoreCase = true) || trimmed.startsWith("hsla(", ignoreCase = true)) { + return true + } val candidate = value.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim().orEmpty() val digits = candidate.removePrefix("#") - return candidate.startsWith("rgba", ignoreCase = true) || - (candidate.startsWith("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || + return (candidate.startsWith("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || (digits.length == 8 && digits.toLongOrNull(16) != null) } diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt index d8fc2d51..1134948c 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt @@ -24,6 +24,13 @@ class ColorExtTest { assertEquals(original.lowercase(), parsed.toHex(includeAlpha = colorStringHasAlpha(original))) } + @Test + fun `functional alpha colors are detected before component splitting`() { + assertEquals(true, colorStringHasAlpha("rgba(10, 20, 30, 0.5)")) + assertEquals(true, colorStringHasAlpha("HSLA(120, 50%, 50%, 0.25)")) + assertEquals(false, colorStringHasAlpha("rgb(10, 20, 30)")) + } + @Test fun `hex parser rejects malformed values`() { assertNull(parseHexColor("#12345")) From a9f0142042a83e1cd3d063cfa3ca0d85a1ff96cf Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:44:01 +1000 Subject: [PATCH 16/21] fix: handle unavailable sandbox roots safely --- .../plugin/logic/DefaultExecutionFileSystem.kt | 9 +++++---- .../plugin/logic/DefaultPluginFileSystem.kt | 17 +++++++++-------- .../plugin/logic/SandboxFileOperations.kt | 7 +++++++ .../logic/SandboxFileSystemSecurityTest.kt | 16 ++++++++++++++++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt index 818461ff..22b82374 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt @@ -24,13 +24,13 @@ class DefaultExecutionFileSystem( } override suspend fun readFile(relativePath: RelativePath): ByteArray? { - val path = resolvePath(relativePath) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } override suspend fun readTextFile(relativePath: RelativePath): String? { - val path = resolvePath(relativePath) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -58,11 +58,12 @@ class DefaultExecutionFileSystem( } override suspend fun exists(relativePath: RelativePath): Boolean { - return SystemFileSystem.exists(resolvePath(relativePath)) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return false + return SystemFileSystem.exists(path) } override suspend fun listFiles(relativePath: RelativePath): List { - val path = resolvePath(relativePath) + val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return emptyList() if (!SystemFileSystem.exists(path)) return emptyList() val metadata = SystemFileSystem.metadataOrNull(path) if (metadata?.isDirectory != true) return emptyList() diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index a68b6753..f0241242 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -31,13 +31,13 @@ class DefaultPluginFileSystem( } override suspend fun readFile(relativePath: RelativePath): ByteArray? { - val path = resolvePath(relativePath) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } override suspend fun readTextFile(relativePath: RelativePath): String? { - val path = resolvePath(relativePath) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -65,11 +65,12 @@ class DefaultPluginFileSystem( } override suspend fun exists(relativePath: RelativePath): Boolean { - return SystemFileSystem.exists(resolvePath(relativePath)) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return false + return SystemFileSystem.exists(path) } override suspend fun listFiles(relativePath: RelativePath): List { - val path = resolvePath(relativePath) + val path = filesOperations.resolveIfRootExists(relativePath) ?: return emptyList() if (!SystemFileSystem.exists(path)) return emptyList() val metadata = SystemFileSystem.metadataOrNull(path) if (metadata?.isDirectory != true) return emptyList() @@ -139,10 +140,10 @@ class DefaultPluginFileSystem( fs.writeTextToCache(relativePath, text) override suspend fun exists(relativePath: RelativePath): Boolean = - SystemFileSystem.exists(fs.resolveCachePath(relativePath)) + fs.cacheOperations.resolveIfRootExists(relativePath)?.let(SystemFileSystem::exists) ?: false override suspend fun listFiles(relativePath: RelativePath): List { - val path = fs.resolveCachePath(relativePath) + val path = fs.cacheOperations.resolveIfRootExists(relativePath) ?: return emptyList() if (!SystemFileSystem.exists(path)) return emptyList() if (SystemFileSystem.metadataOrNull(path)?.isDirectory != true) return emptyList() return SystemFileSystem.list(path).map { it.name } @@ -175,13 +176,13 @@ class DefaultPluginFileSystem( } private suspend fun readFromCache(relativePath: RelativePath): ByteArray? { - val path = resolveCachePath(relativePath) + val path = cacheOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } private suspend fun readTextFromCache(relativePath: RelativePath): String? { - val path = resolveCachePath(relativePath) + val path = cacheOperations.resolveIfRootExists(relativePath) ?: return null if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt index 7503baa1..e4e2d902 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt @@ -5,6 +5,7 @@ import org.wip.plugintoolkit.api.RelativePath import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException import java.nio.file.Path as NioPath import java.nio.file.Paths import java.nio.file.SimpleFileVisitor @@ -36,6 +37,12 @@ internal class SandboxFileOperations(root: String) { return Path(candidate.toString()) } + fun resolveIfRootExists(relativePath: RelativePath): Path? = try { + resolve(relativePath) + } catch (_: NoSuchFileException) { + null + } + fun deleteDirectory(path: Path, recursive: Boolean) { val nioPath = Paths.get(path.toString()) if (!Files.exists(nioPath, LinkOption.NOFOLLOW_LINKS)) return diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt index 55b1f401..2034d3c0 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt @@ -15,6 +15,8 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class SandboxFileSystemSecurityTest { @@ -104,6 +106,20 @@ class SandboxFileSystemSecurityTest { assertTrue(Files.notExists(install.resolve("files/nested/example.txt"))) } + @Test + fun missingSandboxRootUsesReadSemanticsAndRejectsWrites() = runTest { + val install = testRoot.resolve("plugin") + val fileSystem = DefaultPluginFileSystem(install.toString()) + val file = RelativePath.from("missing.txt").getOrThrow() + Files.delete(install.resolve("files")) + + assertNull(fileSystem.readFile(file)) + assertNull(fileSystem.readTextFile(file)) + assertFalse(fileSystem.exists(file)) + assertEquals(emptyList(), fileSystem.listFiles()) + assertTrue(fileSystem.writeTextFile(file, "data").isFailure) + } + private fun createOutsideSecret(): Path { val outside = Files.createDirectories(testRoot.resolve("outside")) Files.writeString(outside.resolve("secret.txt"), "must survive") From 894a2eb546b4be3af66f179a962327f0c45ef5e4 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:24:27 +1000 Subject: [PATCH 17/21] fix: round-trip functional and short alpha colors --- .../features/colorpicker/utils/ColorExt.kt | 17 ++++++++++++++--- .../features/flows/ui/NodeHelpers.kt | 9 ++++++++- .../features/colorpicker/utils/ColorExtTest.kt | 1 + .../features/flows/ui/NodeColorParsingTest.kt | 11 +++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt index b8c01b64..5ed4a835 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExt.kt @@ -8,11 +8,22 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -/** Parses #RRGGBB or #AARRGGBB using the same ARGB order emitted by [toHex]. */ +/** Parses #RGBA, #RRGGBB or #AARRGGBB; long alpha values match the ARGB order emitted by [toHex]. */ fun parseHexColor(value: String): Color? { val digits = value.trim().removePrefix("#") - if (digits.length != 6 && digits.length != 8) return null - val argb = (if (digits.length == 6) "FF$digits" else digits).toLongOrNull(16) ?: return null + val normalized = when (digits.length) { + 4 -> { + val red = digits[0].toString().repeat(2) + val green = digits[1].toString().repeat(2) + val blue = digits[2].toString().repeat(2) + val alpha = digits[3].toString().repeat(2) + "$alpha$red$green$blue" + } + 6 -> "FF$digits" + 8 -> digits + else -> return null + } + val argb = normalized.toLongOrNull(16) ?: return null return Color(argb.toInt()) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt index 776c5a1e..f92d3648 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeHelpers.kt @@ -155,7 +155,14 @@ fun getNodeDescription(node: Node): String { } fun parseColorString(colorStr: String): Color { - val lastColor = colorStr.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim() ?: colorStr + val completeValue = colorStr.trim() + val isFunctionalColor = completeValue.startsWith("rgb(", ignoreCase = true) || + completeValue.startsWith("rgba(", ignoreCase = true) + val lastColor = if (isFunctionalColor) { + completeValue + } else { + colorStr.split(",").lastOrNull { it.trim().isNotEmpty() }?.trim() ?: colorStr + } val trimmed = lastColor.trim() if (trimmed.isEmpty()) return Color.Transparent parseHexColor(trimmed)?.let { return it } diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt index 1134948c..d852c962 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt @@ -14,6 +14,7 @@ class ColorExtTest { assertEquals(Color(0x80336699.toInt()), parseHexColor("80336699")) assertEquals(true, colorStringHasAlpha("#80336699")) assertEquals(true, colorStringHasAlpha("80336699")) + assertEquals(Color(0xAAFF0000.toInt()), parseHexColor("#F00A")) } @Test diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt index 2870e54f..3d9f9d95 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt @@ -11,4 +11,15 @@ class NodeColorParsingTest { assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("#80336699").toArgb()) assertEquals(Color(0x80336699.toInt()).toArgb(), parseColorString("80336699").toArgb()) } + + @Test + fun `parses functional colors before treating commas as array separators`() { + assertEquals(Color.Red.toArgb(), parseColorString("rgb(255, 0, 0)").toArgb()) + assertEquals(Color(0x80FF0000.toInt()).toArgb(), parseColorString("rgba(255, 0, 0, 0.5)").toArgb()) + } + + @Test + fun `parses short RGBA consistently with alpha detection`() { + assertEquals(Color(0xAAFF0000.toInt()).toArgb(), parseColorString("#F00A").toArgb()) + } } From 2f15ae110076fbb0641e37215258aeaa1b184a70 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:49:03 +1000 Subject: [PATCH 18/21] fix: apply manifest defaults to capability gates --- .../features/flows/ui/PaletteSidebar.kt | 6 ++++-- .../plugin/ui/DirectExecutionSidebar.kt | 3 ++- .../plugin/model/PluginSettingDefaultsTest.kt | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt index 6ae40b5a..b050683e 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt @@ -53,6 +53,7 @@ import org.wip.plugintoolkit.api.PluginEntry import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.plugin.logic.PluginManager +import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.features.plugin.ui.lockedClickInterceptor import org.wip.plugintoolkit.shared.components.ToolkitTextField import plugintoolkit.composeapp.generated.resources.Res @@ -248,8 +249,9 @@ private fun CapabilitiesPalette( ) ) caps.forEach { cap -> - val isReady = remember(cap, settingsStore.settings, manifest?.settings) { - cap.isReady(settingsStore.settings, manifest?.settings) + val providedSettings = settingsStore.resolveProvidedValues(manifest) + val isReady = remember(cap, providedSettings, manifest?.settings) { + cap.isReady(providedSettings, manifest?.settings) } val targetSettingKey = cap.requiredLocks.firstOrNull() diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt index 04b1be57..adcf844e 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt @@ -50,6 +50,7 @@ import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.PluginEntry import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme +import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.shared.components.ToolkitTextField import org.wip.plugintoolkit.shared.components.sidebar.NavigationSidebar import org.wip.plugintoolkit.shared.components.sidebar.SidebarElement @@ -167,7 +168,7 @@ fun DirectExecutionSidebar( val manifest = plugin.getManifest().getOrThrow() val pluginManager: org.wip.plugintoolkit.features.plugin.logic.PluginManager = org.koin.compose.koinInject() val settingsStore = pluginManager.loadPluginSettings(pluginId) - val settings = settingsStore.settings + settingsStore.globalParams + val settings = settingsStore.resolveProvidedValues(manifest) val pluginLocksState by pluginManager.pluginLocksState.collectAsState() val locks = pluginLocksState[pluginId] ?: pluginLocksState.values.fold(emptyMap()) { acc, map -> acc + map } 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 index 6b645980..7b1765df 100644 --- 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 @@ -1,15 +1,19 @@ package org.wip.plugintoolkit.features.plugin.model import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.Capability 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 org.wip.plugintoolkit.features.plugin.utils.CapabilityLockStatus +import org.wip.plugintoolkit.features.plugin.utils.CapabilityLockUtils import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue class PluginSettingDefaultsTest { private val manifest = PluginManifest( @@ -62,4 +66,21 @@ class PluginSettingDefaultsTest { assertEquals(JsonPrimitive("https://custom.test"), store.resolveCustomSettings(manifest)["endpoint"]) assertEquals(JsonPrimitive("global-collision"), store.resolveProvidedValues(manifest)["endpoint"]) } + + @Test + fun `manifest defaults unlock capability gates before settings are persisted`() { + val capability = Capability( + name = "call", + description = "Call the configured endpoint", + returnType = DataType.Primitive(PrimitiveType.STRING), + requiresSettings = listOf("endpoint") + ) + val provided = PluginSettingsStore().resolveProvidedValues(manifest) + + assertTrue(capability.isReady(provided, manifest.settings)) + assertTrue( + CapabilityLockUtils.checkCapabilityLockStatus(capability, emptyMap(), provided) is + CapabilityLockStatus.Unlocked + ) + } } From e203a5c0d805d9ce725ef3f2c2e02d0b1c296e72 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:07:11 +1000 Subject: [PATCH 19/21] fix: keep cache filesystem operations isolated --- .../plugin/logic/DefaultPluginFileSystem.kt | 37 ++++++++++++++++++- .../logic/SandboxFileSystemSecurityTest.kt | 35 ++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index f0241242..3e32e1db 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -1,6 +1,8 @@ package org.wip.plugintoolkit.features.plugin.logic import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem @@ -125,7 +127,7 @@ class DefaultPluginFileSystem( fun createCacheOnly(pluginInstallPath: String, jarPath: String? = null): PluginFileSystem { return DefaultPluginFileSystem(pluginInstallPath, jarPath).let { fs -> // Create a variant that uses cachePath as basePath - object : PluginFileSystem by fs { + object : PluginFileSystem { override fun getBasePath(): String = fs.cachePath override suspend fun readFile(relativePath: RelativePath): ByteArray? = fs.readFromCache(relativePath) @@ -139,6 +141,39 @@ class DefaultPluginFileSystem( override suspend fun writeTextFile(relativePath: RelativePath, text: String): Result = fs.writeTextToCache(relativePath, text) + // Keep every compound/stream operation explicitly cache-routed. In + // particular, do not use Kotlin interface delegation here: generated + // forwards would bypass these overrides and touch persistent files. + override suspend fun readStream(relativePath: RelativePath): Flow = flow { + fs.readFromCache(relativePath)?.let { emit(it) } + } + + override suspend fun writeStream( + relativePath: RelativePath, + stream: Flow + ): Result = runCatching { + val bytes = mutableListOf() + stream.collect { chunk -> chunk.forEach { byte -> bytes.add(byte) } } + fs.writeToCache(relativePath, bytes.toByteArray()).getOrThrow() + } + + override suspend fun copyFile( + source: RelativePath, + destination: RelativePath + ): Result = runCatching { + val content = fs.readFromCache(source) + ?: throw IllegalArgumentException("Source file does not exist") + fs.writeToCache(destination, content).getOrThrow() + } + + override suspend fun moveFile( + source: RelativePath, + destination: RelativePath + ): Result = runCatching { + copyFile(source, destination).getOrThrow() + fs.deleteFromCache(source).getOrThrow() + } + override suspend fun exists(relativePath: RelativePath): Boolean = fs.cacheOperations.resolveIfRootExists(relativePath)?.let(SystemFileSystem::exists) ?: false diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt index 2034d3c0..63059d18 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt @@ -1,5 +1,6 @@ package org.wip.plugintoolkit.features.plugin.logic +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.wip.plugintoolkit.api.RelativePath import org.wip.plugintoolkit.api.ScopedFileSystem @@ -106,6 +107,40 @@ class SandboxFileSystemSecurityTest { assertTrue(Files.notExists(install.resolve("files/nested/example.txt"))) } + @Test + fun cacheOnlyCompoundAndStreamOperationsNeverTouchPersistentFiles() = runTest { + val install = testRoot.resolve("plugin") + val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString()) + val source = RelativePath.from("source.bin").getOrThrow() + val copied = RelativePath.from("copied.bin").getOrThrow() + val moved = RelativePath.from("moved.bin").getOrThrow() + val streamed = RelativePath.from("streamed.bin").getOrThrow() + + Files.writeString(install.resolve("files/source.bin"), "persistent") + assertTrue(fileSystem.writeFile(source, "cache".encodeToByteArray()).isSuccess) + + assertTrue(fileSystem.copyFile(source, copied).isSuccess) + assertEquals("cache", Files.readString(install.resolve("cache/copied.bin"))) + assertTrue(Files.notExists(install.resolve("files/copied.bin"))) + + assertTrue(fileSystem.moveFile(source, moved).isSuccess) + assertTrue(Files.notExists(install.resolve("cache/source.bin"))) + assertEquals("cache", Files.readString(install.resolve("cache/moved.bin"))) + assertEquals("persistent", Files.readString(install.resolve("files/source.bin"))) + assertTrue(Files.notExists(install.resolve("files/moved.bin"))) + + assertTrue( + fileSystem.writeStream( + streamed, + flowOf("stream-".encodeToByteArray(), "cache".encodeToByteArray()) + ).isSuccess + ) + val chunks = mutableListOf() + fileSystem.readStream(streamed).collect { chunks.add(it) } + assertEquals("stream-cache", chunks.flatMap { it.asIterable() }.toByteArray().decodeToString()) + assertTrue(Files.notExists(install.resolve("files/streamed.bin"))) + } + @Test fun missingSandboxRootUsesReadSemanticsAndRejectsWrites() = runTest { val install = testRoot.resolve("plugin") From 62f058740c4304a2cdc47e29ac5ec257e3345c4d Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:22 +1000 Subject: [PATCH 20/21] Revert "Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue-13-color-picker-rework" This reverts commit 092c09e32c1c4126595cff5587a1ff58013c2df6, reversing changes made to ffaf22e55681ab523befd180f543f55bc1dfc8a6. --- .../logic/DefaultExecutionFileSystem.kt | 39 ++-- .../plugin/logic/DefaultPluginFileSystem.kt | 126 +++---------- .../plugin/logic/PluginLifecycleManager.kt | 2 +- .../plugin/logic/SandboxFileOperations.kt | 69 ------- .../logic/DefaultExecutionFileSystemTest.kt | 18 -- .../logic/SandboxFileSystemSecurityTest.kt | 177 ------------------ docs/PluginDevelopment.md | 1 - .../org/wip/plugintoolkit/api/Interfaces.kt | 4 - .../org/wip/plugintoolkit/api/RelativePath.kt | 31 ++- .../wip/plugintoolkit/api/RelativePathTest.kt | 6 - 10 files changed, 64 insertions(+), 409 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt delete mode 100644 composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt index 22b82374..fea6db80 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystem.kt @@ -17,20 +17,35 @@ class DefaultExecutionFileSystem( SystemFileSystem.createDirectories(Path(sandboxPath)) } - private val sandboxOperations = SandboxFileOperations(sandboxPath) - private fun resolvePath(relativePath: RelativePath): Path { - return sandboxOperations.resolve(relativePath) + val resolved = Path(sandboxPath, relativePath.value) + val file = java.io.File(resolved.toString()) + val normalized = try { + file.canonicalPath + } catch (e: Exception) { + throw SecurityException("Failed to resolve canonical path for '${relativePath.value}': ${e.message}") + } + val baseFile = java.io.File(sandboxPath) + val baseCanonical = try { + baseFile.canonicalPath + } catch (e: Exception) { + throw SecurityException("Failed to resolve base canonical path for '$sandboxPath': ${e.message}") + } + + if (normalized != baseCanonical && !normalized.startsWith(baseCanonical + java.io.File.separator)) { + throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the sandbox.") + } + return resolved } override suspend fun readFile(relativePath: RelativePath): ByteArray? { - val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return null + val path = resolvePath(relativePath) if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } override suspend fun readTextFile(relativePath: RelativePath): String? { - val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return null + val path = resolvePath(relativePath) if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -58,12 +73,11 @@ class DefaultExecutionFileSystem( } override suspend fun exists(relativePath: RelativePath): Boolean { - val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return false - return SystemFileSystem.exists(path) + return SystemFileSystem.exists(resolvePath(relativePath)) } override suspend fun listFiles(relativePath: RelativePath): List { - val path = sandboxOperations.resolveIfRootExists(relativePath) ?: return emptyList() + val path = resolvePath(relativePath) if (!SystemFileSystem.exists(path)) return emptyList() val metadata = SystemFileSystem.metadataOrNull(path) if (metadata?.isDirectory != true) return emptyList() @@ -83,14 +97,5 @@ class DefaultExecutionFileSystem( } } - override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { - SystemFileSystem.createDirectories(resolvePath(relativePath)) - } - - override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { - require(relativePath.value.isNotEmpty()) { "The execution sandbox root cannot be deleted" } - sandboxOperations.deleteDirectory(resolvePath(relativePath), recursive) - } - override fun getBasePath(): String = sandboxPath } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt index 3e32e1db..e32f5a16 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultPluginFileSystem.kt @@ -1,8 +1,6 @@ package org.wip.plugintoolkit.features.plugin.logic import kotlinx.coroutines.withContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem @@ -25,21 +23,25 @@ class DefaultPluginFileSystem( SystemFileSystem.createDirectories(Path(cachePath)) } - private val filesOperations = SandboxFileOperations(basePath) - private val cacheOperations = SandboxFileOperations(cachePath) - private fun resolvePath(relativePath: RelativePath): Path { - return filesOperations.resolve(relativePath) + val resolved = Path(basePath, relativePath.value) + val normalized = resolved.toString().replace('\\', '/') + val baseCanonical = Path(basePath).toString().replace('\\', '/') + + if (normalized != baseCanonical && !normalized.startsWith(if (baseCanonical.endsWith("/")) baseCanonical else "$baseCanonical/")) { + throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the plugin files directory.") + } + return resolved } override suspend fun readFile(relativePath: RelativePath): ByteArray? { - val path = filesOperations.resolveIfRootExists(relativePath) ?: return null + val path = resolvePath(relativePath) if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } override suspend fun readTextFile(relativePath: RelativePath): String? { - val path = filesOperations.resolveIfRootExists(relativePath) ?: return null + val path = resolvePath(relativePath) if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -67,12 +69,11 @@ class DefaultPluginFileSystem( } override suspend fun exists(relativePath: RelativePath): Boolean { - val path = filesOperations.resolveIfRootExists(relativePath) ?: return false - return SystemFileSystem.exists(path) + return SystemFileSystem.exists(resolvePath(relativePath)) } override suspend fun listFiles(relativePath: RelativePath): List { - val path = filesOperations.resolveIfRootExists(relativePath) ?: return emptyList() + val path = resolvePath(relativePath) if (!SystemFileSystem.exists(path)) return emptyList() val metadata = SystemFileSystem.metadataOrNull(path) if (metadata?.isDirectory != true) return emptyList() @@ -92,15 +93,6 @@ class DefaultPluginFileSystem( } } - override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { - SystemFileSystem.createDirectories(resolvePath(relativePath)) - } - - override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = runCatching { - require(relativePath.value.isNotEmpty()) { "The plugin files root cannot be deleted" } - filesOperations.deleteDirectory(resolvePath(relativePath), recursive) - } - override suspend fun extractResource(resourcePath: String, targetRelativePath: RelativePath): Result { if (resourcePath.contains("..") || resourcePath.startsWith("/") || resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) { return Result.failure(SecurityException("Invalid resource path: $resourcePath")) @@ -124,10 +116,10 @@ class DefaultPluginFileSystem( override fun getBasePath(): String = basePath companion object { - fun createCacheOnly(pluginInstallPath: String, jarPath: String? = null): PluginFileSystem { - return DefaultPluginFileSystem(pluginInstallPath, jarPath).let { fs -> + fun createCacheOnly(pluginInstallPath: String): PluginFileSystem { + return DefaultPluginFileSystem(pluginInstallPath).let { fs -> // Create a variant that uses cachePath as basePath - object : PluginFileSystem { + object : PluginFileSystem by fs { override fun getBasePath(): String = fs.cachePath override suspend fun readFile(relativePath: RelativePath): ByteArray? = fs.readFromCache(relativePath) @@ -141,83 +133,35 @@ class DefaultPluginFileSystem( override suspend fun writeTextFile(relativePath: RelativePath, text: String): Result = fs.writeTextToCache(relativePath, text) - // Keep every compound/stream operation explicitly cache-routed. In - // particular, do not use Kotlin interface delegation here: generated - // forwards would bypass these overrides and touch persistent files. - override suspend fun readStream(relativePath: RelativePath): Flow = flow { - fs.readFromCache(relativePath)?.let { emit(it) } - } - - override suspend fun writeStream( - relativePath: RelativePath, - stream: Flow - ): Result = runCatching { - val bytes = mutableListOf() - stream.collect { chunk -> chunk.forEach { byte -> bytes.add(byte) } } - fs.writeToCache(relativePath, bytes.toByteArray()).getOrThrow() - } - - override suspend fun copyFile( - source: RelativePath, - destination: RelativePath - ): Result = runCatching { - val content = fs.readFromCache(source) - ?: throw IllegalArgumentException("Source file does not exist") - fs.writeToCache(destination, content).getOrThrow() - } - - override suspend fun moveFile( - source: RelativePath, - destination: RelativePath - ): Result = runCatching { - copyFile(source, destination).getOrThrow() - fs.deleteFromCache(source).getOrThrow() - } - override suspend fun exists(relativePath: RelativePath): Boolean = - fs.cacheOperations.resolveIfRootExists(relativePath)?.let(SystemFileSystem::exists) ?: false - - override suspend fun listFiles(relativePath: RelativePath): List { - val path = fs.cacheOperations.resolveIfRootExists(relativePath) ?: return emptyList() - if (!SystemFileSystem.exists(path)) return emptyList() - if (SystemFileSystem.metadataOrNull(path)?.isDirectory != true) return emptyList() - return SystemFileSystem.list(path).map { it.name } - } + SystemFileSystem.exists(fs.resolveCachePath(relativePath)) override suspend fun deleteFile(relativePath: RelativePath): Result = fs.deleteFromCache(relativePath) - - override suspend fun createDirectory(relativePath: RelativePath): Result = runCatching { - SystemFileSystem.createDirectories(fs.resolveCachePath(relativePath)) - } - - override suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean): Result = - runCatching { - require(relativePath.value.isNotEmpty()) { "The plugin cache root cannot be deleted" } - fs.cacheOperations.deleteDirectory(fs.resolveCachePath(relativePath), recursive) - } - - override suspend fun extractResource( - resourcePath: String, - targetRelativePath: RelativePath - ): Result = fs.extractResourceToCache(resourcePath, targetRelativePath) } } } } private fun resolveCachePath(relativePath: RelativePath): Path { - return cacheOperations.resolve(relativePath) + val resolved = Path(cachePath, relativePath.value) + val normalized = resolved.toString().replace('\\', '/') + val baseCanonical = Path(cachePath).toString().replace('\\', '/') + + if (normalized != baseCanonical && !normalized.startsWith(if (baseCanonical.endsWith("/")) baseCanonical else "$baseCanonical/")) { + throw SecurityException("Access to path '${relativePath.value}' is denied. It is outside the plugin cache directory.") + } + return resolved } private suspend fun readFromCache(relativePath: RelativePath): ByteArray? { - val path = cacheOperations.resolveIfRootExists(relativePath) ?: return null + val path = resolveCachePath(relativePath) if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readByteArray() } } private suspend fun readTextFromCache(relativePath: RelativePath): String? { - val path = cacheOperations.resolveIfRootExists(relativePath) ?: return null + val path = resolveCachePath(relativePath) if (!SystemFileSystem.exists(path)) return null return SystemFileSystem.source(path).buffered().use { it.readString() } } @@ -255,22 +199,4 @@ class DefaultPluginFileSystem( Result.failure(e) } } - - private suspend fun extractResourceToCache( - resourcePath: String, - targetRelativePath: RelativePath - ): Result { - if (resourcePath.contains("..") || resourcePath.startsWith("/") || - resourcePath.startsWith("\\") || resourcePath.contains("\u0000")) { - return Result.failure(SecurityException("Invalid resource path: $resourcePath")) - } - return runCatching { - withContext(loomDispatcher) { - val jar = jarPath ?: error("No JAR path configured for resource extraction") - val data = org.wip.plugintoolkit.core.utils.PlatformUtils.readBytesFromZip(jar, resourcePath) - ?: error("Resource not found in JAR: $resourcePath") - writeToCache(targetRelativePath, data).getOrThrow() - } - } - } } 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 b889509d..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 @@ -355,7 +355,7 @@ class PluginLifecycleManager( logger = pluginLogger, progress = progressReporter, fileSystem = DefaultPluginFileSystem(installPath, jarFullPath), - cacheFileSystem = DefaultPluginFileSystem.createCacheOnly(installPath, jarFullPath), + cacheFileSystem = DefaultPluginFileSystem.createCacheOnly(installPath), executionFileSystem = executionFileSystem ?: DefaultExecutionFileSystem("${installPath}/temp_execution"), hostFileSystem = HostFileSystemImpl(allowedPaths, isDestructiveAllowed), settings = mergedSettings, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt deleted file mode 100644 index e4e2d902..00000000 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileOperations.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.wip.plugintoolkit.features.plugin.logic - -import kotlinx.io.files.Path -import org.wip.plugintoolkit.api.RelativePath -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.LinkOption -import java.nio.file.NoSuchFileException -import java.nio.file.Path as NioPath -import java.nio.file.Paths -import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes - -internal class SandboxFileOperations(root: String) { - private val base = Paths.get(root).toAbsolutePath().normalize() - private val realBase = base.toRealPath() - - fun resolve(relativePath: RelativePath): Path { - if (base.toRealPath() != realBase) { - throw SecurityException("The sandbox root changed after it was initialized") - } - val candidate = base.resolve(relativePath.value).normalize() - if (!candidate.startsWith(base)) { - throw SecurityException("Access to path '${relativePath.value}' is outside the sandbox") - } - - var current = base - base.relativize(candidate).forEach { segment -> - current = current.resolve(segment) - if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { - val realCurrent = current.toRealPath() - if (!realCurrent.startsWith(realBase)) { - throw SecurityException("Access to path '${relativePath.value}' escapes the sandbox through a symbolic link") - } - } - } - return Path(candidate.toString()) - } - - fun resolveIfRootExists(relativePath: RelativePath): Path? = try { - resolve(relativePath) - } catch (_: NoSuchFileException) { - null - } - - fun deleteDirectory(path: Path, recursive: Boolean) { - val nioPath = Paths.get(path.toString()) - if (!Files.exists(nioPath, LinkOption.NOFOLLOW_LINKS)) return - require(Files.isDirectory(nioPath, LinkOption.NOFOLLOW_LINKS)) { "Path is not a directory: $path" } - - if (!recursive) { - Files.delete(nioPath) - return - } - - Files.walkFileTree(nioPath, object : SimpleFileVisitor() { - override fun visitFile(file: NioPath, attrs: BasicFileAttributes): FileVisitResult { - Files.delete(file) - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(dir: NioPath, error: java.io.IOException?): FileVisitResult { - if (error != null) throw error - Files.delete(dir) - return FileVisitResult.CONTINUE - } - }) - } -} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt index 406a0b05..51da2951 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/DefaultExecutionFileSystemTest.kt @@ -84,24 +84,6 @@ class DefaultExecutionFileSystemTest { assertTrue(files.contains("file2.txt")) } - @Test - fun testCreateAndDeleteDirectory() = runTest { - val directory = RelativePath.from("models/nested").getOrThrow() - assertTrue(fileSystem.createDirectory(directory).isSuccess) - assertTrue(fileSystem.exists(directory)) - - fileSystem.writeTextFile(RelativePath.from("models/nested/model.txt").getOrThrow(), "model") - assertTrue(fileSystem.deleteDirectory(RelativePath.from("models").getOrThrow()).isFailure) - assertTrue(fileSystem.deleteDirectory(RelativePath.from("models").getOrThrow(), recursive = true).isSuccess) - assertFalse(fileSystem.exists(RelativePath.from("models").getOrThrow())) - } - - @Test - fun testCannotDeleteSandboxRoot() = runTest { - assertTrue(fileSystem.deleteDirectory(RelativePath.ROOT, recursive = true).isFailure) - assertTrue(SystemFileSystem.exists(Path(sandboxPath))) - } - @Test fun testPathTraversalPrevention() = runTest { // Attempt to create a path outside the sandbox using ../ diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt deleted file mode 100644 index 63059d18..00000000 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/SandboxFileSystemSecurityTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -package org.wip.plugintoolkit.features.plugin.logic - -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest -import org.wip.plugintoolkit.api.RelativePath -import org.wip.plugintoolkit.api.ScopedFileSystem -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes -import java.util.jar.JarEntry -import java.util.jar.JarOutputStream -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFailsWith -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class SandboxFileSystemSecurityTest { - private lateinit var testRoot: Path - - @BeforeTest - fun setUp() { - testRoot = Files.createTempDirectory("plugin-toolkit-sandbox-") - } - - @AfterTest - fun tearDown() { - if (!Files.exists(testRoot)) return - Files.walkFileTree(testRoot, object : SimpleFileVisitor() { - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.deleteIfExists(file) - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult { - if (error != null) throw error - Files.deleteIfExists(dir) - return FileVisitResult.CONTINUE - } - }) - } - - @Test - fun rootAliasesCannotDeleteAnySandboxRoot() = runTest { - val alias = RelativePath.from("././").getOrThrow() - val execution = DefaultExecutionFileSystem(testRoot.resolve("execution").toString()) - val pluginInstall = testRoot.resolve("plugin").toString() - val plugin = DefaultPluginFileSystem(pluginInstall) - val cache = DefaultPluginFileSystem.createCacheOnly(pluginInstall) - - assertTrue(execution.deleteDirectory(alias, recursive = true).isFailure) - assertTrue(plugin.deleteDirectory(alias, recursive = true).isFailure) - assertTrue(cache.deleteDirectory(alias, recursive = true).isFailure) - assertTrue(Files.isDirectory(testRoot.resolve("execution"))) - assertTrue(Files.isDirectory(testRoot.resolve("plugin/files"))) - assertTrue(Files.isDirectory(testRoot.resolve("plugin/cache"))) - } - - @Test - fun executionSandboxCannotReadThroughSymlinkAndDoesNotFollowItOnDelete() = runTest { - val sandbox = testRoot.resolve("execution") - val outside = createOutsideSecret() - val fileSystem = DefaultExecutionFileSystem(sandbox.toString()) - - verifySymlinkIsContained(fileSystem, sandbox, outside) - } - - @Test - fun pluginFilesCannotReadThroughSymlinkAndDoNotFollowItOnDelete() = runTest { - val install = testRoot.resolve("plugin") - val outside = createOutsideSecret() - val fileSystem = DefaultPluginFileSystem(install.toString()) - - verifySymlinkIsContained(fileSystem, install.resolve("files"), outside) - } - - @Test - fun pluginCacheCannotReadThroughSymlinkAndDoesNotFollowItOnDelete() = runTest { - val install = testRoot.resolve("plugin") - val outside = createOutsideSecret() - val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString()) - - verifySymlinkIsContained(fileSystem, install.resolve("cache"), outside) - } - - @Test - fun cacheOnlyVariantListsAndExtractsResourcesInsideCache() = runTest { - val install = testRoot.resolve("plugin") - Files.createDirectories(install) - val jar = install.resolve("plugin.jar") - JarOutputStream(Files.newOutputStream(jar)).use { output -> - output.putNextEntry(JarEntry("assets/example.txt")) - output.write("resource".encodeToByteArray()) - output.closeEntry() - } - val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString(), jar.toString()) - val target = RelativePath.from("nested/example.txt").getOrThrow() - - assertTrue(fileSystem.extractResource("assets/example.txt", target).isSuccess) - assertEquals("resource", fileSystem.readTextFile(target)) - assertEquals(listOf("example.txt"), fileSystem.listFiles(RelativePath.from("nested").getOrThrow())) - assertTrue(Files.notExists(install.resolve("files/nested/example.txt"))) - } - - @Test - fun cacheOnlyCompoundAndStreamOperationsNeverTouchPersistentFiles() = runTest { - val install = testRoot.resolve("plugin") - val fileSystem = DefaultPluginFileSystem.createCacheOnly(install.toString()) - val source = RelativePath.from("source.bin").getOrThrow() - val copied = RelativePath.from("copied.bin").getOrThrow() - val moved = RelativePath.from("moved.bin").getOrThrow() - val streamed = RelativePath.from("streamed.bin").getOrThrow() - - Files.writeString(install.resolve("files/source.bin"), "persistent") - assertTrue(fileSystem.writeFile(source, "cache".encodeToByteArray()).isSuccess) - - assertTrue(fileSystem.copyFile(source, copied).isSuccess) - assertEquals("cache", Files.readString(install.resolve("cache/copied.bin"))) - assertTrue(Files.notExists(install.resolve("files/copied.bin"))) - - assertTrue(fileSystem.moveFile(source, moved).isSuccess) - assertTrue(Files.notExists(install.resolve("cache/source.bin"))) - assertEquals("cache", Files.readString(install.resolve("cache/moved.bin"))) - assertEquals("persistent", Files.readString(install.resolve("files/source.bin"))) - assertTrue(Files.notExists(install.resolve("files/moved.bin"))) - - assertTrue( - fileSystem.writeStream( - streamed, - flowOf("stream-".encodeToByteArray(), "cache".encodeToByteArray()) - ).isSuccess - ) - val chunks = mutableListOf() - fileSystem.readStream(streamed).collect { chunks.add(it) } - assertEquals("stream-cache", chunks.flatMap { it.asIterable() }.toByteArray().decodeToString()) - assertTrue(Files.notExists(install.resolve("files/streamed.bin"))) - } - - @Test - fun missingSandboxRootUsesReadSemanticsAndRejectsWrites() = runTest { - val install = testRoot.resolve("plugin") - val fileSystem = DefaultPluginFileSystem(install.toString()) - val file = RelativePath.from("missing.txt").getOrThrow() - Files.delete(install.resolve("files")) - - assertNull(fileSystem.readFile(file)) - assertNull(fileSystem.readTextFile(file)) - assertFalse(fileSystem.exists(file)) - assertEquals(emptyList(), fileSystem.listFiles()) - assertTrue(fileSystem.writeTextFile(file, "data").isFailure) - } - - private fun createOutsideSecret(): Path { - val outside = Files.createDirectories(testRoot.resolve("outside")) - Files.writeString(outside.resolve("secret.txt"), "must survive") - return outside - } - - private suspend fun verifySymlinkIsContained( - fileSystem: ScopedFileSystem, - sandbox: Path, - outside: Path - ) { - val nested = Files.createDirectories(sandbox.resolve("nested")) - Files.createSymbolicLink(nested.resolve("escape"), outside) - val escapedFile = RelativePath.from("nested/escape/secret.txt").getOrThrow() - - assertFailsWith { fileSystem.readTextFile(escapedFile) } - assertTrue(fileSystem.deleteDirectory(RelativePath.from("nested").getOrThrow(), recursive = true).isSuccess) - assertTrue(Files.readString(outside.resolve("secret.txt")) == "must survive") - } -} diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index f7e68b33..d08fb3ed 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -320,7 +320,6 @@ While you can set a plugin to "not support cancellation" the host app can force- The `PluginContext` (and focused interfaces like `PluginLogger`, `PluginFileSystem`, `ExecutionFileSystem`, `HostFileSystem`) provide access to host services: - **Logger**: `PluginLogger` (e.g. `logger.info("Message")`) - **Plugin File System**: `PluginFileSystem` (Persistent, isolated storage for the plugin. Preserved across executions. e.g. `fileSystem.getBasePath()`) -- **Directory operations**: scoped file systems support `createDirectory` and guarded `deleteDirectory`; recursive deletion must be requested explicitly and the sandbox root can never be deleted. - **Execution File System**: `ExecutionFileSystem` (Temporary, isolated sandbox storage for the current execution. Cleared automatically after the flow finishes.) - **Host File System**: `HostFileSystem` (External file access. Restricted to paths explicitly granted by the user via file input/output parameters: `@CapabilityInput` and `@CapabilityOutput`.) - **Plugin Storage**: `PluginStorage` (`context.storage`) provides a persistent, internal key-value store (`get`, `put`, `getAll`, `remove`) for saving plugin-internal state without polluting user settings. diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt index 3b86f7c0..5b4702b9 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/Interfaces.kt @@ -102,10 +102,6 @@ interface ScopedFileSystem { suspend fun exists(relativePath: RelativePath): Boolean suspend fun listFiles(relativePath: RelativePath = RelativePath.ROOT): List suspend fun deleteFile(relativePath: RelativePath): Result - suspend fun createDirectory(relativePath: RelativePath): Result = - Result.failure(UnsupportedOperationException("Directory creation is not supported by this host")) - suspend fun deleteDirectory(relativePath: RelativePath, recursive: Boolean = false): Result = - Result.failure(UnsupportedOperationException("Directory deletion is not supported by this host")) /** * Get the absolute base path of the managed file area. diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt index a9b1104c..7940117a 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/RelativePath.kt @@ -13,6 +13,7 @@ value class RelativePath private constructor(val value: String) { companion object { val ROOT = RelativePath("") private val NULL_BYTE_REGEX = Regex("\u0000") + private val TRAVERSAL_REGEX = Regex("""(?:^|/|\\|\.|\u2024|\uFF0E|\u3002)(?:\.\.|\u2024\u2024|\uFF0E\uFF0E|%2e%2e|%2E%2E|%252e%252e)(?:/|\\|${'$'}|\.)""", RegexOption.IGNORE_CASE) private val ENCODED_SLASH_REGEX = Regex("""%2f|%5c""", RegexOption.IGNORE_CASE) /** @@ -37,27 +38,25 @@ value class RelativePath private constructor(val value: String) { return Result.failure(SecurityException("Path must be relative, but contains drive letter: $normalized")) } - if (normalized.contains(ENCODED_SLASH_REGEX)) { + // Check for encoded slashes or traversal sequences + if (normalized.contains(ENCODED_SLASH_REGEX) || normalized.contains(TRAVERSAL_REGEX)) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - val segments = normalized.replace('\\', '/').split('/').filter { it.isNotEmpty() && it != "." } - val validationSegments = segments.map { segment -> - segment - .replace("\u2024", ".") - .replace("\uFF0E", ".") - .replace("\u3002", ".") - .replace("%2e", ".", ignoreCase = true) - } - if (validationSegments.any { - it == ".." || it.contains(Regex("%25(?:2e|2f|5c)", RegexOption.IGNORE_CASE)) - } - ) { + // Normalized path checks: convert unicode dot variants to regular dot for safety check + val sanitized = normalized + .replace("\u2024", ".") + .replace("\uFF0E", ".") + .replace("\u3002", ".") + .replace("%2e", ".", ignoreCase = true) + .replace("%2f", "/", ignoreCase = true) + .replace("%5c", "\\", ignoreCase = true) + + if (sanitized.contains(TRAVERSAL_REGEX) || sanitized.contains("../") || sanitized.contains("..\\")) { return Result.failure(SecurityException("Path traversal attempt detected: $normalized")) } - - // Validation uses a security-normalized view, but the filename itself is not decoded or rewritten. - return Result.success(RelativePath(segments.joinToString("/"))) + + return Result.success(RelativePath(normalized)) } } } diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt index b328f843..96ae01bd 100644 --- a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/RelativePathTest.kt @@ -52,11 +52,5 @@ class RelativePathTest { val path = " foo/bar.txt ".toRelativePath() assertTrue(path.isSuccess) assertEquals("foo/bar.txt", path.getOrNull()?.value, "Should trim whitespace") - assertEquals(RelativePath.ROOT, ".".toRelativePath().getOrThrow()) - assertEquals(RelativePath.ROOT, "././".toRelativePath().getOrThrow()) - assertEquals("foo/bar", "foo/./bar".toRelativePath().getOrThrow().value) - assertEquals("foo/bar", "foo\\bar".toRelativePath().getOrThrow().value) - assertEquals("file%2ename.txt", "file%2ename.txt".toRelativePath().getOrThrow().value) - assertEquals("file\u2024txt", "file\u2024txt".toRelativePath().getOrThrow().value) } } From 71d1596b371597c28b6f5853c2caf46cc597df1e Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:22 +1000 Subject: [PATCH 21/21] Revert "Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue-13-color-picker-rework" This reverts commit ffaf22e55681ab523befd180f543f55bc1dfc8a6, reversing changes made to 894a2eb546b4be3af66f179a962327f0c45ef5e4. --- .../org/wip/complete/CompleteExamplePlugin.kt | 4 +- .../composeResources/values-it/strings.xml | 2 - .../composeResources/values/strings.xml | 2 - .../features/flows/ui/PaletteSidebar.kt | 6 +- .../plugin/logic/PluginLifecycleManager.kt | 22 +- .../plugin/logic/PluginSettingsResolver.kt | 63 ------ .../plugin/model/PluginSettingsStore.kt | 13 -- .../plugin/ui/DirectExecutionSidebar.kt | 3 +- .../features/plugin/ui/PluginContent.kt | 12 +- .../plugin/ui/PluginSettingsContent.kt | 203 +++++++----------- .../viewmodel/PluginSettingsViewModel.kt | 17 +- .../logic/PluginLifecycleManagerTest.kt | 59 ----- .../plugin/model/PluginSettingDefaultsTest.kt | 86 -------- .../plugin/ui/PluginSettingPartitionTest.kt | 40 ---- .../PluginSettingsAutogenerationTest.kt | 61 ------ docs/PluginDevelopment.md | 5 +- .../wip/plugintoolkit/api/ManifestModels.kt | 36 +--- .../api/annotations/Annotations.kt | 8 +- .../plugintoolkit/api/ManifestModelsTest.kt | 20 -- .../api/processor/ManifestJsonGenerator.kt | 8 - .../processor/generators/ManifestGenerator.kt | 63 +----- .../SettingMetadataBinaryCompatibilityTest.kt | 27 --- 22 files changed, 117 insertions(+), 643 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt delete mode 100644 plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt diff --git a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt index fcb26363..3bb804ac 100644 --- a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt +++ b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt @@ -39,9 +39,7 @@ import java.io.File data class CompleteExampleSettings( @PluginSetting( description = "Public configuration value example", - defaultValue = "default_api_key", - minLength = 8, - semanticTypes = ["text/plain"] + defaultValue = "default_api_key" ) val apiKey: String? = "default_api_key", @PluginSetting( diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 30872c98..f78b6015 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1,6 +1,4 @@ - Obbligatorie - Facoltative PluginToolkit Runner Dashboard diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 07343f42..24b8091f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -195,8 +195,6 @@ Actions Custom Settings Global Parameter Defaults - Required - Optional Capability: %1$s Configure required settings to unlock options Locked capability: %1$s diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt index b050683e..6ae40b5a 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/ui/PaletteSidebar.kt @@ -53,7 +53,6 @@ import org.wip.plugintoolkit.api.PluginEntry import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.plugin.logic.PluginManager -import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.features.plugin.ui.lockedClickInterceptor import org.wip.plugintoolkit.shared.components.ToolkitTextField import plugintoolkit.composeapp.generated.resources.Res @@ -249,9 +248,8 @@ private fun CapabilitiesPalette( ) ) caps.forEach { cap -> - val providedSettings = settingsStore.resolveProvidedValues(manifest) - val isReady = remember(cap, providedSettings, manifest?.settings) { - cap.isReady(providedSettings, manifest?.settings) + val isReady = remember(cap, settingsStore.settings, manifest?.settings) { + cap.isReady(settingsStore.settings, manifest?.settings) } val targetSettingKey = cap.requiredLocks.firstOrNull() 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 4a76a74e..6119f11e 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,7 +21,6 @@ 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 @@ -288,7 +287,6 @@ class PluginLifecycleManager( } val decryptedStore = store.copy(settings = decryptedSettings) - .withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) _pluginSettingsState.update { it + (pkg to decryptedStore) } return decryptedStore } @@ -298,8 +296,7 @@ class PluginLifecycleManager( val settingsFile = "${plugin.installPath}/settings.json" val manifest = getManifest(pkg) - val resolvedStore = store.withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) - val encryptedSettings = resolvedStore.settings.mapValues { (key, value) -> + val encryptedSettings = store.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) @@ -308,12 +305,12 @@ class PluginLifecycleManager( value } } - val storeToSave = resolvedStore.copy(settings = encryptedSettings) + val storeToSave = store.copy(settings = encryptedSettings) try { fileSystem.writeFile(settingsFile, json.encodeToString(storeToSave)) // Update cache with the decrypted store - _pluginSettingsState.update { it + (pkg to resolvedStore) } + _pluginSettingsState.update { it + (pkg to store) } } catch (t: Throwable) { Logger.e(t) { "Failed to save settings for $pkg" } } @@ -333,10 +330,17 @@ 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 storedSettings = (overriddenSettings ?: loadPluginSettings(pkg)) - .withResolvedAutogeneratedSettings(actualManifest?.settings.orEmpty()) - val mergedSettings = storedSettings.resolveCustomSettings(actualManifest) + 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 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 deleted file mode 100644 index 4c5a413b..00000000 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt +++ /dev/null @@ -1,63 +0,0 @@ -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 8ed2f01a..472b0a24 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,7 +2,6 @@ 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( @@ -10,15 +9,3 @@ 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/DirectExecutionSidebar.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt index adcf844e..04b1be57 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/DirectExecutionSidebar.kt @@ -50,7 +50,6 @@ import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.PluginEntry import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme -import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.shared.components.ToolkitTextField import org.wip.plugintoolkit.shared.components.sidebar.NavigationSidebar import org.wip.plugintoolkit.shared.components.sidebar.SidebarElement @@ -168,7 +167,7 @@ fun DirectExecutionSidebar( val manifest = plugin.getManifest().getOrThrow() val pluginManager: org.wip.plugintoolkit.features.plugin.logic.PluginManager = org.koin.compose.koinInject() val settingsStore = pluginManager.loadPluginSettings(pluginId) - val settings = settingsStore.resolveProvidedValues(manifest) + val settings = settingsStore.settings + settingsStore.globalParams val pluginLocksState by pluginManager.pluginLocksState.collectAsState() val locks = pluginLocksState[pluginId] ?: pluginLocksState.values.fold(emptyMap()) { acc, map -> acc + map } 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 897b845d..62632836 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 @@ -40,13 +40,15 @@ 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.plugin.viewmodel.PluginViewModel import org.wip.plugintoolkit.shared.components.plugin.JobResultCard import plugintoolkit.composeapp.generated.resources.Res @@ -119,8 +121,12 @@ fun PluginContent( val providedSettings = remember(pluginId, pluginSettingsState) { val store = if (pluginId != null) pluginSettingsState[pluginId] ?: pluginManager.loadPluginSettings(pluginId) else null val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() - (store ?: org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore()) - .resolveProvidedValues(manifest) + 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()) } if (selectedCapability == null) { 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 7c7304db..a05b5883 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,8 +21,6 @@ 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 @@ -53,7 +51,6 @@ 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 @@ -75,10 +72,8 @@ 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 @@ -100,8 +95,6 @@ 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 @@ -109,11 +102,6 @@ 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, @@ -137,8 +125,6 @@ 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) @@ -187,10 +173,6 @@ 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>() @@ -264,8 +246,7 @@ fun PluginSettingsContent( // Auto-scroll to requested setting or section LaunchedEffect(scrollToSetting, sectionIndices, customSettings) { if (scrollToSetting != null) { - val isCustomSetting = customSettings.containsKey(scrollToSetting) - val targetKey = if (isCustomSetting) { + val targetKey = if (customSettings.containsKey(scrollToSetting)) { "section_custom" } else if (capabilities.any { it.parameters?.containsKey(scrollToSetting) == true }) { val cap = capabilities.first { it.parameters?.containsKey(scrollToSetting) == true } @@ -279,10 +260,6 @@ fun PluginSettingsContent( val targetIndex = targetKey?.let { sectionIndices[it] } if (targetIndex != null) { lazyListState.animateScrollToItem(targetIndex) - if (isCustomSetting) { - withFrameNanos { } - customSettingRequesters[scrollToSetting]?.bringIntoView() - } } } } @@ -436,8 +413,15 @@ fun PluginSettingsContent( ) } } else { - val providedSettings = remember(manifest, store.settings) { - store.resolveCustomSettings(manifest) + 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 } LazyColumn( @@ -490,91 +474,75 @@ fun PluginSettingsContent( modifier = Modifier.fillMaxWidth().padding(top = ToolkitTheme.spacing.small), verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.mediumSmall) ) { - 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) + 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 ) - }, - 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 - ) - } } } } @@ -706,21 +674,6 @@ 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 3b614b0f..6094902a 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,28 +10,21 @@ 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() { - val manifest = pluginManager.getManifest(pkg) - private val initialStore = pluginManager.loadPluginSettings(pkg) - private val _store = MutableStateFlow(initialStore.withAutogeneratedSettings()) + private val _store = MutableStateFlow(pluginManager.loadPluginSettings(pkg)) val store = _store.asStateFlow() private val _isBusy = MutableStateFlow(false) val isBusy = _isBusy.asStateFlow() - val locks = MutableStateFlow>(emptyMap()) + val manifest = pluginManager.getManifest(pkg) - private fun PluginSettingsStore.withAutogeneratedSettings(): PluginSettingsStore { - val settingMetadata = manifest?.settings ?: return this - return withResolvedAutogeneratedSettings(settingMetadata) - } + val locks = MutableStateFlow>(emptyMap()) init { viewModelScope.launch { @@ -52,7 +45,7 @@ class PluginSettingsViewModel( fun updateSetting(key: String, value: JsonElement) { _store.update { current -> - val updated = current.copy(settings = current.settings + (key to value)).withAutogeneratedSettings() + val updated = current.copy(settings = current.settings + (key to value)) viewModelScope.launch { val newLocks = pluginManager.refreshLocks(pkg, updated) locks.value = newLocks @@ -63,7 +56,7 @@ class PluginSettingsViewModel( fun updateGlobalParam(key: String, value: JsonElement) { _store.update { current -> - val updated = current.copy(globalParams = current.globalParams + (key to value)).withAutogeneratedSettings() + val updated = current.copy(globalParams = current.globalParams + (key to value)) 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 eced3f26..acf03ddb 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,16 +7,9 @@ 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 @@ -55,58 +48,6 @@ 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 deleted file mode 100644 index 7b1765df..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.wip.plugintoolkit.features.plugin.model - -import kotlinx.serialization.json.JsonPrimitive -import org.wip.plugintoolkit.api.Capability -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 org.wip.plugintoolkit.features.plugin.utils.CapabilityLockStatus -import org.wip.plugintoolkit.features.plugin.utils.CapabilityLockUtils -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -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"]) - } - - @Test - fun `manifest defaults unlock capability gates before settings are persisted`() { - val capability = Capability( - name = "call", - description = "Call the configured endpoint", - returnType = DataType.Primitive(PrimitiveType.STRING), - requiresSettings = listOf("endpoint") - ) - val provided = PluginSettingsStore().resolveProvidedValues(manifest) - - assertTrue(capability.isReady(provided, manifest.settings)) - assertTrue( - CapabilityLockUtils.checkCapabilityLockStatus(capability, emptyMap(), provided) is - CapabilityLockStatus.Unlocked - ) - } -} 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 deleted file mode 100644 index 93d238d9..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 2bdeab26..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 d08fb3ed..d69b5025 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -37,8 +37,7 @@ The `@PluginSetting` annotation supports identical validation constraints to tho data class MyAdvancedSettings( @PluginSetting( description = "Service Endpoint", - regex = "^https?://.*", - semanticTypes = ["text/uri"] + regex = "^https?://.*" ) val endpoint: String, @PluginSetting( @@ -49,8 +48,6 @@ 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 52509212..d82c0c3f 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,39 +251,8 @@ 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(), - // 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 - ) -} + val requiredByCapabilities: List = emptyList() +) /** * The complete manifest of a plugin, describing its capabilities and requirements. @@ -663,3 +632,4 @@ 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 77edcbdf..b82f97a5 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 @@ -169,8 +169,6 @@ 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) @@ -186,9 +184,7 @@ annotation class PluginSetting( val regex: String = "", val multiSelect: Boolean = false, val minChoices: Int = -1, - val maxChoices: Int = -1, - val semanticTypes: Array = [], - val pathTemplate: String = "" + val maxChoices: Int = -1 ) /** @@ -275,4 +271,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 f3930871..a7e8ba13 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,30 +1,10 @@ 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/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt index 7334907d..06505316 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,12 +279,6 @@ 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 @@ -309,8 +303,6 @@ object ManifestJsonGenerator { required = required, secret = secret, constraints = constraints, - semanticTypes = semanticTypes, - autogeneratedPattern = pathTemplate.ifBlank { null }, requiredByCapabilities = requiredBy ) } 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 2c0f770c..ff0682ea 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 @@ -355,30 +355,6 @@ 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) @@ -390,52 +366,17 @@ 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>(), constraints = %L, required = %L, secret = %L, semanticTypes = %L, autogeneratedPattern = %L, requiredByCapabilities = %L)", + "%S to %T(defaultValue = %L, description = %S, type = %M<%T>(), required = %L, secret = %L)", propName, CN_SETTING_METADATA, defaultValueCode, desc, MN_GET_DATA_TYPE, propType, - constraintsCode, required, - secret, - semanticTypesCode, - autogeneratedPatternCode, - requiredByCapabilitiesCode + secret ) if (index < settingsProperties.size - 1) settingsCode.add(",\n") else settingsCode.add("\n") } 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 deleted file mode 100644 index e8ba24ab..00000000 --- a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.wip.plugintoolkit.api - -import kotlinx.serialization.json.JsonElement -import kotlin.test.Test -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" - ) - } -}