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
+7
View File
@@ -1,6 +1,13 @@
# Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows. # Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows.
# proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc. # proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc.
# ru.fromchat: keep names for plugin hooks; allow unused code removal.
-keepnames,allowshrinking class ru.fromchat.** { *; }
-keepclassmembernames,allowshrinking class ru.fromchat.** { *; }
# Plugin SDK loaded from plugin artifacts.
-keepnames class ru.fromchat.plugins.** { *; }
# Crash reports: keep real .kt file names and line numbers; class names stay obfuscated. # Crash reports: keep real .kt file names and line numbers; class names stay obfuscated.
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.notifications.MessageNotificationCoordinator import ru.fromchat.notifications.MessageNotificationCoordinator
import ru.fromchat.plugins.integration.AndroidPluginBootstrap
class App : Application() { class App : Application() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@@ -16,6 +17,7 @@ class App : Application() {
super.onCreate() super.onCreate()
UtilsLibrary.init(this) UtilsLibrary.init(this)
MessageNotificationCoordinator.install() MessageNotificationCoordinator.install()
AndroidPluginBootstrap.init(this)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
runCatching { ApiClient.loadPersistedData() } runCatching { ApiClient.loadPersistedData() }
@@ -142,6 +142,7 @@ private object DesktopApplicationBootstrap {
scope.launch { scope.launch {
runCatching { ApiClient.loadPersistedData() } runCatching { ApiClient.loadPersistedData() }
runCatching { AttachmentTransferBootstrap.runColdStart() } runCatching { AttachmentTransferBootstrap.runColdStart() }
runCatching { ru.fromchat.plugins.integration.DesktopPluginBootstrap.init() }
} }
} }
} }
+2
View File
@@ -165,6 +165,7 @@ kotlin {
} }
androidMain.dependencies { androidMain.dependencies {
implementation(project(":plugins:host"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov) implementation(libs.bouncycastle.bcprov)
@@ -200,6 +201,7 @@ kotlin {
} else { } else {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
} }
implementation(project(":plugins:host"))
implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
@@ -0,0 +1,27 @@
package ru.fromchat.plugins.integration
import android.content.Context
import ru.fromchat.Logger
import ru.fromchat.plugins.AppEvent
import ru.fromchat.plugins.host.FeatureGate
import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.plugins.host.PluginHookDispatcher
import ru.fromchat.plugins.host.initAndroidPluginPlatform
import ru.fromchat.plugins.host.ui.PluginBulletinStore
import ru.fromchat.plugins.host.util.PluginLogger
import java.io.File
object AndroidPluginBootstrap {
fun init(context: Context) {
initAndroidPluginPlatform(context)
PluginLogger.sink = { tag, message -> Logger.d(tag, message) }
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
PluginEngine.pluginsRootProvider = {
File(context.filesDir, "plugins").apply { mkdirs() }
}
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
PluginEngine.init()
PluginEngine.dispatchAppEvent(AppEvent.START)
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
}
}
@@ -339,6 +339,16 @@
<string name="debug_tools">Отладка API</string> <string name="debug_tools">Отладка API</string>
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string> <string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
<string name="plugins_title">Плагины</string>
<string name="plugins_engine">Движок плагинов</string>
<string name="plugins_engine_d">Запуск Kotlin-плагинов для расширения FromChat.</string>
<string name="plugins_developer_mode">Режим разработчика</string>
<string name="plugins_developer_mode_d">Включает локальный DevServer на порту 42690.</string>
<string name="plugins_installed">Установленные плагины</string>
<string name="plugins_none_installed">Плагины ещё не установлены.</string>
<string name="plugins_enabled">Включено</string>
<string name="plugins_settings_title">Настройки плагина</string>
<string name="settings_category_appearance">Оформление</string> <string name="settings_category_appearance">Оформление</string>
<string name="settings_category_appearance_d">Тема и Material You</string> <string name="settings_category_appearance_d">Тема и Material You</string>
<string name="settings_category_server_tools">Сервер и инструменты</string> <string name="settings_category_server_tools">Сервер и инструменты</string>
@@ -370,6 +370,16 @@
<string name="debug_tools">Debug API</string> <string name="debug_tools">Debug API</string>
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string> <string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
<string name="plugins_title">Plugins</string>
<string name="plugins_engine">Plugin engine</string>
<string name="plugins_engine_d">Run Kotlin plugins that extend FromChat.</string>
<string name="plugins_developer_mode">Developer mode</string>
<string name="plugins_developer_mode_d">Enable the local DevServer on port 42690.</string>
<string name="plugins_installed">Installed plugins</string>
<string name="plugins_none_installed">No plugins installed yet.</string>
<string name="plugins_enabled">Enabled</string>
<string name="plugins_settings_title">Plugin settings</string>
<!-- Settings categories --> <!-- Settings categories -->
<string name="settings_category_appearance">Appearance</string> <string name="settings_category_appearance">Appearance</string>
<string name="settings_category_appearance_d">Theme and Material You</string> <string name="settings_category_appearance_d">Theme and Material You</string>
@@ -197,13 +197,21 @@ object OutgoingMessageCoordinator {
clientMessageId: String, clientMessageId: String,
optimisticMessage: Message, optimisticMessage: Message,
) { ) {
val hooked = ru.fromchat.plugins.host.PluginHookDispatcher.interceptSendMessage(
ru.fromchat.plugins.SendMessageHookContext(
text = content,
isDm = false,
recipientId = null,
),
) ?: return
val finalContent = hooked.text
val instanceId = CacheContext.requireActiveInstanceId() val instanceId = CacheContext.requireActiveInstanceId()
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
markOutboundActive(clientMessageId) markOutboundActive(clientMessageId)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.upsertPublicMessage(optimisticMessage) MessageRepository.upsertPublicMessage(optimisticMessage)
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}") Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${finalContent.length}")
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId)) val payload = json.encodeToString(PublicOutboxPayload(finalContent, replyToId))
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
instanceId = instanceId, instanceId = instanceId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
@@ -227,12 +235,20 @@ object OutgoingMessageCoordinator {
transportFiles: List<SendDmFile> = emptyList(), transportFiles: List<SendDmFile> = emptyList(),
uploadedFileIds: List<String> = emptyList(), uploadedFileIds: List<String> = emptyList(),
) { ) {
val hooked = ru.fromchat.plugins.host.PluginHookDispatcher.interceptSendMessage(
ru.fromchat.plugins.SendMessageHookContext(
text = plaintext,
isDm = true,
recipientId = recipientId,
),
) ?: return
val finalPlaintext = hooked.text
val instanceId = CacheContext.requireActiveInstanceId() val instanceId = CacheContext.requireActiveInstanceId()
val conversationId = conversationIdForDm(recipientId) val conversationId = conversationIdForDm(recipientId)
markOutboundActive(clientMessageId) markOutboundActive(clientMessageId)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.upsertDmMessage(recipientId, optimisticMessage) MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId) val outboundPlaintext = buildDmOutboundPlaintext(finalPlaintext, replyToId)
val payload = json.encodeToString( val payload = json.encodeToString(
DmOutboxPayload( DmOutboxPayload(
recipientId = recipientId, recipientId = recipientId,
@@ -534,6 +534,7 @@ fun App(
} }
ScreenSurface { ScreenSurface {
ru.fromchat.plugins.host.ui.PluginUiHost {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -774,6 +775,7 @@ fun App(
ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false }) ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false })
} }
} }
}
} }
} }
} }
@@ -24,6 +24,8 @@ import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
import ru.fromchat.ui.main.settings.LogFilesScreen import ru.fromchat.ui.main.settings.LogFilesScreen
import ru.fromchat.ui.main.settings.LogsScreen import ru.fromchat.ui.main.settings.LogsScreen
import ru.fromchat.ui.main.settings.NotificationsScreen import ru.fromchat.ui.main.settings.NotificationsScreen
import ru.fromchat.ui.main.settings.PluginDetailScreen
import ru.fromchat.ui.main.settings.PluginsScreen
import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen import ru.fromchat.ui.main.settings.account.AccountScreen
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
@@ -288,6 +290,22 @@ fun NavGraphBuilder.settingsDetailDestinations(
composable(SettingsRoutes.ServerConfig) { composable(SettingsRoutes.ServerConfig) {
ServerConfigScreen() ServerConfigScreen()
} }
composable(SettingsRoutes.Plugins) {
PluginsScreen()
}
composable(
route = SettingsRoutes.PluginDetail,
arguments = listOf(
navArgument("pluginId") { type = NavType.StringType },
),
) { entry ->
val pluginId = entry.savedStateHandle.get<String>("pluginId").orEmpty()
if (pluginId.isNotBlank()) {
PluginDetailScreen(pluginId)
}
}
} }
private fun NavBackStackEntry.pathInt(name: String): Int = private fun NavBackStackEntry.pathInt(name: String): Int =
@@ -0,0 +1,218 @@
package ru.fromchat.ui.main.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.verticalScroll
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.plugins.PluginSetting
import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text
import ru.fromchat.plugins_title
import ru.fromchat.plugins_engine
import ru.fromchat.plugins_engine_d
import ru.fromchat.plugins_developer_mode
import ru.fromchat.plugins_developer_mode_d
import ru.fromchat.plugins_installed
import ru.fromchat.plugins_none_installed
import ru.fromchat.plugins_settings_title
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PluginsScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
val engineEnabled by PluginEngine.engineEnabled.collectAsState()
val developerMode by PluginEngine.developerMode.collectAsState()
val installed by PluginEngine.installed.collectAsState()
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(stringResource(Res.string.plugins_title), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
scrollBehavior = scrollBehavior,
)
},
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll()
.padding(innerPadding),
) {
Category(Modifier.padding(top = 16.dp)) {
ListItem(
headline = stringResource(Res.string.plugins_engine),
supportingText = stringResource(Res.string.plugins_engine_d),
trailingContent = {
Switch(checked = engineEnabled, onCheckedChange = PluginEngine::setEngineEnabled)
},
)
ListItem(
headline = stringResource(Res.string.plugins_developer_mode),
supportingText = stringResource(Res.string.plugins_developer_mode_d),
trailingContent = {
Switch(
checked = developerMode,
onCheckedChange = PluginEngine::setDeveloperMode,
enabled = engineEnabled,
)
},
)
}
Category(Modifier.padding(top = 8.dp)) {
Text(
text = stringResource(Res.string.plugins_installed),
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
)
if (installed.isEmpty()) {
ListItem(
headline = stringResource(Res.string.plugins_none_installed),
leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) },
)
} else {
installed.forEach { manifest ->
ListItem(
headline = manifest.name,
supportingText = manifest.description.ifBlank { manifest.id },
leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) },
trailingContent = {
Switch(
checked = PluginEngine.isPluginEnabled(manifest.id),
onCheckedChange = { enabled ->
PluginEngine.setPluginEnabled(manifest.id, enabled)
},
enabled = engineEnabled,
)
},
onClick = { navController.navigate(SettingsRoutes.pluginDetail(manifest.id)) },
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PluginDetailScreen(pluginId: String) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val plugin = PluginEngine.loadedPlugin(pluginId)
val settings = plugin?.instance?.createSettings().orEmpty()
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(
plugin?.manifest?.name ?: pluginId,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
scrollBehavior = scrollBehavior,
)
},
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll()
.padding(innerPadding),
) {
Category(Modifier.padding(top = 16.dp)) {
Text(
text = stringResource(Res.string.plugins_settings_title),
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
)
settings.forEach { setting ->
when (setting) {
is PluginSetting.Header -> {
Text(
text = setting.text,
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
)
}
is PluginSetting.Text -> {
ListItem(
headline = setting.text,
supportingText = setting.subtext,
)
}
is PluginSetting.Switch -> {
var checked by remember(pluginId, setting.key) {
mutableStateOf(
PluginEngine.getPluginSettingBoolean(pluginId, setting.key, setting.default),
)
}
ListItem(
headline = setting.text,
supportingText = setting.subtext,
trailingContent = {
Switch(
checked = checked,
onCheckedChange = { value ->
checked = value
PluginEngine.setPluginSetting(pluginId, setting.key, value.toString())
},
)
},
)
}
is PluginSetting.Input -> {
var text by remember(pluginId, setting.key) {
mutableStateOf(
PluginEngine.getPluginSettingString(
pluginId,
setting.key,
setting.default,
),
)
}
OutlinedTextField(
value = text,
onValueChange = { value ->
text = value
PluginEngine.setPluginSetting(pluginId, setting.key, value)
},
label = { Text(setting.text) },
modifier = Modifier
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp)
.fillMaxSize(),
)
}
}
}
}
}
}
}
@@ -20,4 +20,8 @@ object SettingsRoutes {
const val About = "about" const val About = "about"
const val Logs = "settings/logs" const val Logs = "settings/logs"
const val LogFiles = "settings/logs/files" const val LogFiles = "settings/logs/files"
const val Plugins = "settings/plugins"
const val PluginDetail = "settings/plugins/{pluginId}"
fun pluginDetail(pluginId: String): String = "settings/plugins/$pluginId"
} }
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
@@ -40,6 +41,8 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.change_server import ru.fromchat.change_server
import ru.fromchat.change_server_d import ru.fromchat.change_server_d
import ru.fromchat.logs_title import ru.fromchat.logs_title
import ru.fromchat.plugins_title
import ru.fromchat.plugins_engine_d
import ru.fromchat.profile import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.settings_category_account import ru.fromchat.settings_category_account
@@ -176,7 +179,15 @@ fun SettingsTab() {
headline = stringResource(Res.string.logs_title), headline = stringResource(Res.string.logs_title),
supportingText = stringResource(Res.string.settings_hub_logs_sub), supportingText = stringResource(Res.string.settings_hub_logs_sub),
onClick = { openDetail(SettingsRoutes.Logs) }, onClick = { openDetail(SettingsRoutes.Logs) },
leadingContent = { Icon(Icons.Outlined.BugReport, null) } leadingContent = { Icon(Icons.Outlined.BugReport, null) },
divider = true,
)
ListItem(
headline = stringResource(Res.string.plugins_title),
supportingText = stringResource(Res.string.plugins_engine_d),
onClick = { openDetail(SettingsRoutes.Plugins) },
leadingContent = { Icon(Icons.Filled.Extension, null) },
) )
} }
} }
@@ -0,0 +1,44 @@
package ru.fromchat.plugins.integration
import ru.fromchat.Logger
import ru.fromchat.plugins.AppEvent
import ru.fromchat.plugins.host.FeatureGate
import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.plugins.host.PluginHookDispatcher
import ru.fromchat.plugins.host.ui.PluginBulletinStore
import ru.fromchat.plugins.host.util.PluginLogger
import java.io.File
object DesktopPluginBootstrap {
fun init(classLoader: ClassLoader = DesktopPluginBootstrap::class.java.classLoader) {
PluginLogger.sink = { tag, message -> Logger.d(tag, message) }
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } }
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
installBundledPlugin(classLoader)
PluginEngine.init()
PluginEngine.dispatchAppEvent(AppEvent.START)
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
}
private fun installBundledPlugin(classLoader: ClassLoader) {
val installed = desktopPluginsRoot().resolve("hello_world")
if (installed.exists()) return
val stream = classLoader.getResourceAsStream("bundled_plugins/hello_world.fcplugin") ?: return
val temp = File.createTempFile("hello_world", ".fcplugin")
temp.outputStream().use { out -> stream.copyTo(out) }
runCatching { PluginEngine.installFcPluginArchive(temp) }
.onFailure { Logger.w("Plugins", "Bundled plugin install failed: ${it.message}") }
temp.delete()
}
private fun desktopPluginsRoot(): File {
val home = System.getProperty("user.home")
val os = System.getProperty("os.name").lowercase()
return when {
os.contains("mac") -> File(home, "Library/Application Support/FromChat/plugins")
os.contains("win") -> File(System.getenv("APPDATA") ?: home, "FromChat/plugins")
else -> File(home, ".local/share/FromChat/plugins")
}
}
}
+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,
)
+9 -1
View File
@@ -42,5 +42,13 @@ dependencyResolutionManagement {
} }
} }
include(":app:shared", ":utils:shared", ":app:android", ":app:desktop") include(
":app:shared",
":utils:shared",
":app:android",
":app:desktop",
":plugins:sdk",
":plugins:host",
":plugins:sample-hello",
)