Add Kotlin plugin system (SDK, host, hooks, settings UI)

Implement exteraGram-style plugin architecture for Android and desktop:
- plugins/sdk: BasePlugin API, hook types, settings DSL
- plugins/host: PluginEngine, typed send hooks, FeatureGate, HookRegistry
- JVM ByteBuddy and Android Pine hook backends
- DevServer on port 42690 (JVM)
- Plugins settings screens and PluginUiHost bulletin overlay
- hello_world sample plugin packaged as .fcplugin
- ProGuard keepnames for ru.fromchat.** with shrinking enabled

Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Cursor Agent
2026-09-16 22:15:42 +00:00
Unverified
parent 926343e90e
commit b2ef7be57a
33 changed files with 1515 additions and 5 deletions
+20
View File
@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.multiplatform.library)
}
kotlin {
jvm()
androidLibrary {
namespace = "ru.fromchat.plugins.sdk"
compileSdk = 37
minSdk = 24
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
}
}
}
@@ -0,0 +1,83 @@
package ru.fromchat.plugins
interface PluginHostBridge {
fun log(pluginId: String, message: String)
fun getSetting(pluginId: String, key: String, default: Boolean): Boolean
fun getSettingString(pluginId: String, key: String, default: String): String
fun setSetting(pluginId: String, key: String, value: String)
fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>)
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?)
fun registerMenuItem(pluginId: String, item: MenuItemData)
fun showBulletin(message: String)
fun hookSharedMethod(
pluginId: String,
hookId: String,
priority: Int,
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> Unit
}
abstract class BasePlugin {
lateinit var pluginId: String
internal set
private lateinit var bridge: PluginHostBridge
fun attach(id: String, hostBridge: PluginHostBridge) {
pluginId = id
bridge = hostBridge
}
open fun onPluginLoad() {}
open fun onPluginUnload() {}
open fun onAppEvent(event: AppEvent) {}
open fun createSettings(): List<PluginSetting> = emptyList()
protected fun log(message: String) = bridge.log(pluginId, message)
protected fun getSetting(key: String, default: Boolean = false): Boolean =
bridge.getSetting(pluginId, key, default)
protected fun getSettingString(key: String, default: String = ""): String =
bridge.getSettingString(pluginId, key, default)
protected fun setSetting(key: String, value: String, reloadSettings: Boolean = false) {
bridge.setSetting(pluginId, key, value)
if (reloadSettings) {
PluginSettingsReload.request(pluginId)
}
}
protected fun addOnSendMessageHook(
priority: Int = 0,
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
) {
bridge.registerSendMessageHook(pluginId, priority, handler)
}
protected fun registerFeatureOverride(featureId: String, provider: () -> Boolean?) {
bridge.registerFeatureOverride(pluginId, featureId, provider)
}
protected fun addMenuItem(item: MenuItemData) {
bridge.registerMenuItem(pluginId, item)
}
protected fun showBulletin(message: String) {
bridge.showBulletin(message)
}
protected fun hookShared(
hookId: String,
priority: Int = 0,
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after)
}
object PluginSettingsReload {
var listener: ((String) -> Unit)? = null
fun request(pluginId: String) {
listener?.invoke(pluginId)
}
}
@@ -0,0 +1,74 @@
package ru.fromchat.plugins
enum class AppEvent {
START,
STOP,
PAUSE,
RESUME,
}
enum class HookStrategy {
DEFAULT,
CANCEL,
MODIFY,
MODIFY_FINAL,
}
data class HookResult<T>(
val strategy: HookStrategy = HookStrategy.DEFAULT,
val value: T? = null,
)
data class SendMessageHookContext(
var text: String,
val isDm: Boolean,
val recipientId: Int?,
)
enum class MenuItemType {
MESSAGE_CONTEXT,
CHAT_ACTION,
PROFILE_ACTION,
SETTINGS_ROW,
}
data class MenuItemData(
val menuType: MenuItemType,
val text: String,
val subtext: String? = null,
val priority: Int = 0,
val onClickKey: String = "",
)
sealed class PluginSetting {
data class Header(val text: String) : PluginSetting()
data class Switch(
val key: String,
val text: String,
val default: Boolean = false,
val subtext: String? = null,
) : PluginSetting()
data class Input(
val key: String,
val text: String,
val default: String = "",
val subtext: String? = null,
) : PluginSetting()
data class Text(
val text: String,
val subtext: String? = null,
) : PluginSetting()
}
data class PluginManifest(
val id: String,
val name: String,
val description: String = "",
val author: String = "",
val version: String = "1.0.0",
val appVersion: String = "",
val sdkVersion: String = "",
val entryClass: String,
val androidArtifact: String? = null,
val desktopArtifact: String? = null,
)