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
+35
View File
@@ -0,0 +1,35 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.multiplatform.library)
}
kotlin {
android {
namespace = "ru.fromchat.plugins.host"
compileSdk = 37
minSdk = 24
}
jvm()
sourceSets {
commonMain.dependencies {
api(project(":plugins:sdk"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
}
jvmMain.dependencies {
implementation("net.bytebuddy:byte-buddy:1.15.11")
implementation("net.bytebuddy:byte-buddy-agent:1.15.11")
}
androidMain.dependencies {
implementation("top.canyie.pine:core:0.3.0")
}
}
}
@@ -0,0 +1,9 @@
package ru.fromchat.plugins.host
actual object PluginDevServer {
actual fun onDeveloperModeChanged(enabled: Boolean) {
// Android DevServer wiring can mirror JVM; disabled in initial pass.
}
actual fun stop() = Unit
}
@@ -0,0 +1,81 @@
package ru.fromchat.plugins.host
import android.content.Context
import dalvik.system.DexClassLoader
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.PluginManifest
import top.canyie.pine.Pine
import top.canyie.pine.callback.MethodHook
import java.io.File
private lateinit var appContext: Context
fun initAndroidPluginPlatform(context: Context) {
appContext = context.applicationContext
}
actual object PluginPlatform {
actual val current: PluginPlatform? get() = this
private val classLoaders = mutableMapOf<String, DexClassLoader>()
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
actual fun installSharedHook(registration: SharedHookRegistration) {
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
val clazz = Class.forName(target.className)
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
val record = Pine.hook(method, object : MethodHook() {
override fun beforeCall(callFrame: Pine.CallFrame) {
registration.before?.invoke(callFrame.args)?.let { result ->
when (result.strategy) {
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
result.value?.let { callFrame.args = it }
}
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
}
}
}
override fun afterCall(callFrame: Pine.CallFrame) {
registration.after?.invoke(callFrame.args, callFrame.result)?.let { result ->
when (result.strategy) {
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
result.value?.let { callFrame.setResult(it) }
}
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
}
}
}
})
pineHooks.getOrPut(registration.pluginId) { mutableListOf() } += record
}
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
pineHooks.remove(registration.pluginId)?.forEach { Pine.unhook(it) }
}
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
val artifact = manifest.androidArtifact ?: return null
val dex = File(pluginDir, artifact)
if (!dex.exists()) return null
val optimizedDir = File(appContext.codeCacheDir, "plugins_opt").apply { mkdirs() }
val loader = DexClassLoader(
dex.absolutePath,
optimizedDir.absolutePath,
null,
appContext.classLoader,
)
classLoaders[manifest.id] = loader
val clazz = loader.loadClass(manifest.entryClass)
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
return LoadedPlugin(manifest, instance, pluginDir)
}
actual fun unloadPlugin(pluginId: String) {
pineHooks.remove(pluginId)?.forEach { Pine.unhook(it) }
classLoaders.remove(pluginId)
}
}
@@ -0,0 +1,61 @@
package ru.fromchat.plugins.host
import ru.fromchat.plugins.HookResult
data class SharedHookTarget(
val className: String,
val methodName: String,
val paramTypeNames: Array<String>,
)
object HookRegistry {
const val LOGGER_DEBUG = "logger.debug"
val targets = mapOf(
LOGGER_DEBUG to SharedHookTarget(
className = "ru.fromchat.Logger",
methodName = "d",
paramTypeNames = arrayOf("java.lang.String", "java.lang.String"),
),
)
}
data class SharedHookRegistration(
val pluginId: String,
val hookId: String,
val priority: Int,
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
)
object SharedMethodHookRegistry {
private val registrations = mutableListOf<SharedHookRegistration>()
fun register(registration: SharedHookRegistration) {
registrations += registration
registrations.sortByDescending { it.priority }
PluginPlatform.current?.installSharedHook(registration)
}
fun unregisterPlugin(pluginId: String) {
val removed = registrations.filter { it.pluginId == pluginId }
registrations.removeAll { it.pluginId == pluginId }
removed.forEach { PluginPlatform.current?.uninstallSharedHook(it) }
}
fun targetFor(hookId: String): SharedHookTarget? = HookRegistry.targets[hookId]
}
expect object PluginPlatform {
val current: PluginPlatform?
fun installSharedHook(registration: SharedHookRegistration)
fun uninstallSharedHook(registration: SharedHookRegistration)
fun loadPlugin(manifest: ru.fromchat.plugins.PluginManifest, pluginDir: String): LoadedPlugin?
fun unloadPlugin(pluginId: String)
}
data class LoadedPlugin(
val manifest: ru.fromchat.plugins.PluginManifest,
val instance: ru.fromchat.plugins.BasePlugin,
val pluginDir: String,
)
@@ -0,0 +1,6 @@
package ru.fromchat.plugins.host
expect object PluginDevServer {
fun onDeveloperModeChanged(enabled: Boolean)
fun stop()
}
@@ -0,0 +1,270 @@
package ru.fromchat.plugins.host
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.plugins.AppEvent
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.MenuItemData
import ru.fromchat.plugins.PluginHostBridge
import ru.fromchat.plugins.PluginManifest
import ru.fromchat.plugins.PluginSettingsReload
import ru.fromchat.plugins.SendMessageHookContext
import java.io.File
object PluginEngine : PluginHostBridge {
private val _engineEnabled = MutableStateFlow(false)
val engineEnabled: StateFlow<Boolean> = _engineEnabled.asStateFlow()
private val _developerMode = MutableStateFlow(false)
val developerMode: StateFlow<Boolean> = _developerMode.asStateFlow()
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
private val enabledIds = linkedSetOf<String>()
private val loaded = mutableMapOf<String, LoadedPlugin>()
private val settings = mutableMapOf<String, MutableMap<String, String>>()
private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>()
var pluginsRootProvider: () -> File = { error("pluginsRootProvider not set") }
var appVersionProvider: () -> String = { "0.0.0" }
var onSettingsReload: ((String) -> Unit)? = null
fun init() {
PluginSettingsReload.listener = { pluginId -> onSettingsReload?.invoke(pluginId) }
refreshInstalledList()
loadEnabledState()
if (_engineEnabled.value) {
enabledIds.forEach { loadPlugin(it) }
}
}
fun setEngineEnabled(enabled: Boolean) {
if (_engineEnabled.value == enabled) return
_engineEnabled.value = enabled
persistEngineState()
if (enabled) {
enabledIds.forEach { loadPlugin(it) }
} else {
loaded.keys.toList().forEach { unloadPlugin(it) }
}
}
fun setDeveloperMode(enabled: Boolean) {
_developerMode.value = enabled
persistEngineState()
PluginDevServer.onDeveloperModeChanged(enabled)
}
fun setPluginEnabled(pluginId: String, enabled: Boolean) {
if (enabled) {
enabledIds += pluginId
if (_engineEnabled.value) loadPlugin(pluginId)
} else {
enabledIds -= pluginId
unloadPlugin(pluginId)
}
persistEnabledState()
refreshInstalledList()
}
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
fun loadedPlugin(pluginId: String): LoadedPlugin? = loaded[pluginId]
fun getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
getSetting(pluginId, key, default)
fun getPluginSettingString(pluginId: String, key: String, default: String = ""): String =
getSettingString(pluginId, key, default)
fun setPluginSetting(pluginId: String, key: String, value: String) {
setSetting(pluginId, key, value)
}
fun refreshInstalledList() {
val root = pluginsRootProvider()
if (!root.exists()) {
root.mkdirs()
}
val manifests = root.listFiles()?.filter { it.isDirectory }?.mapNotNull { dir ->
val manifestFile = dir.resolve("manifest.json")
if (!manifestFile.exists()) return@mapNotNull null
runCatching { PluginManifestParser.parse(manifestFile.readText()) }.getOrNull()
}.orEmpty().sortedBy { it.name.lowercase() }
_installed.value = manifests
}
fun installFcPluginArchive(archiveFile: File) {
val temp = pluginsRootProvider().resolve(".install-${System.currentTimeMillis()}")
temp.mkdirs()
projectZipExtract(archiveFile, temp)
val manifestFile = temp.walkTopDown().firstOrNull { it.name == "manifest.json" }
?: error("manifest.json not found in archive")
val manifest = PluginManifestParser.parse(manifestFile.readText())
val target = pluginsRootProvider().resolve(manifest.id)
if (target.exists()) target.deleteRecursively()
manifestFile.parentFile?.copyRecursively(target, overwrite = true)
temp.deleteRecursively()
refreshInstalledList()
}
fun dispatchAppEvent(event: AppEvent) {
loaded.values.forEach { runCatching { it.instance.onAppEvent(event) } }
}
override fun log(pluginId: String, message: String) {
ru.fromchat.plugins.host.util.PluginLogger.log(pluginId, message)
}
override fun getSetting(pluginId: String, key: String, default: Boolean): Boolean =
settings[pluginId]?.get(key)?.toBooleanStrictOrNull() ?: default
override fun getSettingString(pluginId: String, key: String, default: String): String =
settings[pluginId]?.get(key) ?: default
override fun setSetting(pluginId: String, key: String, value: String) {
val map = settings.getOrPut(pluginId) { loadSettingsFile(pluginId) }
map[key] = value
saveSettingsFile(pluginId, map)
}
override fun registerSendMessageHook(
pluginId: String,
priority: Int,
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
) {
PluginHookDispatcher.registerSendHook(pluginId, priority, handler)
}
override fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
PluginHookDispatcher.registerFeatureOverride(pluginId, featureId, provider)
}
override fun registerMenuItem(pluginId: String, item: MenuItemData) {
PluginHookDispatcher.registerMenuItem(pluginId, item)
}
override fun showBulletin(message: String) {
PluginHookDispatcher.showBulletin(message)
}
override fun hookSharedMethod(
pluginId: String,
hookId: String,
priority: Int,
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> Unit {
val registration = SharedHookRegistration(pluginId, hookId, priority, before, after)
SharedMethodHookRegistry.register(registration)
val unhook = {
SharedMethodHookRegistry.unregisterPlugin(pluginId)
}
sharedUnhooks.getOrPut(pluginId) { mutableListOf() } += unhook
return unhook
}
private fun loadPlugin(pluginId: String) {
if (loaded.containsKey(pluginId)) return
val dir = pluginsRootProvider().resolve(pluginId)
val manifestFile = dir.resolve("manifest.json")
if (!manifestFile.exists()) return
val manifest = PluginManifestParser.parse(manifestFile.readText())
val platform = PluginPlatform.current ?: return
val loadedPlugin = platform.loadPlugin(manifest, dir.absolutePath) ?: return
loadedPlugin.instance.attach(manifest.id, this)
settings.putIfAbsent(manifest.id, loadSettingsFile(manifest.id))
runCatching { loadedPlugin.instance.onPluginLoad() }
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
loaded[manifest.id] = loadedPlugin
}
private fun unloadPlugin(pluginId: String) {
val plugin = loaded.remove(pluginId) ?: return
runCatching { plugin.instance.onPluginUnload() }
PluginHookDispatcher.unregisterPlugin(pluginId)
sharedUnhooks.remove(pluginId)
PluginPlatform.current?.unloadPlugin(pluginId)
}
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
if (!file.exists()) return mutableMapOf()
return runCatching {
file.readLines()
.mapNotNull { line ->
val idx = line.indexOf('=')
if (idx <= 0) null else line.substring(0, idx) to line.substring(idx + 1)
}
.toMap()
.toMutableMap()
}.getOrElse { mutableMapOf() }
}
private fun saveSettingsFile(pluginId: String, map: Map<String, String>) {
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
file.writeText(map.entries.joinToString("\n") { "${it.key}=${it.value}" })
}
private fun persistEngineState() {
val file = pluginsRootProvider().resolve("engine_state.properties")
file.parentFile?.mkdirs()
file.writeText(
buildString {
appendLine("engineEnabled=${_engineEnabled.value}")
appendLine("developerMode=${_developerMode.value}")
},
)
}
private fun loadEnabledState() {
val file = pluginsRootProvider().resolve("engine_state.properties")
if (file.exists()) {
file.readLines().forEach { line ->
val parts = line.split("=", limit = 2)
if (parts.size == 2) {
when (parts[0]) {
"engineEnabled" -> _engineEnabled.value = parts[1].toBooleanStrictOrNull() == true
"developerMode" -> _developerMode.value = parts[1].toBooleanStrictOrNull() == true
}
}
}
}
val enabledFile = pluginsRootProvider().resolve("enabled_plugins.txt")
if (enabledFile.exists()) {
enabledIds.clear()
enabledIds += enabledFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
}
}
private fun persistEnabledState() {
pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n"))
}
private fun projectZipExtract(archive: File, dest: File) {
java.util.zip.ZipFile(archive).use { zip ->
zip.entries().asSequence().forEach { entry ->
val out = dest.resolve(entry.name)
if (entry.isDirectory) {
out.mkdirs()
} else {
out.parentFile?.mkdirs()
zip.getInputStream(entry).use { input -> out.outputStream().use { input.copyTo(it) } }
}
}
}
}
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()) {
runCatching { installFcPluginArchive(archive) }
}
}
}
}
@@ -0,0 +1,83 @@
package ru.fromchat.plugins.host
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.MenuItemData
import ru.fromchat.plugins.SendMessageHookContext
data class RegisteredSendHook(
val pluginId: String,
val priority: Int,
val handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
)
object PluginHookDispatcher {
private val sendHooks = mutableListOf<RegisteredSendHook>()
private val featureOverrides = mutableMapOf<String, MutableList<Pair<String, () -> Boolean?>>>()
private val menuItems = mutableListOf<Pair<String, MenuItemData>>()
var bulletinHandler: ((String) -> Unit)? = null
fun registerSendHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>) {
sendHooks += RegisteredSendHook(pluginId, priority, handler)
sendHooks.sortByDescending { it.priority }
}
fun unregisterPlugin(pluginId: String) {
sendHooks.removeAll { it.pluginId == pluginId }
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
menuItems.removeAll { it.first == pluginId }
SharedMethodHookRegistry.unregisterPlugin(pluginId)
}
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
featureOverrides.getOrPut(featureId) { mutableListOf() } += pluginId to provider
}
fun registerMenuItem(pluginId: String, item: MenuItemData) {
menuItems += pluginId to item
}
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }.map { it.second }
fun featureOverrideValue(id: String): Boolean? {
featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
provider()?.let { return it }
}
return null
}
fun interceptSendMessage(context: SendMessageHookContext): SendMessageHookContext? {
var current = context
for (hook in sendHooks) {
val result = hook.handler(current)
when (result.strategy) {
HookStrategy.DEFAULT -> Unit
HookStrategy.CANCEL -> return null
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> {
result.value?.let { current = it }
if (result.strategy == HookStrategy.MODIFY_FINAL) return current
}
}
}
return current
}
fun showBulletin(message: String) {
bulletinHandler?.invoke(message)
}
}
object FeatureGate {
private val defaults = mutableMapOf<String, () -> Boolean>()
var pluginResolver: ((String, Boolean) -> Boolean)? = null
fun registerDefault(id: String, provider: () -> Boolean) {
defaults[id] = provider
}
fun isEnabled(id: String, default: Boolean = true): Boolean {
PluginHookDispatcher.featureOverrideValue(id)?.let { return it }
return defaults[id]?.invoke() ?: default
}
}
@@ -0,0 +1,33 @@
package ru.fromchat.plugins.host
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.plugins.PluginManifest
object PluginManifestParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(raw: String): PluginManifest {
val root = json.parseToJsonElement(raw).jsonObject
val artifacts = root["artifacts"]?.jsonObject
return PluginManifest(
id = root.requireString("id"),
name = root.requireString("name"),
description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(),
author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(),
version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0",
appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
entryClass = root.requireString("entry_class"),
androidArtifact = artifacts?.get("android")?.jsonPrimitive?.contentOrNull,
desktopArtifact = artifacts?.get("desktop")?.jsonPrimitive?.contentOrNull,
)
}
private fun JsonObject.requireString(key: String): String =
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?: error("manifest missing $key")
}
@@ -0,0 +1,55 @@
package ru.fromchat.plugins.host.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
data class PluginBulletin(val message: String, val id: Long)
object PluginBulletinStore {
val bulletins = mutableStateListOf<PluginBulletin>()
private var nextId = 0L
fun show(message: String) {
val id = ++nextId
bulletins += PluginBulletin(message, id)
}
}
@Composable
fun PluginUiHost(content: @Composable () -> Unit) {
val bulletins = remember { PluginBulletinStore.bulletins }
Box(Modifier.fillMaxSize()) {
content()
bulletins.lastOrNull()?.let { bulletin ->
LaunchedEffect(bulletin.id) {
delay(4_000)
bulletins.remove(bulletin)
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
tonalElevation = 6.dp,
shape = MaterialTheme.shapes.medium,
) {
Text(
text = bulletin.message,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@@ -0,0 +1,10 @@
package ru.fromchat.plugins.host.util
object PluginLogger {
var sink: ((tag: String, message: String) -> Unit)? = null
fun log(pluginId: String, message: String) {
val tag = "Plugin:$pluginId"
sink?.invoke(tag, message) ?: println("$tag $message")
}
}
@@ -0,0 +1,85 @@
package ru.fromchat.plugins.host
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
actual object PluginDevServer {
private var serverJob: Job? = null
private val scope = CoroutineScope(Dispatchers.IO)
actual fun onDeveloperModeChanged(enabled: Boolean) {
if (enabled) start() else stop()
}
actual fun stop() {
serverJob?.cancel()
serverJob = null
}
private fun start() {
if (serverJob != null) return
serverJob = scope.launch {
runCatching {
ServerSocket(42690, 50, InetAddress.getByName("127.0.0.1")).use { server ->
while (true) {
val socket = server.accept()
scope.launch { handleClient(socket) }
}
}
}.onFailure {
PluginEngine.log("devserver", "failed: ${it.message}")
}
}
}
private fun handleClient(socket: Socket) {
socket.use { client ->
val reader = BufferedReader(InputStreamReader(client.getInputStream()))
val writer = PrintWriter(client.getOutputStream(), true)
val line = reader.readLine() ?: return
val requestId = Regex("\"#\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1) ?: "0"
val command = Regex("\"@\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)
when (command) {
"ping" -> writer.println("""{"#":"$requestId","pong":true}""")
"get_plugins" -> {
val plugins = PluginEngine.installed.value.joinToString(",") { manifest ->
"""{"id":"${manifest.id}","enabled":${PluginEngine.isPluginEnabled(manifest.id)}}"""
}
writer.println("""{"#":"$requestId","plugins":[$plugins]}""")
}
"enable_plugin" -> {
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
PluginEngine.setPluginEnabled(it, true)
}
writer.println("""{"#":"$requestId","success":true}""")
}
"disable_plugin" -> {
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
PluginEngine.setPluginEnabled(it, false)
}
writer.println("""{"#":"$requestId","success":true}""")
}
"reload_plugin" -> {
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let { id ->
PluginEngine.setPluginEnabled(id, false)
PluginEngine.setPluginEnabled(id, true)
}
writer.println("""{"#":"$requestId","success":true}""")
}
else -> writer.println("""{"#":"$requestId","error":"unknown command"}""")
}
}
}
}
private fun PluginEngine.log(tag: String, message: String) {
ru.fromchat.plugins.host.util.PluginLogger.log(tag, message)
}
@@ -0,0 +1,122 @@
package ru.fromchat.plugins.host
import net.bytebuddy.agent.ByteBuddyAgent
import net.bytebuddy.implementation.MethodDelegation
import net.bytebuddy.implementation.bind.annotation.AllArguments
import net.bytebuddy.implementation.bind.annotation.Origin
import net.bytebuddy.implementation.bind.annotation.RuntimeType
import net.bytebuddy.implementation.bind.annotation.SuperCall
import net.bytebuddy.matcher.ElementMatchers
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.PluginManifest
import java.io.File
import java.lang.reflect.Method
import java.net.URLClassLoader
import java.util.concurrent.Callable
import net.bytebuddy.ByteBuddy
actual object PluginPlatform {
actual val current: PluginPlatform? get() = this
private val classLoaders = mutableMapOf<String, URLClassLoader>()
private val instances = mutableMapOf<String, BasePlugin>()
private val hookHandles = mutableMapOf<String, MutableList<SharedHookHandle>>()
private var agentInstalled = false
actual fun installSharedHook(registration: SharedHookRegistration) {
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
val handle = JvmSharedHookHandle(registration, target)
handle.install()
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
}
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
}
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
val artifact = manifest.desktopArtifact ?: return null
val jar = File(pluginDir, artifact)
if (!jar.exists()) return null
val loader = URLClassLoader(arrayOf(jar.toURI().toURL()), BasePlugin::class.java.classLoader)
classLoaders[manifest.id] = loader
val clazz = loader.loadClass(manifest.entryClass)
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
instances[manifest.id] = instance
return LoadedPlugin(manifest, instance, pluginDir)
}
actual fun unloadPlugin(pluginId: String) {
hookHandles.remove(pluginId)?.forEach { it.uninstall() }
instances.remove(pluginId)
classLoaders.remove(pluginId)?.close()
}
private fun ensureAgent() {
if (agentInstalled) return
runCatching { ByteBuddyAgent.install() }
agentInstalled = true
}
private interface SharedHookHandle {
val registration: SharedHookRegistration
fun install()
fun uninstall()
}
private class JvmSharedHookHandle(
override val registration: SharedHookRegistration,
private val target: SharedHookTarget,
) : SharedHookHandle {
private var installed = false
override fun install() {
if (installed) return
ensureAgent()
val clazz = Class.forName(target.className)
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
ByteBuddy()
.redefine(clazz)
.method(ElementMatchers.named(target.methodName))
.intercept(MethodDelegation.to(JvmHookInterceptor(registration)))
.make()
.load(clazz.classLoader)
installed = true
}
override fun uninstall() {
installed = false
}
}
class JvmHookInterceptor(private val registration: SharedHookRegistration) {
@RuntimeType
fun intercept(
@AllArguments args: Array<Any?>,
@Origin method: Method,
@SuperCall callable: Callable<Any?>,
): Any? {
registration.before?.let { before ->
val result = before(args)
when (result.strategy) {
HookStrategy.CANCEL -> return null
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> result.value?.let { return@intercept callable.call() }
HookStrategy.DEFAULT -> Unit
}
}
val value = callable.call()
registration.after?.let { after ->
val result = after(args, value)
when (result.strategy) {
HookStrategy.CANCEL -> return null
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> return result.value
HookStrategy.DEFAULT -> Unit
}
}
return value
}
}
}
+62
View File
@@ -0,0 +1,62 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(project(":plugins:sdk"))
}
tasks.register("packageFcPlugin") {
group = "plugins"
description = "Build hello_world.fcplugin with desktop JAR"
dependsOn(tasks.named("compileKotlin"))
doLast {
val buildDir = layout.buildDirectory.get().asFile
val staging = buildDir.resolve("fcplugin-staging")
staging.deleteRecursively()
staging.mkdirs()
staging.resolve("desktop").mkdirs()
staging.resolve("android").mkdirs()
val jvmClasses = buildDir.resolve("classes/kotlin/main")
val jarFile = staging.resolve("desktop/plugin.jar")
val jarProcess = ProcessBuilder(
"jar",
"cf",
jarFile.absolutePath,
"-C",
jvmClasses.absolutePath,
".",
).inheritIO().start()
check(jarProcess.waitFor() == 0) { "jar failed" }
staging.resolve("manifest.json").writeText(
"""
{
"id": "hello_world",
"name": "Hello World",
"description": "Rewrites .hello commands into a friendly greeting",
"author": "FromChat",
"version": "1.0.0",
"app_version": ">=1.0.0",
"sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
"artifacts": {
"android": "android/plugin.dex",
"desktop": "desktop/plugin.jar"
}
}
""".trimIndent(),
)
val archive = buildDir.resolve("distributions/hello_world.fcplugin")
archive.parentFile.mkdirs()
if (archive.exists()) archive.delete()
val zipProcess = ProcessBuilder("zip", "-r", archive.absolutePath, ".")
.directory(staging)
.inheritIO()
.start()
check(zipProcess.waitFor() == 0) { "zip failed" }
println("Created ${archive.absolutePath}")
}
}
@@ -0,0 +1,41 @@
package ru.fromchat.plugins.sample.hello
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.PluginSetting
import ru.fromchat.plugins.SendMessageHookContext
class HelloWorldPlugin : BasePlugin() {
override fun onPluginLoad() {
log("Hello World plugin loaded")
showBulletin("Hello World plugin is active")
addOnSendMessageHook { context ->
onSendMessage(context)
}
}
override fun createSettings(): List<PluginSetting> = listOf(
PluginSetting.Header("Hello World"),
PluginSetting.Input(
key = "template",
text = "Greeting template",
default = "Hello, {name}!",
subtext = "Use {name} for the entered name",
),
PluginSetting.Text(
text = "Send .hello Alice in a chat",
subtext = "Example: .hello Alice -> Hello, Alice!",
),
)
private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
val raw = context.text.trim()
if (!raw.startsWith(".hello")) return HookResult()
val parts = raw.split(" ", limit = 2)
val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" }
val template = getSettingString("template", "Hello, {name}!")
context.text = template.replace("{name}", name)
return HookResult(strategy = HookStrategy.MODIFY, value = context)
}
}
+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,
)