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 0000000..6b07b07
Binary files /dev/null and b/app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin differ
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
index 5f75ac8..477be5d 100644
--- a/app/shared/build.gradle.kts
+++ b/app/shared/build.gradle.kts
@@ -165,6 +165,7 @@ kotlin {
}
androidMain.dependencies {
+ implementation(project(":plugins:host"))
implementation(libs.androidx.core.ktx)
implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov)
@@ -200,6 +201,7 @@ kotlin {
} else {
implementation(compose.desktop.currentOs)
}
+ implementation(project(":plugins:host"))
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio)
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/AndroidPluginBootstrap.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/AndroidPluginBootstrap.kt
new file mode 100644
index 0000000..0435442
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/AndroidPluginBootstrap.kt
@@ -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 }
+ }
+}
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