diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d125da03..0007b22f 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 packagePortableZip # run: .\gradlew packageReleaseDistributionForCurrentOS packageReleaseUberJarForCurrentOS packagePortableZip - - 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 c22e02b0..af1199fe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ This is a Kotlin Multiplatform project targeting Desktop (JVM). +## Plugin-defined pages + +Plugins can organize capabilities into pages rendered by the host, without bundling Compose UI binaries: + +```kotlin +@PluginUiPage( + id = "convert", + title = "Convert media", + description = "Choose an operation to begin.", + capabilityNames = ["Convert image", "Convert video"] +) +@PluginInfo(/* ... */) +class MediaPlugin +``` + +Unknown capability names are ignored. The declarative contract stays usable across host UI upgrades and +can also be interpreted by future web or command-line front ends. + * [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders: - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. @@ -22,6 +40,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 +64,34 @@ 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, 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:") +} +``` + +> **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/completeExample/build.gradle.kts b/completeExample/build.gradle.kts index f8a5e7c5..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) } @@ -34,3 +34,5 @@ dependencies { tasks.withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + +apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts")) diff --git a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt index 3bb804ac..05b6b876 100644 --- a/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt +++ b/completeExample/src/main/kotlin/org/wip/complete/CompleteExamplePlugin.kt @@ -23,6 +23,7 @@ import org.wip.plugintoolkit.api.annotations.CapabilityParam import org.wip.plugintoolkit.api.annotations.CapabilityResult import org.wip.plugintoolkit.api.annotations.PluginAction import org.wip.plugintoolkit.api.annotations.PluginInfo +import org.wip.plugintoolkit.api.annotations.PluginUiPage import org.wip.plugintoolkit.api.annotations.PluginLoad import org.wip.plugintoolkit.api.annotations.PluginSetting import org.wip.plugintoolkit.api.annotations.PluginSetup @@ -39,7 +40,9 @@ import java.io.File data class CompleteExampleSettings( @PluginSetting( description = "Public configuration value example", - defaultValue = "default_api_key" + defaultValue = "default_api_key", + minLength = 8, + semanticTypes = ["text/plain"] ) val apiKey: String? = "default_api_key", @PluginSetting( @@ -117,6 +120,17 @@ enum class FeatureMode { description = "Complete showcase of plugin API features including settings, validation, signals, storage, file system, lifecycle hooks, and flow contexts.", supportedOs = [OS.WINDOWS, OS.LINUX, OS.MACOS] ) +@PluginUiPage( + id = "essentials", + title = "Essential capabilities", + description = "Common storage and file operations.", + capabilityNames = ["capabilityWithFileAccess", "capabilityWithDataStorage"] +) +@PluginUiPage( + id = "advanced", + title = "Advanced capabilities", + capabilityNames = ["capabilityWithPauseResume", "capabilityWithComplexObjectsAndSemanticTypes"] +) class CompleteExamplePlugin(val settings: CompleteExampleSettings) { @PluginLoad diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 9303dcb6..0aea9ad9 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1,5 +1,7 @@ + Obbligatorie + Facoltative PluginToolkit Runner Dashboard @@ -426,6 +428,16 @@ Apri le impostazioni del plugin in un popup invece di navigare via Attenzione: Impostazioni Sul Posto 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 + 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. Questa funzione non è disponibile nella versione portatile dell\'applicazione Posizione Cache Scegli dove archiviare la cache e i file temporanei dell\'applicazione diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ba373128..c4abeb2f 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 @@ -201,7 +203,6 @@ No archived jobs Ended Jobs No ended jobs recorded yet - Scheduler coming soon Error: %1$s Choose Install Location Action Blocked @@ -426,6 +427,16 @@ Open plugin settings in an overlay popup instead of navigating away Warning: In-Place Settings By Section + 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 + 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. This feature is not available in the portable version of the application Cache Location Choose where application cache and temporary files are stored @@ -474,4 +485,4 @@ Filter: Sort: Sync All - \ No newline at end of file + 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/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/JobManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/JobManager.kt index a1386f09..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 @@ -11,8 +11,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +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 @@ -25,16 +31,29 @@ 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.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 import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.Uuid class JobManager( /** Injected [AppScope] for managing job lifecycles and worker coordination. */ private val scope: CoroutineScope, private val settingsRepository: SettingsRepository ) { + // 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 private val maxHistoryLength get() = settingsRepository.settings.value.jobs.maxHistoryLength @@ -55,6 +74,11 @@ class JobManager( private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _history.asStateFlow() + 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() @@ -71,6 +95,15 @@ 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() + 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 + private val scheduleReadinessRetryNotBefore = mutableMapOf() init { scope.launch { @@ -100,6 +133,211 @@ 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@{ + val initialized = scheduleMutex.withLock { hydrateSchedulesLocked() } + 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() } + } + } + initialized + } + + @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 + } + val now = Clock.System.now() + val normalizedInterval = intervalMinutes.normalizedScheduleInterval() + val schedule = ScheduledJob( + id = "schedule-${Uuid.random()}", + jobTemplate = job.asFreshRun(now), + intervalMinutes = normalizedInterval, + nextRunAt = now + normalizedInterval.minutes + ) + if (!replaceSchedules(_schedules.value + schedule)) return@withLock null + return schedule + } + + suspend fun removeSchedule(id: String): Boolean = scheduleMutex.withLock { + if (!hydrateSchedulesLocked()) return@withLock false + val updated = _schedules.value.filterNot { it.id == id } + 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 } + if (updated == _schedules.value) return@withLock true + replaceSchedules(updated) + } + + 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 false + val updated = current.map { if (it.id == id) it.afterRun(now) else it } + 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 + // 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. + 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 } + // 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)) } + } + } + + private fun isScheduledJobReady(job: BackgroundJob): Boolean = when (job.type) { + org.wip.plugintoolkit.features.job.model.JobType.Capability -> + scheduleCapabilityReadiness(job.pluginId, job.capabilityName) + 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 -> + scheduleCapabilityReadiness(node.pluginInfo.id, node.capability.name) + 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()}", + status = JobStatus.Queued, + enqueuedAt = now, + startedAt = null, + completedAt = null, + errorMessage = null, + result = null, + 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 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 + } + val supported = loaded.filter { it.jobTemplate.type.canBeScheduled() } + if (supported != loaded && !persistSchedules(supported)) { + _scheduleLoadFailed.value = true + return false + } + _schedules.value = supported + schedulesLoaded = true + _scheduleLoadFailed.value = false + return true + } + + private suspend fun persistSchedules(updated: List): Boolean = + scheduleRepository.save(updated).fold( + 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 { + val dueWait = _schedules.value.asSequence() + .filter { it.enabled } + .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 + val retryWait = scheduleRetryNotBefore + ?.let { (it - now).inWholeMilliseconds.coerceAtLeast(0) } + ?: 0L + return maxOf(dueWait, retryWait).coerceAtMost(MAX_SCHEDULER_WAIT_MS) } private fun startWorkers() { @@ -564,3 +802,8 @@ 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 +private const val SCHEDULE_READINESS_RETRY_MS = 30_000L 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..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 @@ -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,69 @@ 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 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 (inputUsesWindowsStyle && !nativeWindows) { + normalizeWindowsPath(slashNormalized) + } else { + java.io.File(path).canonicalPath.replace('\\', '/') + } + val windowsStyle = Regex("^[A-Za-z]:/").containsMatchIn(value) || value.startsWith("//") + ComparablePath(value.trimEnd('/'), windowsStyle) + } catch (_: Exception) { + null + } + } + + private fun normalizeWindowsPath(path: String): String { + val isUnc = path.startsWith("//") + val segments = mutableListOf() + path.drop(2).split('/').forEach { segment -> + when (segment) { + "", "." -> Unit + // 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 + } + } + val root = if (isUnc) "//" else "${path.take(2)}/" + return "$root${segments.joinToString("/")}".trimEnd('/') + } } fun resolveFileAccess( @@ -454,4 +499,3 @@ fun deleteRecursively(path: kotlinx.io.files.Path) { } } } - 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..08b40086 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepository.kt @@ -0,0 +1,85 @@ +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) + 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) { + runCatching { + val primary = file() + val backup = Path("$primary.bak") + if (!SystemFileSystem.exists(primary) && !SystemFileSystem.exists(backup)) { + return@runCatching emptyList() + } + + 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) { + var temporary: Path? = null + var backupTemporary: Path? = null + runCatching { + 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) + } + } + SystemFileSystem.atomicMove(temporary!!, primary) + }.onFailure { Logger.e(it) { "Failed to save schedules atomically" } } + .also { + temporary?.let { path -> runCatching { if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) } } + backupTemporary?.let { path -> runCatching { if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) } } + } + } + + 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 ca37beb0..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 @@ -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 { @@ -26,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, @@ -56,3 +63,22 @@ 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, + 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 0a71279d..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 @@ -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 @@ -35,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 @@ -42,9 +44,11 @@ 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 +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -80,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 @@ -103,7 +109,15 @@ 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.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 +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 @@ -213,7 +227,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) { } } @@ -308,6 +322,49 @@ 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()) } + + 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 = { + 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!!) { succeeded -> + if (succeeded) 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( @@ -338,7 +395,14 @@ 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 = if (job.type.canBeScheduled()) { + { + viewModel.clearScheduleError() + intervalText = DEFAULT_SCHEDULE_INTERVAL_MINUTES.toString() + jobToSchedule = job + } + } else null ) } } else { @@ -351,27 +415,84 @@ fun EndedTab(viewModel: JobViewModel) { } @Composable -fun SchedulerTab() { - Column( +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 && !scheduleLoadFailed) { + EmptyState(stringResource(Res.string.job_schedule_empty), 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 - ) + 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)) { + 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(), + 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( + stringResource( + Res.string.job_schedule_next_format, + schedule.intervalMinutes, + 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 = stringResource(Res.string.job_schedule_run_now)) + } + IconButton(onClick = { viewModel.removeSchedule(schedule.id) }) { + Icon(Icons.Default.Delete, contentDescription = stringResource(Res.string.job_schedule_delete)) + } + } + } + } } } +private const val DEFAULT_SCHEDULE_INTERVAL_MINUTES = 24L * 60L + @Composable fun HistoryTab(viewModel: JobViewModel) { val history by viewModel.history.collectAsState() @@ -482,4 +603,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..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 @@ -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 @@ -18,6 +20,10 @@ class JobViewModel( val jobLogs = jobManager.jobLogs 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() val runningJobs = jobs.map { list -> list.filter { it.status == JobStatus.Running } @@ -72,4 +78,26 @@ class JobViewModel( jobManager.clearAllEndedJobs() } } + + 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 { _scheduleOperationFailed.value = !jobManager.removeSchedule(id) } + } + + fun setScheduleEnabled(id: String, enabled: Boolean) { + viewModelScope.launch { _scheduleOperationFailed.value = !jobManager.setScheduleEnabled(id, enabled) } + } + + fun runScheduleNow(id: String) { + viewModelScope.launch { _scheduleOperationFailed.value = !jobManager.runScheduleNow(id) } + } + + fun clearScheduleError() { _scheduleOperationFailed.value = false } } diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt index 6119f11e..4a76a74e 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManager.kt @@ -21,6 +21,7 @@ import org.wip.plugintoolkit.core.utils.FileSystem import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore +import org.wip.plugintoolkit.features.plugin.model.resolveCustomSettings import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import org.wip.plugintoolkit.features.settings.model.PluginUnplugBehavior import org.wip.plugintoolkit.features.plugin.utils.PluginCompatibilityUtils @@ -287,6 +288,7 @@ class PluginLifecycleManager( } val decryptedStore = store.copy(settings = decryptedSettings) + .withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) _pluginSettingsState.update { it + (pkg to decryptedStore) } return decryptedStore } @@ -296,7 +298,8 @@ class PluginLifecycleManager( val settingsFile = "${plugin.installPath}/settings.json" val manifest = getManifest(pkg) - val encryptedSettings = store.settings.mapValues { (key, value) -> + val resolvedStore = store.withResolvedAutogeneratedSettings(manifest?.settings.orEmpty()) + val encryptedSettings = resolvedStore.settings.mapValues { (key, value) -> val isSecret = manifest?.settings?.get(key)?.secret == true if (isSecret && value is kotlinx.serialization.json.JsonPrimitive && value.isString) { val encrypted = org.wip.plugintoolkit.core.utils.SecureStorage.encrypt(value.content) @@ -305,12 +308,12 @@ class PluginLifecycleManager( value } } - val storeToSave = store.copy(settings = encryptedSettings) + val storeToSave = resolvedStore.copy(settings = encryptedSettings) try { fileSystem.writeFile(settingsFile, json.encodeToString(storeToSave)) // Update cache with the decrypted store - _pluginSettingsState.update { it + (pkg to store) } + _pluginSettingsState.update { it + (pkg to resolvedStore) } } catch (t: Throwable) { Logger.e(t) { "Failed to save settings for $pkg" } } @@ -330,17 +333,10 @@ class PluginLifecycleManager( val installPath = plugin?.installPath ?: "" val jarFullPath = plugin?.let { "${it.installPath}/${it.jarFileName}" } - val storedSettings = overriddenSettings ?: loadPluginSettings(pkg) val actualManifest = manifest ?: getManifest(pkg) - val mergedSettings = mutableMapOf() - - // 1. Manifest defaults - actualManifest?.settings?.forEach { (key, meta) -> - meta.defaultValue?.let { mergedSettings[key] = it } - } - - // 2. User overrides - mergedSettings.putAll(storedSettings.settings) + val storedSettings = (overriddenSettings ?: loadPluginSettings(pkg)) + .withResolvedAutogeneratedSettings(actualManifest?.settings.orEmpty()) + val mergedSettings = storedSettings.resolveCustomSettings(actualManifest) val pluginLogger = jobManager.getPluginLogger(pkg, jobId) val progressReporter = object : ProgressReporter { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt new file mode 100644 index 00000000..4c5a413b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginSettingsResolver.kt @@ -0,0 +1,63 @@ +package org.wip.plugintoolkit.features.plugin.logic + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata +import org.wip.plugintoolkit.features.flows.logic.PathPatternResolver +import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore +import org.wip.plugintoolkit.features.plugin.utils.SettingsUtils + +internal fun resolveAutogeneratedSettings( + metadata: Map, + settings: Map, + additionalValues: Map = emptyMap() +): Map { + val resolvedSettings = settings.toMutableMap() + val defaults = metadata.mapNotNull { (key, value) -> value.defaultValue?.let { key to it } }.toMap() + + repeat(metadata.size.coerceAtLeast(1)) { + var changed = false + metadata.forEach { (key, settingMetadata) -> + val pattern = settingMetadata.autogeneratedPattern?.takeIf { it.isNotBlank() } ?: return@forEach + val availableValues = defaults + additionalValues + resolvedSettings + val stringValues = availableValues.mapValues { (valueKey, value) -> + val valueType = metadata[valueKey]?.type + if (valueType != null) SettingsUtils.jsonToString(value, valueType) else value.toString().trim('"') + } + val generated = runCatching { PathPatternResolver.tryResolve(pattern, stringValues) }.getOrNull() + ?: return@forEach + val generatedValue = generatedSettingValue(generated, settingMetadata.type) ?: return@forEach + if (resolvedSettings[key] != generatedValue) { + resolvedSettings[key] = generatedValue + changed = true + } + } + if (!changed) return resolvedSettings + } + + return resolvedSettings +} + +internal fun PluginSettingsStore.withResolvedAutogeneratedSettings( + metadata: Map +): PluginSettingsStore { + val resolved = resolveAutogeneratedSettings(metadata, settings, globalParams) + return if (resolved == settings) this else copy(settings = resolved) +} + +private fun generatedSettingValue(value: String, type: DataType): JsonElement? = when (type) { + is DataType.Primitive -> when (type.primitiveType) { + PrimitiveType.STRING, PrimitiveType.ANY, PrimitiveType.UNKNOWN -> JsonPrimitive(value) + PrimitiveType.BOOLEAN -> value.toBooleanStrictOrNull()?.let(::JsonPrimitive) + PrimitiveType.INT -> value.toIntOrNull()?.let(::JsonPrimitive) + PrimitiveType.LONG -> value.toLongOrNull()?.let(::JsonPrimitive) + PrimitiveType.SHORT -> value.toShortOrNull()?.let { JsonPrimitive(it.toInt()) } + PrimitiveType.BYTE -> value.toByteOrNull()?.let { JsonPrimitive(it.toInt()) } + PrimitiveType.DOUBLE -> value.toDoubleOrNull()?.let(::JsonPrimitive) + PrimitiveType.FLOAT -> value.toFloatOrNull()?.let { JsonPrimitive(it.toDouble()) } + PrimitiveType.UNIT -> null + } + else -> value.takeIf { it.isNotBlank() }?.let { runCatching { SettingsUtils.stringToJson(it, type) }.getOrNull() } +} diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt index 472b0a24..8ed2f01a 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingsStore.kt @@ -2,6 +2,7 @@ package org.wip.plugintoolkit.features.plugin.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import org.wip.plugintoolkit.api.PluginManifest @Serializable data class PluginSettingsStore( @@ -9,3 +10,15 @@ data class PluginSettingsStore( val globalParams: Map = emptyMap(), val capabilityParams: Map> = emptyMap() ) + +fun PluginManifest.defaultCustomSettings(): Map = settings.orEmpty().mapNotNull { (key, metadata) -> + metadata.defaultValue?.let { key to it } +}.toMap() + +/** Manifest defaults with persisted user values taking precedence. */ +fun PluginSettingsStore.resolveCustomSettings(manifest: PluginManifest?): Map = + (manifest?.defaultCustomSettings() ?: emptyMap()) + settings + +/** Values available to generated inputs and lock evaluation in the settings UI. */ +fun PluginSettingsStore.resolveProvidedValues(manifest: PluginManifest?): Map = + resolveCustomSettings(manifest) + globalParams diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt index e8d0fd4b..cc9a0b2a 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginContent.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -40,15 +41,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import kotlinx.serialization.json.JsonPrimitive -import org.wip.plugintoolkit.api.DataType -import org.wip.plugintoolkit.api.PrimitiveType import org.jetbrains.compose.resources.stringResource import org.wip.plugintoolkit.api.Capability import org.wip.plugintoolkit.api.ParameterRole import org.wip.plugintoolkit.api.PluginManifest import org.wip.plugintoolkit.features.job.model.BackgroundJob import org.wip.plugintoolkit.features.job.model.JobStatus +import org.wip.plugintoolkit.features.plugin.model.resolveProvidedValues import org.wip.plugintoolkit.features.navigation.model.Screen import org.wip.plugintoolkit.features.plugin.viewmodel.PluginViewModel import org.wip.plugintoolkit.shared.components.plugin.JobResultCard @@ -119,19 +118,24 @@ fun PluginContent( emptyMap() } } - val providedSettings = remember(pluginId, pluginSettingsState) { + val selectedManifest = remember(pluginId, viewModel.selectedPlugin) { + viewModel.selectedPlugin?.getManifest()?.getOrNull() + } + val providedSettings = remember(pluginId, pluginSettingsState, selectedManifest) { val store = if (pluginId != null) pluginSettingsState[pluginId] ?: pluginManager.loadPluginSettings(pluginId) else null - val manifest = viewModel.selectedPlugin?.getManifest()?.getOrNull() - val manifestDefaults = (manifest?.settings?.mapValues { (_, meta) -> - meta.defaultValue ?: if (meta.type is DataType.Primitive && (meta.type as DataType.Primitive).primitiveType == PrimitiveType.BOOLEAN) { - JsonPrimitive(false) - } else null - }?.filterValues { it != null } ?: emptyMap()) as Map - manifestDefaults + (store?.settings ?: emptyMap()) + (store?.globalParams ?: emptyMap()) + (store ?: org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore()) + .resolveProvidedValues(selectedManifest) } if (selectedCapability == null) { - EmptyState(stringResource(Res.string.plugin_select_capability_hint)) + if (selectedManifest != null && selectedManifest.uiPages.isNotEmpty()) { + PluginDefinedPages( + manifest = selectedManifest, + onCapabilitySelected = viewModel::selectCapability + ) + } else { + EmptyState(stringResource(Res.string.plugin_select_capability_hint)) + } } else { Column( modifier = Modifier @@ -203,6 +207,51 @@ fun PluginContent( } } +@Composable +private fun PluginDefinedPages( + manifest: PluginManifest, + onCapabilitySelected: (Capability) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(ToolkitTheme.spacing.extraLarge), + verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.extraLarge) + ) { + manifest.uiPages.forEach { page -> + key(page.id) { + val capabilities = page.capabilityNames.mapNotNull { name -> + manifest.capabilities.firstOrNull { it.name == name } + } + Column(verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small)) { + Text(page.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + if (page.description.isNotBlank()) { + Text( + page.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + capabilities.forEach { capability -> + OutlinedButton( + onClick = { onCapabilitySelected(capability) }, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(capability.name, style = MaterialTheme.typography.titleMedium) + capability.description?.takeIf { it.isNotBlank() }?.let { description -> + Text(description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } + } +} + @Composable fun PluginHeader(manifest: PluginManifest) { Column { diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt index e723fa67..99c0dcfb 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingsContent.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt @@ -51,6 +53,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -72,8 +75,10 @@ import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.ParameterMetadata import org.wip.plugintoolkit.api.PluginAction import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata import org.wip.plugintoolkit.core.model.localized import org.wip.plugintoolkit.core.theme.ToolkitTheme +import org.wip.plugintoolkit.features.plugin.model.resolveCustomSettings import org.wip.plugintoolkit.features.plugin.utils.SettingsUtils import org.wip.plugintoolkit.features.plugin.viewmodel.PluginSettingsViewModel import org.wip.plugintoolkit.shared.components.ToolkitChip @@ -95,6 +100,8 @@ import plugintoolkit.composeapp.generated.resources.plugin_settings_by_section import plugintoolkit.composeapp.generated.resources.plugin_settings_capability import plugintoolkit.composeapp.generated.resources.plugin_settings_custom import plugintoolkit.composeapp.generated.resources.plugin_settings_global_defaults +import plugintoolkit.composeapp.generated.resources.plugin_settings_optional +import plugintoolkit.composeapp.generated.resources.plugin_settings_required import plugintoolkit.composeapp.generated.resources.settings import plugintoolkit.composeapp.generated.resources.settings_locked_capability import plugintoolkit.composeapp.generated.resources.settings_no_results @@ -102,6 +109,11 @@ import plugintoolkit.composeapp.generated.resources.settings_search_placeholder import org.wip.plugintoolkit.shared.components.verticalFadingEdges +internal fun partitionSettings( + settings: Map +): Pair, Map> = + settings.filterValues { it.required } to settings.filterValues { !it.required } + @Composable fun PluginSettingsContent( pkg: String, @@ -125,6 +137,8 @@ fun PluginSettingsContent( val actionsTitle = stringResource(Res.string.plugin_settings_actions) val customTitle = stringResource(Res.string.plugin_settings_custom) val globalTitle = stringResource(Res.string.plugin_settings_global_defaults) + val requiredTitle = stringResource(Res.string.plugin_settings_required) + val optionalTitle = stringResource(Res.string.plugin_settings_optional) val capabilityTitles = manifest.capabilities.associate { it.name to stringResource(Res.string.plugin_settings_capability, it.name) @@ -173,6 +187,10 @@ fun PluginSettingsContent( val hasGlobalParams = globalParams.isNotEmpty() val hasCapabilities = capabilities.isNotEmpty() val hasAnyResults = hasActions || hasCustomSettings || hasGlobalParams || hasCapabilities + val (requiredSettings, optionalSettings) = remember(customSettings) { partitionSettings(customSettings) } + val customSettingRequesters = remember(customSettings.keys) { + customSettings.keys.associateWith { BringIntoViewRequester() } + } val lockedEnumOptions = remember(manifest) { val result = mutableMapOf>() @@ -246,7 +264,8 @@ fun PluginSettingsContent( // Auto-scroll to requested setting or section LaunchedEffect(scrollToSetting, sectionIndices, customSettings) { if (scrollToSetting != null) { - val targetKey = if (customSettings.containsKey(scrollToSetting)) { + val isCustomSetting = customSettings.containsKey(scrollToSetting) + val targetKey = if (isCustomSetting) { "section_custom" } else if (capabilities.any { it.parameters?.containsKey(scrollToSetting) == true }) { val cap = capabilities.first { it.parameters?.containsKey(scrollToSetting) == true } @@ -260,6 +279,10 @@ fun PluginSettingsContent( val targetIndex = targetKey?.let { sectionIndices[it] } if (targetIndex != null) { lazyListState.animateScrollToItem(targetIndex) + if (isCustomSetting) { + withFrameNanos { } + customSettingRequesters[scrollToSetting]?.bringIntoView() + } } } } @@ -410,15 +433,8 @@ fun PluginSettingsContent( ) } } else { - val manifestDefaults = remember(manifest) { - (manifest.settings?.mapValues { (_, meta) -> - meta.defaultValue ?: if (meta.type is DataType.Primitive && (meta.type as DataType.Primitive).primitiveType == PrimitiveType.BOOLEAN) { - JsonPrimitive(false) - } else null - }?.filterValues { it != null } ?: emptyMap()) as Map - } - val providedSettings = remember(manifestDefaults, store.settings) { - manifestDefaults + store.settings + val providedSettings = remember(manifest, store.settings) { + store.resolveCustomSettings(manifest) } LazyColumn( @@ -472,75 +488,91 @@ fun PluginSettingsContent( modifier = Modifier.fillMaxWidth().padding(top = ToolkitTheme.spacing.small), verticalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.mediumSmall) ) { - customSettings.forEach { (key, meta) -> - Column(modifier = Modifier.fillMaxWidth()) { - val value = store.settings[key] ?: meta.defaultValue - DynamicParameterInput( - name = key, - metadata = ParameterMetadata( - description = meta.description, - type = meta.type, - defaultValue = meta.defaultValue, - required = meta.required, - secret = meta.secret - ), - value = SettingsUtils.jsonToString(value, meta.type), - onValueChange = { - viewModel.updateSetting( - key, - SettingsUtils.stringToJson(it, meta.type) - ) - }, - enabled = !isBusy, - providedSettings = providedSettings, - providedLocks = locks - ) - - val lockedOptionsForSetting = lockedEnumOptions[key]?.distinct() ?: emptyList() - - if (meta.requiredByCapabilities.isNotEmpty() || lockedOptionsForSetting.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = ToolkitTheme.spacing.medium, - bottom = ToolkitTheme.spacing.mediumSmall, - end = ToolkitTheme.spacing.medium - ) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small) - ) { - meta.requiredByCapabilities.forEach { capName -> - ToolkitChip( - text = stringResource( - Res.string.settings_locked_capability, - capName - ), - icon = { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) - ) - }, - style = ToolkitChipStyle.Tinted - ) - } - if (lockedOptionsForSetting.isNotEmpty()) { - ToolkitChip( - text = "Unlocks Enum Options", - modifier = Modifier.tooltip( - text = "Unlocks values:\n" + lockedOptionsForSetting.joinToString("\n"), - ), - icon = { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) - ) - }, - style = ToolkitChipStyle.Outlined + listOf( + requiredTitle to requiredSettings, + optionalTitle to optionalSettings + ).forEach { (groupTitle, groupSettings) -> + if (groupSettings.isNotEmpty()) { + PluginSettingGroupHeader(groupTitle, groupSettings.size) + } + groupSettings.forEach { (key, meta) -> + Column( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(customSettingRequesters.getValue(key)) + ) { + val value = store.settings[key] ?: meta.defaultValue + DynamicParameterInput( + name = key, + metadata = ParameterMetadata( + description = meta.description, + type = meta.type, + defaultValue = meta.defaultValue, + constraints = meta.constraints, + required = meta.required, + secret = meta.secret, + semanticTypes = meta.semanticTypes, + autogeneratedPattern = meta.autogeneratedPattern + ), + value = SettingsUtils.jsonToString(value, meta.type), + onValueChange = { + viewModel.updateSetting( + key, + SettingsUtils.stringToJson(it, meta.type) ) + }, + enabled = !isBusy && meta.autogeneratedPattern == null, + isAutoGenerated = meta.autogeneratedPattern != null, + providedSettings = providedSettings, + providedLocks = locks + ) + + val lockedOptionsForSetting = lockedEnumOptions[key]?.distinct() ?: emptyList() + + if (meta.requiredByCapabilities.isNotEmpty() || lockedOptionsForSetting.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = ToolkitTheme.spacing.medium, + bottom = ToolkitTheme.spacing.mediumSmall, + end = ToolkitTheme.spacing.medium + ) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ToolkitTheme.spacing.small) + ) { + meta.requiredByCapabilities.forEach { capName -> + ToolkitChip( + text = stringResource( + Res.string.settings_locked_capability, + capName + ), + icon = { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) + ) + }, + style = ToolkitChipStyle.Tinted + ) + } + if (lockedOptionsForSetting.isNotEmpty()) { + ToolkitChip( + text = "Unlocks Enum Options", + modifier = Modifier.tooltip( + text = "Unlocks values:\n" + lockedOptionsForSetting.joinToString("\n"), + ), + icon = { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(ToolkitTheme.dimensions.iconExtraSmall) + ) + }, + style = ToolkitChipStyle.Outlined + ) + } } } } @@ -672,6 +704,21 @@ private fun PluginSectionHeader(title: String) { ) } +@Composable +private fun PluginSettingGroupHeader(title: String, count: Int) { + Text( + text = "$title ($count)", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = ToolkitTheme.spacing.medium, + top = ToolkitTheme.spacing.small, + bottom = ToolkitTheme.spacing.extraSmall + ) + ) +} + @Composable private fun ActionParametersDialog( action: PluginAction, diff --git a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt index 6094902a..3b614b0f 100644 --- a/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsViewModel.kt @@ -10,22 +10,29 @@ import kotlinx.serialization.json.JsonElement import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.job.model.JobStatus import org.wip.plugintoolkit.features.plugin.logic.PluginManager +import org.wip.plugintoolkit.features.plugin.logic.withResolvedAutogeneratedSettings +import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore class PluginSettingsViewModel( val pkg: String, private val pluginManager: PluginManager, private val jobManager: JobManager, ) : ViewModel() { - private val _store = MutableStateFlow(pluginManager.loadPluginSettings(pkg)) + val manifest = pluginManager.getManifest(pkg) + private val initialStore = pluginManager.loadPluginSettings(pkg) + private val _store = MutableStateFlow(initialStore.withAutogeneratedSettings()) val store = _store.asStateFlow() private val _isBusy = MutableStateFlow(false) val isBusy = _isBusy.asStateFlow() - val manifest = pluginManager.getManifest(pkg) - val locks = MutableStateFlow>(emptyMap()) + private fun PluginSettingsStore.withAutogeneratedSettings(): PluginSettingsStore { + val settingMetadata = manifest?.settings ?: return this + return withResolvedAutogeneratedSettings(settingMetadata) + } + init { viewModelScope.launch { pluginManager.refreshLocks(pkg) @@ -45,7 +52,7 @@ class PluginSettingsViewModel( fun updateSetting(key: String, value: JsonElement) { _store.update { current -> - val updated = current.copy(settings = current.settings + (key to value)) + val updated = current.copy(settings = current.settings + (key to value)).withAutogeneratedSettings() viewModelScope.launch { val newLocks = pluginManager.refreshLocks(pkg, updated) locks.value = newLocks @@ -56,7 +63,7 @@ class PluginSettingsViewModel( fun updateGlobalParam(key: String, value: JsonElement) { _store.update { current -> - val updated = current.copy(globalParams = current.globalParams + (key to value)) + val updated = current.copy(globalParams = current.globalParams + (key to value)).withAutogeneratedSettings() viewModelScope.launch { val newLocks = pluginManager.refreshLocks(pkg, updated) locks.value = newLocks diff --git a/composeApp/src/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..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 @@ -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,15 @@ fun JobResultCard( StatusBadge(job.status) Spacer(modifier = Modifier.width(ToolkitTheme.spacing.small)) + if (onSchedule != null) { + IconButton(onClick = onSchedule) { + Icon( + Icons.Default.Schedule, + contentDescription = stringResource(Res.string.job_schedule_action) + ) + } + } + 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))) + } +} 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") diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt index acf03ddb..eced3f26 100644 --- a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/logic/PluginLifecycleManagerTest.kt @@ -7,9 +7,16 @@ import org.wip.plugintoolkit.core.utils.FileSystem import org.wip.plugintoolkit.features.job.logic.JobManager import org.wip.plugintoolkit.features.plugin.model.InstalledPlugin import org.wip.plugintoolkit.features.plugin.model.PluginSettingsStore +import org.wip.plugintoolkit.features.plugin.model.resolveCustomSettings import org.wip.plugintoolkit.features.settings.logic.SettingsPersistence import org.wip.plugintoolkit.features.settings.logic.SettingsRepository import org.wip.plugintoolkit.features.settings.model.AppSettings +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PluginInfo +import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.Requirements +import org.wip.plugintoolkit.api.SettingMetadata import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotSame @@ -48,6 +55,58 @@ class PluginLifecycleManagerTest { override fun openLatestLog() {} } + @Test + fun testPluginContextReceivesExactlyResolvedCustomSettings() = runTest { + val fileSystem = FakeFileSystem() + val settingsRepo = SettingsRepository(FakeSettingsPersistence(), backgroundScope) + val registry = PluginRegistry( + settingsRepo, + backgroundScope, + loomDispatcher, + io.mockk.mockk(relaxed = true) + ) + val lifecycleManager = PluginLifecycleManager( + registry, + JobManager(backgroundScope, settingsRepo), + settingsRepo, + fileSystem + ) + val pkg = "test.context.defaults" + registry.addOrUpdatePlugin( + InstalledPlugin(pkg, "Test", "1.0.0", "/tmp/test.context.defaults") + ) + val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo(pkg, "Test", "1.0.0", "Test plugin"), + requirements = Requirements(128, 10), + settings = mapOf( + "endpoint" to SettingMetadata( + defaultValue = JsonPrimitive("https://default.test"), + description = "Endpoint", + type = DataType.Primitive(PrimitiveType.STRING) + ), + "optionalFlag" to SettingMetadata( + description = "Optional flag", + type = DataType.Primitive(PrimitiveType.BOOLEAN) + ) + ) + ) + val store = PluginSettingsStore( + settings = mapOf("endpoint" to JsonPrimitive("https://custom.test")), + globalParams = mapOf("region" to JsonPrimitive("eu")) + ) + + val context = lifecycleManager.createPluginContext( + pkg = pkg, + manifest = manifest, + overriddenSettings = store + ) + + assertEquals(store.resolveCustomSettings(manifest), context.settings) + kotlin.test.assertFalse(context.settings.containsKey("optionalFlag")) + kotlin.test.assertFalse(context.settings.containsKey("region")) + } + @Test fun testSettingsCaching() = runTest { val fileSystem = FakeFileSystem() diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt new file mode 100644 index 00000000..6b645980 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/model/PluginSettingDefaultsTest.kt @@ -0,0 +1,65 @@ +package org.wip.plugintoolkit.features.plugin.model + +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PluginInfo +import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.Requirements +import org.wip.plugintoolkit.api.SettingMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PluginSettingDefaultsTest { + private val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1.0", "Example plugin"), + requirements = Requirements(128, 10), + settings = mapOf( + "endpoint" to SettingMetadata( + defaultValue = JsonPrimitive("https://example.test"), + description = "Endpoint", + type = DataType.Primitive(PrimitiveType.STRING) + ), + "enabled" to SettingMetadata( + description = "Enabled", + type = DataType.Primitive(PrimitiveType.BOOLEAN) + ) + ) + ) + + @Test + fun `manifest defaults are available before a user saves settings`() { + val resolved = PluginSettingsStore().resolveCustomSettings(manifest) + + assertEquals(JsonPrimitive("https://example.test"), resolved["endpoint"]) + assertFalse(resolved.containsKey("enabled")) + } + + @Test + fun `user values override defaults and global values are exposed separately`() { + val store = PluginSettingsStore( + settings = mapOf("endpoint" to JsonPrimitive("https://custom.test")), + globalParams = mapOf("region" to JsonPrimitive("eu")) + ) + + val custom = store.resolveCustomSettings(manifest) + val provided = store.resolveProvidedValues(manifest) + + assertEquals(JsonPrimitive("https://custom.test"), custom["endpoint"]) + assertFalse(custom.containsKey("enabled")) + assertEquals(JsonPrimitive("eu"), provided["region"]) + } + + @Test + fun `global parameters cannot shadow custom settings in custom setting resolution`() { + val store = PluginSettingsStore( + settings = mapOf("endpoint" to JsonPrimitive("https://custom.test")), + globalParams = mapOf("endpoint" to JsonPrimitive("global-collision")) + ) + + assertEquals(JsonPrimitive("https://custom.test"), store.resolveCustomSettings(manifest)["endpoint"]) + assertEquals(JsonPrimitive("global-collision"), store.resolveProvidedValues(manifest)["endpoint"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt new file mode 100644 index 00000000..93d238d9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/ui/PluginSettingPartitionTest.kt @@ -0,0 +1,40 @@ +package org.wip.plugintoolkit.features.plugin.ui + +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PluginSettingPartitionTest { + @Test + fun `partitions required and optional settings while preserving order`() { + val settings = linkedMapOf( + "optionalFirst" to setting(required = false), + "requiredFirst" to setting(required = true), + "requiredSecond" to setting(required = true), + "optionalSecond" to setting(required = false) + ) + + val (required, optional) = partitionSettings(settings) + + assertEquals(listOf("requiredFirst", "requiredSecond"), required.keys.toList()) + assertEquals(listOf("optionalFirst", "optionalSecond"), optional.keys.toList()) + assertEquals(settings.keys, (required.keys + optional.keys).toSet()) + } + + @Test + fun `empty settings produce two empty groups`() { + val (required, optional) = partitionSettings(emptyMap()) + + assertTrue(required.isEmpty()) + assertTrue(optional.isEmpty()) + } + + private fun setting(required: Boolean) = SettingMetadata( + description = "Setting", + type = DataType.Primitive(PrimitiveType.STRING), + required = required + ) +} diff --git a/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt new file mode 100644 index 00000000..2bdeab26 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/wip/plugintoolkit/features/plugin/viewmodel/PluginSettingsAutogenerationTest.kt @@ -0,0 +1,61 @@ +package org.wip.plugintoolkit.features.plugin.viewmodel + +import kotlinx.serialization.json.JsonPrimitive +import org.wip.plugintoolkit.api.DataType +import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.SettingMetadata +import org.wip.plugintoolkit.features.plugin.logic.resolveAutogeneratedSettings +import kotlin.test.Test +import kotlin.test.assertEquals + +class PluginSettingsAutogenerationTest { + private val stringType = DataType.Primitive(PrimitiveType.STRING) + private val metadata = mapOf( + "input" to SettingMetadata(description = "Input", type = stringType), + "output" to SettingMetadata( + description = "Output", + type = stringType, + autogeneratedPattern = "{input.dir}/{input.nameWithoutExtension}.result" + ) + ) + + @Test + fun `derived setting follows its dependency`() { + val result = resolveAutogeneratedSettings( + metadata = metadata, + settings = mapOf("input" to JsonPrimitive("work/photo.png")) + ) + + assertEquals(JsonPrimitive("work/photo.result"), result["output"]) + } + + @Test + fun `derived setting preserves the last explicit value when dependency is missing`() { + val result = resolveAutogeneratedSettings( + metadata = metadata, + settings = mapOf("output" to JsonPrimitive("stale.result")) + ) + + assertEquals(JsonPrimitive("stale.result"), result["output"]) + } + + @Test + fun `invalid generated value does not replace a valid persisted value`() { + val numberType = DataType.Primitive(PrimitiveType.INT) + val result = resolveAutogeneratedSettings( + metadata = metadata + ( + "count" to SettingMetadata( + description = "Count", + type = numberType, + autogeneratedPattern = "{input.nameWithoutExtension}" + ) + ), + settings = mapOf( + "input" to JsonPrimitive("photo.png"), + "count" to JsonPrimitive(42) + ) + ) + + assertEquals(JsonPrimitive(42), result["count"]) + } +} diff --git a/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..e5a3dea6 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/cli/ToolkitCli.kt @@ -0,0 +1,174 @@ +package org.wip.plugintoolkit.cli + +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.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 + +sealed interface ToolkitCliCommand { + data object Help : ToolkitCliCommand + data object Version : ToolkitCliCommand + data object Status : ToolkitCliCommand + data object Plugins : 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 + listOf("status") -> ToolkitCliCommand.Status + listOf("plugins"), listOf("plugins", "list") -> ToolkitCliCommand.Plugins + listOf("flows"), listOf("flows", "list") -> ToolkitCliCommand.Flows + else -> null +} + +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 + } + 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( + val plugins: List = emptyList(), + val flowNames: List = emptyList() +) + +internal suspend fun runToolkitCli( + command: ToolkitCliCommand, + output: (String) -> Unit = ::println, + error: (String) -> Unit = System.err::println, + dataLoader: suspend (ToolkitCliCommand) -> ToolkitCliData = { loadToolkitCliData(it) } +): Int { + when (command) { + ToolkitCliCommand.Help -> { + output(CLI_HELP) + return 0 + } + ToolkitCliCommand.Version -> { + output(AppConfig.VERSION) + return 0 + } + else -> Unit + } + + return try { + val data = withTimeout(CLI_STARTUP_TIMEOUT_MS) { dataLoader(command) } + when (command) { + ToolkitCliCommand.Status -> { + output("PluginToolkit ${AppConfig.VERSION}") + output("Plugins: ${data.plugins.size} installed, ${data.plugins.count { it.isEnabled }} enabled") + } + ToolkitCliCommand.Plugins -> { + if (data.plugins.isEmpty()) output("No plugins installed.") + data.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 -> { + if (data.flowNames.isEmpty()) output("No flows saved.") else data.flowNames.sorted().forEach(output) + } + ToolkitCliCommand.Help, ToolkitCliCommand.Version -> Unit + } + 0 + } catch (exception: Throwable) { + error("CLI error: ${exception.message ?: exception::class.simpleName}") + 1 + } +} + +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() + } +} + +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 + +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/core/utils/PlatformPathUtils.kt b/composeApp/src/jvmMain/kotlin/org/wip/plugintoolkit/core/utils/PlatformPathUtils.kt index b84996d7..4ff52955 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,14 @@ 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 = appConfig.getAppDataDir() + fun getAppDataDir(appConfig: SystemConfig = injectedAppConfig): String = appConfig.getAppDataDir() - fun getCacheDir(systemManaged: Boolean = false): String = appConfig.getCacheDir(systemManaged) + fun getCacheDir( + systemManaged: Boolean = false, + appConfig: SystemConfig = injectedAppConfig + ): String = appConfig.getCacheDir(systemManaged) } 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..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,18 +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 : SettingsPersistence, KoinComponent { - private val appConfig: SystemConfig by inject() +class JvmSettingsPersistence( + private val configuredAppConfig: SystemConfig? = null, + private val configuredSettingsDir: String? = null +) : SettingsPersistence, KoinComponent { + 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 6270480f..bd7fb9f5 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 @@ -99,6 +100,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 @@ -134,6 +136,9 @@ import javax.swing.JOptionPane.showMessageDialog import javax.swing.JWindow import kotlin.system.exitProcess import kotlin.time.Duration.Companion.seconds +import org.wip.plugintoolkit.cli.parseToolkitCliInvocation +import org.wip.plugintoolkit.cli.runToolkitCli +import org.wip.plugintoolkit.cli.ToolkitCliInvocation fun detectSystemConfig(): SystemConfig { val userDir = java.io.File(System.getProperty("user.dir")) @@ -169,6 +174,20 @@ fun detectSystemConfig(): SystemConfig { @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) 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) + } + is ToolkitCliInvocation.Invalid -> { + System.err.println("Unknown command: ${invocation.arguments.joinToString(" ")}. Use --help.") + exitProcess(2) + } + ToolkitCliInvocation.Desktop -> Unit + } + ComposeFoundationFlags.isNewContextMenuEnabled = true val splashWindow = try { showSplashWindow() @@ -315,16 +334,35 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = updateStatus("Initializing plugins...") // Initialize registry and subsequently load plugins appScope.launch { + // 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 { + val jobManager = koin.get().apply { + 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" } + } + } + try { 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 { @@ -348,6 +386,7 @@ suspend fun performStartup(args: Array, updateStatus: (String) -> Unit = } } } + pluginStartupJobs.joinAll() } updateStatus("Refreshing repositories...") 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..fcbb9a82 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/cli/ToolkitCliTest.kt @@ -0,0 +1,82 @@ +package org.wip.plugintoolkit.cli + +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 + 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 launcher arguments do not abort GUI startup`() { + assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("--background"))) + assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("--launcher-token"))) + assertEquals(ToolkitCliInvocation.Desktop, parseToolkitCliInvocation(arrayOf("document.toolkit"))) + assertIs(parseToolkitCliInvocation(arrayOf("plugins", "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")) + } + + @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") + } +} 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))) 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() 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..e2ff0b5f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/wip/plugintoolkit/features/job/logic/ScheduleRepositoryTest.kt @@ -0,0 +1,263 @@ +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 org.wip.plugintoolkit.features.settings.model.JobSettings +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 +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class ScheduleRepositoryTest { + private class TempPersistence(private val root: Path) : SettingsPersistence { + 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() + 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).apply { + scheduleCapabilityReadiness = { _, _ -> true } + } + 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 `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 `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 { + scheduleCapabilityReadiness = { _, _ -> 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 `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, _ -> + 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()) + } + } + + @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() + ) + } + } + + @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)) + } + } + + @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 + ) { + val root = Files.createTempDirectory("plugin-toolkit-schedules-") + try { + block(TempPersistence(root), root) + } finally { + root.toFile().deleteRecursively() + } + } +} diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index d69b5025..3dd72f6d 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -4,6 +4,19 @@ 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:") +} +``` + +> **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. @@ -37,7 +50,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 +62,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/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/minimalExample/build.gradle.kts b/minimalExample/build.gradle.kts index f8a5e7c5..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) } @@ -34,3 +34,5 @@ dependencies { tasks.withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + +apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts")) 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/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt index d82c0c3f..d8c897c2 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/ManifestModels.kt @@ -251,8 +251,67 @@ data class SettingMetadata( * This allows UI to show which capabilities are locked behind this setting * without making the setting globally required for the plugin to load. */ - val requiredByCapabilities: List = emptyList() -) + val requiredByCapabilities: List = emptyList(), + // Keep new fields after the original constructor fields so component7 retains + // its pre-existing requiredByCapabilities meaning for old compiled callers. + val semanticTypes: List = emptyList(), + val autogeneratedPattern: String? = null +) { + /** + * Retains the JVM constructor used by plugins compiled before semantic hints and + * autogenerated patterns were added. The host classloader deliberately shares + * plugin-api classes, so removing this descriptor would break installed plugins + * before their compatibility metadata can be inspected. + */ + @Deprecated("Binary compatibility constructor", level = DeprecationLevel.HIDDEN) + constructor( + defaultValue: JsonElement? = null, + description: String, + type: DataType, + required: Boolean = false, + secret: Boolean = false, + constraints: ParameterConstraints? = null, + requiredByCapabilities: List = emptyList() + ) : this( + defaultValue = defaultValue, + description = description, + type = type, + required = required, + secret = secret, + constraints = constraints, + requiredByCapabilities = requiredByCapabilities, + semanticTypes = emptyList(), + autogeneratedPattern = null + ) + + @Deprecated("Binary compatibility copy", level = DeprecationLevel.HIDDEN) + fun copy( + defaultValue: JsonElement?, description: String, type: DataType, required: Boolean, + secret: Boolean, constraints: ParameterConstraints?, requiredByCapabilities: List + ): SettingMetadata = SettingMetadata( + defaultValue, description, type, required, secret, constraints, requiredByCapabilities, + semanticTypes, autogeneratedPattern + ) + + companion object { + @JvmStatic + @Deprecated("Binary compatibility copy bridge", level = DeprecationLevel.HIDDEN) + fun `copy$default`( + self: SettingMetadata, + defaultValue: JsonElement?, description: String?, type: DataType?, required: Boolean, + secret: Boolean, constraints: ParameterConstraints?, requiredByCapabilities: List?, + mask: Int, marker: Any? + ): SettingMetadata = self.copy( + if (mask and 0x01 != 0) self.defaultValue else defaultValue, + if (mask and 0x02 != 0) self.description else requireNotNull(description), + if (mask and 0x04 != 0) self.type else requireNotNull(type), + if (mask and 0x08 != 0) self.required else required, + if (mask and 0x10 != 0) self.secret else secret, + if (mask and 0x20 != 0) self.constraints else constraints, + if (mask and 0x40 != 0) self.requiredByCapabilities else requireNotNull(requiredByCapabilities) + ) + } +} /** * The complete manifest of a plugin, describing its capabilities and requirements. @@ -271,7 +330,85 @@ data class PluginManifest( val changelog: Changelog? = null, val hasUpdateHandler: Boolean = false, val hasSetupHandler: Boolean = false, - val hasMigrations: Boolean = false + val hasMigrations: Boolean = false, + /** Optional declarative pages rendered by the host. Unknown capability names are ignored. */ + val uiPages: List = emptyList() +) { + @Deprecated("Binary compatibility constructor", level = DeprecationLevel.HIDDEN) + constructor( + manifestVersion: String, + plugin: PluginInfo, + requirements: Requirements, + defaultParameters: Map? = null, + capabilities: List = emptyList(), + actions: List = emptyList(), + settings: Map? = null, + changelog: Changelog? = null, + hasUpdateHandler: Boolean = false, + hasSetupHandler: Boolean = false, + hasMigrations: Boolean = false + ) : this( + manifestVersion = manifestVersion, + plugin = plugin, + requirements = requirements, + defaultParameters = defaultParameters, + capabilities = capabilities, + actions = actions, + settings = settings, + changelog = changelog, + hasUpdateHandler = hasUpdateHandler, + hasSetupHandler = hasSetupHandler, + hasMigrations = hasMigrations, + uiPages = emptyList() + ) + + @Deprecated("Binary compatibility copy", level = DeprecationLevel.HIDDEN) + fun copy( + manifestVersion: String, plugin: PluginInfo, requirements: Requirements, + defaultParameters: Map?, capabilities: List, + actions: List, settings: Map?, changelog: Changelog?, + hasUpdateHandler: Boolean, hasSetupHandler: Boolean, hasMigrations: Boolean + ): PluginManifest = PluginManifest( + manifestVersion, plugin, requirements, defaultParameters, capabilities, actions, settings, changelog, + hasUpdateHandler, hasSetupHandler, hasMigrations, uiPages + ) + + companion object { + @JvmStatic + @Deprecated("Binary compatibility copy bridge", level = DeprecationLevel.HIDDEN) + fun `copy$default`( + self: PluginManifest, + manifestVersion: String?, plugin: PluginInfo?, requirements: Requirements?, + defaultParameters: Map?, capabilities: List?, + actions: List?, settings: Map?, changelog: Changelog?, + hasUpdateHandler: Boolean, hasSetupHandler: Boolean, hasMigrations: Boolean, + mask: Int, marker: Any? + ): PluginManifest = self.copy( + if (mask and 0x001 != 0) self.manifestVersion else requireNotNull(manifestVersion), + if (mask and 0x002 != 0) self.plugin else requireNotNull(plugin), + if (mask and 0x004 != 0) self.requirements else requireNotNull(requirements), + if (mask and 0x008 != 0) self.defaultParameters else defaultParameters, + if (mask and 0x010 != 0) self.capabilities else requireNotNull(capabilities), + if (mask and 0x020 != 0) self.actions else requireNotNull(actions), + if (mask and 0x040 != 0) self.settings else settings, + if (mask and 0x080 != 0) self.changelog else changelog, + if (mask and 0x100 != 0) self.hasUpdateHandler else hasUpdateHandler, + if (mask and 0x200 != 0) self.hasSetupHandler else hasSetupHandler, + if (mask and 0x400 != 0) self.hasMigrations else hasMigrations + ) + } +} + +/** + * A host-rendered plugin page. Keeping this declarative avoids coupling plugin JARs to a + * particular Compose version while still allowing plugins to shape their user experience. + */ +@Serializable +data class PluginUiPage( + val id: String, + val title: String, + val description: String = "", + val capabilityNames: List = emptyList() ) @Serializable @@ -632,4 +769,3 @@ data class PluginAction( val functionName: String, val parameters: Map? = null ) - diff --git a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt index b82f97a5..705ec25c 100644 --- a/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt +++ b/plugin-api/src/commonMain/kotlin/org/wip/plugintoolkit/api/annotations/Annotations.kt @@ -14,6 +14,17 @@ annotation class PluginInfo( val supportedOs: Array = [] ) +/** Declares a host-rendered page grouping capabilities without bundling UI code. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +@Repeatable +annotation class PluginUiPage( + val id: String, + val title: String, + val description: String = "", + val capabilityNames: Array = [] +) + /** * Provides metadata for a capability result. * Can be applied to a single-return capability function or to properties of a custom data class return type. @@ -169,6 +180,8 @@ annotation class CapabilityOutput( * @property defaultValue The default value for the setting (as a string). * @property required Whether the setting is mandatory for the plugin to function. * @property secret Whether the setting contains sensitive information (e.g., API keys). + * @property semanticTypes Semantic hints used to select a specialized editor (for example `color/rgb`). + * @property pathTemplate Optional template used to derive this setting from other values. */ @Target(AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.SOURCE) @@ -184,7 +197,9 @@ annotation class PluginSetting( val regex: String = "", val multiSelect: Boolean = false, val minChoices: Int = -1, - val maxChoices: Int = -1 + val maxChoices: Int = -1, + val semanticTypes: Array = [], + val pathTemplate: String = "" ) /** @@ -271,4 +286,4 @@ annotation class ComplexObject( val id: String = "", val description: String = "", val version: Int = 1 -) \ No newline at end of file +) diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt index a7e8ba13..f3930871 100644 --- a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/ManifestModelsTest.kt @@ -1,10 +1,30 @@ package org.wip.plugintoolkit.api import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals class ManifestModelsTest { + @Test + fun `setting metadata preserves capability-style input hints`() { + val metadata = SettingMetadata( + defaultValue = JsonPrimitive("#112233"), + description = "Brand color", + type = DataType.Primitive(PrimitiveType.STRING), + constraints = ParameterConstraints(regex = "#[0-9A-Fa-f]{6}"), + semanticTypes = parseSemanticTypes("color/rgb"), + autogeneratedPattern = "{theme}/brand.hex" + ) + val json = Json { encodeDefaults = true } + + val decoded = json.decodeFromString(json.encodeToString(metadata)) + + assertEquals(metadata.constraints, decoded.constraints) + assertEquals(metadata.semanticTypes, decoded.semanticTypes) + assertEquals(metadata.autogeneratedPattern, decoded.autogeneratedPattern) + } + @Test fun testCapabilityDeserialization() { val jsonString = """{ diff --git a/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt new file mode 100644 index 00000000..c29cebca --- /dev/null +++ b/plugin-api/src/commonTest/kotlin/org/wip/plugintoolkit/api/PluginUiPageTest.kt @@ -0,0 +1,25 @@ +package org.wip.plugintoolkit.api + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class PluginUiPageTest { + @Test + fun `plugin pages round trip through the manifest`() { + val manifest = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1", "Example"), + requirements = Requirements(64, 10), + uiPages = listOf( + PluginUiPage("home", "Home", "Common actions", listOf("convert")) + ) + ) + + val json = Json.encodeToString(manifest) + val restored = Json.decodeFromString(json) + + assertEquals(manifest.uiPages, restored.uiPages) + } +} diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt index 3bf562ee..4b68d921 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/GeneratorUtils.kt @@ -10,6 +10,7 @@ import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.ksp.toTypeName import org.wip.plugintoolkit.api.DataType import org.wip.plugintoolkit.api.PrimitiveType +import org.wip.plugintoolkit.api.PluginUiPage import org.wip.plugintoolkit.api.SemanticType import org.wip.plugintoolkit.api.parseSemanticTypes @@ -109,6 +110,40 @@ object GeneratorUtils { return this.annotationType.resolve().declaration.qualifiedName?.asString() == name } + fun extractUiPages( + classDeclaration: KSClassDeclaration, + reportError: (String) -> Unit = {} + ): List = + classDeclaration.annotations + .filter { it.hasQualifiedName(ProcessorConstants.PLUGIN_UI_PAGE_ANNOTATION) } + .mapNotNull { annotation -> + val id = annotation.arguments.find { it.name?.asString() == "id" }?.value as? String + val title = annotation.arguments.find { it.name?.asString() == "title" }?.value as? String + if (id == null || title == null) { + reportError("@PluginUiPage requires string 'id' and 'title' arguments") + return@mapNotNull null + } + val rawCapabilities = annotation.arguments + .find { it.name?.asString() == "capabilityNames" } + ?.value + if (rawCapabilities != null && rawCapabilities !is List<*>) { + reportError("@PluginUiPage.capabilityNames must be a string array") + return@mapNotNull null + } + val capabilityNames = (rawCapabilities as? List<*>)?.filterIsInstance().orEmpty() + if ((rawCapabilities as? List<*>)?.size != capabilityNames.size) { + reportError("@PluginUiPage.capabilityNames must contain only strings") + return@mapNotNull null + } + PluginUiPage( + id = id, + title = title, + description = annotation.arguments.find { it.name?.asString() == "description" }?.value as? String ?: "", + capabilityNames = capabilityNames + ) + } + .toList() + fun generateDataTypeCode(dataType: DataType): com.squareup.kotlinpoet.CodeBlock { val cnDataType = com.squareup.kotlinpoet.ClassName("org.wip.plugintoolkit.api", "DataType") val cnPrimitiveType = com.squareup.kotlinpoet.ClassName("org.wip.plugintoolkit.api", "PrimitiveType") diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt index 2b4e20b1..642dbfb2 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/KotlinGenerator.kt @@ -50,7 +50,7 @@ object KotlinGenerator { it.getAllProperties() .filter { p -> p.annotations.any { a -> a.hasQualifiedName(org.wip.plugintoolkit.api.processor.ProcessorConstants.PLUGIN_SETTING_ANNOTATION) } } }.toList(), - actions, updateFunction != null, setupFunction != null + actions, updateFunction != null, setupFunction != null, classDeclaration ) fileSpec.addType(manifestType) diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestJsonGenerator.kt index 06505316..b6cf7251 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 ) } @@ -364,7 +372,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, @@ -372,7 +380,8 @@ object ManifestJsonGenerator { changelog = changelogObj, hasUpdateHandler = updateFunction != null, hasSetupHandler = setupFunction != null, - hasMigrations = hasMigrations + hasMigrations = hasMigrations, + uiPages = GeneratorUtils.extractUiPages(classDeclaration) ) val json = Json { diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt index 52c538ed..8eb3e213 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ManifestProcessor.kt @@ -176,6 +176,25 @@ class ManifestProcessor( it.annotations.any { ann -> ann.hasQualifiedName(PLUGIN_ACTION_ANNOTATION) } }.toList() + val uiPages = org.wip.plugintoolkit.api.processor.GeneratorUtils.extractUiPages(classDeclaration) { message -> + logger.error(message, classDeclaration) + } + uiPages.filter { it.id.isBlank() }.forEach { + logger.error("@PluginUiPage.id must not be blank", classDeclaration) + } + uiPages.groupBy { it.id }.filterValues { it.size > 1 }.keys.forEach { duplicateId -> + logger.error("Duplicate @PluginUiPage id '$duplicateId'", classDeclaration) + } + val capabilityNames = functions.map { function -> + val annotation = function.annotations.first { it.hasQualifiedName(CAPABILITY_ANNOTATION) } + annotation.arguments.first { it.name?.asString() == "name" }.value as String + }.toSet() + uiPages.flatMap { page -> page.capabilityNames.map { page.id to it } } + .filter { (_, capabilityName) -> capabilityName !in capabilityNames } + .forEach { (pageId, capabilityName) -> + logger.warn("@PluginUiPage '$pageId' references unknown capability '$capabilityName'", classDeclaration) + } + // 1. Parse Changelog var changelogObj: Changelog? = null val sourceFile = classDeclaration.containingFile diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt index b509c422..199780a3 100644 --- a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt +++ b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/ProcessorConstants.kt @@ -20,6 +20,7 @@ import org.wip.plugintoolkit.api.PluginFileSystem import org.wip.plugintoolkit.api.PluginInfo import org.wip.plugintoolkit.api.PluginLogger import org.wip.plugintoolkit.api.PluginManifest +import org.wip.plugintoolkit.api.PluginUiPage import org.wip.plugintoolkit.api.PluginModuleProvider import org.wip.plugintoolkit.api.PluginRequest import org.wip.plugintoolkit.api.PluginResponse @@ -34,6 +35,7 @@ object ProcessorConstants { // Annotations const val PLUGIN_INFO_ANNOTATION = "$ANNOTATION_PACKAGE.PluginInfo" + const val PLUGIN_UI_PAGE_ANNOTATION = "$ANNOTATION_PACKAGE.PluginUiPage" const val CAPABILITY_ANNOTATION = "$ANNOTATION_PACKAGE.Capability" const val CAPABILITY_PARAM_ANNOTATION = "$ANNOTATION_PACKAGE.CapabilityParam" const val CAPABILITY_INPUT_ANNOTATION = "$ANNOTATION_PACKAGE.CapabilityInput" @@ -53,6 +55,7 @@ object ProcessorConstants { // API Classes val CN_PLUGIN_MANIFEST = PluginManifest::class.asClassName() + val CN_PLUGIN_UI_PAGE = PluginUiPage::class.asClassName() val CN_PLUGIN_INFO = PluginInfo::class.asClassName() val CN_REQUIREMENTS = Requirements::class.asClassName() val CN_CAPABILITY = Capability::class.asClassName() diff --git a/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt b/plugin-api/src/jvmMain/kotlin/org/wip/plugintoolkit/api/processor/generators/ManifestGenerator.kt index ff0682ea..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 @@ -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) @@ -158,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() @@ -355,6 +353,30 @@ object ManifestGenerator { val secret = ann.arguments.find { it.name?.asString() == "secret" }?.value as? Boolean ?: false val propName = prop.simpleName.asString() val propType = prop.type.resolve().toTypeName() + val minValue = ann.arguments.find { it.name?.asString() == "minValue" }?.value as? Double ?: Double.NaN + val maxValue = ann.arguments.find { it.name?.asString() == "maxValue" }?.value as? Double ?: Double.NaN + val minLength = ann.arguments.find { it.name?.asString() == "minLength" }?.value as? Int ?: -1 + val maxLength = ann.arguments.find { it.name?.asString() == "maxLength" }?.value as? Int ?: -1 + val regex = ann.arguments.find { it.name?.asString() == "regex" }?.value as? String ?: "" + val multiSelect = ann.arguments.find { it.name?.asString() == "multiSelect" }?.value as? Boolean ?: false + val minChoices = ann.arguments.find { it.name?.asString() == "minChoices" }?.value as? Int ?: -1 + val maxChoices = ann.arguments.find { it.name?.asString() == "maxChoices" }?.value as? Int ?: -1 + val semanticTypeValues = + (ann.arguments.find { it.name?.asString() == "semanticTypes" }?.value as? List<*>) + ?.filterIsInstance() + ?: emptyList() + val pathTemplate = ann.arguments.find { it.name?.asString() == "pathTemplate" }?.value as? String ?: "" + val requiredByCapabilities = functions.mapNotNull { function -> + val capabilityAnnotation = function.annotations.find { + it.hasQualifiedName(CAPABILITY_ANNOTATION) + } ?: return@mapNotNull null + val requiredSettings = + (capabilityAnnotation.arguments.find { it.name?.asString() == "requiresSettings" }?.value as? List<*>) + ?.filterIsInstance() + ?: emptyList() + if (propName !in requiredSettings) return@mapNotNull null + capabilityAnnotation.arguments.find { it.name?.asString() == "name" }?.value as? String + } val defaultValueCode = if (defaultVal.isNotEmpty()) { try { kotlinx.serialization.json.Json.parseToJsonElement(defaultVal) @@ -366,17 +388,49 @@ object ManifestGenerator { CodeBlock.of("null") } + val hasConstraints = + !minValue.isNaN() || !maxValue.isNaN() || minLength != -1 || maxLength != -1 || + regex.isNotEmpty() || multiSelect || minChoices != -1 || maxChoices != -1 + val constraintsCode = if (hasConstraints) { + CodeBlock.of( + "%T(minValue = %L, maxValue = %L, minLength = %L, maxLength = %L, regex = %L, multiSelect = %L, minChoices = %L, maxChoices = %L)", + CN_PARAMETER_CONSTRAINTS, + if (!minValue.isNaN()) minValue else "null", + if (!maxValue.isNaN()) maxValue else "null", + if (minLength != -1) minLength else "null", + if (maxLength != -1) maxLength else "null", + if (regex.isNotEmpty()) CodeBlock.of("%S", regex) else CodeBlock.of("null"), + if (multiSelect) "true" else "null", + if (minChoices != -1) minChoices else "null", + if (maxChoices != -1) maxChoices else "null" + ) + } else CodeBlock.of("null") + val semanticTypesCode = generateSemanticTypesCode( + semanticTypeValues.flatMap { org.wip.plugintoolkit.api.parseSemanticTypes(it) } + ) + val autogeneratedPatternCode = + if (pathTemplate.isBlank()) CodeBlock.of("null") else CodeBlock.of("%S", pathTemplate) + val requiredByCapabilitiesCode = if (requiredByCapabilities.isEmpty()) { + CodeBlock.of("emptyList()") + } else { + stringListCodeBlock(requiredByCapabilities) + } + settingsCode.add( - "%S to %T(defaultValue = %L, description = %S, type = %M<%T>(), required = %L, secret = %L)", + "%S to %T(defaultValue = %L, description = %S, type = %M<%T>(), constraints = %L, required = %L, secret = %L, semanticTypes = %L, autogeneratedPattern = %L, requiredByCapabilities = %L)", propName, CN_SETTING_METADATA, defaultValueCode, desc, MN_GET_DATA_TYPE, propType, + constraintsCode, required, - secret + secret, + semanticTypesCode, + autogeneratedPatternCode, + requiredByCapabilitiesCode ) if (index < settingsProperties.size - 1) settingsCode.add(",\n") else settingsCode.add("\n") } @@ -451,6 +505,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 +554,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() @@ -489,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-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..eadec022 --- /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.wip.plugintoolkit.api.ManifestLoader +import org.wip.plugintoolkit.api.PluginManifest +import kotlin.system.exitProcess + +/** Entry point embedded in standalone plugin JARs. */ +fun main(args: Array) { + 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, + loadManifest: () -> Result = ::loadStandaloneManifest +): Int { + when (args.firstOrNull()) { + "--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 manifest = loadManifest().getOrElse { failure -> + error("Plugin manifest could not be loaded: ${failure.message ?: failure::class.simpleName}") + return 2 + } + + output(describeStandaloneManifest(manifest)) + return 0 +} + +private fun loadStandaloneManifest(): Result = runCatching { + // Inspection must never instantiate third-party plugin code or synthesize missing settings. + ManifestLoader.loadFromResources(PluginManifest::class.java) +} + +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/PluginManifestBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt new file mode 100644 index 00000000..3e441aaf --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/PluginManifestBinaryCompatibilityTest.kt @@ -0,0 +1,42 @@ +package org.wip.plugintoolkit.api + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PluginManifestBinaryCompatibilityTest { + @Test + fun `retains constructors used before plugin UI pages`() { + val constructors = PluginManifest::class.java.declaredConstructors.map { constructor -> + constructor.parameterTypes.toList() + } + + assertTrue(constructors.any { it.size == 11 && it.last() == Boolean::class.javaPrimitiveType }) + assertTrue(constructors.any { + it.size == 13 && + it[it.lastIndex - 1] == Int::class.javaPrimitiveType && + it.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" + }) + } + + @Test + fun `retains copy bridges used before plugin UI pages`() { + val methods = PluginManifest::class.java.declaredMethods + val oldCopy = methods.single { it.name == "copy" && it.parameterCount == 11 } + val oldDefaultCopy = methods.single { it.name == "copy\$default" && it.parameterCount == 14 } + val original = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("id", "name", "1", "description"), + requirements = Requirements(1, 1), + uiPages = listOf(PluginUiPage("page", "Page")) + ) + + val copied = oldDefaultCopy.invoke( + null, original, null, null, null, null, null, null, null, null, + false, false, false, 0x7FF, null + ) as PluginManifest + + assertEquals(original, copied) + assertEquals(PluginManifest::class.java, oldCopy.returnType) + } +} diff --git a/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt new file mode 100644 index 00000000..fbce25af --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/SettingMetadataBinaryCompatibilityTest.kt @@ -0,0 +1,50 @@ +package org.wip.plugintoolkit.api + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SettingMetadataBinaryCompatibilityTest { + @Test + fun `retains pre-hints JVM constructor`() { + val oldParameterTypes = listOf( + JsonElement::class.java, + String::class.java, + DataType::class.java, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + ParameterConstraints::class.java, + List::class.java + ) + + assertTrue( + SettingMetadata::class.java.declaredConstructors.any { constructor -> + constructor.parameterTypes.toList() == oldParameterTypes + }, + "SettingMetadata must keep the constructor used by plugins compiled against the previous API" + ) + } + + @Test + fun `retains pre-hints copy bridges`() { + val methods = SettingMetadata::class.java.declaredMethods + val oldCopy = methods.single { it.name == "copy" && it.parameterCount == 7 } + val oldDefaultCopy = methods.single { it.name == "copy\$default" && it.parameterCount == 10 } + val original = SettingMetadata( + defaultValue = JsonPrimitive("value"), + description = "description", + type = DataType.Primitive(PrimitiveType.STRING), + semanticTypes = listOf(SemanticType(null, "text", null)), + autogeneratedPattern = "{input}" + ) + + val copied = oldDefaultCopy.invoke( + null, original, null, null, null, false, false, null, null, 0x7F, null + ) as SettingMetadata + + assertEquals(original, copied) + assertEquals(SettingMetadata::class.java, oldCopy.returnType) + } +} 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..2d55dc44 --- /dev/null +++ b/plugin-api/src/jvmTest/kotlin/org/wip/plugintoolkit/api/standalone/StandalonePluginMainTest.kt @@ -0,0 +1,38 @@ +package org.wip.plugintoolkit.api.standalone + +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(), {}, {}, 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"), {}, {}, loadManifest = { error("must not load") })) + } + + @Test + fun `info describes a discovered plugin`() { + val output = mutableListOf() + + 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 manifest() = PluginManifest( + manifestVersion = "1", + plugin = PluginInfo("example", "Example", "1.0", "Example plugin"), + requirements = Requirements(64, 10) + ) +} diff --git a/plugin-processor/build.gradle.kts b/plugin-processor/build.gradle.kts new file mode 100644 index 00000000..93276f16 --- /dev/null +++ b/plugin-processor/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + id("maven-publish") +} + +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) + 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 new file mode 100644 index 00000000..2198d849 --- /dev/null +++ b/scripts/standalone-plugin.gradle.kts @@ -0,0 +1,148 @@ +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 +import java.util.jar.Manifest + +@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("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( + 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/") || + 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. +val standaloneJar = tasks.register("standaloneJar") { + group = "distribution" + description = "Builds an executable plugin JAR with its runtime dependencies." + archiveClassifier.set("standalone") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + dependsOn(pluginJar, mergeStandaloneServices) + from({ zipTree(pluginJar.get().archiveFile.get().asFile) }) { + exclude("META-INF/services/**") + } + from({ + runtimeClasspath.map { dependency -> + if (dependency.isDirectory) dependency else zipTree(dependency) + } + }) { + 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) + 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") -