Redesign plugins UI and demo raw + high-level hooks

- Match exteraGram-style card layout with master toggle and plugin actions
- Add usage manifest field, pin/delete/removePlugin engine APIs
- Hello World plugin hooks Logger.d (raw) and send pipeline (high-level)
- Bundle updated hello_world.fcplugin; stop debug auto-enable

Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Cursor Agent
2026-09-16 22:35:49 +00:00
Unverified
parent ad7b4d01b9
commit efbb5de9c6
14 changed files with 541 additions and 67 deletions
@@ -24,6 +24,7 @@ object PluginEngine : PluginHostBridge {
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
private val enabledIds = linkedSetOf<String>()
private val pinnedIds = linkedSetOf<String>()
private val loaded = mutableMapOf<String, LoadedPlugin>()
private val settings = mutableMapOf<String, MutableMap<String, String>>()
private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>()
@@ -72,6 +73,40 @@ object PluginEngine : PluginHostBridge {
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
fun isPluginPinned(pluginId: String): Boolean = pluginId in pinnedIds
fun setPluginPinned(pluginId: String, pinned: Boolean) {
if (pinned) {
pinnedIds += pluginId
} else {
pinnedIds -= pluginId
}
persistPinnedState()
refreshInstalledList()
}
fun sortedInstalled(): List<PluginManifest> {
val manifests = _installed.value
return manifests.sortedWith(
compareByDescending<PluginManifest> { it.id in pinnedIds }
.thenBy { it.name.lowercase() },
)
}
fun removePlugin(pluginId: String) {
unloadPlugin(pluginId)
enabledIds -= pluginId
pinnedIds -= pluginId
settings.remove(pluginId)
val dir = pluginsRootProvider().resolve(pluginId)
if (dir.exists()) {
dir.deleteRecursively()
}
persistEnabledState()
persistPinnedState()
refreshInstalledList()
}
fun loadedPlugin(pluginId: String): LoadedPlugin? = loaded[pluginId]
fun getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
@@ -238,12 +273,21 @@ object PluginEngine : PluginHostBridge {
enabledIds.clear()
enabledIds += enabledFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
}
val pinnedFile = pluginsRootProvider().resolve("pinned_plugins.txt")
if (pinnedFile.exists()) {
pinnedIds.clear()
pinnedIds += pinnedFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
}
}
private fun persistEnabledState() {
pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n"))
}
private fun persistPinnedState() {
pluginsRootProvider().resolve("pinned_plugins.txt").writeText(pinnedIds.joinToString("\n"))
}
private fun projectZipExtract(archive: File, dest: File) {
java.util.zip.ZipFile(archive).use { zip ->
zip.entries().asSequence().forEach { entry ->
@@ -261,10 +305,30 @@ object PluginEngine : PluginHostBridge {
fun ensureBundledPlugins(bundledDir: File) {
if (!bundledDir.exists()) return
bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive ->
val installed = pluginsRootProvider().resolve("hello_world")
if (!installed.exists()) {
val manifestInArchive = readManifestFromArchive(archive) ?: return@forEach
val installedDir = pluginsRootProvider().resolve(manifestInArchive.id)
val installedManifest = installedDir.resolve("manifest.json")
val shouldInstall = when {
!installedManifest.exists() -> true
else -> {
val current = runCatching {
PluginManifestParser.parse(installedManifest.readText())
}.getOrNull()
current == null || current.version != manifestInArchive.version
}
}
if (shouldInstall) {
runCatching { installFcPluginArchive(archive) }
}
}
}
private fun readManifestFromArchive(archive: File): PluginManifest? {
return runCatching {
java.util.zip.ZipFile(archive).use { zip ->
val entry = zip.getEntry("manifest.json") ?: return null
zip.getInputStream(entry).bufferedReader().use { PluginManifestParser.parse(it.readText()) }
}
}.getOrNull()
}
}
@@ -19,6 +19,7 @@ object PluginManifestParser {
description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(),
author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(),
version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0",
usage = root["usage"]?.jsonPrimitive?.contentOrNull.orEmpty(),
appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
entryClass = root.requireString("entry_class"),
+3 -2
View File
@@ -35,9 +35,10 @@ tasks.register("packageFcPlugin") {
{
"id": "hello_world",
"name": "Hello World",
"description": "Rewrites .hello commands into a friendly greeting",
"description": "Demonstrates high-level send hooks and raw Logger.d hooks.",
"author": "FromChat",
"version": "1.0.0",
"version": "1.0.1",
"usage": "Send .hello Name in any chat to rewrite the message. Raw hook shows a bulletin when a message is queued for sending.",
"app_version": ">=1.0.0",
"sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
@@ -13,6 +13,17 @@ class HelloWorldPlugin : BasePlugin() {
addOnSendMessageHook { context ->
onSendMessage(context)
}
hookShared(
hookId = "logger.debug",
after = { args, _ ->
val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult()
val message = args.getOrNull(1) as? String ?: return@hookShared HookResult()
if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) {
showBulletin("Raw hook: intercepted outgoing message pipeline")
}
HookResult()
},
)
}
override fun createSettings(): List<PluginSetting> = listOf(
@@ -36,6 +47,7 @@ class HelloWorldPlugin : BasePlugin() {
val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" }
val template = getSettingString("template", "Hello, {name}!")
context.text = template.replace("{name}", name)
showBulletin("High-level hook: rewrote .hello command")
return HookResult(strategy = HookStrategy.MODIFY, value = context)
}
}
@@ -66,6 +66,7 @@ data class PluginManifest(
val description: String = "",
val author: String = "",
val version: String = "1.0.0",
val usage: String = "",
val appVersion: String = "",
val sdkVersion: String = "",
val entryClass: String,