From b2ef7be57ade6bf31a1d09682832c92c41a07eee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 22:15:42 +0000 Subject: [PATCH] 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 --- app/android/proguard-rules.pro | 7 + .../src/main/kotlin/ru/fromchat/App.kt | 2 + .../main/kotlin/ru/fromchat/desktop/Main.kt | 1 + .../bundled_plugins/hello_world.fcplugin | Bin 0 -> 3742 bytes app/shared/build.gradle.kts | 2 + .../integration/AndroidPluginBootstrap.kt | 27 ++ .../composeResources/values-ru/strings.xml | 10 + .../composeResources/values/strings.xml | 10 + .../local/send/OutgoingMessageCoordinator.kt | 22 +- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 2 + .../ui/main/MainDetailDestinations.kt | 18 ++ .../ui/main/settings/PluginsScreen.kt | 218 ++++++++++++++ .../ui/main/settings/SettingsRoutes.kt | 4 + .../fromchat/ui/main/settings/SettingsTab.kt | 13 +- .../integration/DesktopPluginBootstrap.kt | 44 +++ plugins/host/build.gradle.kts | 35 +++ .../plugins/host/PluginDevServer.android.kt | 9 + .../plugins/host/PluginPlatform.android.kt | 81 ++++++ .../ru/fromchat/plugins/host/HookRegistry.kt | 61 ++++ .../fromchat/plugins/host/PluginDevServer.kt | 6 + .../ru/fromchat/plugins/host/PluginEngine.kt | 270 ++++++++++++++++++ .../plugins/host/PluginHookDispatcher.kt | 83 ++++++ .../plugins/host/PluginManifestParser.kt | 33 +++ .../fromchat/plugins/host/ui/PluginUiHost.kt | 55 ++++ .../plugins/host/util/PluginLogger.kt | 10 + .../plugins/host/PluginDevServer.jvm.kt | 85 ++++++ .../plugins/host/PluginPlatform.jvm.kt | 122 ++++++++ plugins/sample-hello/build.gradle.kts | 62 ++++ .../plugins/sample/hello/HelloWorldPlugin.kt | 41 +++ plugins/sdk/build.gradle.kts | 20 ++ .../kotlin/ru/fromchat/plugins/BasePlugin.kt | 83 ++++++ .../kotlin/ru/fromchat/plugins/PluginTypes.kt | 74 +++++ settings.gradle.kts | 10 +- 33 files changed, 1515 insertions(+), 5 deletions(-) create mode 100644 app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/AndroidPluginBootstrap.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt create mode 100644 plugins/host/build.gradle.kts create mode 100644 plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.android.kt create mode 100644 plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/util/PluginLogger.kt create mode 100644 plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.jvm.kt create mode 100644 plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt create mode 100644 plugins/sample-hello/build.gradle.kts create mode 100644 plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt create mode 100644 plugins/sdk/build.gradle.kts create mode 100644 plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt create mode 100644 plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt diff --git a/app/android/proguard-rules.pro b/app/android/proguard-rules.pro index e2812f3..3adf3c4 100644 --- a/app/android/proguard-rules.pro +++ b/app/android/proguard-rules.pro @@ -1,6 +1,13 @@ # Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows. # 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. -keepattributes SourceFile,LineNumberTable diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt index 702e41f..1d58a3b 100644 --- a/app/android/src/main/kotlin/ru/fromchat/App.kt +++ b/app/android/src/main/kotlin/ru/fromchat/App.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.notifications.MessageNotificationCoordinator +import ru.fromchat.plugins.integration.AndroidPluginBootstrap class App : Application() { @OptIn(DelicateCoroutinesApi::class) @@ -16,6 +17,7 @@ class App : Application() { super.onCreate() UtilsLibrary.init(this) MessageNotificationCoordinator.install() + AndroidPluginBootstrap.init(this) GlobalScope.launch(Dispatchers.IO) { runCatching { ApiClient.loadPersistedData() } diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 7b6b784..07a12d9 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -142,6 +142,7 @@ private object DesktopApplicationBootstrap { scope.launch { runCatching { ApiClient.loadPersistedData() } runCatching { AttachmentTransferBootstrap.runColdStart() } + runCatching { ru.fromchat.plugins.integration.DesktopPluginBootstrap.init() } } } } diff --git a/app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin b/app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin new file mode 100644 index 0000000000000000000000000000000000000000..6b07b07ecd9af61738ea9ef43e778b4eacf66cfd GIT binary patch literal 3742 zcma)3xf3C1OX|ADoF1H0V&c1 z1f)n)nsh>0eRg+zc4yZ2oqO&x=X|;5%m0Gkz}rqACKy_^0y1>pez zCBOzaAW$}z7A|NJTbCy&LjzI(AGlIt`|Ce-CkOBdR`CJguktiT&JO=3O|aKRgxb@X z%v&^4c=2NI?s|~eY-v}(OAi*97B8|!Iy#~C7R|%YUCUSjM3g(;%{&(;X|S`p{1ut& zF9;hMfo#84^Km5IG!vP^LaNYXB4Zv!@Rp${v;7wmGWANP=MAl@;r1P)dZXu&dWxx{4Dvj+XuEXyG}5^bsgh)Tyj1Q3 zAGi31;ka_>`bbXT1MgxznHpK>qlSkZwZb|qr$uE``PyvyH}`i?CwkY61|3{*ggQSH zBKw&M@K-{AHUBCkKNCV)xY(heINtbEIKK$LGX@W&2HYbD0Bb4$p#2-c(f+BG4NAlo z;ru67W)i7Hf)@uCd#6K#sV@fk4AHJt)5{V?+X3r_k5@$de9#N8&?jX?a7(IQei=MX z!XNw{6!$jH$Lz=YEN&LERkE9Pc#(18GT@T6b%?`V_zk)YTm%j0zO*0!{DD?2{smg| zK>*Jh&pKh=m=t=IvS{lG0Q6(%F9DYVF1SLz547a3R>`KZT*p_rTiwlF|Mg8xQZSOZ zDk<3Aq_08DM>Tm5>vZFNExVzJKC9&L8Bk+!w8_&RnF zvz<{CM&A61#gBW576(S-+s1EFHW@4((<#WAG>CVx0Wo| z)=?{w*vSXR#*l!`=oF+;*;+-3!izNEu~JbQS}2*~v>(&**e ztZq%lS21Zk1J1PQ9~m5Lb-Sfkb+nhQ0~Ni|rwBwlWj?ofxksv>?$b*Dv(bKjMy9J= zqU%RJ8RdlZ3FaJ}G<~K+bT?oviYW{oHg@%$@bRopRy5Wkc1y;siHKJJO*Y8M%`DjT z<_hbWSN<`Kq3q~Yeb2K;)%}?#T7poPY~h22DYm5y}Y4cX{i5qbY|S*O0F4LjJH_7)AW?BwGZ^j_iG6E{ET_1Fsb;* zG~>${YL$?6RD{(BlpOxU9};0?aVd;_Jl10sgtkU_BHV9PtomczH8{bP!*p4`4{aLr zHd37(r0UMwj-OWcTF)QsOFl?%{ZAt2yxJz}}?w#iux%aL`W>5DtyAb8b&Zs@5WF37| zd9$h?fm%^CQ<=Sy(`jfS+|rqDmEFF(4arr_Y{&!RzOy`-lyZbzxUv`b$yg zb(M%$GJS&N;7TwC%bJN4sc~da@ID)3dn=$?>VaKPZ!XUZK}2ruhNEa+6P<3e>AYG< zP#ia~O3oE2pzpI>B%5p;l3L*F1 ztosU!!<(SQ3M@;1%cfz;HkS0%87%N*PkjrzZNKw4V4ANOrbS?BN2p zF+7pnoHIbkZ$*5x@}a9!@%2zIyo0p#gM4G1)Cy4oHA zURmia54ybU$Ew>C^hfVtLBor;N3OUiFSV1><{v35s(#ve$Q@-?WRD2oFwOAHA2f;S z4G~uL1Z8)y@uSJ^Dk_OQnd6frF{+1Fml`7{OYx>2@dvoQ-rFpflDe@X05#Fj;0X#i zebJNV#^$etw)6N(m6^wO+N2%~t;Jf}QJO$^Iq!u~&JX%Jxobqdc8YxLLyApWy4hhh zy@alP@xa9UUtyC30ctn;!SBQ^w@f!A3`GZ{Qq(eF5^A^Ksy;=DLiTaXbLyX15zjIfXoY6iV!8!1vX>My(FJYsBaO`SP*y01&a94WxV0< ztml0ysjeWBaCl_-`2N(nQkbR_tUabuR2M6cuDA_Pj^Zr27F1xzdJ=q4wtm(vW9+Q8 z-vKg1rqya@J-e@`(k$OmYcT#9*QRyHp}b%4t*%g!lM)mot5IT|9}d!}sM$a10>5aX zEet`2Sq8i(8iWgfApVDuqi-8_GlKGv5$!Z7H!ly#Y`sv`?t>gqgcySG@G$qG{cW6UX;a)YSP$ zX^?)b;G7g4828w!z?*aNXi*@OdI5jFW(X30ka!4{=8)dc>m6`$Z<*m<&?t%F=_^ed z3nDpXhDFR4yOpp!X&xYyV-eC?@)Fr;xX=?=vBJ{}SeAj*qkA?HU5;{iM?N zdB{JEFH|8V(0{rqsJm0pF?GJ1mkW!bBG2AYo(nnTqNY{}lL6IPz)wlYrCydOqK|44XI$AJ>Bo-@|4BjZqVI?(oPMe@FPU)!C=(U ziT)aQ@AI{j<6YmzIc$5qRY80Pm*)q#Z`h|Al{zuC_`cmenb;Pt0v}oYkQSKQl?6j*5FzmUd>>D&x?xu5!m?KVoA%L62x&Yo@u5 z%5kQ{KErC%%}QiUqPXr4)ZIzd->a6lWSG(a{7$%|M^$eh@ee8BN;SQ zSVw8gff9Q=u7p8uzKfNM{krAWgS` znoN$l@VkVY-8hqCzp&cYIa6C9W^Kh$kQpe9oU{#`ZM5oMRox=y+unp|$Z^g;NmG#4 zFgMe|l?9zc4{-`3vln1rGdA-53n-3BSoVbDa?XsPrBp%c8g`@w`15g(Kp~x<*dYIp z;~wu4JO1zU{y!Cp=`U6IX;Z+ffM4x@2^HZ#h3da#N(b@_`M;dwpGW|p0XCGlf0BP2 z$e(Dx7yM8E_&4p>g2=zo{%#|G;{JcOyr2Mp-% 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 } + } +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index dcb2df8..48927e6 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -339,6 +339,16 @@ Отладка API Просмотр ответов API профиля и личных сообщений. + Плагины + Движок плагинов + Запуск Kotlin-плагинов для расширения FromChat. + Режим разработчика + Включает локальный DevServer на порту 42690. + Установленные плагины + Плагины ещё не установлены. + Включено + Настройки плагина + Оформление Тема и Material You Сервер и инструменты diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 4d1fda0..6f7e9f6 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -370,6 +370,16 @@ Debug API Inspect profile and DM endpoints used by the client. + Plugins + Plugin engine + Run Kotlin plugins that extend FromChat. + Developer mode + Enable the local DevServer on port 42690. + Installed plugins + No plugins installed yet. + Enabled + Plugin settings + Appearance Theme and Material You diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index 6a6990b..e5883bc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -197,13 +197,21 @@ object OutgoingMessageCoordinator { clientMessageId: String, 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 conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) markOutboundActive(clientMessageId) withContext(Dispatchers.Default) { MessageRepository.upsertPublicMessage(optimisticMessage) - Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}") - val payload = json.encodeToString(PublicOutboxPayload(content, replyToId)) + Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${finalContent.length}") + val payload = json.encodeToString(PublicOutboxPayload(finalContent, replyToId)) MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( instanceId = instanceId, clientMessageId = clientMessageId, @@ -227,12 +235,20 @@ object OutgoingMessageCoordinator { transportFiles: List = emptyList(), uploadedFileIds: List = 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 conversationId = conversationIdForDm(recipientId) markOutboundActive(clientMessageId) withContext(Dispatchers.Default) { MessageRepository.upsertDmMessage(recipientId, optimisticMessage) - val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId) + val outboundPlaintext = buildDmOutboundPlaintext(finalPlaintext, replyToId) val payload = json.encodeToString( DmOutboxPayload( recipientId = recipientId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 0647c7e..25d7c00 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -534,6 +534,7 @@ fun App( } ScreenSurface { + ru.fromchat.plugins.host.ui.PluginUiHost { Box( Modifier .fillMaxSize() @@ -774,6 +775,7 @@ fun App( ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false }) } } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt index 562258a..ab4b894 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt @@ -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.LogsScreen 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.account.AccountScreen import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen @@ -288,6 +290,22 @@ fun NavGraphBuilder.settingsDetailDestinations( composable(SettingsRoutes.ServerConfig) { ServerConfigScreen() } + + composable(SettingsRoutes.Plugins) { + PluginsScreen() + } + + composable( + route = SettingsRoutes.PluginDetail, + arguments = listOf( + navArgument("pluginId") { type = NavType.StringType }, + ), + ) { entry -> + val pluginId = entry.savedStateHandle.get("pluginId").orEmpty() + if (pluginId.isNotBlank()) { + PluginDetailScreen(pluginId) + } + } } private fun NavBackStackEntry.pathInt(name: String): Int = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt new file mode 100644 index 0000000..02552d5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt @@ -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(), + ) + } + } + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt index 574b1d1..cf869e7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt @@ -20,4 +20,8 @@ object SettingsRoutes { const val About = "about" const val Logs = "settings/logs" 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" } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt index ba6ccf3..320203c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle 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.Notifications 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_d import ru.fromchat.logs_title +import ru.fromchat.plugins_title +import ru.fromchat.plugins_engine_d import ru.fromchat.profile import ru.fromchat.settings import ru.fromchat.settings_category_account @@ -176,7 +179,15 @@ fun SettingsTab() { headline = stringResource(Res.string.logs_title), supportingText = stringResource(Res.string.settings_hub_logs_sub), 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) }, ) } } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt new file mode 100644 index 0000000..0e2a03e --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt @@ -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") + } + } +} diff --git a/plugins/host/build.gradle.kts b/plugins/host/build.gradle.kts new file mode 100644 index 0000000..a9ca9fc --- /dev/null +++ b/plugins/host/build.gradle.kts @@ -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") + } + } +} diff --git a/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.android.kt b/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.android.kt new file mode 100644 index 0000000..f20b43a --- /dev/null +++ b/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.android.kt @@ -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 +} diff --git a/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt b/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt new file mode 100644 index 0000000..a405d6b --- /dev/null +++ b/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt @@ -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() + private val pineHooks = mutableMapOf>() + + 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) + } +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt new file mode 100644 index 0000000..dcb6d29 --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt @@ -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, +) + +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) -> HookResult>)?, + val after: ((Array, Any?) -> HookResult)?, +) + +object SharedMethodHookRegistry { + private val registrations = mutableListOf() + + 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, +) diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.kt new file mode 100644 index 0000000..39376e0 --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.kt @@ -0,0 +1,6 @@ +package ru.fromchat.plugins.host + +expect object PluginDevServer { + fun onDeveloperModeChanged(enabled: Boolean) + fun stop() +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt new file mode 100644 index 0000000..e66cb7f --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt @@ -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 = _engineEnabled.asStateFlow() + + private val _developerMode = MutableStateFlow(false) + val developerMode: StateFlow = _developerMode.asStateFlow() + + private val _installed = MutableStateFlow>(emptyList()) + val installed: StateFlow> = _installed.asStateFlow() + + private val enabledIds = linkedSetOf() + private val loaded = mutableMapOf() + private val settings = mutableMapOf>() + private val sharedUnhooks = mutableMapOf 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, + ) { + 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) -> HookResult>)?, + after: ((Array, Any?) -> HookResult)?, + ): () -> 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 { + 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) { + 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) } + } + } + } +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt new file mode 100644 index 0000000..0a4e667 --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt @@ -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, +) + +object PluginHookDispatcher { + private val sendHooks = mutableListOf() + private val featureOverrides = mutableMapOf Boolean?>>>() + private val menuItems = mutableListOf>() + var bulletinHandler: ((String) -> Unit)? = null + + fun registerSendHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult) { + 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 = + 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 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 + } +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt new file mode 100644 index 0000000..8747e5d --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt @@ -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") +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt new file mode 100644 index 0000000..eb691da --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt @@ -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() + 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, + ) + } + } + } +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/util/PluginLogger.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/util/PluginLogger.kt new file mode 100644 index 0000000..587bfb6 --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/util/PluginLogger.kt @@ -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") + } +} diff --git a/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.jvm.kt b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.jvm.kt new file mode 100644 index 0000000..dece0b6 --- /dev/null +++ b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginDevServer.jvm.kt @@ -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) +} diff --git a/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt new file mode 100644 index 0000000..fa09473 --- /dev/null +++ b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt @@ -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() + private val instances = mutableMapOf() + private val hookHandles = mutableMapOf>() + 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, + @Origin method: Method, + @SuperCall callable: Callable, + ): 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 + } + } +} diff --git a/plugins/sample-hello/build.gradle.kts b/plugins/sample-hello/build.gradle.kts new file mode 100644 index 0000000..7e06f64 --- /dev/null +++ b/plugins/sample-hello/build.gradle.kts @@ -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}") + } +} diff --git a/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt b/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt new file mode 100644 index 0000000..2be48ab --- /dev/null +++ b/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt @@ -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 = 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 { + 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) + } +} diff --git a/plugins/sdk/build.gradle.kts b/plugins/sdk/build.gradle.kts new file mode 100644 index 0000000..3ca646c --- /dev/null +++ b/plugins/sdk/build.gradle.kts @@ -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) + } + } +} diff --git a/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt new file mode 100644 index 0000000..96ecd73 --- /dev/null +++ b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt @@ -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) + 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) -> HookResult>)?, + after: ((Array, Any?) -> HookResult)?, + ): () -> 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 = 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, + ) { + 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) -> HookResult>)? = null, + after: ((Array, Any?) -> HookResult)? = null, + ): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after) +} + +object PluginSettingsReload { + var listener: ((String) -> Unit)? = null + fun request(pluginId: String) { + listener?.invoke(pluginId) + } +} diff --git a/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt new file mode 100644 index 0000000..493a1ad --- /dev/null +++ b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt @@ -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( + 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, +) diff --git a/settings.gradle.kts b/settings.gradle.kts index 9503ea2..bc2b37b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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", +) \ No newline at end of file