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/52] 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/52] 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/52] 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/52] 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/52] 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 feed807547f74d26e0a987e1180d96b29fcd0313 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:58:46 +1000 Subject: [PATCH 06/52] Add declarative plugin-defined pages --- README.md | 23 +++++++- .../features/plugin/ui/PluginContent.kt | 54 ++++++++++++++++++- .../wip/plugintoolkit/api/ManifestModels.kt | 17 +++++- .../wip/plugintoolkit/api/PluginUiPageTest.kt | 25 +++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt diff --git a/README.md b/README.md index c22e02b0..5c3fff2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,26 @@ This is a Kotlin Multiplatform project targeting Desktop (JVM). +## Plugin-defined pages + +Plugins can organize capabilities into pages rendered by the host, without bundling Compose UI binaries: + +```kotlin +PluginManifest( + // ... + uiPages = listOf( + PluginUiPage( + id = "convert", + title = "Convert media", + description = "Choose an operation to begin.", + capabilityNames = listOf("Convert image", "Convert video") + ) + ) +) +``` + +Unknown capability names are ignored. The declarative contract stays usable across host UI upgrades and +can also be interpreted by future web or command-line front ends. + * [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders: - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. @@ -33,4 +54,4 @@ The internal job execution engine (`FlowEngine` and `JobWorker`) enforces strict - **Recursion Depth Limits**: Deep subflow execution limits the stack frame depth to 50 iterations. Attempting to create an infinitely recursive subflow safely fails before hitting a JVM StackOverflow. - **Configurable Capabilities Policies**: Transient network execution failures in plugins automatically back off and retry up to `maxRetries` (configurable in app settings). Executions are also bound by a strict `pluginTimeoutMs` to prevent hung plugins. -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… diff --git a/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..34c942e6 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -130,7 +131,15 @@ fun PluginContent( } if (selectedCapability == null) { - EmptyState(stringResource(Res.string.plugin_select_capability_hint)) + val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() + if (manifest != null && manifest.uiPages.isNotEmpty()) { + PluginDefinedPages( + manifest = manifest, + onCapabilitySelected = viewModel::selectCapability + ) + } else { + EmptyState(stringResource(Res.string.plugin_select_capability_hint)) + } } else { Column( modifier = Modifier @@ -202,6 +211,49 @@ fun PluginContent( } } +@Composable +private fun PluginDefinedPages( + manifest: PluginManifest, + onCapabilitySelected: (Capability) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(ToolkitTheme.spacing.extraLarge), + verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.extraLarge) + ) { + manifest.uiPages.forEach { page -> + val capabilities = page.capabilityNames.mapNotNull { name -> + manifest.capabilities.firstOrNull { it.name == name } + } + Column(verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small)) { + Text(page.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + if (page.description.isNotBlank()) { + Text( + page.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + capabilities.forEach { capability -> + OutlinedButton( + onClick = { onCapabilitySelected(capability) }, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(capability.name, style = MaterialTheme.typography.titleMedium) + capability.description?.takeIf { it.isNotBlank() }?.let { description -> + Text(description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } +} + @Composable fun PluginHeader(manifest: PluginManifest) { Column { diff --git a/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..1cbba141 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 @@ -271,7 +271,21 @@ data class PluginManifest( val changelog: Changelog? = null, val hasUpdateHandler: Boolean = false, val hasSetupHandler: Boolean = false, - val hasMigrations: Boolean = false + val hasMigrations: Boolean = false, + /** Optional declarative pages rendered by the host. Unknown capability names are ignored. */ + val uiPages: List = emptyList() +) + +/** + * A host-rendered plugin page. Keeping this declarative avoids coupling plugin JARs to a + * particular Compose version while still allowing plugins to shape their user experience. + */ +@Serializable +data class PluginUiPage( + val id: String, + val title: String, + val description: String = "", + val capabilityNames: List = emptyList() ) @Serializable @@ -632,4 +646,3 @@ data class PluginAction( val functionName: String, val parameters: Map? = null ) - diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt new file mode 100644 index 00000000..c29cebca --- /dev/null +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt @@ -0,0 +1,25 @@ +package org.wip.plugintoolkit.api + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class PluginUiPageTest { + @Test + fun `plugin pages round trip through the manifest`() { + val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1", "Example"), + requirements = Requirements(64, 10), + uiPages = listOf( + PluginUiPage("home", "Home", "Common actions", listOf("convert")) + ) + ) + + val json = Json.encodeToString(manifest) + val restored = Json.decodeFromString(json) + + assertEquals(manifest.uiPages, restored.uiPages) + } +} From 6879e2b3e15872609d6e9c44d84ad47ad9b68d11 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:01:29 +1000 Subject: [PATCH 07/52] Build plugins as standalone executable jars --- README.md | 18 ++++++- completeExample/build.gradle.kts | 2 + minimalExample/build.gradle.kts | 2 + .../api/standalone/StandalonePluginMain.kt | 50 +++++++++++++++++++ scripts/standalone-plugin.gradle.kts | 21 ++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt create mode 100644 scripts/standalone-plugin.gradle.kts diff --git a/README.md b/README.md index c22e02b0..e1007c8e 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,20 @@ The internal job execution engine (`FlowEngine` and `JobWorker`) enforces strict - **Recursion Depth Limits**: Deep subflow execution limits the stack frame depth to 50 iterations. Attempting to create an infinitely recursive subflow safely fails before hitting a JVM StackOverflow. - **Configurable Capabilities Policies**: Transient network execution failures in plugins automatically back off and retry up to `maxRetries` (configurable in app settings). Executions are also bound by a strict `pluginTimeoutMs` to prevent hung plugins. -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… + +## Standalone plugin JARs + +JVM plugin modules can build a self-contained executable artifact by applying the bundled script: + +```kotlin +apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts")) +``` + +Run `./gradlew :yourPlugin:standaloneJar`, then inspect the plugin without the desktop host: + +```shell +java -jar yourPlugin/build/libs/yourPlugin-version-standalone.jar --info +``` + +The generated JAR contains runtime dependencies and preserves `ServiceLoader` plugin discovery. diff --git a/completeExample/build.gradle.kts b/completeExample/build.gradle.kts index f8a5e7c5..bcc541cd 100644 --- a/completeExample/build.gradle.kts +++ b/completeExample/build.gradle.kts @@ -34,3 +34,5 @@ dependencies { tasks.withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + +apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts")) diff --git a/minimalExample/build.gradle.kts b/minimalExample/build.gradle.kts index f8a5e7c5..bcc541cd 100644 --- a/minimalExample/build.gradle.kts +++ b/minimalExample/build.gradle.kts @@ -34,3 +34,5 @@ dependencies { tasks.withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + +apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts")) diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt new file mode 100644 index 00000000..ebb86442 --- /dev/null +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt @@ -0,0 +1,50 @@ +package org.wip.plugintoolkit.api.standalone + +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.wip.plugintoolkit.api.PluginEntry +import org.wip.plugintoolkit.api.PluginModuleProvider +import java.util.ServiceLoader + +/** Entry point embedded in standalone plugin JARs. */ +fun main(args: Array) { + val plugins = loadStandalonePlugins() + if (plugins.isEmpty()) { + System.err.println("No PluginEntry service was found in this JAR.") + return + } + + when (args.firstOrNull()) { + null, "--info" -> println(describeStandalonePlugins(plugins)) + "--help", "-h" -> println("Usage: java -jar -standalone.jar [--info|--help]") + else -> System.err.println("Unknown option '${args.first()}'. Use --help.") + } +} + +private fun loadStandalonePlugins(): List { + val directEntries = ServiceLoader.load(PluginEntry::class.java).toList() + if (directEntries.isNotEmpty()) return directEntries + + val providers = ServiceLoader.load(PluginModuleProvider::class.java).toList() + if (providers.isEmpty()) return emptyList() + + stopKoin() + val application = startKoin { + modules(providers.map { it.getKoinModule(emptyMap()) }) + } + return application.koin.getAll() +} + +internal fun describeStandalonePlugins(plugins: List): String = plugins.joinToString("\n\n") { entry -> + entry.getManifest().fold( + onSuccess = { manifest -> + buildString { + appendLine("${manifest.plugin.name} ${manifest.plugin.version}") + appendLine(manifest.plugin.description) + append("Capabilities: ") + append(manifest.capabilities.joinToString { it.name }.ifBlank { "none" }) + } + }, + onFailure = { error -> "Invalid plugin manifest: ${error.message ?: error::class.simpleName}" } + ) +} diff --git a/scripts/standalone-plugin.gradle.kts b/scripts/standalone-plugin.gradle.kts new file mode 100644 index 00000000..24add046 --- /dev/null +++ b/scripts/standalone-plugin.gradle.kts @@ -0,0 +1,21 @@ +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.jvm.tasks.Jar + +// Apply from a JVM plugin module after its dependencies have been declared. +tasks.register("standaloneJar") { + group = "distribution" + description = "Builds an executable plugin JAR with its runtime dependencies." + archiveClassifier.set("standalone") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + dependsOn("jar") + from({ zipTree(tasks.named("jar").get().archiveFile.get().asFile) }) + from({ + configurations.getByName("runtimeClasspath").map { dependency -> + if (dependency.isDirectory) dependency else zipTree(dependency) + } + }) + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") + manifest.attributes["Main-Class"] = + "org.wip.plugintoolkit.api.standalone.StandalonePluginMainKt" +} From 168f648aaab79f3c7971d794dcbf21bc2b77e394 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:03:30 +1000 Subject: [PATCH 08/52] Add a headless toolkit CLI --- README.md | 15 ++- .../org/wip/plugintoolkit/cli/ToolkitCli.kt | 110 ++++++++++++++++++ .../kotlin/org/wip/plugintoolkit/main.kt | 7 ++ .../wip/plugintoolkit/cli/ToolkitCliTest.kt | 20 ++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt diff --git a/README.md b/README.md index c22e02b0..bbdcf736 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,19 @@ in your IDE’s toolbar or run it directly from the terminal: .\gradlew.bat :composeApp:run ``` +### Command-line interface + +The desktop distribution can also run without opening a window: + +```shell +plugintoolkit status +plugintoolkit plugins list +plugintoolkit flows list +plugintoolkit --version +``` + +Use `plugintoolkit --help` for the complete command summary. + --- ## Execution Engine & Concurrency (PluginToolkit) @@ -33,4 +46,4 @@ The internal job execution engine (`FlowEngine` and `JobWorker`) enforces strict - **Recursion Depth Limits**: Deep subflow execution limits the stack frame depth to 50 iterations. Attempting to create an infinitely recursive subflow safely fails before hitting a JVM StackOverflow. - **Configurable Capabilities Policies**: Transient network execution failures in plugins automatically back off and retry up to `maxRetries` (configurable in app settings). Executions are also bound by a strict `pluginTimeoutMs` to prevent hung plugins. -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt new file mode 100644 index 00000000..b5814d68 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt @@ -0,0 +1,110 @@ +package org.wip.plugintoolkit.cli + +import kotlinx.coroutines.flow.first +import org.koin.core.context.stopKoin +import org.koin.mp.KoinPlatform.getKoin +import org.wip.plugintoolkit.AppConfig +import org.wip.plugintoolkit.features.plugin.logic.PluginRegistry +import org.wip.plugintoolkit.features.settings.logic.SettingsPersistence +import org.wip.plugintoolkit.performStartup +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.isRegularFile +import kotlin.io.path.nameWithoutExtension + +sealed interface ToolkitCliCommand { + data object Help : ToolkitCliCommand + data object Version : ToolkitCliCommand + data object Status : ToolkitCliCommand + data object Plugins : ToolkitCliCommand + data object Flows : ToolkitCliCommand +} + +fun parseToolkitCliCommand(args: Array): ToolkitCliCommand? = when (args.toList()) { + listOf("--help"), listOf("-h"), listOf("help") -> ToolkitCliCommand.Help + listOf("--version"), listOf("version") -> ToolkitCliCommand.Version + listOf("status") -> ToolkitCliCommand.Status + listOf("plugins"), listOf("plugins", "list") -> ToolkitCliCommand.Plugins + listOf("flows"), listOf("flows", "list") -> ToolkitCliCommand.Flows + else -> null +} + +suspend fun runToolkitCli( + command: ToolkitCliCommand, + output: (String) -> Unit = ::println, + error: (String) -> Unit = System.err::println +): Int { + when (command) { + ToolkitCliCommand.Help -> { + output(CLI_HELP) + return 0 + } + ToolkitCliCommand.Version -> { + output(AppConfig.VERSION) + return 0 + } + else -> Unit + } + + return try { + performStartup(emptyArray()) + val koin = getKoin() + val registry = koin.get() + registry.isReady.first { it } + + when (command) { + ToolkitCliCommand.Status -> { + val plugins = registry.installedPlugins.value + output("PluginToolkit ${AppConfig.VERSION}") + output("Plugins: ${plugins.size} installed, ${plugins.count { it.isEnabled }} enabled") + } + ToolkitCliCommand.Plugins -> { + val plugins = registry.installedPlugins.value + if (plugins.isEmpty()) output("No plugins installed.") + plugins.forEach { plugin -> + val state = when { + !plugin.isCompatible -> "incompatible" + !plugin.isEnabled -> "disabled" + plugin.isValidated -> "ready" + else -> "setup required" + } + output("${plugin.pkg}\t${plugin.version}\t$state") + } + } + ToolkitCliCommand.Flows -> { + val settingsDir = koin.get().getSettingsDir() + val flowsDir = Path.of(settingsDir, "flows") + val flows = if (Files.isDirectory(flowsDir)) { + Files.list(flowsDir).use { paths -> + paths.filter { it.isRegularFile() && it.extension == "json" } + .map { it.nameWithoutExtension } + .sorted() + .toList() + } + } else emptyList() + if (flows.isEmpty()) output("No flows saved.") else flows.forEach(output) + } + else -> Unit + } + stopKoin() + 0 + } catch (exception: Throwable) { + error("CLI error: ${exception.message ?: exception::class.simpleName}") + stopKoin() + 1 + } +} + +private val CLI_HELP = """ + PluginToolkit ${AppConfig.VERSION} + + Usage: plugintoolkit + + Commands: + status Show application and plugin status + plugins list List installed plugins and readiness + flows list List saved flows + version Print the application version + help Show this help +""".trimIndent() diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index 5addfd0c..b35b0143 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -133,9 +133,16 @@ import javax.swing.JOptionPane.showMessageDialog import javax.swing.JWindow import kotlin.system.exitProcess import kotlin.time.Duration.Companion.seconds +import org.wip.plugintoolkit.cli.parseToolkitCliCommand +import org.wip.plugintoolkit.cli.runToolkitCli @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) fun main(args: Array) { + parseToolkitCliCommand(args)?.let { command -> + val exitCode = kotlinx.coroutines.runBlocking { runToolkitCli(command) } + exitProcess(exitCode) + } + ComposeFoundationFlags.isNewContextMenuEnabled = true val splashWindow = try { showSplashWindow() diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt new file mode 100644 index 00000000..1f372cc9 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt @@ -0,0 +1,20 @@ +package org.wip.plugintoolkit.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ToolkitCliTest { + @Test + fun `known commands are parsed without starting the desktop UI`() { + assertEquals(ToolkitCliCommand.Help, parseToolkitCliCommand(arrayOf("--help"))) + assertEquals(ToolkitCliCommand.Plugins, parseToolkitCliCommand(arrayOf("plugins", "list"))) + assertEquals(ToolkitCliCommand.Flows, parseToolkitCliCommand(arrayOf("flows"))) + } + + @Test + fun `desktop flags and unknown commands remain desktop arguments`() { + assertNull(parseToolkitCliCommand(arrayOf("--background"))) + assertNull(parseToolkitCliCommand(arrayOf("unknown"))) + } +} From 08dd88ddb8c33e682491948d30168b3db91601c3 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:06:30 +1000 Subject: [PATCH 09/52] Add persistent recurring job scheduler --- .../features/job/logic/JobManager.kt | 70 ++++++++++++++++++- .../features/job/logic/ScheduleRepository.kt | 41 +++++++++++ .../plugintoolkit/features/job/model/Job.kt | 19 +++++ .../features/job/ui/JobDashboard.kt | 68 ++++++++++++------ .../features/job/viewmodel/JobViewModel.kt | 13 ++++ .../shared/components/plugin/JobResultCard.kt | 8 +++ .../features/job/model/ScheduledJobTest.kt | 33 +++++++++ 7 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt create mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/job/model/ScheduledJobTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index a1386f09..6be19046 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.TimeZone @@ -25,10 +27,12 @@ import org.wip.plugintoolkit.core.loomDispatcher import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobHistoryEntry import org.wip.plugintoolkit.features.job.model.JobStatus +import org.wip.plugintoolkit.features.job.model.ScheduledJob import org.wip.plugintoolkit.features.plugin.logic.DefaultPluginFileSystem import org.wip.plugintoolkit.features.plugin.logic.PluginLoader import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes class JobManager( /** Injected [AppScope] for managing job lifecycles and worker coordination. */ @@ -55,6 +59,9 @@ class JobManager( private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _history.asStateFlow() + private val _schedules = MutableStateFlow>(emptyList()) + val schedules: StateFlow> = _schedules.asStateFlow() + private val _jobLogs = MutableStateFlow>>(emptyMap()) val jobLogs: StateFlow>> = _jobLogs.asStateFlow() @@ -71,6 +78,8 @@ class JobManager( private val settingsPersistence: org.wip.plugintoolkit.features.settings.logic.SettingsPersistence = settingsRepository.persistence private val jobRepository = JobRepository(settingsPersistence) + private val scheduleRepository = ScheduleRepository(settingsPersistence) + private val scheduleMutex = Mutex() init { scope.launch { @@ -100,8 +109,68 @@ class JobManager( } } } + scope.launch { + val savedSchedules = scheduleRepository.load() + _schedules.update { current -> + savedSchedules.filterNot { saved -> current.any { it.id == saved.id } } + current + } + launch { _schedules.collect(scheduleRepository::save) } + while (isActive) { + runDueSchedules(Clock.System.now()) + delay(1_000) + } + } } + fun scheduleJob(job: BackgroundJob, intervalMinutes: Long = 24 * 60L): ScheduledJob { + val now = Clock.System.now() + val schedule = ScheduledJob( + id = "schedule-${now.toEpochMilliseconds()}-${job.id}", + jobTemplate = job.asFreshRun(now), + intervalMinutes = intervalMinutes.coerceAtLeast(1), + nextRunAt = now + intervalMinutes.coerceAtLeast(1).minutes + ) + _schedules.update { it + schedule } + return schedule + } + + fun removeSchedule(id: String) { + _schedules.update { schedules -> schedules.filterNot { it.id == id } } + } + + fun setScheduleEnabled(id: String, enabled: Boolean) { + _schedules.update { schedules -> schedules.map { if (it.id == id) it.copy(enabled = enabled) else it } } + } + + suspend fun runScheduleNow(id: String) = scheduleMutex.withLock { + val now = Clock.System.now() + val schedule = _schedules.value.firstOrNull { it.id == id } ?: return@withLock + enqueueJob(schedule.jobTemplate.asFreshRun(now)) + _schedules.update { schedules -> schedules.map { if (it.id == id) schedule.afterRun(now) else it } } + } + + internal suspend fun runDueSchedules(now: kotlin.time.Instant) = scheduleMutex.withLock { + val due = _schedules.value.filter { it.isDue(now) } + due.forEach { enqueueJob(it.jobTemplate.asFreshRun(now)) } + if (due.isNotEmpty()) { + val dueIds = due.mapTo(mutableSetOf()) { it.id } + _schedules.update { schedules -> + schedules.map { if (it.id in dueIds) it.afterRun(now) else it } + } + } + } + + private fun BackgroundJob.asFreshRun(now: kotlin.time.Instant): BackgroundJob = copy( + id = "$id-${now.toEpochMilliseconds()}", + status = JobStatus.Queued, + enqueuedAt = now, + startedAt = null, + completedAt = null, + errorMessage = null, + result = null, + resumeState = null + ) + private fun startWorkers() { repeat(maxConcurrentJobs) { val worker = JobWorker(it, this, scope) @@ -563,4 +632,3 @@ class JobManager( } } } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt new file mode 100644 index 00000000..6d52cc1c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt @@ -0,0 +1,41 @@ +package org.wip.plugintoolkit.features.job.logic + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString +import kotlinx.io.writeString +import kotlinx.serialization.json.Json +import org.wip.plugintoolkit.features.job.model.ScheduledJob +import org.wip.plugintoolkit.features.settings.logic.SettingsPersistence + +class ScheduleRepository(private val settingsPersistence: SettingsPersistence) { + private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true } + + private fun file(): Path { + val jobsDir = Path(settingsPersistence.getJobsDir()) + if (!SystemFileSystem.exists(jobsDir)) SystemFileSystem.createDirectories(jobsDir) + return Path("$jobsDir/schedules.json") + } + + suspend fun load(): List = withContext(Dispatchers.IO) { + val file = file() + if (!SystemFileSystem.exists(file)) return@withContext emptyList() + runCatching> { + SystemFileSystem.source(file).buffered().use { source -> + source.readString().takeIf { it.isNotBlank() } + ?.let { json.decodeFromString>(it) } + ?: emptyList() + } + }.onFailure { Logger.e(it) { "Failed to load schedules" } }.getOrDefault(emptyList()) + } + + suspend fun save(schedules: List) = withContext(Dispatchers.IO) { + runCatching { + SystemFileSystem.sink(file()).buffered().use { it.writeString(json.encodeToString(schedules)) } + }.onFailure { Logger.e(it) { "Failed to save schedules" } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt index ca37beb0..c5d31f46 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlin.time.Clock import kotlin.time.Instant +import kotlin.time.Duration.Companion.minutes @Serializable enum class JobStatus { @@ -56,3 +57,21 @@ data class JobHistoryEntry( val event: String, // "Started", "Stopped", "Failed", etc. val details: String? = null ) + +@Serializable +data class ScheduledJob( + val id: String, + val jobTemplate: BackgroundJob, + val intervalMinutes: Long, + val nextRunAt: Instant, + val enabled: Boolean = true, + val lastRunAt: Instant? = null +) { + fun isDue(now: Instant): Boolean = enabled && nextRunAt <= now + + /** Reschedule from the actual run time so missed intervals never create a catch-up burst. */ + fun afterRun(now: Instant): ScheduledJob = copy( + lastRunAt = now, + nextRunAt = now + intervalMinutes.coerceAtLeast(1).minutes + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt index 0a71279d..64528462 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -45,6 +46,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -103,7 +105,6 @@ import plugintoolkit.composeapp.generated.resources.job_no_ended import plugintoolkit.composeapp.generated.resources.job_paused_jobs import plugintoolkit.composeapp.generated.resources.job_queue import plugintoolkit.composeapp.generated.resources.job_running_jobs -import plugintoolkit.composeapp.generated.resources.job_scheduler_soon import plugintoolkit.composeapp.generated.resources.nav_job_archive import plugintoolkit.composeapp.generated.resources.nav_job_ended import plugintoolkit.composeapp.generated.resources.nav_job_general @@ -213,7 +214,7 @@ fun JobDashboard( is JobNavKey.General -> NavEntry(key) { GeneralTab(viewModel) } is JobNavKey.Archive -> NavEntry(key) { ArchiveTab(viewModel) } is JobNavKey.Ended -> NavEntry(key) { EndedTab(viewModel) } - is JobNavKey.Scheduler -> NavEntry(key) { SchedulerTab() } + is JobNavKey.Scheduler -> NavEntry(key) { SchedulerTab(viewModel) } is JobNavKey.History -> NavEntry(key) { HistoryTab(viewModel) } else -> NavEntry(key) { } } @@ -338,7 +339,8 @@ fun EndedTab(viewModel: JobViewModel) { job = job, progress = progressMap[job.id] ?: org.wip.plugintoolkit.features.job.model.JobProgress(), logs = logsMap[job.id] ?: emptyList(), - onClear = { viewModel.clearEndedJob(job.id) } + onClear = { viewModel.clearEndedJob(job.id) }, + onSchedule = { viewModel.scheduleDaily(job) } ) } } else { @@ -351,24 +353,51 @@ fun EndedTab(viewModel: JobViewModel) { } @Composable -fun SchedulerTab() { - Column( +fun SchedulerTab(viewModel: JobViewModel) { + val schedules by viewModel.schedules.collectAsState() + + if (schedules.isEmpty()) { + EmptyState("Schedule a completed job to run it every day.", Icons.Default.Schedule) + return + } + + LazyColumn( modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) ) { - Icon( - imageVector = Icons.Default.Schedule, - contentDescription = null, - modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraLarge), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = ToolkitTheme.opacity.glassBackground) - ) - Spacer(modifier = Modifier.height(ToolkitTheme.spacing.medium)) - Text( - text = stringResource(Res.string.job_scheduler_soon), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + items(schedules, key = { it.id }) { schedule -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = ToolkitTheme.opacity.glassBackground) + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(ToolkitTheme.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(schedule.jobTemplate.name, style = MaterialTheme.typography.titleMedium) + Text( + "Every ${schedule.intervalMinutes} minutes · next ${formatTime(schedule.nextRunAt)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = schedule.enabled, + onCheckedChange = { viewModel.setScheduleEnabled(schedule.id, it) } + ) + IconButton(onClick = { viewModel.runScheduleNow(schedule.id) }) { + Icon(Icons.Default.PlayArrow, contentDescription = "Run now") + } + IconButton(onClick = { viewModel.removeSchedule(schedule.id) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete schedule") + } + } + } + } } } @@ -482,4 +511,3 @@ private fun formatTime(instant: Instant): String { localDateTime.minute.toString().padStart(2, '0') }:${localDateTime.second.toString().padStart(2, '0')}" } - diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt index 2d5a2676..9280b6bd 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt @@ -18,6 +18,7 @@ class JobViewModel( val jobLogs = jobManager.jobLogs val history = jobManager.history val endedJobs = jobManager.endedJobs + val schedules = jobManager.schedules val runningJobs = jobs.map { list -> list.filter { it.status == JobStatus.Running } @@ -72,4 +73,16 @@ class JobViewModel( jobManager.clearAllEndedJobs() } } + + fun scheduleDaily(job: BackgroundJob) { + jobManager.scheduleJob(job) + } + + fun removeSchedule(id: String) = jobManager.removeSchedule(id) + + fun setScheduleEnabled(id: String, enabled: Boolean) = jobManager.setScheduleEnabled(id, enabled) + + fun runScheduleNow(id: String) { + viewModelScope.launch { jobManager.runScheduleNow(id) } + } } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt index 28cefbfc..63ddfd82 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DividerDefaults import androidx.compose.material3.HorizontalDivider @@ -95,6 +96,7 @@ fun JobResultCard( onPause: (() -> Unit)? = null, onResume: (() -> Unit)? = null, onClear: (() -> Unit)? = null, + onSchedule: (() -> Unit)? = null, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } @@ -156,6 +158,12 @@ fun JobResultCard( StatusBadge(job.status) Spacer(modifier = Modifier.width(ToolkitTheme.spacing.small)) + if (onSchedule != null) { + IconButton(onClick = onSchedule) { + Icon(Icons.Default.Schedule, contentDescription = "Schedule daily") + } + } + if (onDelete != null) { IconButton( onClick = onDelete, diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/job/model/ScheduledJobTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/job/model/ScheduledJobTest.kt new file mode 100644 index 00000000..420e40b0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/job/model/ScheduledJobTest.kt @@ -0,0 +1,33 @@ +package org.wip.plugintoolkit.features.job.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class ScheduledJobTest { + private val template = BackgroundJob( + id = "job", name = "Example", type = JobType.Capability, + pluginId = "plugin", capabilityName = "run" + ) + + @Test + fun `due schedules advance from actual execution time`() { + val dueAt = Instant.fromEpochMilliseconds(1_000) + val now = Instant.fromEpochMilliseconds(5_000) + val schedule = ScheduledJob("schedule", template, 10, dueAt) + + assertTrue(schedule.isDue(now)) + val advanced = schedule.afterRun(now) + assertEquals(now, advanced.lastRunAt) + assertEquals(Instant.fromEpochMilliseconds(605_000), advanced.nextRunAt) + assertFalse(advanced.isDue(now)) + } + + @Test + fun `disabled schedules never become due`() { + val schedule = ScheduledJob("schedule", template, 10, Instant.fromEpochMilliseconds(0), enabled = false) + assertFalse(schedule.isDue(Instant.fromEpochMilliseconds(5_000))) + } +} 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 10/52] 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 11/52] 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 12/52] 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 13/52] 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 14/52] 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 a2cb1b308f625e200ef32068361c1e6cbf78cc5a Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:00:13 +1000 Subject: [PATCH 15/52] fix: generate plugin UI pages without breaking ABI --- README.md | 17 +++++------ .../org/wip/complete/CompleteExamplePlugin.kt | 12 ++++++++ .../features/plugin/ui/PluginContent.kt | 16 ++++++---- .../wip/plugintoolkit/api/ManifestModels.kt | 30 ++++++++++++++++++- .../api/annotations/Annotations.kt | 13 +++++++- .../api/processor/GeneratorUtils.kt | 16 ++++++++++ .../api/processor/KotlinGenerator.kt | 2 +- .../api/processor/ManifestJsonGenerator.kt | 3 +- .../api/processor/ManifestProcessor.kt | 17 +++++++++++ .../api/processor/ProcessorConstants.kt | 3 ++ .../processor/generators/ManifestGenerator.kt | 28 +++++++++++++++-- .../PluginManifestBinaryCompatibilityTest.kt | 20 +++++++++++++ 12 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt diff --git a/README.md b/README.md index 5c3fff2d..a2c7aad3 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,14 @@ This is a Kotlin Multiplatform project targeting Desktop (JVM). Plugins can organize capabilities into pages rendered by the host, without bundling Compose UI binaries: ```kotlin -PluginManifest( - // ... - uiPages = listOf( - PluginUiPage( - id = "convert", - title = "Convert media", - description = "Choose an operation to begin.", - capabilityNames = listOf("Convert image", "Convert video") - ) - ) +@PluginUiPage( + id = "convert", + title = "Convert media", + description = "Choose an operation to begin.", + capabilityNames = ["Convert image", "Convert video"] ) +@PluginInfo(/* ... */) +class MediaPlugin ``` Unknown capability names are ignored. The declarative contract stays usable across host UI upgrades and diff --git a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt index 3bb804ac..050eb9af 100644 --- a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt +++ b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt @@ -23,6 +23,7 @@ import org.wip.plugintoolkit.api.annotations.CapabilityParam import org.wip.plugintoolkit.api.annotations.CapabilityResult import org.wip.plugintoolkit.api.annotations.PluginAction import org.wip.plugintoolkit.api.annotations.PluginInfo +import org.wip.plugintoolkit.api.annotations.PluginUiPage import org.wip.plugintoolkit.api.annotations.PluginLoad import org.wip.plugintoolkit.api.annotations.PluginSetting import org.wip.plugintoolkit.api.annotations.PluginSetup @@ -117,6 +118,17 @@ enum class FeatureMode { description = "Complete showcase of plugin API features including settings, validation, signals, storage, file system, lifecycle hooks, and flow contexts.", supportedOs = [OS.WINDOWS, OS.LINUX, OS.MACOS] ) +@PluginUiPage( + id = "essentials", + title = "Essential capabilities", + description = "Common storage and file operations.", + capabilityNames = ["capabilityWithFileAccess", "capabilityWithDataStorage"] +) +@PluginUiPage( + id = "advanced", + title = "Advanced capabilities", + capabilityNames = ["capabilityWithPauseResume", "capabilityWithComplexObjectsAndSemanticTypes"] +) class CompleteExamplePlugin(val settings: CompleteExampleSettings) { @PluginLoad diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt index 34c942e6..ac335fec 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 @@ -34,6 +34,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -119,10 +120,12 @@ fun PluginContent( emptyMap() } } - val providedSettings = remember(pluginId, pluginSettingsState) { + val selectedManifest = remember(pluginId, viewModel.selectedPlugin) { + viewModel.selectedPlugin?.getManifest()?.getOrNull() + } + val providedSettings = remember(pluginId, pluginSettingsState, selectedManifest) { val store = if (pluginId != null) pluginSettingsState[pluginId] ?: pluginManager.loadPluginSettings(pluginId) else null - val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() - val manifestDefaults = (manifest?.settings?.mapValues { (_, meta) -> + val manifestDefaults = (selectedManifest?.settings?.mapValues { (_, meta) -> meta.defaultValue ?: if (meta.type is DataType.Primitive && (meta.type as DataType.Primitive).primitiveType == PrimitiveType.BOOLEAN) { JsonPrimitive(false) } else null @@ -131,10 +134,9 @@ fun PluginContent( } if (selectedCapability == null) { - val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() - if (manifest != null && manifest.uiPages.isNotEmpty()) { + if (selectedManifest != null && selectedManifest.uiPages.isNotEmpty()) { PluginDefinedPages( - manifest = manifest, + manifest = selectedManifest, onCapabilitySelected = viewModel::selectCapability ) } else { @@ -224,6 +226,7 @@ private fun PluginDefinedPages( verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.extraLarge) ) { manifest.uiPages.forEach { page -> + key(page.id) { val capabilities = page.capabilityNames.mapNotNull { name -> manifest.capabilities.firstOrNull { it.name == name } } @@ -250,6 +253,7 @@ private fun PluginDefinedPages( } } } + } } } } 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 1cbba141..f472b0dc 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 @@ -274,7 +274,35 @@ data class PluginManifest( val hasMigrations: Boolean = false, /** Optional declarative pages rendered by the host. Unknown capability names are ignored. */ val uiPages: List = emptyList() -) +) { + @Deprecated("Binary compatibility constructor", level = DeprecationLevel.HIDDEN) + constructor( + manifestVersion: String, + plugin: PluginInfo, + requirements: Requirements, + defaultParameters: Map? = null, + capabilities: List = emptyList(), + actions: List = emptyList(), + settings: Map? = null, + changelog: Changelog? = null, + hasUpdateHandler: Boolean = false, + hasSetupHandler: Boolean = false, + hasMigrations: Boolean = false + ) : this( + manifestVersion = manifestVersion, + plugin = plugin, + requirements = requirements, + defaultParameters = defaultParameters, + capabilities = capabilities, + actions = actions, + settings = settings, + changelog = changelog, + hasUpdateHandler = hasUpdateHandler, + hasSetupHandler = hasSetupHandler, + hasMigrations = hasMigrations, + uiPages = emptyList() + ) +} /** * A host-rendered plugin page. Keeping this declarative avoids coupling plugin JARs to a 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..8befc89e 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt @@ -14,6 +14,17 @@ annotation class PluginInfo( val supportedOs: Array = [] ) +/** Declares a host-rendered page grouping capabilities without bundling UI code. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +@Repeatable +annotation class PluginUiPage( + val id: String, + val title: String, + val description: String = "", + val capabilityNames: Array = [] +) + /** * Provides metadata for a capability result. * Can be applied to a single-return capability function or to properties of a custom data class return type. @@ -271,4 +282,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/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt index 3bf562ee..bd2771e9 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt @@ -10,6 +10,7 @@ import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.ksp.toTypeName import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.PluginUiPage import org.wip.plugintoolkit.api.SemanticType import org.wip.plugintoolkit.api.parseSemanticTypes @@ -109,6 +110,21 @@ object GeneratorUtils { return this.annotationType.resolve().declaration.qualifiedName?.asString() == name } + fun extractUiPages(classDeclaration: KSClassDeclaration): List = + classDeclaration.annotations + .filter { it.hasQualifiedName(ProcessorConstants.PLUGIN_UI_PAGE_ANNOTATION) } + .map { annotation -> + PluginUiPage( + id = annotation.arguments.first { it.name?.asString() == "id" }.value as String, + title = annotation.arguments.first { it.name?.asString() == "title" }.value as String, + description = annotation.arguments.find { it.name?.asString() == "description" }?.value as? String ?: "", + capabilityNames = (annotation.arguments.find { it.name?.asString() == "capabilityNames" }?.value as? List<*>) + ?.filterIsInstance() + ?: emptyList() + ) + } + .toList() + fun generateDataTypeCode(dataType: DataType): com.squareup.kotlinpoet.CodeBlock { val cnDataType = com.squareup.kotlinpoet.ClassName("org.wip.plugintoolkit.api", "DataType") val cnPrimitiveType = com.squareup.kotlinpoet.ClassName("org.wip.plugintoolkit.api", "PrimitiveType") diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt index 2b4e20b1..642dbfb2 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt @@ -50,7 +50,7 @@ object KotlinGenerator { it.getAllProperties() .filter { p -> p.annotations.any { a -> a.hasQualifiedName(org.wip.plugintoolkit.api.processor.ProcessorConstants.PLUGIN_SETTING_ANNOTATION) } } }.toList(), - actions, updateFunction != null, setupFunction != null + actions, updateFunction != null, setupFunction != null, classDeclaration ) fileSpec.addType(manifestType) diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt index 06505316..00924a25 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 @@ -372,7 +372,8 @@ object ManifestJsonGenerator { changelog = changelogObj, hasUpdateHandler = updateFunction != null, hasSetupHandler = setupFunction != null, - hasMigrations = hasMigrations + hasMigrations = hasMigrations, + uiPages = GeneratorUtils.extractUiPages(classDeclaration) ) val json = Json { diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt index 52c538ed..58703513 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt @@ -176,6 +176,23 @@ class ManifestProcessor( it.annotations.any { ann -> ann.hasQualifiedName(PLUGIN_ACTION_ANNOTATION) } }.toList() + val uiPages = org.wip.plugintoolkit.api.processor.GeneratorUtils.extractUiPages(classDeclaration) + uiPages.filter { it.id.isBlank() }.forEach { + logger.error("@PluginUiPage.id must not be blank", classDeclaration) + } + uiPages.groupBy { it.id }.filterValues { it.size > 1 }.keys.forEach { duplicateId -> + logger.error("Duplicate @PluginUiPage id '$duplicateId'", classDeclaration) + } + val capabilityNames = functions.map { function -> + val annotation = function.annotations.first { it.hasQualifiedName(CAPABILITY_ANNOTATION) } + annotation.arguments.first { it.name?.asString() == "name" }.value as String + }.toSet() + uiPages.flatMap { page -> page.capabilityNames.map { page.id to it } } + .filter { (_, capabilityName) -> capabilityName !in capabilityNames } + .forEach { (pageId, capabilityName) -> + logger.warn("@PluginUiPage '$pageId' references unknown capability '$capabilityName'", classDeclaration) + } + // 1. Parse Changelog var changelogObj: Changelog? = null val sourceFile = classDeclaration.containingFile diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt index b509c422..199780a3 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt @@ -20,6 +20,7 @@ import org.wip.plugintoolkit.api.PluginFileSystem import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.api.PluginLogger import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PluginUiPage import org.wip.plugintoolkit.api.PluginModuleProvider import org.wip.plugintoolkit.api.PluginRequest import org.wip.plugintoolkit.api.PluginResponse @@ -34,6 +35,7 @@ object ProcessorConstants { // Annotations const val PLUGIN_INFO_ANNOTATION = "$ANNOTATION_PACKAGE.PluginInfo" + const val PLUGIN_UI_PAGE_ANNOTATION = "$ANNOTATION_PACKAGE.PluginUiPage" const val CAPABILITY_ANNOTATION = "$ANNOTATION_PACKAGE.Capability" const val CAPABILITY_PARAM_ANNOTATION = "$ANNOTATION_PACKAGE.CapabilityParam" const val CAPABILITY_INPUT_ANNOTATION = "$ANNOTATION_PACKAGE.CapabilityInput" @@ -53,6 +55,7 @@ object ProcessorConstants { // API Classes val CN_PLUGIN_MANIFEST = PluginManifest::class.asClassName() + val CN_PLUGIN_UI_PAGE = PluginUiPage::class.asClassName() val CN_PLUGIN_INFO = PluginInfo::class.asClassName() val CN_REQUIREMENTS = Requirements::class.asClassName() val CN_CAPABILITY = Capability::class.asClassName() diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt index ff0682ea..ce990d76 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt @@ -1,6 +1,7 @@ package org.wip.plugintoolkit.api.processor.generators import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -62,7 +63,8 @@ object ManifestGenerator { settingsProperties: List, actions: List, hasUpdateHandler: Boolean, - hasSetupHandler: Boolean + hasSetupHandler: Boolean, + classDeclaration: KSClassDeclaration ): TypeSpec { val manifestType = TypeSpec.objectBuilder(manifestName) @@ -451,6 +453,27 @@ object ManifestGenerator { } supportedOsCode.add(")") + val uiPagesCode = CodeBlock.builder().add("listOf(\n").indent() + val uiPages = GeneratorUtils.extractUiPages(classDeclaration) + uiPages.forEachIndexed { index, page -> + val capabilityNamesCode = CodeBlock.builder().add("listOf(") + page.capabilityNames.forEachIndexed { capabilityIndex, capabilityName -> + capabilityNamesCode.add("%S", capabilityName) + if (capabilityIndex < page.capabilityNames.lastIndex) capabilityNamesCode.add(", ") + } + capabilityNamesCode.add(")") + uiPagesCode.add( + "%T(id = %S, title = %S, description = %S, capabilityNames = %L)", + ProcessorConstants.CN_PLUGIN_UI_PAGE, + page.id, + page.title, + page.description, + capabilityNamesCode.build() + ) + if (index < uiPages.lastIndex) uiPagesCode.add(",\n") else uiPagesCode.add("\n") + } + uiPagesCode.unindent().add(")") + manifestType.addProperty( PropertySpec.builder("manifest", CN_PLUGIN_MANIFEST) .initializer( @@ -479,7 +502,8 @@ object ManifestGenerator { .add(actionsCode.build()) .add(",\nsettings = ") .add(settingsCode.build()) - .add(",\nhasUpdateHandler = %L,\nhasSetupHandler = %L\n", hasUpdateHandler, hasSetupHandler) + .add(",\nhasUpdateHandler = %L,\nhasSetupHandler = %L,\n", hasUpdateHandler, hasSetupHandler) + .add("uiPages = %L\n", uiPagesCode.build()) .unindent() .add(")") .build() diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt new file mode 100644 index 00000000..cce8835e --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt @@ -0,0 +1,20 @@ +package org.wip.plugintoolkit.api + +import kotlin.test.Test +import kotlin.test.assertTrue + +class PluginManifestBinaryCompatibilityTest { + @Test + fun `retains constructors used before plugin UI pages`() { + val constructors = PluginManifest::class.java.declaredConstructors.map { constructor -> + constructor.parameterTypes.toList() + } + + assertTrue(constructors.any { it.size == 11 && it.last() == Boolean::class.javaPrimitiveType }) + assertTrue(constructors.any { + it.size == 13 && + it[it.lastIndex - 1] == Int::class.javaPrimitiveType && + it.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" + }) + } +} From 1ac5dbd56bcbb3ce33b1518afb46ce8614d16027 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:04:43 +1000 Subject: [PATCH 16/52] fix: separate processor from standalone runtime --- README.md | 3 +- completeExample/build.gradle.kts | 2 +- minimalExample/build.gradle.kts | 2 +- plugin-api/build.gradle.kts | 14 +- .../org/wip/plugintoolkit/api/ApiVersion.kt | 4 + .../api/processor/ManifestJsonGenerator.kt | 2 +- .../api/standalone/StandalonePluginMain.kt | 36 +++-- .../standalone/StandalonePluginMainTest.kt | 47 +++++++ plugin-processor/build.gradle.kts | 29 ++++ scripts/standalone-plugin.gradle.kts | 128 +++++++++++++++++- settings.gradle.kts | 2 +- 11 files changed, 246 insertions(+), 23 deletions(-) create mode 100644 plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ApiVersion.kt create mode 100644 plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt create mode 100644 plugin-processor/build.gradle.kts diff --git a/README.md b/README.md index e1007c8e..33a75694 100644 --- a/README.md +++ b/README.md @@ -49,4 +49,5 @@ Run `./gradlew :yourPlugin:standaloneJar`, then inspect the plugin without the d java -jar yourPlugin/build/libs/yourPlugin-version-standalone.jar --info ``` -The generated JAR contains runtime dependencies and preserves `ServiceLoader` plugin discovery. +The generated JAR contains runtime dependencies, merges `META-INF/services` providers, and leaves KSP/compiler +dependencies in the separate `plugin-processor` build-time artifact. diff --git a/completeExample/build.gradle.kts b/completeExample/build.gradle.kts index bcc541cd..df818615 100644 --- a/completeExample/build.gradle.kts +++ b/completeExample/build.gradle.kts @@ -26,7 +26,7 @@ dependencies { implementation(libs.koin.core) implementation(libs.kotlinx.serialization.json) implementation(project(":plugin-api")) - ksp(project(":plugin-api")) + ksp(project(":plugin-processor")) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/minimalExample/build.gradle.kts b/minimalExample/build.gradle.kts index bcc541cd..df818615 100644 --- a/minimalExample/build.gradle.kts +++ b/minimalExample/build.gradle.kts @@ -26,7 +26,7 @@ dependencies { implementation(libs.koin.core) implementation(libs.kotlinx.serialization.json) implementation(project(":plugin-api")) - ksp(project(":plugin-api")) + ksp(project(":plugin-processor")) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index 5832b66a..0a8c9c17 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -40,15 +40,19 @@ kotlin { implementation(kotlin("test")) } jvmMain.dependencies { - - // Processor dependencies - implementation(libs.ksp.api) - implementation(libs.kotlinpoet) - implementation(libs.kotlinpoet.ksp) + // KSP implementation is compiled and published by :plugin-processor. } } } +tasks.withType().configureEach { + exclude("org/wip/plugintoolkit/api/processor/**") +} + +tasks.withType().configureEach { + exclude("META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider") +} + publishing { repositories { maven { diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ApiVersion.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ApiVersion.kt new file mode 100644 index 00000000..6e6f7475 --- /dev/null +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ApiVersion.kt @@ -0,0 +1,4 @@ +package org.wip.plugintoolkit.api + +/** Public bridge used by the separately compiled KSP processor. */ +val PLUGIN_API_VERSION: String get() = ApiConfig.VERSION 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..f0168b3d 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 @@ -364,7 +364,7 @@ object ManifestJsonGenerator { requirements = Requirements( minMemoryMb = minMemoryMb, minExecutionTimeMs = minExecutionTimeMs, - targetAppVersion = org.wip.plugintoolkit.api.ApiConfig.VERSION + targetAppVersion = org.wip.plugintoolkit.api.PLUGIN_API_VERSION ), capabilities = manifestCapabilities, actions = manifestActions, diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt index ebb86442..3a9b4fa3 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt @@ -5,20 +5,40 @@ import org.koin.core.context.stopKoin import org.wip.plugintoolkit.api.PluginEntry import org.wip.plugintoolkit.api.PluginModuleProvider import java.util.ServiceLoader +import kotlin.system.exitProcess /** Entry point embedded in standalone plugin JARs. */ fun main(args: Array) { - val plugins = loadStandalonePlugins() - if (plugins.isEmpty()) { - System.err.println("No PluginEntry service was found in this JAR.") - return - } + val exitCode = runStandalone(args, System.out::println, System.err::println) + if (exitCode != 0) exitProcess(exitCode) +} +internal fun runStandalone( + args: Array, + output: (String) -> Unit, + error: (String) -> Unit, + loadPlugins: () -> List = ::loadStandalonePlugins +): Int { when (args.firstOrNull()) { - null, "--info" -> println(describeStandalonePlugins(plugins)) - "--help", "-h" -> println("Usage: java -jar -standalone.jar [--info|--help]") - else -> System.err.println("Unknown option '${args.first()}'. Use --help.") + "--help", "-h" -> { + output("Usage: java -jar -standalone.jar [--info|--help]") + return 0 + } + null, "--info" -> Unit + else -> { + error("Unknown option '${args.first()}'. Use --help.") + return 2 + } } + + val plugins = loadPlugins() + if (plugins.isEmpty()) { + error("No PluginEntry service was found in this JAR.") + return 2 + } + + output(describeStandalonePlugins(plugins)) + return 0 } private fun loadStandalonePlugins(): List { diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt new file mode 100644 index 00000000..0358c155 --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt @@ -0,0 +1,47 @@ +package org.wip.plugintoolkit.api.standalone + +import org.wip.plugintoolkit.api.DataProcessor +import org.wip.plugintoolkit.api.PluginEntry +import org.wip.plugintoolkit.api.PluginInfo +import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.Requirements +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StandalonePluginMainTest { + @Test + fun `missing plugin and unknown options return non-zero`() { + assertEquals(2, runStandalone(emptyArray(), {}, {}, loadPlugins = { emptyList() })) + assertEquals(2, runStandalone(arrayOf("--wat"), {}, {}, loadPlugins = { error("must not load") })) + } + + @Test + fun `help succeeds without loading plugin services`() { + assertEquals(0, runStandalone(arrayOf("--help"), {}, {}, loadPlugins = { error("must not load") })) + } + + @Test + fun `info describes a discovered plugin`() { + val output = mutableListOf() + + val exitCode = runStandalone(arrayOf("--info"), output::add, {}, loadPlugins = { listOf(plugin()) }) + + assertEquals(0, exitCode) + assertTrue(output.single().contains("Example 1.0")) + assertTrue(output.single().contains("Capabilities: none")) + } + + private fun plugin() = object : PluginEntry { + override fun getManifest() = Result.success( + PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1.0", "Example plugin"), + requirements = Requirements(64, 10) + ) + ) + + override fun getProcessor(): Result = Result.failure(UnsupportedOperationException()) + override fun setDebug(isDebug: Boolean) = Unit + } +} diff --git a/plugin-processor/build.gradle.kts b/plugin-processor/build.gradle.kts new file mode 100644 index 00000000..d3cdcc33 --- /dev/null +++ b/plugin-processor/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +group = "org.wip.plugintoolkit" +version = libs.versions.app.get() + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + } +} + +sourceSets { + main { + kotlin.srcDir("../plugin-api/src/jvmMain/kotlin") + kotlin.include("org/wip/plugintoolkit/api/processor/**") + resources.srcDir("../plugin-api/src/jvmMain/resources") + } +} + +dependencies { + implementation(project(":plugin-api")) + implementation(libs.ksp.api) + implementation(libs.kotlinpoet) + implementation(libs.kotlinpoet.ksp) + implementation(libs.kotlinx.serialization.json) +} diff --git a/scripts/standalone-plugin.gradle.kts b/scripts/standalone-plugin.gradle.kts index 24add046..98979459 100644 --- a/scripts/standalone-plugin.gradle.kts +++ b/scripts/standalone-plugin.gradle.kts @@ -1,21 +1,139 @@ import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction import org.gradle.jvm.tasks.Jar +import java.util.LinkedHashMap +import java.util.LinkedHashSet +import java.util.zip.ZipFile + +@CacheableTask +abstract class MergeStandaloneServices : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val inputArchives: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun merge() { + val outputRoot = outputDirectory.get().asFile + outputRoot.deleteRecursively() + val services = LinkedHashMap>() + + fun collect(path: String, text: String) { + if (!path.startsWith("META-INF/services/")) return + val lines = services.getOrPut(path) { LinkedHashSet() } + text.lineSequence() + .map { it.substringBefore('#').trim() } + .filter { it.isNotEmpty() } + .forEach(lines::add) + } + + inputArchives.files.forEach { input -> + if (input.isDirectory) { + input.walkTopDown().filter { it.isFile }.forEach { file -> + val path = file.relativeTo(input).invariantSeparatorsPath + if (path.startsWith("META-INF/services/")) collect(path, file.readText()) + } + } else { + ZipFile(input).use { zip -> + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.startsWith("META-INF/services/")) { + collect(entry.name, zip.getInputStream(entry).bufferedReader().use { it.readText() }) + } + } + } + } + } + + services.forEach { (path, providers) -> + val output = outputRoot.resolve(path) + output.parentFile.mkdirs() + output.writeText(providers.joinToString(separator = "\n", postfix = "\n")) + } + } +} + +@CacheableTask +abstract class VerifyStandaloneJar : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val archiveFile: RegularFileProperty + + @TaskAction + fun verify() { + ZipFile(archiveFile.get().asFile).use { zip -> + val names = zip.entries().asSequence().map { it.name }.toList() + check("org/wip/plugintoolkit/api/standalone/StandalonePluginMainKt.class" in names) { + "Standalone launcher is missing" + } + check(names.none { + it.startsWith("org/wip/plugintoolkit/api/processor/") || + it.startsWith("com/google/devtools/ksp/") || + it.startsWith("com/squareup/kotlinpoet/") + }) { "Standalone JAR contains build-time processor dependencies" } + val servicePath = "META-INF/services/org.wip.plugintoolkit.api.PluginModuleProvider" + val service = zip.getEntry(servicePath) ?: error("PluginModuleProvider service descriptor is missing") + val providers = zip.getInputStream(service).bufferedReader().useLines { lines -> + lines.map { it.substringBefore('#').trim() }.filter { it.isNotEmpty() }.toList() + } + check(providers.isNotEmpty()) { "PluginModuleProvider service descriptor is empty" } + check(providers.size == providers.distinct().size) { "PluginModuleProvider contains duplicate providers" } + } + } +} + +val standaloneServicesDir = layout.buildDirectory.dir("generated/standalone-services") +val pluginJar = tasks.named("jar") +val runtimeClasspath = configurations.getByName("runtimeClasspath") + +val mergeStandaloneServices = tasks.register("mergeStandaloneServices") { + dependsOn(pluginJar) + inputArchives.from(pluginJar.flatMap { it.archiveFile }, runtimeClasspath) + outputDirectory.set(standaloneServicesDir) +} // Apply from a JVM plugin module after its dependencies have been declared. -tasks.register("standaloneJar") { +val standaloneJar = tasks.register("standaloneJar") { group = "distribution" description = "Builds an executable plugin JAR with its runtime dependencies." archiveClassifier.set("standalone") duplicatesStrategy = DuplicatesStrategy.EXCLUDE - dependsOn("jar") - from({ zipTree(tasks.named("jar").get().archiveFile.get().asFile) }) + dependsOn(pluginJar, mergeStandaloneServices) + from({ zipTree(pluginJar.get().archiveFile.get().asFile) }) { + exclude("META-INF/services/**") + } from({ - configurations.getByName("runtimeClasspath").map { dependency -> + runtimeClasspath.map { dependency -> if (dependency.isDirectory) dependency else zipTree(dependency) } - }) + }) { + exclude("META-INF/services/**") + exclude("org/wip/plugintoolkit/api/processor/**") + } + from(standaloneServicesDir) exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") manifest.attributes["Main-Class"] = "org.wip.plugintoolkit.api.standalone.StandalonePluginMainKt" } + +val verifyStandaloneJar = tasks.register("verifyStandaloneJar") { + dependsOn(standaloneJar) + archiveFile.set(standaloneJar.flatMap { it.archiveFile }) +} + +tasks.named("check") { + dependsOn(verifyStandaloneJar) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ad47b643..b5c6e740 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,6 @@ plugins { include(":composeApp") include(":plugin-api") +include(":plugin-processor") include(":minimalExample") include(":completeExample") - From 36ff162244a10c3a76683a290d9e395590177617 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:09:14 +1000 Subject: [PATCH 17/52] Harden headless toolkit CLI --- .../features/flows/logic/FlowRepository.kt | 145 +++++++++--------- .../org/wip/plugintoolkit/cli/ToolkitCli.kt | 97 ++++++++---- .../settings/logic/JvmSettingsPersistence.kt | 7 +- .../kotlin/org/wip/plugintoolkit/main.kt | 16 +- .../wip/plugintoolkit/cli/ToolkitCliTest.kt | 37 ++++- .../features/flows/FlowRepositoryTest.kt | 35 +++++ 6 files changed, 224 insertions(+), 113 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/FlowRepository.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/FlowRepository.kt index ce9dbc03..2bca7af2 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/FlowRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/FlowRepository.kt @@ -46,92 +46,99 @@ class FlowRepository( } private fun getFlowPath(appDataDir: String, flowName: String): Path { - val safeName = flowName.replace(Regex("[\\\\/:*?\"<>|]"), "_") - return Path("$appDataDir/flows/$safeName.json") + return storedFlowPath(appDataDir, flowName) } fun reloadFlows() { scope.launch(Dispatchers.IO) { try { - val appDataDir = settingsPersistence.getSettingsDir() - val flowsDir = Path("$appDataDir/flows") - - if (!SystemFileSystem.exists(flowsDir)) { - SystemFileSystem.createDirectories(flowsDir) - } - - // Check for legacy migration first - val legacyFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}") - if (SystemFileSystem.exists(legacyFile)) { - try { - val legacyContent = SystemFileSystem.source(legacyFile).buffered().use { it.readString() } - if (legacyContent.isNotBlank()) { - val loadedFlows = json.decodeFromString>(legacyContent) - loadedFlows.forEach { flow -> - val targetFile = getFlowPath(appDataDir, flow.name) - if (!SystemFileSystem.exists(targetFile)) { - val flowContent = json.encodeToString(Flow.serializer(), flow) - SystemFileSystem.sink(targetFile).buffered().use { it.writeString(flowContent) } - } - } - } - val backupFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}.bak") - if (SystemFileSystem.exists(backupFile)) { - SystemFileSystem.delete(backupFile) - } - SystemFileSystem.source(legacyFile).buffered().use { source -> - SystemFileSystem.sink(backupFile).buffered().use { sink -> - val data = source.readString() - sink.writeString(data) + val storedFlows = loadStoredFlows(settingsPersistence, appConfig) + val manifests = pluginManager.installedPlugins.value.filter { it.isEnabled } + .associate { it.pkg to pluginManager.getManifest(it.pkg) } + .filterValues { it != null }.mapValues { it.value!! } + _flows.value = storedFlows.map { flow -> + val updatedNodes = flow.nodes.map { node -> + if (node is Node.CapabilityNode) { + val currentManifest = manifests[node.pluginInfo.id] + val actualCapability = currentManifest?.capabilities?.find { it.name == node.capability.name } + if (currentManifest == null || actualCapability == null) { + node.copy(isBroken = true) + } else { + node.copy( + isBroken = false, + capability = actualCapability, + pluginInfo = currentManifest.plugin + ) } + } else { + node } - SystemFileSystem.delete(legacyFile) - Logger.i { "Legacy flows.json successfully migrated and backed up" } - } catch (e: Exception) { - Logger.e(e) { "Migration failed" } } + flow.copy(nodes = updatedNodes) } + } catch (e: Exception) { + Logger.e(e) { "Failed to reload flows" } + } + } + } - val loadedFlows = mutableListOf() - val manifests = pluginManager.installedPlugins.value.filter { it.isEnabled } - .associate { it.pkg to pluginManager.getManifest(it.pkg) } - .filterValues { it != null }.mapValues { it.value!! } + companion object { + private val storageJson = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + private fun storedFlowPath(appDataDir: String, flowName: String): Path { + val safeName = flowName.replace(Regex("[\\\\/:*?\"<>|]"), "_") + return Path("$appDataDir/flows/$safeName.json") + } - SystemFileSystem.list(flowsDir).forEach { file -> - if (file.name.endsWith(".json")) { - try { - val content = SystemFileSystem.source(file).buffered().use { it.readString() } - val flow = json.decodeFromString(content) - - val updatedNodes = flow.nodes.map { node -> - if (node is Node.CapabilityNode) { - val currentManifest = manifests[node.pluginInfo.id] - val actualCapability = - currentManifest?.capabilities?.find { it.name == node.capability.name } - if (currentManifest == null || actualCapability == null) { - node.copy(isBroken = true) - } else { - node.copy( - isBroken = false, - capability = actualCapability, - pluginInfo = currentManifest.plugin - ) - } - } else { - node + suspend fun loadStoredFlows( + settingsPersistence: SettingsPersistence, + appConfig: SystemConfig + ): List = kotlinx.coroutines.withContext(Dispatchers.IO) { + val appDataDir = settingsPersistence.getSettingsDir() + val flowsDir = Path("$appDataDir/flows") + if (!SystemFileSystem.exists(flowsDir)) SystemFileSystem.createDirectories(flowsDir) + + val legacyFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}") + if (SystemFileSystem.exists(legacyFile)) { + try { + val legacyContent = SystemFileSystem.source(legacyFile).buffered().use { it.readString() } + if (legacyContent.isNotBlank()) { + storageJson.decodeFromString>(legacyContent).forEach { flow -> + val targetFile = storedFlowPath(appDataDir, flow.name) + if (!SystemFileSystem.exists(targetFile)) { + SystemFileSystem.sink(targetFile).buffered().use { + it.writeString(storageJson.encodeToString(Flow.serializer(), flow)) } } - loadedFlows.add(flow.copy(nodes = updatedNodes)) - } catch (e: Exception) { - Logger.e(e) { "Failed to parse flow file: ${file.name}" } } } + val backupFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}.bak") + if (SystemFileSystem.exists(backupFile)) SystemFileSystem.delete(backupFile) + SystemFileSystem.source(legacyFile).buffered().use { source -> + SystemFileSystem.sink(backupFile).buffered().use { sink -> sink.writeString(source.readString()) } + } + SystemFileSystem.delete(legacyFile) + Logger.i { "Legacy flows.json successfully migrated and backed up" } + } catch (error: Exception) { + Logger.e(error) { "Migration failed" } } - - _flows.value = loadedFlows - } catch (e: Exception) { - Logger.e(e) { "Failed to reload flows" } } + + SystemFileSystem.list(flowsDir) + .filter { it.name.endsWith(".json") } + .mapNotNull { file -> + try { + val content = SystemFileSystem.source(file).buffered().use { it.readString() } + storageJson.decodeFromString(content) + } catch (error: Exception) { + Logger.e(error) { "Failed to parse flow file: ${file.name}" } + null + } + } } } diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt index b5814d68..69b86d9b 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt @@ -1,17 +1,19 @@ package org.wip.plugintoolkit.cli +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first -import org.koin.core.context.stopKoin -import org.koin.mp.KoinPlatform.getKoin +import kotlinx.coroutines.withTimeout import org.wip.plugintoolkit.AppConfig +import org.wip.plugintoolkit.core.DefaultSystemConfig +import org.wip.plugintoolkit.core.loomDispatcher +import org.wip.plugintoolkit.features.flows.logic.FlowRepository import org.wip.plugintoolkit.features.plugin.logic.PluginRegistry -import org.wip.plugintoolkit.features.settings.logic.SettingsPersistence -import org.wip.plugintoolkit.performStartup -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.extension -import kotlin.io.path.isRegularFile -import kotlin.io.path.nameWithoutExtension +import org.wip.plugintoolkit.features.plugin.model.InstalledPlugin +import org.wip.plugintoolkit.features.settings.logic.JvmSettingsPersistence +import org.wip.plugintoolkit.features.settings.logic.SettingsRepository sealed interface ToolkitCliCommand { data object Help : ToolkitCliCommand @@ -21,6 +23,12 @@ sealed interface ToolkitCliCommand { data object Flows : ToolkitCliCommand } +sealed interface ToolkitCliInvocation { + data object Desktop : ToolkitCliInvocation + data class Command(val command: ToolkitCliCommand) : ToolkitCliInvocation + data class Invalid(val arguments: List) : ToolkitCliInvocation +} + fun parseToolkitCliCommand(args: Array): ToolkitCliCommand? = when (args.toList()) { listOf("--help"), listOf("-h"), listOf("help") -> ToolkitCliCommand.Help listOf("--version"), listOf("version") -> ToolkitCliCommand.Version @@ -30,10 +38,24 @@ fun parseToolkitCliCommand(args: Array): ToolkitCliCommand? = when (args else -> null } -suspend fun runToolkitCli( +fun parseToolkitCliInvocation(args: Array): ToolkitCliInvocation { + parseToolkitCliCommand(args)?.let { return ToolkitCliInvocation.Command(it) } + if (args.isEmpty() || args.all { it == DefaultSystemConfig().STARTUP_FLAG_BACKGROUND || it.startsWith("-psn_") }) { + return ToolkitCliInvocation.Desktop + } + return ToolkitCliInvocation.Invalid(args.toList()) +} + +internal data class ToolkitCliData( + val plugins: List = emptyList(), + val flowNames: List = emptyList() +) + +internal suspend fun runToolkitCli( command: ToolkitCliCommand, output: (String) -> Unit = ::println, - error: (String) -> Unit = System.err::println + error: (String) -> Unit = System.err::println, + dataLoader: suspend (ToolkitCliCommand) -> ToolkitCliData = ::loadToolkitCliData ): Int { when (command) { ToolkitCliCommand.Help -> { @@ -48,21 +70,17 @@ suspend fun runToolkitCli( } return try { - performStartup(emptyArray()) - val koin = getKoin() - val registry = koin.get() - registry.isReady.first { it } - + // The CLI owns the process and emits only its data on stdout. + Logger.setLogWriters() + val data = withTimeout(CLI_STARTUP_TIMEOUT_MS) { dataLoader(command) } when (command) { ToolkitCliCommand.Status -> { - val plugins = registry.installedPlugins.value output("PluginToolkit ${AppConfig.VERSION}") - output("Plugins: ${plugins.size} installed, ${plugins.count { it.isEnabled }} enabled") + output("Plugins: ${data.plugins.size} installed, ${data.plugins.count { it.isEnabled }} enabled") } ToolkitCliCommand.Plugins -> { - val plugins = registry.installedPlugins.value - if (plugins.isEmpty()) output("No plugins installed.") - plugins.forEach { plugin -> + if (data.plugins.isEmpty()) output("No plugins installed.") + data.plugins.forEach { plugin -> val state = when { !plugin.isCompatible -> "incompatible" !plugin.isEnabled -> "disabled" @@ -73,29 +91,40 @@ suspend fun runToolkitCli( } } ToolkitCliCommand.Flows -> { - val settingsDir = koin.get().getSettingsDir() - val flowsDir = Path.of(settingsDir, "flows") - val flows = if (Files.isDirectory(flowsDir)) { - Files.list(flowsDir).use { paths -> - paths.filter { it.isRegularFile() && it.extension == "json" } - .map { it.nameWithoutExtension } - .sorted() - .toList() - } - } else emptyList() - if (flows.isEmpty()) output("No flows saved.") else flows.forEach(output) + if (data.flowNames.isEmpty()) output("No flows saved.") else data.flowNames.sorted().forEach(output) } else -> Unit } - stopKoin() 0 } catch (exception: Throwable) { error("CLI error: ${exception.message ?: exception::class.simpleName}") - stopKoin() 1 } } +private suspend fun loadToolkitCliData(command: ToolkitCliCommand): ToolkitCliData { + val appConfig = DefaultSystemConfig() + val persistence = JvmSettingsPersistence(appConfig) + if (command == ToolkitCliCommand.Flows) { + return ToolkitCliData( + flowNames = FlowRepository.loadStoredFlows(persistence, appConfig).map { it.name } + ) + } + + val scope = CoroutineScope(SupervisorJob() + loomDispatcher) + return try { + val settingsRepository = SettingsRepository(persistence, scope) + settingsRepository.isLoaded.first { it } + val registry = PluginRegistry(settingsRepository, scope, loomDispatcher, appConfig) + registry.initialize() + ToolkitCliData(plugins = registry.installedPlugins.value) + } finally { + scope.cancel() + } +} + +private const val CLI_STARTUP_TIMEOUT_MS = 15_000L + private val CLI_HELP = """ PluginToolkit ${AppConfig.VERSION} diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt index a7370b55..f829b61e 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt @@ -15,8 +15,11 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.wip.plugintoolkit.features.settings.model.AppSettings -class JvmSettingsPersistence : SettingsPersistence, KoinComponent { - private val appConfig: SystemConfig by inject() +class JvmSettingsPersistence( + private val configuredAppConfig: SystemConfig? = null +) : SettingsPersistence, KoinComponent { + private val injectedAppConfig: SystemConfig by inject() + private val appConfig: SystemConfig get() = configuredAppConfig ?: injectedAppConfig private val json = Json { prettyPrint = true ignoreUnknownKeys = true diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index b35b0143..ddcfad7a 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -133,14 +133,22 @@ import javax.swing.JOptionPane.showMessageDialog import javax.swing.JWindow import kotlin.system.exitProcess import kotlin.time.Duration.Companion.seconds -import org.wip.plugintoolkit.cli.parseToolkitCliCommand +import org.wip.plugintoolkit.cli.parseToolkitCliInvocation import org.wip.plugintoolkit.cli.runToolkitCli +import org.wip.plugintoolkit.cli.ToolkitCliInvocation @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) fun main(args: Array) { - parseToolkitCliCommand(args)?.let { command -> - val exitCode = kotlinx.coroutines.runBlocking { runToolkitCli(command) } - exitProcess(exitCode) + when (val invocation = parseToolkitCliInvocation(args)) { + is ToolkitCliInvocation.Command -> { + val exitCode = kotlinx.coroutines.runBlocking { runToolkitCli(invocation.command) } + exitProcess(exitCode) + } + is ToolkitCliInvocation.Invalid -> { + System.err.println("Unknown command: ${invocation.arguments.joinToString(" ")}. Use --help.") + exitProcess(2) + } + ToolkitCliInvocation.Desktop -> Unit } ComposeFoundationFlags.isNewContextMenuEnabled = true diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt index 1f372cc9..c643ab84 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt @@ -2,7 +2,8 @@ package org.wip.plugintoolkit.cli import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import kotlin.test.assertIs class ToolkitCliTest { @Test @@ -13,8 +14,36 @@ class ToolkitCliTest { } @Test - fun `desktop flags and unknown commands remain desktop arguments`() { - assertNull(parseToolkitCliCommand(arrayOf("--background"))) - assertNull(parseToolkitCliCommand(arrayOf("unknown"))) + fun `desktop flags and unknown commands are distinguished`() { + assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("--background"))) + assertIs(parseToolkitCliInvocation(arrayOf("unknown"))) + } + + @Test + fun `run cli prints decoded flow names from its data source`() = runTest { + val output = mutableListOf() + + val code = runToolkitCli( + ToolkitCliCommand.Flows, + output = output::add, + dataLoader = { ToolkitCliData(flowNames = listOf("A/B")) } + ) + + assertEquals(0, code) + assertEquals(listOf("A/B"), output) + } + + @Test + fun `run cli reports startup failure instead of waiting forever`() = runTest { + val errors = mutableListOf() + + val code = runToolkitCli( + ToolkitCliCommand.Status, + error = errors::add, + dataLoader = { error("registry failed") } + ) + + assertEquals(1, code) + kotlin.test.assertTrue(errors.single().contains("registry failed")) } } diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowRepositoryTest.kt index 7c2c0ef7..07fcea89 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowRepositoryTest.kt @@ -8,13 +8,48 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import org.wip.plugintoolkit.features.flows.logic.FlowRepository +import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.plugin.logic.PluginManager import org.wip.plugintoolkit.features.plugin.model.InstalledPlugin +import org.wip.plugintoolkit.features.settings.logic.SettingsPersistence +import org.wip.plugintoolkit.features.settings.model.AppSettings +import org.wip.plugintoolkit.core.DefaultSystemConfig +import kotlinx.serialization.json.Json +import java.nio.file.Files import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class FlowRepositoryTest { + @Test + fun `stored flows are decoded by content and corrupt files are skipped`() = runBlocking { + val root = Files.createTempDirectory("plugin-toolkit-flows-") + try { + val flowsDir = Files.createDirectories(root.resolve("flows")) + Files.writeString( + flowsDir.resolve("A_B.json"), + Json.encodeToString(Flow.serializer(), Flow(name = "A/B")) + ) + Files.writeString(flowsDir.resolve("partial.json"), "{") + + val persistence = object : SettingsPersistence { + override suspend fun load(): AppSettings = AppSettings() + override suspend fun save(settings: AppSettings) = Unit + override fun getSettingsDir(): String = root.toString() + override fun getJobsDir(): String = root.resolve("jobs").toString() + override fun openLogFolder() = Unit + override fun openLatestLog() = Unit + } + + val flows = FlowRepository.loadStoredFlows(persistence, DefaultSystemConfig()) + + assertEquals(listOf("A/B"), flows.map { it.name }) + } finally { + root.toFile().deleteRecursively() + } + } + @Test fun testReloadFlowsOnPluginChange() = runBlocking { val persistence = MockSettingsPersistence() From 35ea7d1654499d69779823023a706ce7431d9ad5 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:16:35 +1000 Subject: [PATCH 18/52] Harden recurring job scheduling --- .../composeResources/values-it/strings.xml | 9 +- .../composeResources/values/strings.xml | 11 +- .../features/job/logic/JobManager.kt | 117 +++++++++++++---- .../features/job/logic/ScheduleRepository.kt | 62 +++++++-- .../plugintoolkit/features/job/model/Job.kt | 9 +- .../features/job/ui/JobDashboard.kt | 64 +++++++++- .../features/job/viewmodel/JobViewModel.kt | 12 +- .../shared/components/plugin/JobResultCard.kt | 5 +- .../kotlin/org/wip/plugintoolkit/main.kt | 9 +- .../job/logic/ScheduleRepositoryTest.kt | 119 ++++++++++++++++++ 10 files changed, 364 insertions(+), 53 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 6c6bccf3..6d33c3ea 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -185,7 +185,6 @@ Nessun lavoro archiviato Lavori Terminati Nessun lavoro terminato registrato ancora - Pianificatore disponibile presto Errore: %1$s @@ -376,4 +375,12 @@ Attenzione: Impostazioni Sul Posto L\'apertura delle impostazioni sul posto potrebbe causare il mancato aggiornamento dello stato di sblocco di alcuni componenti fino al ricaricamento. Sei sicuro di voler abilitare questa modalità? Per Sezione + Pianifica esecuzione ricorrente + Crea pianificazione ricorrente + Intervallo (minuti) + Pianifica + Pianifica una capability o un flow completato con un intervallo ricorrente. + Ogni %1$d minuti · prossima %2$s + Esegui ora la pianificazione + Elimina pianificazione diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 48aa407f..bb8c95d8 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -201,7 +201,6 @@ No archived jobs Ended Jobs No ended jobs recorded yet - Scheduler coming soon Error: %1$s Choose Install Location Action Blocked @@ -427,4 +426,12 @@ 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 + Schedule recurring run + Create recurring schedule + Interval (minutes) + Schedule + Schedule a completed capability or flow to run at a recurring interval. + Every %1$d minutes · next %2$s + Run schedule now + Delete schedule + diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index 6be19046..007fc55f 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -11,8 +11,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.TimeZone @@ -28,11 +28,14 @@ import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobHistoryEntry import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.job.model.ScheduledJob +import org.wip.plugintoolkit.features.job.model.canBeScheduled +import org.wip.plugintoolkit.features.job.model.normalizedScheduleInterval import org.wip.plugintoolkit.features.plugin.logic.DefaultPluginFileSystem import org.wip.plugintoolkit.features.plugin.logic.PluginLoader import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +import kotlin.uuid.Uuid class JobManager( /** Injected [AppScope] for managing job lifecycles and worker coordination. */ @@ -80,6 +83,9 @@ class JobManager( private val jobRepository = JobRepository(settingsPersistence) private val scheduleRepository = ScheduleRepository(settingsPersistence) private val scheduleMutex = Mutex() + private val scheduleStartMutex = Mutex() + private val scheduleSignal = Channel(Channel.CONFLATED) + private var schedulerStarted = false init { scope.launch { @@ -109,59 +115,93 @@ class JobManager( } } } - scope.launch { - val savedSchedules = scheduleRepository.load() - _schedules.update { current -> - savedSchedules.filterNot { saved -> current.any { it.id == saved.id } } + current + } + + /** Starts recurring execution after startup has finished loading plugins. Safe to call more than once. */ + suspend fun startScheduler(): Boolean = scheduleStartMutex.withLock start@{ + if (schedulerStarted) return@start true + + val loadedSchedules = scheduleRepository.load().getOrElse { error -> + Logger.e(error) { "Scheduler disabled because persisted schedules could not be recovered" } + return@start false + } + val savedSchedules = loadedSchedules.filter { it.jobTemplate.type.canBeScheduled() } + val initialized = scheduleMutex.withLock initialize@{ + val current = _schedules.value + val merged = savedSchedules.filterNot { saved -> current.any { it.id == saved.id } } + current + val needsSanitization = savedSchedules.size != loadedSchedules.size + if ((needsSanitization || merged != savedSchedules) && !persistSchedules(merged)) { + return@initialize false } - launch { _schedules.collect(scheduleRepository::save) } + _schedules.value = merged + true + } + if (!initialized) return@start false + + schedulerStarted = true + scope.launch { while (isActive) { - runDueSchedules(Clock.System.now()) - delay(1_000) + val now = Clock.System.now() + runDueSchedules(now) + val waitMillis = nextSchedulerWaitMillis(Clock.System.now()) + withTimeoutOrNull(waitMillis) { scheduleSignal.receive() } } } + true } - fun scheduleJob(job: BackgroundJob, intervalMinutes: Long = 24 * 60L): ScheduledJob { + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + suspend fun scheduleJob(job: BackgroundJob, intervalMinutes: Long = 24 * 60L): ScheduledJob? = scheduleMutex.withLock { + if (!job.type.canBeScheduled()) { + Logger.w { "Refusing to schedule unsupported job type ${job.type}" } + return@withLock null + } val now = Clock.System.now() + val normalizedInterval = intervalMinutes.normalizedScheduleInterval() val schedule = ScheduledJob( - id = "schedule-${now.toEpochMilliseconds()}-${job.id}", + id = "schedule-${Uuid.random()}", jobTemplate = job.asFreshRun(now), - intervalMinutes = intervalMinutes.coerceAtLeast(1), - nextRunAt = now + intervalMinutes.coerceAtLeast(1).minutes + intervalMinutes = normalizedInterval, + nextRunAt = now + normalizedInterval.minutes ) - _schedules.update { it + schedule } + if (!replaceSchedules(_schedules.value + schedule)) return@withLock null return schedule } - fun removeSchedule(id: String) { - _schedules.update { schedules -> schedules.filterNot { it.id == id } } + suspend fun removeSchedule(id: String): Boolean = scheduleMutex.withLock { + val updated = _schedules.value.filterNot { it.id == id } + updated != _schedules.value && replaceSchedules(updated) } - fun setScheduleEnabled(id: String, enabled: Boolean) { - _schedules.update { schedules -> schedules.map { if (it.id == id) it.copy(enabled = enabled) else it } } + suspend fun setScheduleEnabled(id: String, enabled: Boolean): Boolean = scheduleMutex.withLock { + val updated = _schedules.value.map { if (it.id == id) it.copy(enabled = enabled) else it } + updated != _schedules.value && replaceSchedules(updated) } suspend fun runScheduleNow(id: String) = scheduleMutex.withLock { val now = Clock.System.now() - val schedule = _schedules.value.firstOrNull { it.id == id } ?: return@withLock + val current = _schedules.value + val schedule = current.firstOrNull { it.id == id } ?: return@withLock + val updated = current.map { if (it.id == id) it.afterRun(now) else it } + if (!replaceSchedules(updated)) return@withLock enqueueJob(schedule.jobTemplate.asFreshRun(now)) - _schedules.update { schedules -> schedules.map { if (it.id == id) schedule.afterRun(now) else it } } } internal suspend fun runDueSchedules(now: kotlin.time.Instant) = scheduleMutex.withLock { - val due = _schedules.value.filter { it.isDue(now) } - due.forEach { enqueueJob(it.jobTemplate.asFreshRun(now)) } + val current = _schedules.value + val due = current.filter { it.isDue(now) } if (due.isNotEmpty()) { val dueIds = due.mapTo(mutableSetOf()) { it.id } - _schedules.update { schedules -> - schedules.map { if (it.id in dueIds) it.afterRun(now) else it } - } + val updated = current.map { if (it.id in dueIds) it.afterRun(now) else it } + // Persist the next occurrence first. A crash can skip a run, but cannot replay it twice. + if (!replaceSchedules(updated)) return@withLock + due.forEach { enqueueJob(it.jobTemplate.asFreshRun(now)) } } } + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) private fun BackgroundJob.asFreshRun(now: kotlin.time.Instant): BackgroundJob = copy( - id = "$id-${now.toEpochMilliseconds()}", + id = "$id-${Uuid.random()}", status = JobStatus.Queued, enqueuedAt = now, startedAt = null, @@ -171,6 +211,30 @@ class JobManager( resumeState = null ) + private suspend fun replaceSchedules(updated: List): Boolean { + if (!persistSchedules(updated)) return false + _schedules.value = updated + scheduleSignal.trySend(Unit) + return true + } + + private suspend fun persistSchedules(updated: List): Boolean = + scheduleRepository.save(updated).fold( + onSuccess = { true }, + onFailure = { + Logger.e(it) { "Schedule change was not applied because persistence failed" } + false + } + ) + + private fun nextSchedulerWaitMillis(now: kotlin.time.Instant): Long = + _schedules.value.asSequence() + .filter { it.enabled } + .map { (it.nextRunAt - now).inWholeMilliseconds } + .minOrNull() + ?.coerceIn(MIN_SCHEDULER_WAIT_MS, MAX_SCHEDULER_WAIT_MS) + ?: MAX_SCHEDULER_WAIT_MS + private fun startWorkers() { repeat(maxConcurrentJobs) { val worker = JobWorker(it, this, scope) @@ -632,3 +696,6 @@ class JobManager( } } } + +private const val MIN_SCHEDULER_WAIT_MS = 1_000L +private const val MAX_SCHEDULER_WAIT_MS = 60_000L diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt index 6d52cc1c..627c8ee8 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt @@ -21,21 +21,57 @@ class ScheduleRepository(private val settingsPersistence: SettingsPersistence) { return Path("$jobsDir/schedules.json") } - suspend fun load(): List = withContext(Dispatchers.IO) { - val file = file() - if (!SystemFileSystem.exists(file)) return@withContext emptyList() - runCatching> { - SystemFileSystem.source(file).buffered().use { source -> - source.readString().takeIf { it.isNotBlank() } - ?.let { json.decodeFromString>(it) } - ?: emptyList() - } - }.onFailure { Logger.e(it) { "Failed to load schedules" } }.getOrDefault(emptyList()) + suspend fun load(): Result> = withContext(Dispatchers.IO) { + val primary = file() + val backup = Path("$primary.bak") + if (!SystemFileSystem.exists(primary) && !SystemFileSystem.exists(backup)) { + return@withContext Result.success(emptyList()) + } + + val primaryResult = runCatching { read(primary) } + if (primaryResult.isSuccess) return@withContext primaryResult + + Logger.e(primaryResult.exceptionOrNull()) { "Failed to load schedules; trying backup" } + if (!SystemFileSystem.exists(backup)) return@withContext primaryResult + + runCatching { read(backup) } + .onSuccess { Logger.w { "Recovered schedules from backup" } } + .onFailure { Logger.e(it) { "Failed to load schedule backup" } } } - suspend fun save(schedules: List) = withContext(Dispatchers.IO) { + suspend fun save(schedules: List): Result = withContext(Dispatchers.IO) { + val primary = file() + val temporary = Path("$primary.tmp") + val backup = Path("$primary.bak") + val backupTemporary = Path("$primary.bak.tmp") + runCatching { - SystemFileSystem.sink(file()).buffered().use { it.writeString(json.encodeToString(schedules)) } - }.onFailure { Logger.e(it) { "Failed to save schedules" } } + write(temporary, schedules) + + // Never replace a known-good backup with a corrupt/partial primary. + if (SystemFileSystem.exists(primary)) { + runCatching { read(primary) }.getOrNull()?.let { previous -> + write(backupTemporary, previous) + SystemFileSystem.atomicMove(backupTemporary, backup) + } + } + + SystemFileSystem.atomicMove(temporary, primary) + }.onFailure { Logger.e(it) { "Failed to save schedules atomically" } } + .also { + runCatching { if (SystemFileSystem.exists(temporary)) SystemFileSystem.delete(temporary) } + runCatching { if (SystemFileSystem.exists(backupTemporary)) SystemFileSystem.delete(backupTemporary) } + } + } + + private fun read(path: Path): List { + if (!SystemFileSystem.exists(path)) error("Schedule file does not exist: $path") + val content = SystemFileSystem.source(path).buffered().use { it.readString() } + check(content.isNotBlank()) { "Schedule file is empty or partially written: $path" } + return json.decodeFromString(content) + } + + private fun write(path: Path, schedules: List) { + SystemFileSystem.sink(path).buffered().use { it.writeString(json.encodeToString(schedules)) } } } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt index c5d31f46..12c0fabd 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/model/Job.kt @@ -27,6 +27,12 @@ enum class JobType { PluginInstallation } +fun JobType.canBeScheduled(): Boolean = this == JobType.Capability || this == JobType.Flow + +const val MAX_SCHEDULE_INTERVAL_MINUTES = 10L * 365L * 24L * 60L + +fun Long.normalizedScheduleInterval(): Long = coerceIn(1L, MAX_SCHEDULE_INTERVAL_MINUTES) + @Serializable data class BackgroundJob( val id: String, @@ -72,6 +78,7 @@ data class ScheduledJob( /** Reschedule from the actual run time so missed intervals never create a catch-up burst. */ fun afterRun(now: Instant): ScheduledJob = copy( lastRunAt = now, - nextRunAt = now + intervalMinutes.coerceAtLeast(1).minutes + intervalMinutes = intervalMinutes.normalizedScheduleInterval(), + nextRunAt = now + intervalMinutes.normalizedScheduleInterval().minutes ) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt index 64528462..a4cbcdb0 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -43,6 +44,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -82,6 +84,8 @@ import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobStatus +import org.wip.plugintoolkit.features.job.model.MAX_SCHEDULE_INTERVAL_MINUTES +import org.wip.plugintoolkit.features.job.model.canBeScheduled import org.wip.plugintoolkit.features.job.viewmodel.JobViewModel import org.wip.plugintoolkit.shared.components.SectionHeader import org.wip.plugintoolkit.shared.components.ToolkitChip @@ -105,6 +109,13 @@ import plugintoolkit.composeapp.generated.resources.job_no_ended import plugintoolkit.composeapp.generated.resources.job_paused_jobs import plugintoolkit.composeapp.generated.resources.job_queue import plugintoolkit.composeapp.generated.resources.job_running_jobs +import plugintoolkit.composeapp.generated.resources.job_schedule_create +import plugintoolkit.composeapp.generated.resources.job_schedule_delete +import plugintoolkit.composeapp.generated.resources.job_schedule_empty +import plugintoolkit.composeapp.generated.resources.job_schedule_interval_label +import plugintoolkit.composeapp.generated.resources.job_schedule_next_format +import plugintoolkit.composeapp.generated.resources.job_schedule_run_now +import plugintoolkit.composeapp.generated.resources.job_schedule_title import plugintoolkit.composeapp.generated.resources.nav_job_archive import plugintoolkit.composeapp.generated.resources.nav_job_ended import plugintoolkit.composeapp.generated.resources.nav_job_general @@ -309,6 +320,38 @@ fun EndedTab(viewModel: JobViewModel) { val endedJobs by viewModel.endedJobs.collectAsState() val logsMap by viewModel.jobLogs.collectAsState(initial = emptyMap()) val progressMap by viewModel.jobProgress.collectAsState(initial = emptyMap()) + var jobToSchedule by remember { mutableStateOf(null) } + var intervalText by remember { mutableStateOf(DEFAULT_SCHEDULE_INTERVAL_MINUTES.toString()) } + + jobToSchedule?.let { job -> + val interval = intervalText.toLongOrNull()?.takeIf { it in 1..MAX_SCHEDULE_INTERVAL_MINUTES } + AlertDialog( + onDismissRequest = { jobToSchedule = null }, + title = { Text(stringResource(Res.string.job_schedule_title)) }, + text = { + OutlinedTextField( + value = intervalText, + onValueChange = { value -> intervalText = value.filter(Char::isDigit) }, + label = { Text(stringResource(Res.string.job_schedule_interval_label)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton( + enabled = interval != null, + onClick = { + viewModel.scheduleRecurring(job, interval!!) + jobToSchedule = null + } + ) { Text(stringResource(Res.string.job_schedule_create)) } + }, + dismissButton = { + TextButton(onClick = { jobToSchedule = null }) { + Text(stringResource(Res.string.dialog_cancel)) + } + } + ) + } Column(modifier = Modifier.fillMaxSize()) { Row( @@ -340,7 +383,12 @@ fun EndedTab(viewModel: JobViewModel) { progress = progressMap[job.id] ?: org.wip.plugintoolkit.features.job.model.JobProgress(), logs = logsMap[job.id] ?: emptyList(), onClear = { viewModel.clearEndedJob(job.id) }, - onSchedule = { viewModel.scheduleDaily(job) } + onSchedule = if (job.type.canBeScheduled()) { + { + intervalText = DEFAULT_SCHEDULE_INTERVAL_MINUTES.toString() + jobToSchedule = job + } + } else null ) } } else { @@ -357,7 +405,7 @@ fun SchedulerTab(viewModel: JobViewModel) { val schedules by viewModel.schedules.collectAsState() if (schedules.isEmpty()) { - EmptyState("Schedule a completed job to run it every day.", Icons.Default.Schedule) + EmptyState(stringResource(Res.string.job_schedule_empty), Icons.Default.Schedule) return } @@ -380,7 +428,11 @@ fun SchedulerTab(viewModel: JobViewModel) { Column(modifier = Modifier.weight(1f)) { Text(schedule.jobTemplate.name, style = MaterialTheme.typography.titleMedium) Text( - "Every ${schedule.intervalMinutes} minutes · next ${formatTime(schedule.nextRunAt)}", + stringResource( + Res.string.job_schedule_next_format, + schedule.intervalMinutes, + formatTime(schedule.nextRunAt) + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -390,10 +442,10 @@ fun SchedulerTab(viewModel: JobViewModel) { onCheckedChange = { viewModel.setScheduleEnabled(schedule.id, it) } ) IconButton(onClick = { viewModel.runScheduleNow(schedule.id) }) { - Icon(Icons.Default.PlayArrow, contentDescription = "Run now") + Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.job_schedule_run_now)) } IconButton(onClick = { viewModel.removeSchedule(schedule.id) }) { - Icon(Icons.Default.Delete, contentDescription = "Delete schedule") + Icon(Icons.Default.Delete, contentDescription = stringResource(Res.string.job_schedule_delete)) } } } @@ -401,6 +453,8 @@ fun SchedulerTab(viewModel: JobViewModel) { } } +private const val DEFAULT_SCHEDULE_INTERVAL_MINUTES = 24L * 60L + @Composable fun HistoryTab(viewModel: JobViewModel) { val history by viewModel.history.collectAsState() diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt index 9280b6bd..c7993440 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt @@ -74,13 +74,17 @@ class JobViewModel( } } - fun scheduleDaily(job: BackgroundJob) { - jobManager.scheduleJob(job) + fun scheduleRecurring(job: BackgroundJob, intervalMinutes: Long) { + viewModelScope.launch { jobManager.scheduleJob(job, intervalMinutes) } } - fun removeSchedule(id: String) = jobManager.removeSchedule(id) + fun removeSchedule(id: String) { + viewModelScope.launch { jobManager.removeSchedule(id) } + } - fun setScheduleEnabled(id: String, enabled: Boolean) = jobManager.setScheduleEnabled(id, enabled) + fun setScheduleEnabled(id: String, enabled: Boolean) { + viewModelScope.launch { jobManager.setScheduleEnabled(id, enabled) } + } fun runScheduleNow(id: String) { viewModelScope.launch { jobManager.runScheduleNow(id) } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt index 63ddfd82..ce2c5ef0 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/shared/components/plugin/JobResultCard.kt @@ -160,7 +160,10 @@ fun JobResultCard( if (onSchedule != null) { IconButton(onClick = onSchedule) { - Icon(Icons.Default.Schedule, contentDescription = "Schedule daily") + Icon( + Icons.Default.Schedule, + contentDescription = stringResource(Res.string.job_schedule_action) + ) } } diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index 5addfd0c..8b31ba09 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.joinAll import kotlinx.coroutines.withContext import kotlinx.io.files.Path import kotlinx.serialization.json.Json @@ -284,12 +285,13 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = registry.initialize() } catch (e: Throwable) { Logger.e(e) { "Startup: Failed to initialize PluginRegistry" } + return@launch } val pluginsToLoad = pluginManager.installedPlugins.value.filter { it.isEnabled } Logger.i { "Startup: Found ${pluginsToLoad.size} enabled plugins to load/setup" } - pluginsToLoad.forEach { plugin -> + val pluginStartupJobs = pluginsToLoad.map { plugin -> if (plugin.isValidated) { Logger.d { "Startup: Launching load for validated plugin ${plugin.pkg}" } launch { @@ -313,6 +315,11 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = } } } + pluginStartupJobs.joinAll() + + if (!koin.get().startScheduler()) { + Logger.e { "Startup: Scheduler was not started because its state could not be loaded safely" } + } } updateStatus("Refreshing repositories...") diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt new file mode 100644 index 00000000..04509286 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -0,0 +1,119 @@ +package org.wip.plugintoolkit.features.job.logic + +import kotlinx.coroutines.test.runTest +import org.wip.plugintoolkit.features.job.model.BackgroundJob +import org.wip.plugintoolkit.features.job.model.JobType +import org.wip.plugintoolkit.features.job.model.ScheduledJob +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 java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +class ScheduleRepositoryTest { + private class TempPersistence(private val root: Path) : SettingsPersistence { + override suspend fun load(): AppSettings = AppSettings() + override suspend fun save(settings: AppSettings) = Unit + override fun getSettingsDir(): String = root.toString() + override fun getJobsDir(): String = root.resolve("jobs").toString() + override fun openLogFolder() = Unit + override fun openLatestLog() = Unit + } + + private val template = BackgroundJob( + id = "job", + name = "Example", + type = JobType.Capability, + pluginId = "plugin", + capabilityName = "run" + ) + + @Test + fun `corrupt primary recovers the last known-good backup`() = runTest { + withTempPersistence { persistence, root -> + val repository = ScheduleRepository(persistence) + val first = listOf(ScheduledJob("first", template, 10, Instant.fromEpochMilliseconds(1_000))) + val second = listOf(ScheduledJob("second", template, 20, Instant.fromEpochMilliseconds(2_000))) + + assertTrue(repository.save(first).isSuccess) + assertTrue(repository.save(second).isSuccess) + Files.writeString(root.resolve("jobs/schedules.json"), "{") + + assertEquals(first, repository.load().getOrThrow()) + } + } + + @Test + fun `scheduler persists advancement before exposing a due job`() = runTest { + withTempPersistence { persistence, _ -> + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! + val dueNow = schedule.nextRunAt + 1.minutes + + manager.runDueSchedules(dueNow) + + val persisted = ScheduleRepository(persistence).load().getOrThrow().single() + assertEquals(dueNow, persisted.lastRunAt) + assertEquals(dueNow + 1.minutes, persisted.nextRunAt) + assertTrue(manager.history.value.any { it.jobId.startsWith("job-") && it.event == "Enqueued" }) + } + } + + @Test + fun `only capability and flow jobs can be scheduled and ids are unique`() = runTest { + withTempPersistence { persistence, _ -> + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + + val first = manager.scheduleJob(template, 5)!! + val second = manager.scheduleJob(template, 5)!! + val setup = template.copy(id = "setup", type = JobType.Setup) + + assertNotEquals(first.id, second.id) + assertNull(manager.scheduleJob(setup, 5)) + assertEquals(2, manager.schedules.value.size) + } + } + + @Test + fun `unsupported persisted schedules are removed before scheduler startup`() = runTest { + withTempPersistence { persistence, _ -> + val unsafe = ScheduledJob( + id = "setup-schedule", + jobTemplate = template.copy(type = JobType.Setup), + intervalMinutes = 5, + nextRunAt = Instant.fromEpochMilliseconds(0) + ) + ScheduleRepository(persistence).save(listOf(unsafe)).getOrThrow() + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + + assertTrue(manager.startScheduler()) + + assertTrue(manager.schedules.value.isEmpty()) + assertTrue(ScheduleRepository(persistence).load().getOrThrow().isEmpty()) + } + } + + private suspend fun withTempPersistence( + block: suspend (TempPersistence, Path) -> Unit + ) { + val root = Files.createTempDirectory("plugin-toolkit-schedules-") + try { + block(TempPersistence(root), root) + } finally { + root.toFile().deleteRecursively() + } + } +} From 8100c5f10186891037762b0e658ca27d558ac0b6 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:19:54 +1000 Subject: [PATCH 19/52] Isolate scheduler persistence tests --- .../plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index 04509286..c1df9ad4 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -7,6 +7,7 @@ import org.wip.plugintoolkit.features.job.model.ScheduledJob 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.features.settings.model.JobSettings import java.nio.file.Files import java.nio.file.Path import kotlin.test.Test @@ -19,7 +20,7 @@ import kotlin.time.Instant class ScheduleRepositoryTest { private class TempPersistence(private val root: Path) : SettingsPersistence { - override suspend fun load(): AppSettings = AppSettings() + override suspend fun load(): AppSettings = AppSettings(jobs = JobSettings(maxConcurrentJobs = 0)) override suspend fun save(settings: AppSettings) = Unit override fun getSettingsDir(): String = root.toString() override fun getJobsDir(): String = root.resolve("jobs").toString() From b7015ea500ba86984bb783b884929594a2bcaf5a Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:58:14 +1000 Subject: [PATCH 20/52] fix: publish and harden standalone plugin tooling --- .github/workflows/release.yml | 6 ++--- README.md | 9 ++++++++ docs/PluginDevelopment.md | 9 ++++++++ jitpack.yml | 2 +- .../processor/generators/ManifestGenerator.kt | 22 ++++++++++--------- plugin-processor/build.gradle.kts | 20 +++++++++++++++++ .../ManifestGeneratorEscapingTest.kt | 13 +++++++++++ scripts/standalone-plugin.gradle.kts | 8 +++++++ 8 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 plugin-processor/src/test/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGeneratorEscapingTest.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 730bf6a0..2f367655 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,14 +32,14 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Run Unit Tests - run: .\gradlew test + run: .\gradlew test :composeApp:jvmTest :plugin-api:jvmTest - name: Build Release Distribution run: .\gradlew packageDistributionForCurrentOS packageUberJarForCurrentOS # run: .\gradlew packageReleaseDistributionForCurrentOS packageReleaseUberJarForCurrentOS - - name: Publish Plugin API - run: .\gradlew :plugin-api:publish + - name: Publish Plugin API and Processor + run: .\gradlew :plugin-api:publish :plugin-processor:publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index b510adf6..30da8aba 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,12 @@ java -jar yourPlugin/build/libs/yourPlugin-version-standalone.jar --info The generated JAR contains runtime dependencies, merges `META-INF/services` providers, and leaves KSP/compiler dependencies in the separate `plugin-processor` build-time artifact. + +External plugin builds need both artifacts at the same toolkit version: + +```kotlin +dependencies { + implementation("org.wip.plugintoolkit:plugin-api:") + ksp("org.wip.plugintoolkit:plugin-processor:") +} +``` diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index d08fb3ed..8955efc7 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -4,6 +4,15 @@ Welcome to the Plugin Development Guide! This document explains how to create, implement, and package plugins for the toolkit. +Use the runtime API and KSP processor at the same toolkit version: + +```kotlin +dependencies { + implementation("org.wip.plugintoolkit:plugin-api:") + ksp("org.wip.plugintoolkit:plugin-processor:") +} +``` + ## Core Concepts The toolkit uses a modular architecture where plugins are loaded dynamically at runtime. Each plugin is a JAR file containing a `PluginEntry` implementation and a manifest generated by KSP. diff --git a/jitpack.yml b/jitpack.yml index a14d6d33..9ad2c254 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -3,4 +3,4 @@ jdk: before_install: - chmod +x gradlew install: - - ./gradlew :plugin-api:publishToMavenLocal -Dmaven.repo.local=$HOME/.m2/repository + - ./gradlew :plugin-api:publishToMavenLocal :plugin-processor:publishToMavenLocal -Dmaven.repo.local=$HOME/.m2/repository 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 a35b7a71..8600c93a 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 @@ -160,16 +160,12 @@ object ManifestGenerator { if (requiresSettingsList.isEmpty()) { capabilitiesCode.add("requiresSettings = emptyList(),\n") } else { - capabilitiesCode.add( - "requiresSettings = listOf(%L),\n", - requiresSettingsList.joinToString { "\"$it\"" }) + capabilitiesCode.add("requiresSettings = %L,\n", stringListCodeBlock(requiresSettingsList)) } if (requiredLocksList.isEmpty()) { capabilitiesCode.add("requiredLocks = emptyList(),\n") } else { - capabilitiesCode.add( - "requiredLocks = listOf(%L),\n", - requiredLocksList.joinToString { "\"$it\"" }) + capabilitiesCode.add("requiredLocks = %L,\n", stringListCodeBlock(requiredLocksList)) } capabilitiesCode.add("parameters = mapOf(\n") capabilitiesCode.indent() @@ -417,10 +413,7 @@ object ManifestGenerator { val requiredByCapabilitiesCode = if (requiredByCapabilities.isEmpty()) { CodeBlock.of("emptyList()") } else { - CodeBlock.of( - "listOf(%L)", - requiredByCapabilities.joinToString { "\"$it\"" } - ) + stringListCodeBlock(requiredByCapabilities) } @@ -572,3 +565,12 @@ object ManifestGenerator { return manifestType.build() } } + +internal fun stringListCodeBlock(values: List): CodeBlock { + val result = CodeBlock.builder().add("listOf(") + values.forEachIndexed { index, value -> + if (index > 0) result.add(", ") + result.add("%S", value) + } + return result.add(")").build() +} diff --git a/plugin-processor/build.gradle.kts b/plugin-processor/build.gradle.kts index d3cdcc33..93276f16 100644 --- a/plugin-processor/build.gradle.kts +++ b/plugin-processor/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + id("maven-publish") } group = "org.wip.plugintoolkit" @@ -26,4 +27,23 @@ dependencies { implementation(libs.kotlinpoet) implementation(libs.kotlinpoet.ksp) implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/Wip-Sama/plugin-toolkit") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } } diff --git a/plugin-processor/src/test/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGeneratorEscapingTest.kt b/plugin-processor/src/test/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGeneratorEscapingTest.kt new file mode 100644 index 00000000..c02ba39f --- /dev/null +++ b/plugin-processor/src/test/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGeneratorEscapingTest.kt @@ -0,0 +1,13 @@ +package org.wip.plugintoolkit.api.processor.generators + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ManifestGeneratorEscapingTest { + @Test + fun stringListUsesKotlinStringEscaping() { + val generated = stringListCodeBlock(listOf("quote\"", "slash\\", "dollar\$value")).toString() + + assertEquals("listOf(\"quote\\\"\", \"slash\\\\\", \"dollar\${'\$'}value\")", generated) + } +} diff --git a/scripts/standalone-plugin.gradle.kts b/scripts/standalone-plugin.gradle.kts index 98979459..7c8cbaf3 100644 --- a/scripts/standalone-plugin.gradle.kts +++ b/scripts/standalone-plugin.gradle.kts @@ -13,6 +13,7 @@ import org.gradle.jvm.tasks.Jar import java.util.LinkedHashMap import java.util.LinkedHashSet import java.util.zip.ZipFile +import java.util.jar.Manifest @CacheableTask abstract class MergeStandaloneServices : DefaultTask() { @@ -78,6 +79,12 @@ abstract class VerifyStandaloneJar : DefaultTask() { check("org/wip/plugintoolkit/api/standalone/StandalonePluginMainKt.class" in names) { "Standalone launcher is missing" } + val manifestEntry = zip.getEntry("META-INF/MANIFEST.MF") ?: error("JAR manifest is missing") + val manifest = zip.getInputStream(manifestEntry).use(::Manifest) + check( + manifest.mainAttributes.getValue("Main-Class") == + "org.wip.plugintoolkit.api.standalone.StandalonePluginMainKt" + ) { "Standalone JAR has an invalid Main-Class" } check(names.none { it.startsWith("org/wip/plugintoolkit/api/processor/") || it.startsWith("com/google/devtools/ksp/") || @@ -121,6 +128,7 @@ val standaloneJar = tasks.register("standaloneJar") { } }) { exclude("META-INF/services/**") + exclude("META-INF/MANIFEST.MF", "module-info.class", "META-INF/versions/**/module-info.class") exclude("org/wip/plugintoolkit/api/processor/**") } from(standaloneServicesDir) From 2b9a6f4ea77fa2661312a3a84da943c0c48fc6a3 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:00:36 +1000 Subject: [PATCH 21/52] fix: make toolkit CLI reads safe and headless --- .../org/wip/plugintoolkit/cli/ToolkitCli.kt | 86 ++++++++++++------- .../core/utils/PlatformPathUtils.kt | 5 +- .../settings/logic/JvmSettingsPersistence.kt | 9 +- .../kotlin/org/wip/plugintoolkit/main.kt | 2 + .../wip/plugintoolkit/cli/ToolkitCliTest.kt | 31 +++++++ 5 files changed, 96 insertions(+), 37 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt index 69b86d9b..976084c8 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt @@ -1,19 +1,19 @@ package org.wip.plugintoolkit.cli -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString +import kotlinx.serialization.json.Json import org.wip.plugintoolkit.AppConfig import org.wip.plugintoolkit.core.DefaultSystemConfig -import org.wip.plugintoolkit.core.loomDispatcher -import org.wip.plugintoolkit.features.flows.logic.FlowRepository -import org.wip.plugintoolkit.features.plugin.logic.PluginRegistry +import org.wip.plugintoolkit.core.SystemConfig +import org.wip.plugintoolkit.features.flows.model.Flow import org.wip.plugintoolkit.features.plugin.model.InstalledPlugin import org.wip.plugintoolkit.features.settings.logic.JvmSettingsPersistence -import org.wip.plugintoolkit.features.settings.logic.SettingsRepository sealed interface ToolkitCliCommand { data object Help : ToolkitCliCommand @@ -55,7 +55,7 @@ internal suspend fun runToolkitCli( command: ToolkitCliCommand, output: (String) -> Unit = ::println, error: (String) -> Unit = System.err::println, - dataLoader: suspend (ToolkitCliCommand) -> ToolkitCliData = ::loadToolkitCliData + dataLoader: suspend (ToolkitCliCommand) -> ToolkitCliData = { loadToolkitCliData(it) } ): Int { when (command) { ToolkitCliCommand.Help -> { @@ -70,8 +70,6 @@ internal suspend fun runToolkitCli( } return try { - // The CLI owns the process and emits only its data on stdout. - Logger.setLogWriters() val data = withTimeout(CLI_STARTUP_TIMEOUT_MS) { dataLoader(command) } when (command) { ToolkitCliCommand.Status -> { @@ -93,7 +91,7 @@ internal suspend fun runToolkitCli( ToolkitCliCommand.Flows -> { if (data.flowNames.isEmpty()) output("No flows saved.") else data.flowNames.sorted().forEach(output) } - else -> Unit + ToolkitCliCommand.Help, ToolkitCliCommand.Version -> Unit } 0 } catch (exception: Throwable) { @@ -102,25 +100,55 @@ internal suspend fun runToolkitCli( } } -private suspend fun loadToolkitCliData(command: ToolkitCliCommand): ToolkitCliData { - val appConfig = DefaultSystemConfig() - val persistence = JvmSettingsPersistence(appConfig) - if (command == ToolkitCliCommand.Flows) { - return ToolkitCliData( - flowNames = FlowRepository.loadStoredFlows(persistence, appConfig).map { it.name } - ) +internal suspend fun loadToolkitCliData( + command: ToolkitCliCommand, + appConfig: SystemConfig = DefaultSystemConfig(), + settingsDir: String? = null +): ToolkitCliData = withContext(Dispatchers.IO) { + val persistence = JvmSettingsPersistence(appConfig, settingsDir) + when (command) { + ToolkitCliCommand.Flows -> ToolkitCliData(flowNames = loadFlowNamesReadOnly(persistence.getSettingsDir(), appConfig)) + ToolkitCliCommand.Status, ToolkitCliCommand.Plugins -> { + val settings = persistence.load() + val defaultFolder = "${persistence.getSettingsDir()}/${appConfig.PLUGINS_DIR_NAME}" + val folders = (listOf(defaultFolder) + settings.extensions.pluginFolders).distinct() + ToolkitCliData(plugins = folders.flatMap { loadPluginsReadOnly(it, appConfig) }) + } + else -> ToolkitCliData() } +} - val scope = CoroutineScope(SupervisorJob() + loomDispatcher) - return try { - val settingsRepository = SettingsRepository(persistence, scope) - settingsRepository.isLoaded.first { it } - val registry = PluginRegistry(settingsRepository, scope, loomDispatcher, appConfig) - registry.initialize() - ToolkitCliData(plugins = registry.installedPlugins.value) - } finally { - scope.cancel() +private val storageJson = Json { ignoreUnknownKeys = true } + +private fun loadFlowNamesReadOnly(settingsDir: String, appConfig: SystemConfig): List { + val flows = mutableListOf() + val flowsDir = Path("$settingsDir/flows") + if (SystemFileSystem.exists(flowsDir)) { + SystemFileSystem.list(flowsDir) + .filter { it.name.endsWith(".json") } + .mapNotNullTo(flows) { file -> + runCatching { + val content = SystemFileSystem.source(file).buffered().use { it.readString() } + storageJson.decodeFromString(content) + }.getOrNull() + } + } + + val legacyFile = Path("$settingsDir/${appConfig.FLOWS_FILE_NAME}") + if (SystemFileSystem.exists(legacyFile)) { + runCatching { + val content = SystemFileSystem.source(legacyFile).buffered().use { it.readString() } + if (content.isNotBlank()) storageJson.decodeFromString>(content) else emptyList() + }.getOrDefault(emptyList()).forEach(flows::add) } + return flows.distinctBy { it.name }.map { it.name } +} + +private fun loadPluginsReadOnly(folder: String, appConfig: SystemConfig): List { + val registryFile = Path("${folder.replace('\\', '/').removeSuffix("/")}/${appConfig.INSTALLED_PLUGINS_FILE_NAME}") + if (!SystemFileSystem.exists(registryFile)) return emptyList() + val content = SystemFileSystem.source(registryFile).buffered().use { it.readString() } + return storageJson.decodeFromString>(content) } private const val CLI_STARTUP_TIMEOUT_MS = 15_000L diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/core/utils/PlatformPathUtils.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/core/utils/PlatformPathUtils.kt index 4a95b7b3..1f18b50d 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/core/utils/PlatformPathUtils.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/core/utils/PlatformPathUtils.kt @@ -2,12 +2,11 @@ package org.wip.plugintoolkit.core.utils import org.wip.plugintoolkit.core.SystemConfig import org.koin.core.component.KoinComponent -import org.koin.core.component.inject object PlatformPathUtils : KoinComponent { - private val appConfig: SystemConfig by inject() + private val injectedAppConfig: SystemConfig by lazy { getKoin().get() } - fun getAppDataDir(): String { + fun getAppDataDir(appConfig: SystemConfig = injectedAppConfig): String { val appData = System.getenv("APPDATA") return if (PlatformUtils.isWindows && appData != null) { "$appData/${appConfig.APP_DATA_DIR_NAME}" diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt index f829b61e..f8cf8017 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/features/settings/logic/JvmSettingsPersistence.kt @@ -12,21 +12,20 @@ import kotlinx.serialization.json.Json import org.wip.plugintoolkit.core.SystemConfig import org.wip.plugintoolkit.core.utils.PlatformPathUtils import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.wip.plugintoolkit.features.settings.model.AppSettings class JvmSettingsPersistence( - private val configuredAppConfig: SystemConfig? = null + private val configuredAppConfig: SystemConfig? = null, + private val configuredSettingsDir: String? = null ) : SettingsPersistence, KoinComponent { - private val injectedAppConfig: SystemConfig by inject() - private val appConfig: SystemConfig get() = configuredAppConfig ?: injectedAppConfig + private val appConfig: SystemConfig by lazy { configuredAppConfig ?: getKoin().get() } private val json = Json { prettyPrint = true ignoreUnknownKeys = true encodeDefaults = true } - private val settingsDirPath by lazy { PlatformPathUtils.getAppDataDir() } + private val settingsDirPath by lazy { configuredSettingsDir ?: PlatformPathUtils.getAppDataDir(appConfig) } private val settingsDir by lazy { Path(settingsDirPath) } private val settingsFile by lazy { Path("$settingsDirPath/${appConfig.SETTINGS_FILE_NAME}") } diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index ddcfad7a..b13848b2 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -141,6 +141,8 @@ import org.wip.plugintoolkit.cli.ToolkitCliInvocation fun main(args: Array) { when (val invocation = parseToolkitCliInvocation(args)) { is ToolkitCliInvocation.Command -> { + // Keep command output machine-readable; desktop logging is configured only below. + Logger.setLogWriters() val exitCode = kotlinx.coroutines.runBlocking { runToolkitCli(invocation.command) } exitProcess(exitCode) } diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt index c643ab84..80ee4b5b 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt @@ -4,6 +4,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlinx.coroutines.test.runTest import kotlin.test.assertIs +import kotlin.test.assertTrue +import java.nio.file.Files class ToolkitCliTest { @Test @@ -46,4 +48,33 @@ class ToolkitCliTest { assertEquals(1, code) kotlin.test.assertTrue(errors.single().contains("registry failed")) } + + @Test + fun `real flow loader reads current and legacy storage without Koin or migration`() = runTest { + val root = Files.createTempDirectory("toolkit-cli-flows") + val flowsDir = Files.createDirectories(root.resolve("flows")) + Files.writeString(flowsDir.resolve("current.json"), """{"name":"Current","nodes":[],"connections":[]}""") + val legacy = root.resolve("flows.json") + Files.writeString(legacy, """[{"name":"Legacy","nodes":[],"connections":[]}]""") + + val data = loadToolkitCliData(ToolkitCliCommand.Flows, settingsDir = root.toString()) + + assertEquals(setOf("Current", "Legacy"), data.flowNames.toSet()) + assertTrue(Files.exists(legacy), "CLI reads must not migrate or delete legacy data") + assertEquals(1, Files.list(flowsDir).use { it.count() }) + } + + @Test + fun `real plugin loader reads registry without Koin or rewriting it`() = runTest { + val root = Files.createTempDirectory("toolkit-cli-plugins") + val pluginsDir = Files.createDirectories(root.resolve("plugins")) + val registry = pluginsDir.resolve("installed_plugins.json") + val original = """[{"pkg":"example.plugin","name":"Example","version":"1.0","installPath":"/plugins/example"}]""" + Files.writeString(registry, original) + + val data = loadToolkitCliData(ToolkitCliCommand.Plugins, settingsDir = root.toString()) + + assertEquals(listOf("example.plugin"), data.plugins.map { it.pkg }) + assertEquals(original, Files.readString(registry), "CLI reads must not rewrite registry state") + } } From 5d39e5ee5d25d32e1b2901151c624c1960754b41 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:03:01 +1000 Subject: [PATCH 22/52] fix: hydrate schedules before accepting mutations --- .../composeResources/values-it/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../features/job/logic/JobManager.kt | 70 +++++++++++++------ .../features/job/ui/JobDashboard.kt | 43 +++++++++--- .../features/job/viewmodel/JobViewModel.kt | 20 ++++-- .../job/logic/ScheduleRepositoryTest.kt | 25 +++++++ 6 files changed, 125 insertions(+), 35 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index a19d3963..8a747d4f 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -385,4 +385,5 @@ Ogni %1$d minuti · prossima %2$s Esegui ora la pianificazione Elimina pianificazione + Impossibile salvare la modifica. Non è stato cambiato nulla; verifica l’accesso allo spazio di archiviazione e riprova. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c32aa09a..5c6c856a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -436,4 +436,5 @@ Every %1$d minutes · next %2$s Run schedule now Delete schedule + The schedule change could not be saved. Nothing was changed; check storage access and try again. diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index 007fc55f..4156b73c 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -35,6 +35,7 @@ import org.wip.plugintoolkit.features.plugin.logic.PluginLoader import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.milliseconds import kotlin.uuid.Uuid class JobManager( @@ -86,6 +87,9 @@ class JobManager( private val scheduleStartMutex = Mutex() private val scheduleSignal = Channel(Channel.CONFLATED) private var schedulerStarted = false + private var schedulesLoaded = false + private var consecutiveSchedulePersistenceFailures = 0 + private var scheduleRetryNotBefore: kotlin.time.Instant? = null init { scope.launch { @@ -115,27 +119,18 @@ class JobManager( } } } + // Hydrate schedule state eagerly so the Scheduler UI never presents an empty, + // mutable snapshot while startup is still loading plugins. + scope.launch { + scheduleMutex.withLock { hydrateSchedulesLocked() } + } } /** Starts recurring execution after startup has finished loading plugins. Safe to call more than once. */ suspend fun startScheduler(): Boolean = scheduleStartMutex.withLock start@{ if (schedulerStarted) return@start true - val loadedSchedules = scheduleRepository.load().getOrElse { error -> - Logger.e(error) { "Scheduler disabled because persisted schedules could not be recovered" } - return@start false - } - val savedSchedules = loadedSchedules.filter { it.jobTemplate.type.canBeScheduled() } - val initialized = scheduleMutex.withLock initialize@{ - val current = _schedules.value - val merged = savedSchedules.filterNot { saved -> current.any { it.id == saved.id } } + current - val needsSanitization = savedSchedules.size != loadedSchedules.size - if ((needsSanitization || merged != savedSchedules) && !persistSchedules(merged)) { - return@initialize false - } - _schedules.value = merged - true - } + val initialized = scheduleMutex.withLock { hydrateSchedulesLocked() } if (!initialized) return@start false schedulerStarted = true @@ -152,6 +147,7 @@ class JobManager( @OptIn(kotlin.uuid.ExperimentalUuidApi::class) suspend fun scheduleJob(job: BackgroundJob, intervalMinutes: Long = 24 * 60L): ScheduledJob? = scheduleMutex.withLock { + if (!hydrateSchedulesLocked()) return@withLock null if (!job.type.canBeScheduled()) { Logger.w { "Refusing to schedule unsupported job type ${job.type}" } return@withLock null @@ -169,25 +165,30 @@ class JobManager( } suspend fun removeSchedule(id: String): Boolean = scheduleMutex.withLock { + if (!hydrateSchedulesLocked()) return@withLock false val updated = _schedules.value.filterNot { it.id == id } updated != _schedules.value && replaceSchedules(updated) } suspend fun setScheduleEnabled(id: String, enabled: Boolean): Boolean = scheduleMutex.withLock { + if (!hydrateSchedulesLocked()) return@withLock false val updated = _schedules.value.map { if (it.id == id) it.copy(enabled = enabled) else it } updated != _schedules.value && replaceSchedules(updated) } - suspend fun runScheduleNow(id: String) = scheduleMutex.withLock { + suspend fun runScheduleNow(id: String): Boolean = scheduleMutex.withLock { + if (!hydrateSchedulesLocked()) return@withLock false val now = Clock.System.now() val current = _schedules.value - val schedule = current.firstOrNull { it.id == id } ?: return@withLock + val schedule = current.firstOrNull { it.id == id } ?: return@withLock false val updated = current.map { if (it.id == id) it.afterRun(now) else it } - if (!replaceSchedules(updated)) return@withLock + if (!replaceSchedules(updated)) return@withLock false enqueueJob(schedule.jobTemplate.asFreshRun(now)) + true } internal suspend fun runDueSchedules(now: kotlin.time.Instant) = scheduleMutex.withLock { + if (!hydrateSchedulesLocked()) return@withLock val current = _schedules.value val due = current.filter { it.isDue(now) } if (due.isNotEmpty()) { @@ -218,22 +219,48 @@ class JobManager( return true } + private suspend fun hydrateSchedulesLocked(): Boolean { + if (schedulesLoaded) return true + val loaded = scheduleRepository.load().getOrElse { error -> + Logger.e(error) { "Schedules could not be recovered from persistent storage" } + return false + } + val supported = loaded.filter { it.jobTemplate.type.canBeScheduled() } + if (supported != loaded && !persistSchedules(supported)) return false + _schedules.value = supported + schedulesLoaded = true + return true + } + private suspend fun persistSchedules(updated: List): Boolean = scheduleRepository.save(updated).fold( - onSuccess = { true }, + onSuccess = { + consecutiveSchedulePersistenceFailures = 0 + scheduleRetryNotBefore = null + true + }, onFailure = { + consecutiveSchedulePersistenceFailures++ + val multiplier = 1L shl (consecutiveSchedulePersistenceFailures - 1).coerceAtMost(4) + val retryDelay = (SCHEDULER_RETRY_BASE_MS * multiplier).coerceAtMost(MAX_SCHEDULER_WAIT_MS) + scheduleRetryNotBefore = Clock.System.now() + retryDelay.milliseconds Logger.e(it) { "Schedule change was not applied because persistence failed" } false } ) - private fun nextSchedulerWaitMillis(now: kotlin.time.Instant): Long = - _schedules.value.asSequence() + private fun nextSchedulerWaitMillis(now: kotlin.time.Instant): Long { + val dueWait = _schedules.value.asSequence() .filter { it.enabled } .map { (it.nextRunAt - now).inWholeMilliseconds } .minOrNull() ?.coerceIn(MIN_SCHEDULER_WAIT_MS, MAX_SCHEDULER_WAIT_MS) ?: MAX_SCHEDULER_WAIT_MS + val retryWait = scheduleRetryNotBefore + ?.let { (it - now).inWholeMilliseconds.coerceAtLeast(0) } + ?: 0L + return maxOf(dueWait, retryWait).coerceAtMost(MAX_SCHEDULER_WAIT_MS) + } private fun startWorkers() { repeat(maxConcurrentJobs) { @@ -699,3 +726,4 @@ class JobManager( private const val MIN_SCHEDULER_WAIT_MS = 1_000L private const val MAX_SCHEDULER_WAIT_MS = 60_000L +private const val SCHEDULER_RETRY_BASE_MS = 5_000L diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt index a4cbcdb0..887ae30a 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt @@ -112,6 +112,7 @@ import plugintoolkit.composeapp.generated.resources.job_running_jobs import plugintoolkit.composeapp.generated.resources.job_schedule_create import plugintoolkit.composeapp.generated.resources.job_schedule_delete import plugintoolkit.composeapp.generated.resources.job_schedule_empty +import plugintoolkit.composeapp.generated.resources.job_schedule_save_failed import plugintoolkit.composeapp.generated.resources.job_schedule_interval_label import plugintoolkit.composeapp.generated.resources.job_schedule_next_format import plugintoolkit.composeapp.generated.resources.job_schedule_run_now @@ -320,6 +321,7 @@ fun EndedTab(viewModel: JobViewModel) { val endedJobs by viewModel.endedJobs.collectAsState() val logsMap by viewModel.jobLogs.collectAsState(initial = emptyMap()) val progressMap by viewModel.jobProgress.collectAsState(initial = emptyMap()) + val scheduleOperationFailed by viewModel.scheduleOperationFailed.collectAsState() var jobToSchedule by remember { mutableStateOf(null) } var intervalText by remember { mutableStateOf(DEFAULT_SCHEDULE_INTERVAL_MINUTES.toString()) } @@ -329,19 +331,29 @@ fun EndedTab(viewModel: JobViewModel) { onDismissRequest = { jobToSchedule = null }, title = { Text(stringResource(Res.string.job_schedule_title)) }, text = { - OutlinedTextField( - value = intervalText, - onValueChange = { value -> intervalText = value.filter(Char::isDigit) }, - label = { Text(stringResource(Res.string.job_schedule_interval_label)) }, - singleLine = true - ) + Column(verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small)) { + OutlinedTextField( + value = intervalText, + onValueChange = { value -> intervalText = value.filter(Char::isDigit) }, + label = { Text(stringResource(Res.string.job_schedule_interval_label)) }, + singleLine = true + ) + if (scheduleOperationFailed) { + Text( + stringResource(Res.string.job_schedule_save_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } }, confirmButton = { TextButton( enabled = interval != null, onClick = { - viewModel.scheduleRecurring(job, interval!!) - jobToSchedule = null + viewModel.scheduleRecurring(job, interval!!) { succeeded -> + if (succeeded) jobToSchedule = null + } } ) { Text(stringResource(Res.string.job_schedule_create)) } }, @@ -385,6 +397,7 @@ fun EndedTab(viewModel: JobViewModel) { onClear = { viewModel.clearEndedJob(job.id) }, onSchedule = if (job.type.canBeScheduled()) { { + viewModel.clearScheduleError() intervalText = DEFAULT_SCHEDULE_INTERVAL_MINUTES.toString() jobToSchedule = job } @@ -403,8 +416,9 @@ fun EndedTab(viewModel: JobViewModel) { @Composable fun SchedulerTab(viewModel: JobViewModel) { val schedules by viewModel.schedules.collectAsState() + val scheduleOperationFailed by viewModel.scheduleOperationFailed.collectAsState() - if (schedules.isEmpty()) { + if (schedules.isEmpty() && !scheduleOperationFailed) { EmptyState(stringResource(Res.string.job_schedule_empty), Icons.Default.Schedule) return } @@ -413,6 +427,17 @@ fun SchedulerTab(viewModel: JobViewModel) { modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) ) { + if (scheduleOperationFailed) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Text( + stringResource(Res.string.job_schedule_save_failed), + modifier = Modifier.fillMaxWidth().padding(ToolkitTheme.spacing.medium), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } items(schedules, key = { it.id }) { schedule -> Card( modifier = Modifier.fillMaxWidth(), diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt index c7993440..b1a87c25 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt @@ -3,6 +3,8 @@ package org.wip.plugintoolkit.features.job.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -19,6 +21,8 @@ class JobViewModel( val history = jobManager.history val endedJobs = jobManager.endedJobs val schedules = jobManager.schedules + private val _scheduleOperationFailed = MutableStateFlow(false) + val scheduleOperationFailed = _scheduleOperationFailed.asStateFlow() val runningJobs = jobs.map { list -> list.filter { it.status == JobStatus.Running } @@ -74,19 +78,25 @@ class JobViewModel( } } - fun scheduleRecurring(job: BackgroundJob, intervalMinutes: Long) { - viewModelScope.launch { jobManager.scheduleJob(job, intervalMinutes) } + fun scheduleRecurring(job: BackgroundJob, intervalMinutes: Long, onResult: (Boolean) -> Unit = {}) { + viewModelScope.launch { + val succeeded = jobManager.scheduleJob(job, intervalMinutes) != null + _scheduleOperationFailed.value = !succeeded + onResult(succeeded) + } } fun removeSchedule(id: String) { - viewModelScope.launch { jobManager.removeSchedule(id) } + viewModelScope.launch { _scheduleOperationFailed.value = !jobManager.removeSchedule(id) } } fun setScheduleEnabled(id: String, enabled: Boolean) { - viewModelScope.launch { jobManager.setScheduleEnabled(id, enabled) } + viewModelScope.launch { _scheduleOperationFailed.value = !jobManager.setScheduleEnabled(id, enabled) } } fun runScheduleNow(id: String) { - viewModelScope.launch { jobManager.runScheduleNow(id) } + viewModelScope.launch { _scheduleOperationFailed.value = !jobManager.runScheduleNow(id) } } + + fun clearScheduleError() { _scheduleOperationFailed.value = false } } diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index c1df9ad4..705fcf0f 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -107,6 +107,31 @@ class ScheduleRepositoryTest { } } + @Test + fun `mutation before scheduler startup preserves schedules already on disk`() = runTest { + withTempPersistence { persistence, _ -> + val persisted = ScheduledJob( + id = "persisted", + jobTemplate = template, + intervalMinutes = 15, + nextRunAt = Instant.fromEpochMilliseconds(1_000) + ) + ScheduleRepository(persistence).save(listOf(persisted)).getOrThrow() + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + + val added = manager.scheduleJob(template.copy(id = "new"), 30) + + assertTrue(added != null) + assertEquals(setOf("persisted", added.id), manager.schedules.value.map { it.id }.toSet()) + assertEquals( + setOf("persisted", added.id), + ScheduleRepository(persistence).load().getOrThrow().map { it.id }.toSet() + ) + } + } + private suspend fun withTempPersistence( block: suspend (TempPersistence, Path) -> Unit ) { 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 23/52] 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 24/52] 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 25/52] 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 206ae3648837da615ecd5bfe97a36c60215308be Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:11:17 +1000 Subject: [PATCH 26/52] fix: retain data-class copy ABI for plugins --- .../wip/plugintoolkit/api/ManifestModels.kt | 64 +++++++++++++++++++ .../api/processor/GeneratorUtils.kt | 33 ++++++++-- .../api/processor/ManifestProcessor.kt | 4 +- .../PluginManifestBinaryCompatibilityTest.kt | 22 +++++++ .../SettingMetadataBinaryCompatibilityTest.kt | 23 +++++++ 5 files changed, 138 insertions(+), 8 deletions(-) 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 05429aef..d8c897c2 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt @@ -283,6 +283,34 @@ data class SettingMetadata( semanticTypes = emptyList(), autogeneratedPattern = null ) + + @Deprecated("Binary compatibility copy", level = DeprecationLevel.HIDDEN) + fun copy( + defaultValue: JsonElement?, description: String, type: DataType, required: Boolean, + secret: Boolean, constraints: ParameterConstraints?, requiredByCapabilities: List + ): SettingMetadata = SettingMetadata( + defaultValue, description, type, required, secret, constraints, requiredByCapabilities, + semanticTypes, autogeneratedPattern + ) + + companion object { + @JvmStatic + @Deprecated("Binary compatibility copy bridge", level = DeprecationLevel.HIDDEN) + fun `copy$default`( + self: SettingMetadata, + defaultValue: JsonElement?, description: String?, type: DataType?, required: Boolean, + secret: Boolean, constraints: ParameterConstraints?, requiredByCapabilities: List?, + mask: Int, marker: Any? + ): SettingMetadata = self.copy( + if (mask and 0x01 != 0) self.defaultValue else defaultValue, + if (mask and 0x02 != 0) self.description else requireNotNull(description), + if (mask and 0x04 != 0) self.type else requireNotNull(type), + if (mask and 0x08 != 0) self.required else required, + if (mask and 0x10 != 0) self.secret else secret, + if (mask and 0x20 != 0) self.constraints else constraints, + if (mask and 0x40 != 0) self.requiredByCapabilities else requireNotNull(requiredByCapabilities) + ) + } } /** @@ -333,6 +361,42 @@ data class PluginManifest( hasMigrations = hasMigrations, uiPages = emptyList() ) + + @Deprecated("Binary compatibility copy", level = DeprecationLevel.HIDDEN) + fun copy( + manifestVersion: String, plugin: PluginInfo, requirements: Requirements, + defaultParameters: Map?, capabilities: List, + actions: List, settings: Map?, changelog: Changelog?, + hasUpdateHandler: Boolean, hasSetupHandler: Boolean, hasMigrations: Boolean + ): PluginManifest = PluginManifest( + manifestVersion, plugin, requirements, defaultParameters, capabilities, actions, settings, changelog, + hasUpdateHandler, hasSetupHandler, hasMigrations, uiPages + ) + + companion object { + @JvmStatic + @Deprecated("Binary compatibility copy bridge", level = DeprecationLevel.HIDDEN) + fun `copy$default`( + self: PluginManifest, + manifestVersion: String?, plugin: PluginInfo?, requirements: Requirements?, + defaultParameters: Map?, capabilities: List?, + actions: List?, settings: Map?, changelog: Changelog?, + hasUpdateHandler: Boolean, hasSetupHandler: Boolean, hasMigrations: Boolean, + mask: Int, marker: Any? + ): PluginManifest = self.copy( + if (mask and 0x001 != 0) self.manifestVersion else requireNotNull(manifestVersion), + if (mask and 0x002 != 0) self.plugin else requireNotNull(plugin), + if (mask and 0x004 != 0) self.requirements else requireNotNull(requirements), + if (mask and 0x008 != 0) self.defaultParameters else defaultParameters, + if (mask and 0x010 != 0) self.capabilities else requireNotNull(capabilities), + if (mask and 0x020 != 0) self.actions else requireNotNull(actions), + if (mask and 0x040 != 0) self.settings else settings, + if (mask and 0x080 != 0) self.changelog else changelog, + if (mask and 0x100 != 0) self.hasUpdateHandler else hasUpdateHandler, + if (mask and 0x200 != 0) self.hasSetupHandler else hasSetupHandler, + if (mask and 0x400 != 0) self.hasMigrations else hasMigrations + ) + } } /** diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt index bd2771e9..4b68d921 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt @@ -110,17 +110,36 @@ object GeneratorUtils { return this.annotationType.resolve().declaration.qualifiedName?.asString() == name } - fun extractUiPages(classDeclaration: KSClassDeclaration): List = + fun extractUiPages( + classDeclaration: KSClassDeclaration, + reportError: (String) -> Unit = {} + ): List = classDeclaration.annotations .filter { it.hasQualifiedName(ProcessorConstants.PLUGIN_UI_PAGE_ANNOTATION) } - .map { annotation -> + .mapNotNull { annotation -> + val id = annotation.arguments.find { it.name?.asString() == "id" }?.value as? String + val title = annotation.arguments.find { it.name?.asString() == "title" }?.value as? String + if (id == null || title == null) { + reportError("@PluginUiPage requires string 'id' and 'title' arguments") + return@mapNotNull null + } + val rawCapabilities = annotation.arguments + .find { it.name?.asString() == "capabilityNames" } + ?.value + if (rawCapabilities != null && rawCapabilities !is List<*>) { + reportError("@PluginUiPage.capabilityNames must be a string array") + return@mapNotNull null + } + val capabilityNames = (rawCapabilities as? List<*>)?.filterIsInstance().orEmpty() + if ((rawCapabilities as? List<*>)?.size != capabilityNames.size) { + reportError("@PluginUiPage.capabilityNames must contain only strings") + return@mapNotNull null + } PluginUiPage( - id = annotation.arguments.first { it.name?.asString() == "id" }.value as String, - title = annotation.arguments.first { it.name?.asString() == "title" }.value as String, + id = id, + title = title, description = annotation.arguments.find { it.name?.asString() == "description" }?.value as? String ?: "", - capabilityNames = (annotation.arguments.find { it.name?.asString() == "capabilityNames" }?.value as? List<*>) - ?.filterIsInstance() - ?: emptyList() + capabilityNames = capabilityNames ) } .toList() diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt index 58703513..8eb3e213 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt @@ -176,7 +176,9 @@ class ManifestProcessor( it.annotations.any { ann -> ann.hasQualifiedName(PLUGIN_ACTION_ANNOTATION) } }.toList() - val uiPages = org.wip.plugintoolkit.api.processor.GeneratorUtils.extractUiPages(classDeclaration) + val uiPages = org.wip.plugintoolkit.api.processor.GeneratorUtils.extractUiPages(classDeclaration) { message -> + logger.error(message, classDeclaration) + } uiPages.filter { it.id.isBlank() }.forEach { logger.error("@PluginUiPage.id must not be blank", classDeclaration) } diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt index cce8835e..3e441aaf 100644 --- a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt @@ -1,6 +1,7 @@ package org.wip.plugintoolkit.api import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class PluginManifestBinaryCompatibilityTest { @@ -17,4 +18,25 @@ class PluginManifestBinaryCompatibilityTest { it.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" }) } + + @Test + fun `retains copy bridges used before plugin UI pages`() { + val methods = PluginManifest::class.java.declaredMethods + val oldCopy = methods.single { it.name == "copy" && it.parameterCount == 11 } + val oldDefaultCopy = methods.single { it.name == "copy\$default" && it.parameterCount == 14 } + val original = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("id", "name", "1", "description"), + requirements = Requirements(1, 1), + uiPages = listOf(PluginUiPage("page", "Page")) + ) + + val copied = oldDefaultCopy.invoke( + null, original, null, null, null, null, null, null, null, null, + false, false, false, 0x7FF, null + ) as PluginManifest + + assertEquals(original, copied) + assertEquals(PluginManifest::class.java, oldCopy.returnType) + } } diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt index e8ba24ab..fbce25af 100644 --- a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt @@ -1,7 +1,9 @@ package org.wip.plugintoolkit.api import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class SettingMetadataBinaryCompatibilityTest { @@ -24,4 +26,25 @@ class SettingMetadataBinaryCompatibilityTest { "SettingMetadata must keep the constructor used by plugins compiled against the previous API" ) } + + @Test + fun `retains pre-hints copy bridges`() { + val methods = SettingMetadata::class.java.declaredMethods + val oldCopy = methods.single { it.name == "copy" && it.parameterCount == 7 } + val oldDefaultCopy = methods.single { it.name == "copy\$default" && it.parameterCount == 10 } + val original = SettingMetadata( + defaultValue = JsonPrimitive("value"), + description = "description", + type = DataType.Primitive(PrimitiveType.STRING), + semanticTypes = listOf(SemanticType(null, "text", null)), + autogeneratedPattern = "{input}" + ) + + val copied = oldDefaultCopy.invoke( + null, original, null, null, null, false, false, null, null, 0x7F, null + ) as SettingMetadata + + assertEquals(original, copied) + assertEquals(SettingMetadata::class.java, oldCopy.returnType) + } } 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 27/52] 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 28/52] 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 6e74e7b99f4a1c44828c3856007b8c660c3405f8 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:42:49 +1000 Subject: [PATCH 29/52] fix: surface and retry schedule load failures --- .../composeResources/values-it/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../features/job/logic/JobManager.kt | 22 ++++++++--- .../features/job/ui/JobDashboard.kt | 15 +++++++- .../features/job/viewmodel/JobViewModel.kt | 1 + .../job/logic/ScheduleRepositoryTest.kt | 38 +++++++++++++++++++ 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 8a747d4f..3185c788 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -386,4 +386,5 @@ Esegui ora la pianificazione Elimina pianificazione Impossibile salvare la modifica. Non è stato cambiato nulla; verifica l’accesso allo spazio di archiviazione e riprova. + Impossibile caricare le pianificazioni salvate. Non verranno eseguite finché l’archiviazione non sarà di nuovo disponibile; il scheduler riproverà automaticamente. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 5c6c856a..a67d8b16 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -437,4 +437,5 @@ Run schedule now Delete schedule The schedule change could not be saved. Nothing was changed; check storage access and try again. + Saved schedules could not be loaded. They will not run until storage recovers; the scheduler will retry automatically. diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index 4156b73c..e91b6f6d 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -65,6 +65,8 @@ class JobManager( private val _schedules = MutableStateFlow>(emptyList()) val schedules: StateFlow> = _schedules.asStateFlow() + private val _scheduleLoadFailed = MutableStateFlow(false) + val scheduleLoadFailed: StateFlow = _scheduleLoadFailed.asStateFlow() private val _jobLogs = MutableStateFlow>>(emptyMap()) val jobLogs: StateFlow>> = _jobLogs.asStateFlow() @@ -128,21 +130,24 @@ class JobManager( /** Starts recurring execution after startup has finished loading plugins. Safe to call more than once. */ suspend fun startScheduler(): Boolean = scheduleStartMutex.withLock start@{ - if (schedulerStarted) return@start true - val initialized = scheduleMutex.withLock { hydrateSchedulesLocked() } - if (!initialized) return@start false + if (schedulerStarted) return@start initialized schedulerStarted = true scope.launch { while (isActive) { + val hydrated = scheduleMutex.withLock { hydrateSchedulesLocked() } + if (!hydrated) { + withTimeoutOrNull(SCHEDULE_LOAD_RETRY_MS) { scheduleSignal.receive() } + continue + } val now = Clock.System.now() runDueSchedules(now) val waitMillis = nextSchedulerWaitMillis(Clock.System.now()) withTimeoutOrNull(waitMillis) { scheduleSignal.receive() } } } - true + initialized } @OptIn(kotlin.uuid.ExperimentalUuidApi::class) @@ -167,13 +172,15 @@ class JobManager( suspend fun removeSchedule(id: String): Boolean = scheduleMutex.withLock { if (!hydrateSchedulesLocked()) return@withLock false val updated = _schedules.value.filterNot { it.id == id } - updated != _schedules.value && replaceSchedules(updated) + if (updated == _schedules.value) return@withLock true + replaceSchedules(updated) } suspend fun setScheduleEnabled(id: String, enabled: Boolean): Boolean = scheduleMutex.withLock { if (!hydrateSchedulesLocked()) return@withLock false val updated = _schedules.value.map { if (it.id == id) it.copy(enabled = enabled) else it } - updated != _schedules.value && replaceSchedules(updated) + if (updated == _schedules.value) return@withLock true + replaceSchedules(updated) } suspend fun runScheduleNow(id: String): Boolean = scheduleMutex.withLock { @@ -222,6 +229,7 @@ class JobManager( private suspend fun hydrateSchedulesLocked(): Boolean { if (schedulesLoaded) return true val loaded = scheduleRepository.load().getOrElse { error -> + _scheduleLoadFailed.value = true Logger.e(error) { "Schedules could not be recovered from persistent storage" } return false } @@ -229,6 +237,7 @@ class JobManager( if (supported != loaded && !persistSchedules(supported)) return false _schedules.value = supported schedulesLoaded = true + _scheduleLoadFailed.value = false return true } @@ -727,3 +736,4 @@ class JobManager( private const val MIN_SCHEDULER_WAIT_MS = 1_000L private const val MAX_SCHEDULER_WAIT_MS = 60_000L private const val SCHEDULER_RETRY_BASE_MS = 5_000L +private const val SCHEDULE_LOAD_RETRY_MS = 30_000L diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt index 887ae30a..152d2630 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/ui/JobDashboard.kt @@ -112,6 +112,7 @@ import plugintoolkit.composeapp.generated.resources.job_running_jobs import plugintoolkit.composeapp.generated.resources.job_schedule_create import plugintoolkit.composeapp.generated.resources.job_schedule_delete import plugintoolkit.composeapp.generated.resources.job_schedule_empty +import plugintoolkit.composeapp.generated.resources.job_schedule_load_failed import plugintoolkit.composeapp.generated.resources.job_schedule_save_failed import plugintoolkit.composeapp.generated.resources.job_schedule_interval_label import plugintoolkit.composeapp.generated.resources.job_schedule_next_format @@ -417,8 +418,9 @@ fun EndedTab(viewModel: JobViewModel) { fun SchedulerTab(viewModel: JobViewModel) { val schedules by viewModel.schedules.collectAsState() val scheduleOperationFailed by viewModel.scheduleOperationFailed.collectAsState() + val scheduleLoadFailed by viewModel.scheduleLoadFailed.collectAsState() - if (schedules.isEmpty() && !scheduleOperationFailed) { + if (schedules.isEmpty() && !scheduleOperationFailed && !scheduleLoadFailed) { EmptyState(stringResource(Res.string.job_schedule_empty), Icons.Default.Schedule) return } @@ -427,6 +429,17 @@ fun SchedulerTab(viewModel: JobViewModel) { modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.medium) ) { + if (scheduleLoadFailed) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Text( + stringResource(Res.string.job_schedule_load_failed), + modifier = Modifier.fillMaxWidth().padding(ToolkitTheme.spacing.medium), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } if (scheduleOperationFailed) { item { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt index b1a87c25..c410fb4b 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/viewmodel/JobViewModel.kt @@ -21,6 +21,7 @@ class JobViewModel( val history = jobManager.history val endedJobs = jobManager.endedJobs val schedules = jobManager.schedules + val scheduleLoadFailed = jobManager.scheduleLoadFailed private val _scheduleOperationFailed = MutableStateFlow(false) val scheduleOperationFailed = _scheduleOperationFailed.asStateFlow() diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index 705fcf0f..b4b06593 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -12,6 +12,7 @@ import java.nio.file.Files import java.nio.file.Path import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -132,6 +133,43 @@ class ScheduleRepositoryTest { } } + @Test + fun `corrupt schedule storage is surfaced and can recover on retry`() = runTest { + withTempPersistence { persistence, root -> + val jobsDir = Files.createDirectories(root.resolve("jobs")) + Files.writeString(jobsDir.resolve("schedules.json"), "{") + Files.writeString(jobsDir.resolve("schedules.json.bak"), "{") + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + testScheduler.runCurrent() + + assertFalse(manager.startScheduler()) + assertTrue(manager.scheduleLoadFailed.value) + + val recovered = listOf(ScheduledJob("recovered", template, 10, Instant.fromEpochMilliseconds(1_000))) + ScheduleRepository(persistence).save(recovered).getOrThrow() + + assertTrue(manager.startScheduler()) + assertFalse(manager.scheduleLoadFailed.value) + assertEquals(recovered, manager.schedules.value) + } + } + + @Test + fun `idempotent schedule mutations are successful no ops`() = runTest { + withTempPersistence { persistence, _ -> + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + val schedule = manager.scheduleJob(template, 10)!! + + assertTrue(manager.removeSchedule(schedule.id)) + assertTrue(manager.removeSchedule(schedule.id)) + assertTrue(manager.setScheduleEnabled(schedule.id, enabled = false)) + } + } + private suspend fun withTempPersistence( block: suspend (TempPersistence, Path) -> Unit ) { 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 30/52] 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 f307171cf468165f672ea4b38aae0c19777f37ca Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:44:41 +1000 Subject: [PATCH 31/52] fix: preserve desktop startup for launcher arguments --- .../kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt | 9 ++++++++- .../kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt index 976084c8..e5a3dea6 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt @@ -43,7 +43,14 @@ fun parseToolkitCliInvocation(args: Array): ToolkitCliInvocation { if (args.isEmpty() || args.all { it == DefaultSystemConfig().STARTUP_FLAG_BACKGROUND || it.startsWith("-psn_") }) { return ToolkitCliInvocation.Desktop } - return ToolkitCliInvocation.Invalid(args.toList()) + val knownCommandRoots = setOf("--help", "-h", "help", "--version", "version", "status", "plugins", "flows") + return if (args.first() in knownCommandRoots) { + ToolkitCliInvocation.Invalid(args.toList()) + } else { + // Desktop launchers and future file associations may inject their own arguments. + // Preserve the pre-CLI behavior unless the user clearly attempted a toolkit command. + ToolkitCliInvocation.Desktop + } } internal data class ToolkitCliData( diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt index 80ee4b5b..fcbb9a82 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt @@ -16,9 +16,11 @@ class ToolkitCliTest { } @Test - fun `desktop flags and unknown commands are distinguished`() { + fun `desktop flags and launcher arguments do not abort GUI startup`() { assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("--background"))) - assertIs(parseToolkitCliInvocation(arrayOf("unknown"))) + assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("--launcher-token"))) + assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("document.toolkit"))) + assertIs(parseToolkitCliInvocation(arrayOf("plugins", "unknown"))) } @Test From 200c064674d02ff7852522b8347c2dc4ac1f6957 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:45:02 +1000 Subject: [PATCH 32/52] docs: flag the processor coordinate migration --- README.md | 4 ++++ docs/PluginDevelopment.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 30da8aba..33ac48ac 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,7 @@ dependencies { ksp("org.wip.plugintoolkit:plugin-processor:") } ``` + +> **2.0 migration:** replace any previous `ksp("org.wip.plugintoolkit:plugin-api:…")` dependency with +> `plugin-processor`. The old coordinate no longer contains a KSP provider, so leaving it unchanged can produce +> a successful build with no generated manifest or plugin entry point. diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 8955efc7..3dd72f6d 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -13,6 +13,10 @@ dependencies { } ``` +> **Required when upgrading to 2.0:** `plugin-api` is now runtime-only. A build that still declares +> `ksp("org.wip.plugintoolkit:plugin-api:…")` may succeed without running any processor and will produce an +> unusable plugin with no generated manifest or entry point. Change the KSP dependency to `plugin-processor`. + ## Core Concepts The toolkit uses a modular architecture where plugins are loaded dynamically at runtime. Each plugin is a JAR file containing a `PluginEntry` implementation and a manifest generated by KSP. From 3e7c11f009430f5cc896decaeee2b423467c2114 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:58:55 +1000 Subject: [PATCH 33/52] fix: keep scheduler independent and failure-aware --- .../features/job/logic/JobManager.kt | 5 +- .../features/job/logic/ScheduleRepository.kt | 60 +++++++++++-------- .../kotlin/org/wip/plugintoolkit/main.kt | 9 +-- .../job/logic/ScheduleRepositoryTest.kt | 16 +++++ 4 files changed, 59 insertions(+), 31 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index e91b6f6d..42f496c6 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -234,7 +234,10 @@ class JobManager( return false } val supported = loaded.filter { it.jobTemplate.type.canBeScheduled() } - if (supported != loaded && !persistSchedules(supported)) return false + if (supported != loaded && !persistSchedules(supported)) { + _scheduleLoadFailed.value = true + return false + } _schedules.value = supported schedulesLoaded = true _scheduleLoadFailed.value = false diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt index 627c8ee8..08b40086 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt @@ -18,49 +18,57 @@ class ScheduleRepository(private val settingsPersistence: SettingsPersistence) { private fun file(): Path { val jobsDir = Path(settingsPersistence.getJobsDir()) if (!SystemFileSystem.exists(jobsDir)) SystemFileSystem.createDirectories(jobsDir) + check(SystemFileSystem.metadataOrNull(jobsDir)?.isDirectory == true) { + "Schedule storage path is not a directory: $jobsDir" + } return Path("$jobsDir/schedules.json") } suspend fun load(): Result> = withContext(Dispatchers.IO) { - val primary = file() - val backup = Path("$primary.bak") - if (!SystemFileSystem.exists(primary) && !SystemFileSystem.exists(backup)) { - return@withContext Result.success(emptyList()) - } - - val primaryResult = runCatching { read(primary) } - if (primaryResult.isSuccess) return@withContext primaryResult - - Logger.e(primaryResult.exceptionOrNull()) { "Failed to load schedules; trying backup" } - if (!SystemFileSystem.exists(backup)) return@withContext primaryResult + runCatching { + val primary = file() + val backup = Path("$primary.bak") + if (!SystemFileSystem.exists(primary) && !SystemFileSystem.exists(backup)) { + return@runCatching emptyList() + } - runCatching { read(backup) } - .onSuccess { Logger.w { "Recovered schedules from backup" } } - .onFailure { Logger.e(it) { "Failed to load schedule backup" } } + try { + read(primary) + } catch (primaryError: Exception) { + Logger.e(primaryError) { "Failed to load schedules; trying backup" } + if (!SystemFileSystem.exists(backup)) throw primaryError + try { + read(backup).also { Logger.w { "Recovered schedules from backup" } } + } catch (backupError: Exception) { + Logger.e(backupError) { "Failed to load schedule backup" } + throw backupError + } + } + } } suspend fun save(schedules: List): Result = withContext(Dispatchers.IO) { - val primary = file() - val temporary = Path("$primary.tmp") - val backup = Path("$primary.bak") - val backupTemporary = Path("$primary.bak.tmp") - + var temporary: Path? = null + var backupTemporary: Path? = null runCatching { - write(temporary, schedules) + val primary = file() + temporary = Path("$primary.tmp") + val backup = Path("$primary.bak") + backupTemporary = Path("$primary.bak.tmp") + write(temporary!!, schedules) // Never replace a known-good backup with a corrupt/partial primary. if (SystemFileSystem.exists(primary)) { runCatching { read(primary) }.getOrNull()?.let { previous -> - write(backupTemporary, previous) - SystemFileSystem.atomicMove(backupTemporary, backup) + write(backupTemporary!!, previous) + SystemFileSystem.atomicMove(backupTemporary!!, backup) } } - - SystemFileSystem.atomicMove(temporary, primary) + SystemFileSystem.atomicMove(temporary!!, primary) }.onFailure { Logger.e(it) { "Failed to save schedules atomically" } } .also { - runCatching { if (SystemFileSystem.exists(temporary)) SystemFileSystem.delete(temporary) } - runCatching { if (SystemFileSystem.exists(backupTemporary)) SystemFileSystem.delete(backupTemporary) } + temporary?.let { path -> runCatching { if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) } } + backupTemporary?.let { path -> runCatching { if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) } } } } diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index 761ef3e8..7679be16 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -298,6 +298,11 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = updateStatus("Initializing plugins...") // Initialize registry and subsequently load plugins appScope.launch { + // Scheduling is a host service: a blocked or broken third-party plugin must not disable it globally. + if (!koin.get().startScheduler()) { + Logger.e { "Startup: Scheduler state could not be loaded safely; background retries are active" } + } + try { registry.initialize() } catch (e: Throwable) { @@ -333,10 +338,6 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = } } pluginStartupJobs.joinAll() - - if (!koin.get().startScheduler()) { - Logger.e { "Startup: Scheduler was not started because its state could not be loaded safely" } - } } updateStatus("Refreshing repositories...") diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index b4b06593..1f3586ce 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -170,6 +170,22 @@ class ScheduleRepositoryTest { } } + @Test + fun `jobs path failures stay inside the repository result contract`() = runTest { + withTempPersistence { persistence, root -> + Files.writeString(root.resolve("jobs"), "not a directory") + + assertTrue(ScheduleRepository(persistence).load().isFailure) + assertTrue(ScheduleRepository(persistence).save(emptyList()).isFailure) + + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + assertFalse(manager.startScheduler()) + assertTrue(manager.scheduleLoadFailed.value) + } + } + private suspend fun withTempPersistence( block: suspend (TempPersistence, Path) -> Unit ) { From eb44cc99bcfec5dea4e4c6a653ef066045f2ada8 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:00:20 +1000 Subject: [PATCH 34/52] fix: inspect standalone manifests without plugin startup --- .../api/standalone/StandalonePluginMain.kt | 48 ++++++------------- .../standalone/StandalonePluginMainTest.kt | 27 ++++------- scripts/standalone-plugin.gradle.kts | 1 + 3 files changed, 24 insertions(+), 52 deletions(-) diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt index 3a9b4fa3..eadec022 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMain.kt @@ -1,10 +1,7 @@ package org.wip.plugintoolkit.api.standalone -import org.koin.core.context.startKoin -import org.koin.core.context.stopKoin -import org.wip.plugintoolkit.api.PluginEntry -import org.wip.plugintoolkit.api.PluginModuleProvider -import java.util.ServiceLoader +import org.wip.plugintoolkit.api.ManifestLoader +import org.wip.plugintoolkit.api.PluginManifest import kotlin.system.exitProcess /** Entry point embedded in standalone plugin JARs. */ @@ -17,7 +14,7 @@ internal fun runStandalone( args: Array, output: (String) -> Unit, error: (String) -> Unit, - loadPlugins: () -> List = ::loadStandalonePlugins + loadManifest: () -> Result = ::loadStandaloneManifest ): Int { when (args.firstOrNull()) { "--help", "-h" -> { @@ -31,40 +28,23 @@ internal fun runStandalone( } } - val plugins = loadPlugins() - if (plugins.isEmpty()) { - error("No PluginEntry service was found in this JAR.") + val manifest = loadManifest().getOrElse { failure -> + error("Plugin manifest could not be loaded: ${failure.message ?: failure::class.simpleName}") return 2 } - output(describeStandalonePlugins(plugins)) + output(describeStandaloneManifest(manifest)) return 0 } -private fun loadStandalonePlugins(): List { - val directEntries = ServiceLoader.load(PluginEntry::class.java).toList() - if (directEntries.isNotEmpty()) return directEntries - - val providers = ServiceLoader.load(PluginModuleProvider::class.java).toList() - if (providers.isEmpty()) return emptyList() - - stopKoin() - val application = startKoin { - modules(providers.map { it.getKoinModule(emptyMap()) }) - } - return application.koin.getAll() +private fun loadStandaloneManifest(): Result = runCatching { + // Inspection must never instantiate third-party plugin code or synthesize missing settings. + ManifestLoader.loadFromResources(PluginManifest::class.java) } -internal fun describeStandalonePlugins(plugins: List): String = plugins.joinToString("\n\n") { entry -> - entry.getManifest().fold( - onSuccess = { manifest -> - buildString { - appendLine("${manifest.plugin.name} ${manifest.plugin.version}") - appendLine(manifest.plugin.description) - append("Capabilities: ") - append(manifest.capabilities.joinToString { it.name }.ifBlank { "none" }) - } - }, - onFailure = { error -> "Invalid plugin manifest: ${error.message ?: error::class.simpleName}" } - ) +internal fun describeStandaloneManifest(manifest: PluginManifest): String = buildString { + appendLine("${manifest.plugin.name} ${manifest.plugin.version}") + appendLine(manifest.plugin.description) + append("Capabilities: ") + append(manifest.capabilities.joinToString { it.name }.ifBlank { "none" }) } diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt index 0358c155..2d55dc44 100644 --- a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt @@ -1,7 +1,5 @@ package org.wip.plugintoolkit.api.standalone -import org.wip.plugintoolkit.api.DataProcessor -import org.wip.plugintoolkit.api.PluginEntry import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.api.PluginManifest import org.wip.plugintoolkit.api.Requirements @@ -12,36 +10,29 @@ import kotlin.test.assertTrue class StandalonePluginMainTest { @Test fun `missing plugin and unknown options return non-zero`() { - assertEquals(2, runStandalone(emptyArray(), {}, {}, loadPlugins = { emptyList() })) - assertEquals(2, runStandalone(arrayOf("--wat"), {}, {}, loadPlugins = { error("must not load") })) + assertEquals(2, runStandalone(emptyArray(), {}, {}, loadManifest = { Result.failure(Exception("missing")) })) + assertEquals(2, runStandalone(arrayOf("--wat"), {}, {}, loadManifest = { error("must not load") })) } @Test fun `help succeeds without loading plugin services`() { - assertEquals(0, runStandalone(arrayOf("--help"), {}, {}, loadPlugins = { error("must not load") })) + assertEquals(0, runStandalone(arrayOf("--help"), {}, {}, loadManifest = { error("must not load") })) } @Test fun `info describes a discovered plugin`() { val output = mutableListOf() - val exitCode = runStandalone(arrayOf("--info"), output::add, {}, loadPlugins = { listOf(plugin()) }) + val exitCode = runStandalone(arrayOf("--info"), output::add, {}, loadManifest = { Result.success(manifest()) }) assertEquals(0, exitCode) assertTrue(output.single().contains("Example 1.0")) assertTrue(output.single().contains("Capabilities: none")) } - private fun plugin() = object : PluginEntry { - override fun getManifest() = Result.success( - PluginManifest( - manifestVersion = "1", - plugin = PluginInfo("example", "Example", "1.0", "Example plugin"), - requirements = Requirements(64, 10) - ) - ) - - override fun getProcessor(): Result = Result.failure(UnsupportedOperationException()) - override fun setDebug(isDebug: Boolean) = Unit - } + private fun manifest() = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1.0", "Example plugin"), + requirements = Requirements(64, 10) + ) } diff --git a/scripts/standalone-plugin.gradle.kts b/scripts/standalone-plugin.gradle.kts index 7c8cbaf3..2198d849 100644 --- a/scripts/standalone-plugin.gradle.kts +++ b/scripts/standalone-plugin.gradle.kts @@ -79,6 +79,7 @@ abstract class VerifyStandaloneJar : DefaultTask() { check("org/wip/plugintoolkit/api/standalone/StandalonePluginMainKt.class" in names) { "Standalone launcher is missing" } + check("META-INF/manifest.json" in names) { "Plugin manifest is missing" } val manifestEntry = zip.getEntry("META-INF/MANIFEST.MF") ?: error("JAR manifest is missing") val manifest = zip.getInputStream(manifestEntry).use(::Manifest) check( 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 35/52] 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 d7f0bc241132021115abad0f0ff5c4d2df53a9ec Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:28:13 +1000 Subject: [PATCH 36/52] fix: defer scheduled runs until dependencies are ready --- .../features/job/logic/JobManager.kt | 43 ++++++++++++++++++- .../kotlin/org/wip/plugintoolkit/main.kt | 9 ++-- .../job/logic/ScheduleRepositoryTest.kt | 20 ++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index 42f496c6..c7dce4e1 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -15,6 +15,10 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.json.Json @@ -30,6 +34,8 @@ import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.job.model.ScheduledJob import org.wip.plugintoolkit.features.job.model.canBeScheduled import org.wip.plugintoolkit.features.job.model.normalizedScheduleInterval +import org.wip.plugintoolkit.features.flows.model.Flow +import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.features.plugin.logic.DefaultPluginFileSystem import org.wip.plugintoolkit.features.plugin.logic.PluginLoader import org.wip.plugintoolkit.features.settings.logic.SettingsRepository @@ -43,6 +49,8 @@ class JobManager( private val scope: CoroutineScope, private val settingsRepository: SettingsRepository ) { + internal var scheduleReadinessOverride: ((BackgroundJob) -> Boolean)? = null + private val scheduleFlowJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val maxConcurrentJobs get() = settingsRepository.settings.value.jobs.maxConcurrentJobs private val maxEndedJobs get() = settingsRepository.settings.value.jobs.maxEndedJobs private val maxHistoryLength get() = settingsRepository.settings.value.jobs.maxHistoryLength @@ -197,7 +205,11 @@ class JobManager( internal suspend fun runDueSchedules(now: kotlin.time.Instant) = scheduleMutex.withLock { if (!hydrateSchedulesLocked()) return@withLock val current = _schedules.value - val due = current.filter { it.isDue(now) } + // Do not consume an occurrence until all plugin code needed by the job is available. + // A failed/hung plugin startup therefore cannot make other schedules miss their run. + val due = current.filter { + it.isDue(now) && (scheduleReadinessOverride?.invoke(it.jobTemplate) ?: isScheduledJobReady(it.jobTemplate)) + } if (due.isNotEmpty()) { val dueIds = due.mapTo(mutableSetOf()) { it.id } val updated = current.map { if (it.id in dueIds) it.afterRun(now) else it } @@ -207,6 +219,35 @@ class JobManager( } } + private fun isScheduledJobReady(job: BackgroundJob): Boolean = when (job.type) { + org.wip.plugintoolkit.features.job.model.JobType.Capability -> + PluginLoader.getPluginById(job.pluginId) != null + org.wip.plugintoolkit.features.job.model.JobType.Flow -> + isStoredFlowReady(job.capabilityName, mutableSetOf()) + else -> false + } + + private fun isStoredFlowReady(flowName: String, visited: MutableSet): Boolean { + if (!visited.add(flowName)) return true + return runCatching { + val safeName = flowName.replace(Regex("[\\\\/:*?\"<>|]"), "_") + val flowPath = Path("${settingsPersistence.getSettingsDir()}/flows/$safeName.json") + if (!SystemFileSystem.exists(flowPath)) return@runCatching false + val content = SystemFileSystem.source(flowPath).buffered().use { it.readString() } + val flow = scheduleFlowJson.decodeFromString(content) + flow.nodes.all { node -> + when (node) { + is Node.CapabilityNode -> PluginLoader.getPluginById(node.pluginInfo.id) != null + is Node.SubFlowNode -> isStoredFlowReady(node.flowName, visited) + else -> true + } + } + }.getOrElse { error -> + Logger.w(error) { "Scheduled flow '$flowName' is not ready for execution" } + false + } + } + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) private fun BackgroundJob.asFreshRun(now: kotlin.time.Instant): BackgroundJob = copy( id = "$id-${Uuid.random()}", diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index 7679be16..0d69ffd0 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -298,9 +298,12 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = updateStatus("Initializing plugins...") // Initialize registry and subsequently load plugins appScope.launch { - // Scheduling is a host service: a blocked or broken third-party plugin must not disable it globally. - if (!koin.get().startScheduler()) { - Logger.e { "Startup: Scheduler state could not be loaded safely; background retries are active" } + // Scheduling is a host service: storage hydration runs independently, while each due + // occurrence is held until the plugins required by that job are actually available. + launch { + if (!koin.get().startScheduler()) { + Logger.e { "Startup: Scheduler state could not be loaded safely; background retries are active" } + } } try { diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index 1f3586ce..d60bd58e 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -57,7 +57,9 @@ class ScheduleRepositoryTest { withTempPersistence { persistence, _ -> val settings = SettingsRepository(persistence, backgroundScope) testScheduler.advanceUntilIdle() - val manager = JobManager(backgroundScope, settings) + val manager = JobManager(backgroundScope, settings).apply { + scheduleReadinessOverride = { true } + } val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! val dueNow = schedule.nextRunAt + 1.minutes @@ -87,6 +89,22 @@ class ScheduleRepositoryTest { } } + @Test + fun `due occurrence waits until its plugin is loaded`() = runTest { + withTempPersistence { persistence, _ -> + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val manager = JobManager(backgroundScope, settings) + val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! + val dueNow = schedule.nextRunAt + 1.minutes + + manager.runDueSchedules(dueNow) + + assertEquals(schedule.nextRunAt, manager.schedules.value.single().nextRunAt) + assertFalse(manager.history.value.any { it.event == "Enqueued" }) + } + } + @Test fun `unsupported persisted schedules are removed before scheduler startup`() = runTest { withTempPersistence { persistence, _ -> From ad1923eab92c1c1b265c08bbeb1b17b1fb9ade12 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:43:57 +1000 Subject: [PATCH 37/52] fix: require activated plugins for scheduled runs --- .../plugintoolkit/features/job/logic/JobManager.kt | 12 ++++++------ .../src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt | 5 ++++- .../features/job/logic/ScheduleRepositoryTest.kt | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index c7dce4e1..4d955934 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -49,7 +49,9 @@ class JobManager( private val scope: CoroutineScope, private val settingsRepository: SettingsRepository ) { - internal var scheduleReadinessOverride: ((BackgroundJob) -> Boolean)? = null + // Wired to PluginLifecycleManager.loadedPlugins during host startup. Defaulting to false + // is fail-safe for tests and alternate hosts that have not connected the lifecycle signal. + internal var schedulePluginReadiness: (String) -> Boolean = { false } private val scheduleFlowJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val maxConcurrentJobs get() = settingsRepository.settings.value.jobs.maxConcurrentJobs private val maxEndedJobs get() = settingsRepository.settings.value.jobs.maxEndedJobs @@ -207,9 +209,7 @@ class JobManager( val current = _schedules.value // Do not consume an occurrence until all plugin code needed by the job is available. // A failed/hung plugin startup therefore cannot make other schedules miss their run. - val due = current.filter { - it.isDue(now) && (scheduleReadinessOverride?.invoke(it.jobTemplate) ?: isScheduledJobReady(it.jobTemplate)) - } + val due = current.filter { it.isDue(now) && isScheduledJobReady(it.jobTemplate) } if (due.isNotEmpty()) { val dueIds = due.mapTo(mutableSetOf()) { it.id } val updated = current.map { if (it.id in dueIds) it.afterRun(now) else it } @@ -221,7 +221,7 @@ class JobManager( private fun isScheduledJobReady(job: BackgroundJob): Boolean = when (job.type) { org.wip.plugintoolkit.features.job.model.JobType.Capability -> - PluginLoader.getPluginById(job.pluginId) != null + schedulePluginReadiness(job.pluginId) org.wip.plugintoolkit.features.job.model.JobType.Flow -> isStoredFlowReady(job.capabilityName, mutableSetOf()) else -> false @@ -237,7 +237,7 @@ class JobManager( val flow = scheduleFlowJson.decodeFromString(content) flow.nodes.all { node -> when (node) { - is Node.CapabilityNode -> PluginLoader.getPluginById(node.pluginInfo.id) != null + is Node.CapabilityNode -> schedulePluginReadiness(node.pluginInfo.id) is Node.SubFlowNode -> isStoredFlowReady(node.flowName, visited) else -> true } diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index 0d69ffd0..cfd17453 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -301,7 +301,10 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = // Scheduling is a host service: storage hydration runs independently, while each due // occurrence is held until the plugins required by that job are actually available. launch { - if (!koin.get().startScheduler()) { + val jobManager = koin.get().apply { + schedulePluginReadiness = { pkg -> pkg in pluginManager.loadedPlugins.value } + } + if (!jobManager.startScheduler()) { Logger.e { "Startup: Scheduler state could not be loaded safely; background retries are active" } } } diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index d60bd58e..eb6ebc19 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -58,7 +58,7 @@ class ScheduleRepositoryTest { val settings = SettingsRepository(persistence, backgroundScope) testScheduler.advanceUntilIdle() val manager = JobManager(backgroundScope, settings).apply { - scheduleReadinessOverride = { true } + schedulePluginReadiness = { true } } val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! val dueNow = schedule.nextRunAt + 1.minutes From 89dd5a1a0e8960ec877bcfdc6ebe34877ea6d2b2 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:11:28 +1000 Subject: [PATCH 38/52] fix: make release test gate cross-platform --- .../flows/logic/PathPatternResolver.kt | 26 ++++----- .../features/job/logic/JobWorkerUtils.kt | 55 +++++++++++++++---- .../features/flows/FlowCycleTest.kt | 35 ++++++++++-- 3 files changed, 87 insertions(+), 29 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/PathPatternResolver.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/PathPatternResolver.kt index fcbc9178..c6e659ca 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/PathPatternResolver.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/flows/logic/PathPatternResolver.kt @@ -1,7 +1,5 @@ package org.wip.plugintoolkit.features.flows.logic -import kotlinx.io.files.Path - /** * Utility class to evaluate and resolve autogenerated path patterns for node outputs. * @@ -79,30 +77,28 @@ object PathPatternResolver { // If it's empty, we can't extract modifiers like name or ext effectively if (pathString.isBlank()) return "" - val path = Path(pathString) - val isWindows = pathString.contains(":\\") || pathString.contains(":/") + // kotlinx.io Path follows the host OS. Parse separators as data so a Windows path can be + // resolved correctly by Linux/macOS CI (and vice versa). + val lastSeparator = maxOf(pathString.lastIndexOf('/'), pathString.lastIndexOf('\\')) + val name = pathString.substring(lastSeparator + 1) return when (modifier) { "dir" -> { - val parent = path.parent?.toString() ?: "" - val separator = if (pathString.contains("\\")) "\\" else "/" - val normalizedParent = parent.replace("\\", separator).replace("/", separator) - // Edge case handling for windows roots - if (isWindows && parent.isEmpty()) { - pathString.substringBefore("\\").substringBefore("/") + "\\" - } else { - normalizedParent + when { + lastSeparator < 0 -> "" + lastSeparator == 0 -> pathString.substring(0, 1) + lastSeparator == 2 && pathString.getOrNull(1) == ':' -> + pathString.substring(0, lastSeparator + 1) + else -> pathString.substring(0, lastSeparator) } } - "name" -> path.name + "name" -> name "nameWithoutExtension" -> { - val name = path.name if (name.contains(".")) name.substringBeforeLast(".") else name } "ext" -> { - val name = path.name if (name.contains(".")) name.substringAfterLast(".") else "" } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt index f35c2dc8..0e374054 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt @@ -332,11 +332,7 @@ object SystemPathSecurity { customWhitelist: List = emptyList(), sandboxPath: String? = null ): Boolean { - val canonical = try { - java.io.File(pathStr).canonicalPath - } catch (_: Exception) { - return false - } + val canonical = comparablePath(pathStr) ?: return false when (mode) { org.wip.plugintoolkit.features.settings.model.FileAccessMode.Unrestricted -> return true @@ -345,20 +341,60 @@ object SystemPathSecurity { sandboxPath?.let { effectiveWhitelist.add(it) } if (effectiveWhitelist.isEmpty()) return false return effectiveWhitelist.any { allowed -> - val allowedCanonical = try { java.io.File(allowed).canonicalPath } catch (_: Exception) { return@any false } - canonical == allowedCanonical || canonical.startsWith(allowedCanonical + java.io.File.separator) + val allowedCanonical = comparablePath(allowed) ?: return@any false + canonical.isInside(allowedCanonical) } } org.wip.plugintoolkit.features.settings.model.FileAccessMode.Blacklist -> { val effectiveBlacklist = if (customBlacklist.isNotEmpty()) customBlacklist else BUILTIN_BLACKLIST val isDenied = effectiveBlacklist.any { blocked -> - val blockedCanonical = try { java.io.File(blocked).canonicalPath } catch (_: Exception) { return@any false } - canonical == blockedCanonical || canonical.startsWith(blockedCanonical + java.io.File.separator) + val blockedCanonical = comparablePath(blocked) ?: return@any false + canonical.isInside(blockedCanonical) } return !isDenied } } } + + private data class ComparablePath(val value: String, val windowsStyle: Boolean) { + fun isInside(root: ComparablePath): Boolean { + if (windowsStyle != root.windowsStyle) return false + return if (windowsStyle) { + value.equals(root.value, ignoreCase = true) || + value.startsWith(root.value.trimEnd('/') + "/", ignoreCase = true) + } else { + value == root.value || value.startsWith(root.value.trimEnd('/') + "/") + } + } + } + + private fun comparablePath(path: String): ComparablePath? { + val slashNormalized = path.replace('\\', '/') + val windowsStyle = Regex("^[A-Za-z]:/").containsMatchIn(slashNormalized) + return try { + val value = if (windowsStyle) { + normalizeWindowsPath(slashNormalized) + } else { + java.io.File(path).canonicalPath.replace('\\', '/') + } + ComparablePath(value.trimEnd('/'), windowsStyle) + } catch (_: Exception) { + null + } + } + + private fun normalizeWindowsPath(path: String): String { + val root = path.take(2) + val segments = mutableListOf() + path.drop(2).split('/').forEach { segment -> + when (segment) { + "", "." -> Unit + ".." -> if (segments.isNotEmpty()) segments.removeLast() + else -> segments += segment + } + } + return "$root/${segments.joinToString("/")}".trimEnd('/') + } } fun resolveFileAccess( @@ -454,4 +490,3 @@ fun deleteRecursively(path: kotlinx.io.files.Path) { } } } - diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowCycleTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowCycleTest.kt index fe5cd542..40c95ded 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowCycleTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/flows/FlowCycleTest.kt @@ -14,6 +14,7 @@ import kotlinx.io.files.SystemFileSystem import kotlinx.io.writeString import org.wip.plugintoolkit.features.flows.logic.FlowRepository import org.wip.plugintoolkit.features.flows.model.Flow +import org.wip.plugintoolkit.features.flows.model.Node import org.wip.plugintoolkit.features.flows.viewmodel.FlowEditorViewModel import org.wip.plugintoolkit.features.flows.viewmodel.FlowEvent import org.wip.plugintoolkit.features.plugin.logic.PluginManager @@ -151,8 +152,13 @@ class FlowCycleTest { // 2. Add Flow B as a subflow in Flow A viewModelA.onEvent(FlowEvent.AddSubFlowNode("Flow B", Offset(100f, 100f))) - viewModelA.onEvent(FlowEvent.Save) - delay(50) // Wait for save to disk to finish + val flowAWithB = viewModelA.state.value.flow + assertTrue(flowAWithB.nodes.filterIsInstance().any { it.flowName == "Flow B" }) + // Persist the captured state synchronously. Calling Add then Save as separate events races + // the repository collector, which may re-emit the previous disk snapshot between them. + SystemFileSystem.sink(Path("$appDataDir/flows/Flow_A.json")).buffered().use { + it.writeString(json.encodeToString(Flow.serializer(), flowAWithB)) + } // 3. Load Flow B val persistenceB = MockSettingsPersistence() @@ -178,8 +184,11 @@ class FlowCycleTest { // 4. Add Flow C as a subflow in Flow B viewModelB.onEvent(FlowEvent.AddSubFlowNode("Flow C", Offset(100f, 100f))) - viewModelB.onEvent(FlowEvent.Save) - delay(50) + val flowBWithC = viewModelB.state.value.flow + assertTrue(flowBWithC.nodes.filterIsInstance().any { it.flowName == "Flow C" }) + SystemFileSystem.sink(Path("$appDataDir/flows/Flow_B.json")).buffered().use { + it.writeString(json.encodeToString(Flow.serializer(), flowBWithC)) + } // 5. Load Flow C val persistenceC = MockSettingsPersistence() @@ -203,6 +212,24 @@ class FlowCycleTest { } assertTrue(loadedC, "Flows failed to load in Flow C editor") + // Repository loading and saves both run on Dispatchers.IO. Wait for the actual dependency + // graph, not merely the initial list of filenames, before asserting cycle prevention. + var dependencyGraphLoaded = false + for (i in 1..100) { + val flows = viewModelC.state.value.flows + val aReferencesB = flows.find { it.name == "Flow A" } + ?.nodes?.filterIsInstance()?.any { it.flowName == "Flow B" } == true + val bReferencesC = flows.find { it.name == "Flow B" } + ?.nodes?.filterIsInstance()?.any { it.flowName == "Flow C" } == true + if (aReferencesB && bReferencesC) { + dependencyGraphLoaded = true + break + } + realFlowRepoC.reloadFlows() + delay(10) + } + assertTrue(dependencyGraphLoaded, "Nested flow dependency graph failed to load") + // 6. Try to add Flow A as a subflow inside Flow C - This should form a cycle: A -> B -> C -> A val initialNodesCount = viewModelC.state.value.flow.nodes.size viewModelC.onEvent(FlowEvent.AddSubFlowNode("Flow A", Offset(100f, 100f))) From 62dee07263f036912cf8eb820f9aa0aba2ede054 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:25:35 +1000 Subject: [PATCH 39/52] fix: canonicalize native paths before access checks --- .../plugintoolkit/features/job/logic/JobWorkerUtils.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt index 0e374054..5bb38795 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt @@ -370,13 +370,18 @@ object SystemPathSecurity { private fun comparablePath(path: String): ComparablePath? { val slashNormalized = path.replace('\\', '/') - val windowsStyle = Regex("^[A-Za-z]:/").containsMatchIn(slashNormalized) + val inputUsesWindowsDrive = Regex("^[A-Za-z]:/").containsMatchIn(slashNormalized) + val nativeWindows = java.io.File.separatorChar == '\\' return try { - val value = if (windowsStyle) { + // Native paths must always be canonicalized so junctions/symlinks cannot bypass an + // access root. Lexical parsing is only for a foreign Windows path on a Unix host, + // where java.io.File would otherwise prefix the current directory to `C:\\...`. + val value = if (inputUsesWindowsDrive && !nativeWindows) { normalizeWindowsPath(slashNormalized) } else { java.io.File(path).canonicalPath.replace('\\', '/') } + val windowsStyle = Regex("^[A-Za-z]:/").containsMatchIn(value) ComparablePath(value.trimEnd('/'), windowsStyle) } catch (_: Exception) { null From e55e50fd7ca09c8bd91c2996150dbdaf60fe6c61 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:26:41 +1000 Subject: [PATCH 40/52] fix: back off unready schedule probes --- .../features/job/logic/JobManager.kt | 28 +++++++++++++++++-- .../job/logic/ScheduleRepositoryTest.kt | 23 +++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index 4d955934..5670b5fd 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -102,6 +102,7 @@ class JobManager( private var schedulesLoaded = false private var consecutiveSchedulePersistenceFailures = 0 private var scheduleRetryNotBefore: kotlin.time.Instant? = null + private val scheduleReadinessRetryNotBefore = mutableMapOf() init { scope.launch { @@ -209,7 +210,23 @@ class JobManager( val current = _schedules.value // Do not consume an occurrence until all plugin code needed by the job is available. // A failed/hung plugin startup therefore cannot make other schedules miss their run. - val due = current.filter { it.isDue(now) && isScheduledJobReady(it.jobTemplate) } + scheduleReadinessRetryNotBefore.keys.retainAll(current.mapTo(mutableSetOf()) { it.id }) + val due = current.filter { schedule -> + if (!schedule.isDue(now)) return@filter false + val retryAt = scheduleReadinessRetryNotBefore[schedule.id] + if (retryAt != null && now < retryAt) return@filter false + if (isScheduledJobReady(schedule.jobTemplate)) { + scheduleReadinessRetryNotBefore.remove(schedule.id) + true + } else { + // Missing flows and disabled plugins can be permanent. Probe each affected + // schedule independently so one broken flow neither spins at 1 Hz nor delays + // unrelated schedules. + scheduleReadinessRetryNotBefore[schedule.id] = + now + SCHEDULE_READINESS_RETRY_MS.milliseconds + false + } + } if (due.isNotEmpty()) { val dueIds = due.mapTo(mutableSetOf()) { it.id } val updated = current.map { if (it.id in dueIds) it.afterRun(now) else it } @@ -305,7 +322,13 @@ class JobManager( private fun nextSchedulerWaitMillis(now: kotlin.time.Instant): Long { val dueWait = _schedules.value.asSequence() .filter { it.enabled } - .map { (it.nextRunAt - now).inWholeMilliseconds } + .map { schedule -> + val effectiveRunAt = maxOf( + schedule.nextRunAt, + scheduleReadinessRetryNotBefore[schedule.id] ?: schedule.nextRunAt + ) + (effectiveRunAt - now).inWholeMilliseconds + } .minOrNull() ?.coerceIn(MIN_SCHEDULER_WAIT_MS, MAX_SCHEDULER_WAIT_MS) ?: MAX_SCHEDULER_WAIT_MS @@ -781,3 +804,4 @@ private const val MIN_SCHEDULER_WAIT_MS = 1_000L private const val MAX_SCHEDULER_WAIT_MS = 60_000L private const val SCHEDULER_RETRY_BASE_MS = 5_000L private const val SCHEDULE_LOAD_RETRY_MS = 30_000L +private const val SCHEDULE_READINESS_RETRY_MS = 30_000L diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index eb6ebc19..679e8df5 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -17,6 +17,7 @@ import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant class ScheduleRepositoryTest { @@ -105,6 +106,28 @@ class ScheduleRepositoryTest { } } + @Test + fun `unready schedule backs off without delaying its next eligible probe`() = runTest { + withTempPersistence { persistence, _ -> + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + var pluginReady = false + val manager = JobManager(backgroundScope, settings).apply { + schedulePluginReadiness = { pluginReady } + } + val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! + val dueNow = schedule.nextRunAt + 1.minutes + + manager.runDueSchedules(dueNow) + pluginReady = true + manager.runDueSchedules(dueNow + 1.seconds) + assertFalse(manager.history.value.any { it.event == "Enqueued" }) + + manager.runDueSchedules(dueNow + 31.seconds) + assertTrue(manager.history.value.any { it.event == "Enqueued" }) + } + } + @Test fun `unsupported persisted schedules are removed before scheduler startup`() = runTest { withTempPersistence { persistence, _ -> 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 41/52] 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 f39eabff0ca05e7665b929d3a28e576dbd044c22 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:50:07 +1000 Subject: [PATCH 42/52] fix: validate scheduled capabilities before advancing --- .../features/job/logic/JobManager.kt | 12 +++++---- .../kotlin/org/wip/plugintoolkit/main.kt | 10 ++++++- .../job/logic/ScheduleRepositoryTest.kt | 27 +++++++++++++++++-- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index 5670b5fd..141a46e2 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt @@ -49,9 +49,10 @@ class JobManager( private val scope: CoroutineScope, private val settingsRepository: SettingsRepository ) { - // Wired to PluginLifecycleManager.loadedPlugins during host startup. Defaulting to false - // is fail-safe for tests and alternate hosts that have not connected the lifecycle signal. - internal var schedulePluginReadiness: (String) -> Boolean = { false } + // Wired to the active plugin manifests during host startup. Checking both the plugin and + // capability keeps persisted schedules safe across plugin capability renames/removals. + // Defaulting to false is fail-safe for tests and alternate hosts without this signal. + internal var scheduleCapabilityReadiness: (String, String) -> Boolean = { _, _ -> false } private val scheduleFlowJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val maxConcurrentJobs get() = settingsRepository.settings.value.jobs.maxConcurrentJobs private val maxEndedJobs get() = settingsRepository.settings.value.jobs.maxEndedJobs @@ -238,7 +239,7 @@ class JobManager( private fun isScheduledJobReady(job: BackgroundJob): Boolean = when (job.type) { org.wip.plugintoolkit.features.job.model.JobType.Capability -> - schedulePluginReadiness(job.pluginId) + scheduleCapabilityReadiness(job.pluginId, job.capabilityName) org.wip.plugintoolkit.features.job.model.JobType.Flow -> isStoredFlowReady(job.capabilityName, mutableSetOf()) else -> false @@ -254,7 +255,8 @@ class JobManager( val flow = scheduleFlowJson.decodeFromString(content) flow.nodes.all { node -> when (node) { - is Node.CapabilityNode -> schedulePluginReadiness(node.pluginInfo.id) + is Node.CapabilityNode -> + scheduleCapabilityReadiness(node.pluginInfo.id, node.capability.name) is Node.SubFlowNode -> isStoredFlowReady(node.flowName, visited) else -> true } diff --git a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt index cfd17453..e08a6ff4 100644 --- a/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/main.kt @@ -99,6 +99,7 @@ import org.wip.plugintoolkit.features.plugin.logic.PluginFolderManager import org.wip.plugintoolkit.features.plugin.logic.PluginInstaller import org.wip.plugintoolkit.features.plugin.logic.PluginLifecycleCoordinator import org.wip.plugintoolkit.features.plugin.logic.PluginLifecycleManager +import org.wip.plugintoolkit.features.plugin.logic.PluginLoader import org.wip.plugintoolkit.features.plugin.logic.PluginLockProvider import org.wip.plugintoolkit.features.plugin.logic.PluginManager import org.wip.plugintoolkit.features.plugin.logic.PluginRegistry @@ -302,7 +303,14 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = // occurrence is held until the plugins required by that job are actually available. launch { val jobManager = koin.get().apply { - schedulePluginReadiness = { pkg -> pkg in pluginManager.loadedPlugins.value } + scheduleCapabilityReadiness = { pkg, capabilityName -> + pkg in pluginManager.loadedPlugins.value && + PluginLoader.getPluginById(pkg) + ?.getManifest() + ?.getOrNull() + ?.capabilities + ?.any { it.name == capabilityName } == true + } } if (!jobManager.startScheduler()) { Logger.e { "Startup: Scheduler state could not be loaded safely; background retries are active" } diff --git a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt index 679e8df5..e2ff0b5f 100644 --- a/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -59,7 +59,7 @@ class ScheduleRepositoryTest { val settings = SettingsRepository(persistence, backgroundScope) testScheduler.advanceUntilIdle() val manager = JobManager(backgroundScope, settings).apply { - schedulePluginReadiness = { true } + scheduleCapabilityReadiness = { _, _ -> true } } val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! val dueNow = schedule.nextRunAt + 1.minutes @@ -113,7 +113,7 @@ class ScheduleRepositoryTest { testScheduler.advanceUntilIdle() var pluginReady = false val manager = JobManager(backgroundScope, settings).apply { - schedulePluginReadiness = { pluginReady } + scheduleCapabilityReadiness = { _, _ -> pluginReady } } val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! val dueNow = schedule.nextRunAt + 1.minutes @@ -128,6 +128,29 @@ class ScheduleRepositoryTest { } } + @Test + fun `due occurrence waits when its capability no longer exists`() = runTest { + withTempPersistence { persistence, _ -> + val settings = SettingsRepository(persistence, backgroundScope) + testScheduler.advanceUntilIdle() + val readinessChecks = mutableListOf>() + val manager = JobManager(backgroundScope, settings).apply { + scheduleCapabilityReadiness = { pluginId, capabilityName -> + readinessChecks += pluginId to capabilityName + false + } + } + val schedule = manager.scheduleJob(template, intervalMinutes = 1)!! + val dueNow = schedule.nextRunAt + 1.minutes + + manager.runDueSchedules(dueNow) + + assertEquals(listOf("plugin" to "run"), readinessChecks) + assertEquals(schedule.nextRunAt, manager.schedules.value.single().nextRunAt) + assertFalse(manager.history.value.any { it.event == "Enqueued" }) + } + } + @Test fun `unsupported persisted schedules are removed before scheduler startup`() = runTest { withTempPersistence { persistence, _ -> 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 43/52] 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 a2b4a8ef050426fb2fe77774ad9c6af5129c2702 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 44/52] Revert "Merge branch 'codex/issue-13-color-picker-rework' into codex/issue-4-plugin-defined-ui" This reverts commit f8a237d4c99a90228c9d0d0053605dae0a74936f, reversing changes made to 774280b0b617bb366596c3909fe1f38eeedf3125. --- .../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 a232e9edea402b6b6572ca2cf8ade8625488cdcf 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 45/52] Revert "Merge branch 'codex/issue-13-color-picker-rework' into codex/issue-4-plugin-defined-ui" This reverts commit 774280b0b617bb366596c3909fe1f38eeedf3125, reversing changes made to c38bb106fd87df0070018505617a0cb25d703c5e. --- .../composeResources/values-it/strings.xml | 4 - .../composeResources/values/strings.xml | 4 - .../features/colorpicker/ui/ColorPicker.kt | 3 +- .../colorpicker/ui/ColorPickerDialog.kt | 188 ++++++++++-------- .../ui/pickers/ClassicColorPicker.kt | 32 +-- .../features/colorpicker/utils/ColorExt.kt | 43 +--- .../features/flows/ui/NodeDialogs.kt | 9 +- .../features/flows/ui/NodeHelpers.kt | 19 +- .../features/flows/ui/PaletteSidebar.kt | 6 +- .../plugin/ui/DirectExecutionSidebar.kt | 3 +- .../settings/ui/AccentColorControl.kt | 3 +- .../components/plugin/inputs/ColorInput.kt | 17 +- .../colorpicker/utils/ColorExtTest.kt | 71 ------- .../features/flows/ui/NodeColorParsingTest.kt | 25 --- .../plugin/model/PluginSettingDefaultsTest.kt | 21 -- 15 files changed, 133 insertions(+), 315 deletions(-) delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt delete 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 30872c98..06c715fe 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -169,10 +169,6 @@ 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 07343f42..69076655 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -180,10 +180,6 @@ Pause Save Cancel - Choose a color - Hex - Use #RRGGBB or #AARRGGBB - Apply Expand Collapse Settings: %1$s 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 4faf3523..48900e2c 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,14 +21,12 @@ 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, ) @@ -64,3 +62,4 @@ 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 94bc7fda..7d09ca4c 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,13 +9,12 @@ 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 @@ -27,103 +26,119 @@ 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.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.toCMYK +import org.wip.plugintoolkit.features.colorpicker.utils.toHSL 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 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 +import org.wip.plugintoolkit.shared.components.SelectedButtonGroup +import org.wip.plugintoolkit.core.theme.ToolkitTheme -/** A focused, editable color picker dialog with explicit cancel/apply actions. */ +/** + * 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. + */ @Composable fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, - initialColor: Color = Color.White, - showAlpha: Boolean = false, + initialType: ColorPickerType = ColorPickerType.Classic(), onPickedColor: (Color) -> Unit ) { - if (!show) return - - var color by remember(initialColor) { mutableStateOf(initialColor) } - var hexInput by remember(initialColor, showAlpha) { - mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = showAlpha).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 = stringResource(Res.string.color_picker_title), - style = MaterialTheme.typography.headlineSmall - ) + 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) } - ColorPicker( - type = ColorPickerType.Classic(showAlphaBar = showAlpha), - initialColor = initialColor, - onPickedColor = { - color = it - hexInput = it.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase() - } - ) + 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 + } - 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(stringResource(Res.string.color_picker_hex)) }, - supportingText = if (parsedHex == null) { - { Text(stringResource(Res.string.color_picker_hex_hint)) } - } else null, - isError = parsedHex == null, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace) - ) + 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) } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismissRequest) { - Text(stringResource(Res.string.action_cancel)) - } - Button( - onClick = { parsedHex?.let(onPickedColor) }, - enabled = parsedHex != null + 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) ) { - Text(stringResource(Res.string.color_picker_apply)) + 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") + } } } } @@ -135,6 +150,11 @@ 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 ade2963b..2739e6d4 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,8 +35,6 @@ 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.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -47,33 +45,18 @@ 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(initialColor) { mutableStateOf(Offset.Zero) } + var pickerLocation by remember { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - 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) } + var alpha by remember { mutableStateOf(1f) } + var rangeColor by remember { mutableStateOf(Color.White) } + var hueSlider by remember { mutableStateOf(0f) } - var color by remember(initialColor) { mutableStateOf(initialColor) } + var color by remember { mutableStateOf(Color.White) } - LaunchedEffect(colorPickerSize, initialColor) { - if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { - val (saturation, value) = initialSaturationAndValue - pickerLocation = Offset( - x = saturation * colorPickerSize.width, - y = (1f - value) * colorPickerSize.height - ) - pickerInitialized = true - } - } - - LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha, pickerInitialized) { - if (pickerInitialized && colorPickerSize.width > 0 && colorPickerSize.height > 0) { + LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha) { + if (colorPickerSize.width > 0 && colorPickerSize.height > 0) { val xProgress = if (colorPickerSize.width > 0) { (1 - (pickerLocation.x / colorPickerSize.width)).coerceIn(0f, 1f) } else 0f @@ -159,3 +142,4 @@ 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 5ed4a835..16c6e6a6 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,37 +8,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -/** 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("#") - 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()) -} - -/** 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("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || - (digits.length == 8 && digits.toLongOrNull(16) != null) -} - /** * Returns an integer array for all color channels value. */ @@ -267,15 +236,5 @@ internal fun Color.toHueProgress(): Float { hue *= 60 if (hue < 0) hue += 360 - 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 + return hue } 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 20141f82..e567dc53 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,7 +40,6 @@ 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 @@ -307,13 +306,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 existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" - val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } || - colorStringHasAlpha(existingValue) + val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, - initialColor = parseColorString(existingValue), - showAlpha = hasAlpha, onDismissRequest = onDismissColorPicker, onPickedColor = { color -> activeColorInputId.let { inputId -> @@ -328,6 +323,8 @@ 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 f92d3648..0c651456 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,7 +26,6 @@ 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( @@ -155,17 +154,9 @@ fun getNodeDescription(node: Node): String { } fun parseColorString(colorStr: String): Color { - 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 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) @@ -193,10 +184,10 @@ fun parseColorString(colorStr: String): Color { } 8 -> { - 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 + 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 Color(r, g, b, a) } 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/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/settings/ui/AccentColorControl.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt index 75b37a8d..0d55211a 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,6 +17,7 @@ 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 @@ -27,7 +28,7 @@ fun AccentColorControl(settings: AppSettings, onUpdate: (AppSettings) -> Unit) { ColorPickerDialog( show = showColorPicker, - initialColor = Color(settings.appearance.accentColor), + initialType = ColorPickerType.Classic(), onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> onUpdate( 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 f355fa46..0b7da0b7 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,8 +32,6 @@ 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 @@ -54,9 +52,6 @@ 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) } || - colorStringHasAlpha(value) - val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier .fillMaxWidth() @@ -106,16 +101,17 @@ fun ColorInput( if (showColorPicker && enabled) { ColorPickerDialog( show = showColorPicker, - initialColor = parsedColor, - showAlpha = isRgba, onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> showColorPicker = false - val formatted = if (isRgb) { - color.toRGB(rgbPrefix = true, includeAlpha = isRgba) + val formatted = if (metadata.semanticTypes.any { + it.canonicalId.contains("rgb", ignoreCase = true) + } + ) { + color.toRGB() } else { - color.toHex(hexPrefix = true, includeAlpha = isRgba) + color.toHex() } onValueChange(formatted) } @@ -129,7 +125,6 @@ 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 deleted file mode 100644 index d852c962..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -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 - -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")) - assertEquals(Color(0xAAFF0000.toInt()), parseHexColor("#F00A")) - } - - @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 - 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")) - 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 deleted file mode 100644 index 3d9f9d95..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -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()) - 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()) - } -} 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 7b1765df..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 @@ -1,19 +1,15 @@ 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( @@ -66,21 +62,4 @@ 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 ca67411890da04b15b88394899a6e5a48f11ba74 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:23 +1000 Subject: [PATCH 46/52] Revert "Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-standalone-plugin-jar" This reverts commit fcd5149c93a3dddd65f6e33428c599b4cf00eb3c, reversing changes made to 56ad0fa597193d521ebcd96c1e97e9b5485173e9. --- .../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 7a398d60..3dd72f6d 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -333,7 +333,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 3b90c9654177d3cc7c1f44b98755696e7042f8f1 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:23 +1000 Subject: [PATCH 47/52] Revert "Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6-toolkit-cli" This reverts commit 0f42289cf8de9d3cb0159e1a41f57a945d572d49, reversing changes made to d50b8cd5415b9e620d65ed9e0f32f7fb3d3d5177. --- .../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 7a398d60..3dd72f6d 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -333,7 +333,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 9ea12ebccdd9e5fa584b93eefb3e9a1d01934d18 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:23 +1000 Subject: [PATCH 48/52] Revert "Merge branch 'codex/issue-6-toolkit-cli' into codex/issue-7-job-scheduler" This reverts commit 3a49eca661dafd36e475ba956083c87511a7ce7c, reversing changes made to f39eabff0ca05e7665b929d3a28e576dbd044c22. --- .../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 7a398d60..3dd72f6d 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -333,7 +333,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 7ff54dcac11f7343ae72dfc3a7de729be53702c0 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:23 +1000 Subject: [PATCH 49/52] Revert "Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-standalone-plugin-jar" This reverts commit 56ad0fa597193d521ebcd96c1e97e9b5485173e9, reversing changes made to 62dee07263f036912cf8eb820f9aa0aba2ede054. --- .../composeResources/values-it/strings.xml | 4 - .../composeResources/values/strings.xml | 4 - .../features/colorpicker/ui/ColorPicker.kt | 3 +- .../colorpicker/ui/ColorPickerDialog.kt | 188 ++++++++++-------- .../ui/pickers/ClassicColorPicker.kt | 32 +-- .../features/colorpicker/utils/ColorExt.kt | 43 +--- .../features/flows/ui/NodeDialogs.kt | 9 +- .../features/flows/ui/NodeHelpers.kt | 19 +- .../features/flows/ui/PaletteSidebar.kt | 6 +- .../plugin/ui/DirectExecutionSidebar.kt | 3 +- .../settings/ui/AccentColorControl.kt | 3 +- .../components/plugin/inputs/ColorInput.kt | 17 +- .../colorpicker/utils/ColorExtTest.kt | 71 ------- .../features/flows/ui/NodeColorParsingTest.kt | 25 --- .../plugin/model/PluginSettingDefaultsTest.kt | 21 -- 15 files changed, 133 insertions(+), 315 deletions(-) delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt delete 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 30872c98..06c715fe 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -169,10 +169,6 @@ 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 07343f42..69076655 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -180,10 +180,6 @@ Pause Save Cancel - Choose a color - Hex - Use #RRGGBB or #AARRGGBB - Apply Expand Collapse Settings: %1$s 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 4faf3523..48900e2c 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,14 +21,12 @@ 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, ) @@ -64,3 +62,4 @@ 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 94bc7fda..7d09ca4c 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,13 +9,12 @@ 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 @@ -27,103 +26,119 @@ 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.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.toCMYK +import org.wip.plugintoolkit.features.colorpicker.utils.toHSL 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 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 +import org.wip.plugintoolkit.shared.components.SelectedButtonGroup +import org.wip.plugintoolkit.core.theme.ToolkitTheme -/** A focused, editable color picker dialog with explicit cancel/apply actions. */ +/** + * 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. + */ @Composable fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, - initialColor: Color = Color.White, - showAlpha: Boolean = false, + initialType: ColorPickerType = ColorPickerType.Classic(), onPickedColor: (Color) -> Unit ) { - if (!show) return - - var color by remember(initialColor) { mutableStateOf(initialColor) } - var hexInput by remember(initialColor, showAlpha) { - mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = showAlpha).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 = stringResource(Res.string.color_picker_title), - style = MaterialTheme.typography.headlineSmall - ) + 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) } - ColorPicker( - type = ColorPickerType.Classic(showAlphaBar = showAlpha), - initialColor = initialColor, - onPickedColor = { - color = it - hexInput = it.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase() - } - ) + 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 + } - 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(stringResource(Res.string.color_picker_hex)) }, - supportingText = if (parsedHex == null) { - { Text(stringResource(Res.string.color_picker_hex_hint)) } - } else null, - isError = parsedHex == null, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace) - ) + 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) } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismissRequest) { - Text(stringResource(Res.string.action_cancel)) - } - Button( - onClick = { parsedHex?.let(onPickedColor) }, - enabled = parsedHex != null + 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) ) { - Text(stringResource(Res.string.color_picker_apply)) + 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") + } } } } @@ -135,6 +150,11 @@ 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 ade2963b..2739e6d4 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,8 +35,6 @@ 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.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -47,33 +45,18 @@ 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(initialColor) { mutableStateOf(Offset.Zero) } + var pickerLocation by remember { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - 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) } + var alpha by remember { mutableStateOf(1f) } + var rangeColor by remember { mutableStateOf(Color.White) } + var hueSlider by remember { mutableStateOf(0f) } - var color by remember(initialColor) { mutableStateOf(initialColor) } + var color by remember { mutableStateOf(Color.White) } - LaunchedEffect(colorPickerSize, initialColor) { - if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { - val (saturation, value) = initialSaturationAndValue - pickerLocation = Offset( - x = saturation * colorPickerSize.width, - y = (1f - value) * colorPickerSize.height - ) - pickerInitialized = true - } - } - - LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha, pickerInitialized) { - if (pickerInitialized && colorPickerSize.width > 0 && colorPickerSize.height > 0) { + LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha) { + if (colorPickerSize.width > 0 && colorPickerSize.height > 0) { val xProgress = if (colorPickerSize.width > 0) { (1 - (pickerLocation.x / colorPickerSize.width)).coerceIn(0f, 1f) } else 0f @@ -159,3 +142,4 @@ 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 5ed4a835..16c6e6a6 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,37 +8,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -/** 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("#") - 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()) -} - -/** 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("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || - (digits.length == 8 && digits.toLongOrNull(16) != null) -} - /** * Returns an integer array for all color channels value. */ @@ -267,15 +236,5 @@ internal fun Color.toHueProgress(): Float { hue *= 60 if (hue < 0) hue += 360 - 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 + return hue } 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 20141f82..e567dc53 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,7 +40,6 @@ 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 @@ -307,13 +306,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 existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" - val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } || - colorStringHasAlpha(existingValue) + val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, - initialColor = parseColorString(existingValue), - showAlpha = hasAlpha, onDismissRequest = onDismissColorPicker, onPickedColor = { color -> activeColorInputId.let { inputId -> @@ -328,6 +323,8 @@ 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 f92d3648..0c651456 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,7 +26,6 @@ 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( @@ -155,17 +154,9 @@ fun getNodeDescription(node: Node): String { } fun parseColorString(colorStr: String): Color { - 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 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) @@ -193,10 +184,10 @@ fun parseColorString(colorStr: String): Color { } 8 -> { - 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 + 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 Color(r, g, b, a) } 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/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/settings/ui/AccentColorControl.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt index 75b37a8d..0d55211a 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,6 +17,7 @@ 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 @@ -27,7 +28,7 @@ fun AccentColorControl(settings: AppSettings, onUpdate: (AppSettings) -> Unit) { ColorPickerDialog( show = showColorPicker, - initialColor = Color(settings.appearance.accentColor), + initialType = ColorPickerType.Classic(), onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> onUpdate( 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 f355fa46..0b7da0b7 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,8 +32,6 @@ 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 @@ -54,9 +52,6 @@ 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) } || - colorStringHasAlpha(value) - val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier .fillMaxWidth() @@ -106,16 +101,17 @@ fun ColorInput( if (showColorPicker && enabled) { ColorPickerDialog( show = showColorPicker, - initialColor = parsedColor, - showAlpha = isRgba, onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> showColorPicker = false - val formatted = if (isRgb) { - color.toRGB(rgbPrefix = true, includeAlpha = isRgba) + val formatted = if (metadata.semanticTypes.any { + it.canonicalId.contains("rgb", ignoreCase = true) + } + ) { + color.toRGB() } else { - color.toHex(hexPrefix = true, includeAlpha = isRgba) + color.toHex() } onValueChange(formatted) } @@ -129,7 +125,6 @@ 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 deleted file mode 100644 index d852c962..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -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 - -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")) - assertEquals(Color(0xAAFF0000.toInt()), parseHexColor("#F00A")) - } - - @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 - 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")) - 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 deleted file mode 100644 index 3d9f9d95..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -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()) - 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()) - } -} 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 7b1765df..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 @@ -1,19 +1,15 @@ 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( @@ -66,21 +62,4 @@ 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 38337fe8ef2909a14655e5eb8eda95da060b1473 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:23 +1000 Subject: [PATCH 50/52] Revert "Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6-toolkit-cli" This reverts commit d50b8cd5415b9e620d65ed9e0f32f7fb3d3d5177, reversing changes made to c8e158ae7b870814b328c559e5bce1710d35daa3. --- .../composeResources/values-it/strings.xml | 4 - .../composeResources/values/strings.xml | 4 - .../features/colorpicker/ui/ColorPicker.kt | 3 +- .../colorpicker/ui/ColorPickerDialog.kt | 188 ++++++++++-------- .../ui/pickers/ClassicColorPicker.kt | 32 +-- .../features/colorpicker/utils/ColorExt.kt | 43 +--- .../features/flows/ui/NodeDialogs.kt | 9 +- .../features/flows/ui/NodeHelpers.kt | 19 +- .../features/flows/ui/PaletteSidebar.kt | 6 +- .../plugin/ui/DirectExecutionSidebar.kt | 3 +- .../settings/ui/AccentColorControl.kt | 3 +- .../components/plugin/inputs/ColorInput.kt | 17 +- .../colorpicker/utils/ColorExtTest.kt | 71 ------- .../features/flows/ui/NodeColorParsingTest.kt | 25 --- .../plugin/model/PluginSettingDefaultsTest.kt | 21 -- 15 files changed, 133 insertions(+), 315 deletions(-) delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt delete 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 30872c98..06c715fe 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -169,10 +169,6 @@ 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 07343f42..69076655 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -180,10 +180,6 @@ Pause Save Cancel - Choose a color - Hex - Use #RRGGBB or #AARRGGBB - Apply Expand Collapse Settings: %1$s 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 4faf3523..48900e2c 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,14 +21,12 @@ 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, ) @@ -64,3 +62,4 @@ 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 94bc7fda..7d09ca4c 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,13 +9,12 @@ 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 @@ -27,103 +26,119 @@ 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.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.toCMYK +import org.wip.plugintoolkit.features.colorpicker.utils.toHSL 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 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 +import org.wip.plugintoolkit.shared.components.SelectedButtonGroup +import org.wip.plugintoolkit.core.theme.ToolkitTheme -/** A focused, editable color picker dialog with explicit cancel/apply actions. */ +/** + * 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. + */ @Composable fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, - initialColor: Color = Color.White, - showAlpha: Boolean = false, + initialType: ColorPickerType = ColorPickerType.Classic(), onPickedColor: (Color) -> Unit ) { - if (!show) return - - var color by remember(initialColor) { mutableStateOf(initialColor) } - var hexInput by remember(initialColor, showAlpha) { - mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = showAlpha).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 = stringResource(Res.string.color_picker_title), - style = MaterialTheme.typography.headlineSmall - ) + 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) } - ColorPicker( - type = ColorPickerType.Classic(showAlphaBar = showAlpha), - initialColor = initialColor, - onPickedColor = { - color = it - hexInput = it.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase() - } - ) + 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 + } - 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(stringResource(Res.string.color_picker_hex)) }, - supportingText = if (parsedHex == null) { - { Text(stringResource(Res.string.color_picker_hex_hint)) } - } else null, - isError = parsedHex == null, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace) - ) + 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) } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismissRequest) { - Text(stringResource(Res.string.action_cancel)) - } - Button( - onClick = { parsedHex?.let(onPickedColor) }, - enabled = parsedHex != null + 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) ) { - Text(stringResource(Res.string.color_picker_apply)) + 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") + } } } } @@ -135,6 +150,11 @@ 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 ade2963b..2739e6d4 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,8 +35,6 @@ 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.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -47,33 +45,18 @@ 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(initialColor) { mutableStateOf(Offset.Zero) } + var pickerLocation by remember { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - 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) } + var alpha by remember { mutableStateOf(1f) } + var rangeColor by remember { mutableStateOf(Color.White) } + var hueSlider by remember { mutableStateOf(0f) } - var color by remember(initialColor) { mutableStateOf(initialColor) } + var color by remember { mutableStateOf(Color.White) } - LaunchedEffect(colorPickerSize, initialColor) { - if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { - val (saturation, value) = initialSaturationAndValue - pickerLocation = Offset( - x = saturation * colorPickerSize.width, - y = (1f - value) * colorPickerSize.height - ) - pickerInitialized = true - } - } - - LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha, pickerInitialized) { - if (pickerInitialized && colorPickerSize.width > 0 && colorPickerSize.height > 0) { + LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha) { + if (colorPickerSize.width > 0 && colorPickerSize.height > 0) { val xProgress = if (colorPickerSize.width > 0) { (1 - (pickerLocation.x / colorPickerSize.width)).coerceIn(0f, 1f) } else 0f @@ -159,3 +142,4 @@ 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 5ed4a835..16c6e6a6 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,37 +8,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -/** 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("#") - 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()) -} - -/** 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("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || - (digits.length == 8 && digits.toLongOrNull(16) != null) -} - /** * Returns an integer array for all color channels value. */ @@ -267,15 +236,5 @@ internal fun Color.toHueProgress(): Float { hue *= 60 if (hue < 0) hue += 360 - 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 + return hue } 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 20141f82..e567dc53 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,7 +40,6 @@ 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 @@ -307,13 +306,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 existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" - val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } || - colorStringHasAlpha(existingValue) + val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, - initialColor = parseColorString(existingValue), - showAlpha = hasAlpha, onDismissRequest = onDismissColorPicker, onPickedColor = { color -> activeColorInputId.let { inputId -> @@ -328,6 +323,8 @@ 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 f92d3648..0c651456 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,7 +26,6 @@ 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( @@ -155,17 +154,9 @@ fun getNodeDescription(node: Node): String { } fun parseColorString(colorStr: String): Color { - 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 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) @@ -193,10 +184,10 @@ fun parseColorString(colorStr: String): Color { } 8 -> { - 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 + 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 Color(r, g, b, a) } 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/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/settings/ui/AccentColorControl.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt index 75b37a8d..0d55211a 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,6 +17,7 @@ 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 @@ -27,7 +28,7 @@ fun AccentColorControl(settings: AppSettings, onUpdate: (AppSettings) -> Unit) { ColorPickerDialog( show = showColorPicker, - initialColor = Color(settings.appearance.accentColor), + initialType = ColorPickerType.Classic(), onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> onUpdate( 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 f355fa46..0b7da0b7 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,8 +32,6 @@ 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 @@ -54,9 +52,6 @@ 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) } || - colorStringHasAlpha(value) - val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier .fillMaxWidth() @@ -106,16 +101,17 @@ fun ColorInput( if (showColorPicker && enabled) { ColorPickerDialog( show = showColorPicker, - initialColor = parsedColor, - showAlpha = isRgba, onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> showColorPicker = false - val formatted = if (isRgb) { - color.toRGB(rgbPrefix = true, includeAlpha = isRgba) + val formatted = if (metadata.semanticTypes.any { + it.canonicalId.contains("rgb", ignoreCase = true) + } + ) { + color.toRGB() } else { - color.toHex(hexPrefix = true, includeAlpha = isRgba) + color.toHex() } onValueChange(formatted) } @@ -129,7 +125,6 @@ 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 deleted file mode 100644 index d852c962..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -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 - -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")) - assertEquals(Color(0xAAFF0000.toInt()), parseHexColor("#F00A")) - } - - @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 - 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")) - 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 deleted file mode 100644 index 3d9f9d95..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -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()) - 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()) - } -} 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 7b1765df..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 @@ -1,19 +1,15 @@ 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( @@ -66,21 +62,4 @@ 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 c610e7b9f7b491fcf225a434b17319bc4f9086ef Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:23 +1000 Subject: [PATCH 51/52] Revert "Merge branch 'codex/issue-6-toolkit-cli' into codex/issue-7-job-scheduler" This reverts commit c285e08869e2d08e3ed9b5231c9fc40571ffc7a0, reversing changes made to e55e50fd7ca09c8bd91c2996150dbdaf60fe6c61. --- .../composeResources/values-it/strings.xml | 4 - .../composeResources/values/strings.xml | 4 - .../features/colorpicker/ui/ColorPicker.kt | 3 +- .../colorpicker/ui/ColorPickerDialog.kt | 188 ++++++++++-------- .../ui/pickers/ClassicColorPicker.kt | 32 +-- .../features/colorpicker/utils/ColorExt.kt | 43 +--- .../features/flows/ui/NodeDialogs.kt | 9 +- .../features/flows/ui/NodeHelpers.kt | 19 +- .../features/flows/ui/PaletteSidebar.kt | 6 +- .../plugin/ui/DirectExecutionSidebar.kt | 3 +- .../settings/ui/AccentColorControl.kt | 3 +- .../components/plugin/inputs/ColorInput.kt | 17 +- .../colorpicker/utils/ColorExtTest.kt | 71 ------- .../features/flows/ui/NodeColorParsingTest.kt | 25 --- .../plugin/model/PluginSettingDefaultsTest.kt | 21 -- 15 files changed, 133 insertions(+), 315 deletions(-) delete mode 100644 composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt delete 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 aea90310..3185c788 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -169,10 +169,6 @@ 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 14ab40e2..a67d8b16 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -180,10 +180,6 @@ Pause Save Cancel - Choose a color - Hex - Use #RRGGBB or #AARRGGBB - Apply Expand Collapse Settings: %1$s 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 4faf3523..48900e2c 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,14 +21,12 @@ 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, ) @@ -64,3 +62,4 @@ 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 94bc7fda..7d09ca4c 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,13 +9,12 @@ 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 @@ -27,103 +26,119 @@ 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.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.toCMYK +import org.wip.plugintoolkit.features.colorpicker.utils.toHSL 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 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 +import org.wip.plugintoolkit.shared.components.SelectedButtonGroup +import org.wip.plugintoolkit.core.theme.ToolkitTheme -/** A focused, editable color picker dialog with explicit cancel/apply actions. */ +/** + * 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. + */ @Composable fun ColorPickerDialog( show: Boolean, onDismissRequest: () -> Unit, - initialColor: Color = Color.White, - showAlpha: Boolean = false, + initialType: ColorPickerType = ColorPickerType.Classic(), onPickedColor: (Color) -> Unit ) { - if (!show) return - - var color by remember(initialColor) { mutableStateOf(initialColor) } - var hexInput by remember(initialColor, showAlpha) { - mutableStateOf(initialColor.toHex(hexPrefix = true, includeAlpha = showAlpha).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 = stringResource(Res.string.color_picker_title), - style = MaterialTheme.typography.headlineSmall - ) + 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) } - ColorPicker( - type = ColorPickerType.Classic(showAlphaBar = showAlpha), - initialColor = initialColor, - onPickedColor = { - color = it - hexInput = it.toHex(hexPrefix = true, includeAlpha = showAlpha).uppercase() - } - ) + 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 + } - 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(stringResource(Res.string.color_picker_hex)) }, - supportingText = if (parsedHex == null) { - { Text(stringResource(Res.string.color_picker_hex_hint)) } - } else null, - isError = parsedHex == null, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace) - ) + 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) } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismissRequest) { - Text(stringResource(Res.string.action_cancel)) - } - Button( - onClick = { parsedHex?.let(onPickedColor) }, - enabled = parsedHex != null + 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) ) { - Text(stringResource(Res.string.color_picker_apply)) + 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") + } } } } @@ -135,6 +150,11 @@ 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 ade2963b..2739e6d4 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,8 +35,6 @@ 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.roundToInt import org.wip.plugintoolkit.core.theme.ToolkitTheme @@ -47,33 +45,18 @@ 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(initialColor) { mutableStateOf(Offset.Zero) } + var pickerLocation by remember { mutableStateOf(Offset.Zero) } var colorPickerSize by remember { mutableStateOf(IntSize.Zero) } - 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) } + var alpha by remember { mutableStateOf(1f) } + var rangeColor by remember { mutableStateOf(Color.White) } + var hueSlider by remember { mutableStateOf(0f) } - var color by remember(initialColor) { mutableStateOf(initialColor) } + var color by remember { mutableStateOf(Color.White) } - LaunchedEffect(colorPickerSize, initialColor) { - if (colorPickerSize.width > 0 && colorPickerSize.height > 0 && !pickerInitialized) { - val (saturation, value) = initialSaturationAndValue - pickerLocation = Offset( - x = saturation * colorPickerSize.width, - y = (1f - value) * colorPickerSize.height - ) - pickerInitialized = true - } - } - - LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha, pickerInitialized) { - if (pickerInitialized && colorPickerSize.width > 0 && colorPickerSize.height > 0) { + LaunchedEffect(rangeColor, pickerLocation, colorPickerSize, alpha) { + if (colorPickerSize.width > 0 && colorPickerSize.height > 0) { val xProgress = if (colorPickerSize.width > 0) { (1 - (pickerLocation.x / colorPickerSize.width)).coerceIn(0f, 1f) } else 0f @@ -159,3 +142,4 @@ 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 5ed4a835..16c6e6a6 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,37 +8,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -/** 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("#") - 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()) -} - -/** 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("#") && digits.length == 4 && digits.toLongOrNull(16) != null) || - (digits.length == 8 && digits.toLongOrNull(16) != null) -} - /** * Returns an integer array for all color channels value. */ @@ -267,15 +236,5 @@ internal fun Color.toHueProgress(): Float { hue *= 60 if (hue < 0) hue += 360 - 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 + return hue } 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 20141f82..e567dc53 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,7 +40,6 @@ 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 @@ -307,13 +306,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 existingValue = input?.let { getPortValueString(it.value ?: it.defaultValue, it.dataType) } ?: "" - val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } || - colorStringHasAlpha(existingValue) + val hasAlpha = inferredSem.any { it.variant?.contains("rgba", ignoreCase = true) == true } org.wip.plugintoolkit.features.colorpicker.ui.ColorPickerDialog( show = showColorPicker, - initialColor = parseColorString(existingValue), - showAlpha = hasAlpha, onDismissRequest = onDismissColorPicker, onPickedColor = { color -> activeColorInputId.let { inputId -> @@ -328,6 +323,8 @@ 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 f92d3648..0c651456 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,7 +26,6 @@ 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( @@ -155,17 +154,9 @@ fun getNodeDescription(node: Node): String { } fun parseColorString(colorStr: String): Color { - 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 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) @@ -193,10 +184,10 @@ fun parseColorString(colorStr: String): Color { } 8 -> { - 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 + 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 Color(r, g, b, a) } 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/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/settings/ui/AccentColorControl.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/settings/ui/AccentColorControl.kt index 75b37a8d..0d55211a 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,6 +17,7 @@ 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 @@ -27,7 +28,7 @@ fun AccentColorControl(settings: AppSettings, onUpdate: (AppSettings) -> Unit) { ColorPickerDialog( show = showColorPicker, - initialColor = Color(settings.appearance.accentColor), + initialType = ColorPickerType.Classic(), onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> onUpdate( 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 f355fa46..0b7da0b7 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,8 +32,6 @@ 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 @@ -54,9 +52,6 @@ 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) } || - colorStringHasAlpha(value) - val isRgb = metadata.semanticTypes.any { it.canonicalId.contains("rgb", ignoreCase = true) } Column(modifier = Modifier .fillMaxWidth() @@ -106,16 +101,17 @@ fun ColorInput( if (showColorPicker && enabled) { ColorPickerDialog( show = showColorPicker, - initialColor = parsedColor, - showAlpha = isRgba, onDismissRequest = { showColorPicker = false }, onPickedColor = { color -> showColorPicker = false - val formatted = if (isRgb) { - color.toRGB(rgbPrefix = true, includeAlpha = isRgba) + val formatted = if (metadata.semanticTypes.any { + it.canonicalId.contains("rgb", ignoreCase = true) + } + ) { + color.toRGB() } else { - color.toHex(hexPrefix = true, includeAlpha = isRgba) + color.toHex() } onValueChange(formatted) } @@ -129,7 +125,6 @@ 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 deleted file mode 100644 index d852c962..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/colorpicker/utils/ColorExtTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -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 - -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")) - assertEquals(Color(0xAAFF0000.toInt()), parseHexColor("#F00A")) - } - - @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 - 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")) - 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 deleted file mode 100644 index 3d9f9d95..00000000 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/flows/ui/NodeColorParsingTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -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()) - 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()) - } -} 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 7b1765df..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 @@ -1,19 +1,15 @@ 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( @@ -66,21 +62,4 @@ 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 dce0fa2300fec44bcb7972d76895fc4639536795 Mon Sep 17 00:00:00 2001 From: Matteo Mekhail <67237370+matteoiscrying@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:10:24 +1000 Subject: [PATCH 52/52] fix: enforce Windows UNC path policy --- .../features/job/logic/JobWorkerUtils.kt | 14 ++++++---- .../plugin/logic/HostFileSystemImplTest.kt | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt index 5bb38795..04faea66 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobWorkerUtils.kt @@ -371,17 +371,19 @@ object SystemPathSecurity { private fun comparablePath(path: String): ComparablePath? { val slashNormalized = path.replace('\\', '/') val inputUsesWindowsDrive = Regex("^[A-Za-z]:/").containsMatchIn(slashNormalized) + val inputUsesWindowsUnc = slashNormalized.startsWith("//") + val inputUsesWindowsStyle = inputUsesWindowsDrive || inputUsesWindowsUnc val nativeWindows = java.io.File.separatorChar == '\\' return try { // Native paths must always be canonicalized so junctions/symlinks cannot bypass an // access root. Lexical parsing is only for a foreign Windows path on a Unix host, // where java.io.File would otherwise prefix the current directory to `C:\\...`. - val value = if (inputUsesWindowsDrive && !nativeWindows) { + val value = if (inputUsesWindowsStyle && !nativeWindows) { normalizeWindowsPath(slashNormalized) } else { java.io.File(path).canonicalPath.replace('\\', '/') } - val windowsStyle = Regex("^[A-Za-z]:/").containsMatchIn(value) + val windowsStyle = Regex("^[A-Za-z]:/").containsMatchIn(value) || value.startsWith("//") ComparablePath(value.trimEnd('/'), windowsStyle) } catch (_: Exception) { null @@ -389,16 +391,18 @@ object SystemPathSecurity { } private fun normalizeWindowsPath(path: String): String { - val root = path.take(2) + val isUnc = path.startsWith("//") val segments = mutableListOf() path.drop(2).split('/').forEach { segment -> when (segment) { "", "." -> Unit - ".." -> if (segments.isNotEmpty()) segments.removeLast() + // The server and share form the UNC root and cannot be traversed above. + ".." -> if (segments.size > if (isUnc) 2 else 0) segments.removeLast() else -> segments += segment } } - return "$root/${segments.joinToString("/")}".trimEnd('/') + val root = if (isUnc) "//" else "${path.take(2)}/" + return "$root${segments.joinToString("/")}".trimEnd('/') } } diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/HostFileSystemImplTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/HostFileSystemImplTest.kt index 677586fb..7884df23 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/HostFileSystemImplTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/HostFileSystemImplTest.kt @@ -82,6 +82,33 @@ class HostFileSystemImplTest { ) } + @Test + fun windowsUncRulesAreCaseInsensitiveAndSegmentBounded() { + val blocked = "\\\\server\\share\\secret" + + assertFalse( + SystemPathSecurity.isPathAllowed( + "\\\\SERVER\\SHARE\\SECRET\\file.txt", + FileAccessMode.Blacklist, + customBlacklist = listOf(blocked) + ) + ) + assertTrue( + SystemPathSecurity.isPathAllowed( + "\\\\server\\share\\secret-sibling\\file.txt", + FileAccessMode.Blacklist, + customBlacklist = listOf(blocked) + ) + ) + assertTrue( + SystemPathSecurity.isPathAllowed( + "\\\\SERVER\\SHARE\\SECRET\\file.txt", + FileAccessMode.Whitelist, + customWhitelist = listOf(blocked) + ) + ) + } + @Test fun testSystemPathSecurityCustomBlacklistRemovalOfDefaults() { val userCustomBlacklist = listOf("/custom/blocked/folder")