mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Add Kotlin plugin system (SDK, host, hooks, settings UI)
Implement exteraGram-style plugin architecture for Android and desktop: - plugins/sdk: BasePlugin API, hook types, settings DSL - plugins/host: PluginEngine, typed send hooks, FeatureGate, HookRegistry - JVM ByteBuddy and Android Pine hook backends - DevServer on port 42690 (JVM) - Plugins settings screens and PluginUiHost bulletin overlay - hello_world sample plugin packaged as .fcplugin - ProGuard keepnames for ru.fromchat.** with shrinking enabled Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Vendored
+7
@@ -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
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -142,6 +142,7 @@ private object DesktopApplicationBootstrap {
|
||||
scope.launch {
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
runCatching { AttachmentTransferBootstrap.runColdStart() }
|
||||
runCatching { ru.fromchat.plugins.integration.DesktopPluginBootstrap.init() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
import android.content.Context
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.plugins.AppEvent
|
||||
import ru.fromchat.plugins.host.FeatureGate
|
||||
import ru.fromchat.plugins.host.PluginEngine
|
||||
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||
import ru.fromchat.plugins.host.initAndroidPluginPlatform
|
||||
import ru.fromchat.plugins.host.ui.PluginBulletinStore
|
||||
import ru.fromchat.plugins.host.util.PluginLogger
|
||||
import java.io.File
|
||||
|
||||
object AndroidPluginBootstrap {
|
||||
fun init(context: Context) {
|
||||
initAndroidPluginPlatform(context)
|
||||
PluginLogger.sink = { tag, message -> Logger.d(tag, message) }
|
||||
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
|
||||
PluginEngine.pluginsRootProvider = {
|
||||
File(context.filesDir, "plugins").apply { mkdirs() }
|
||||
}
|
||||
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
|
||||
PluginEngine.init()
|
||||
PluginEngine.dispatchAppEvent(AppEvent.START)
|
||||
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,16 @@
|
||||
<string name="debug_tools">Отладка API</string>
|
||||
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
|
||||
|
||||
<string name="plugins_title">Плагины</string>
|
||||
<string name="plugins_engine">Движок плагинов</string>
|
||||
<string name="plugins_engine_d">Запуск Kotlin-плагинов для расширения FromChat.</string>
|
||||
<string name="plugins_developer_mode">Режим разработчика</string>
|
||||
<string name="plugins_developer_mode_d">Включает локальный DevServer на порту 42690.</string>
|
||||
<string name="plugins_installed">Установленные плагины</string>
|
||||
<string name="plugins_none_installed">Плагины ещё не установлены.</string>
|
||||
<string name="plugins_enabled">Включено</string>
|
||||
<string name="plugins_settings_title">Настройки плагина</string>
|
||||
|
||||
<string name="settings_category_appearance">Оформление</string>
|
||||
<string name="settings_category_appearance_d">Тема и Material You</string>
|
||||
<string name="settings_category_server_tools">Сервер и инструменты</string>
|
||||
|
||||
@@ -370,6 +370,16 @@
|
||||
<string name="debug_tools">Debug API</string>
|
||||
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
|
||||
|
||||
<string name="plugins_title">Plugins</string>
|
||||
<string name="plugins_engine">Plugin engine</string>
|
||||
<string name="plugins_engine_d">Run Kotlin plugins that extend FromChat.</string>
|
||||
<string name="plugins_developer_mode">Developer mode</string>
|
||||
<string name="plugins_developer_mode_d">Enable the local DevServer on port 42690.</string>
|
||||
<string name="plugins_installed">Installed plugins</string>
|
||||
<string name="plugins_none_installed">No plugins installed yet.</string>
|
||||
<string name="plugins_enabled">Enabled</string>
|
||||
<string name="plugins_settings_title">Plugin settings</string>
|
||||
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_appearance">Appearance</string>
|
||||
<string name="settings_category_appearance_d">Theme and Material You</string>
|
||||
|
||||
+19
-3
@@ -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<SendDmFile> = emptyList(),
|
||||
uploadedFileIds: List<String> = emptyList(),
|
||||
) {
|
||||
val hooked = ru.fromchat.plugins.host.PluginHookDispatcher.interceptSendMessage(
|
||||
ru.fromchat.plugins.SendMessageHookContext(
|
||||
text = plaintext,
|
||||
isDm = true,
|
||||
recipientId = recipientId,
|
||||
),
|
||||
) ?: return
|
||||
val finalPlaintext = hooked.text
|
||||
val instanceId = CacheContext.requireActiveInstanceId()
|
||||
val 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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>("pluginId").orEmpty()
|
||||
if (pluginId.isNotBlank()) {
|
||||
PluginDetailScreen(pluginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavBackStackEntry.pathInt(name: String): Int =
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package ru.fromchat.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import com.pr0gramm3r101.utils.verticalScroll
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.plugins.PluginSetting
|
||||
import ru.fromchat.plugins.host.PluginEngine
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.plugins_title
|
||||
import ru.fromchat.plugins_engine
|
||||
import ru.fromchat.plugins_engine_d
|
||||
import ru.fromchat.plugins_developer_mode
|
||||
import ru.fromchat.plugins_developer_mode_d
|
||||
import ru.fromchat.plugins_installed
|
||||
import ru.fromchat.plugins_none_installed
|
||||
import ru.fromchat.plugins_settings_title
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PluginsScreen() {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val navController = LocalNavController.current
|
||||
val engineEnabled by PluginEngine.engineEnabled.collectAsState()
|
||||
val developerMode by PluginEngine.developerMode.collectAsState()
|
||||
val installed by PluginEngine.installed.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
Text(stringResource(Res.string.plugins_title), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Category(Modifier.padding(top = 16.dp)) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.plugins_engine),
|
||||
supportingText = stringResource(Res.string.plugins_engine_d),
|
||||
trailingContent = {
|
||||
Switch(checked = engineEnabled, onCheckedChange = PluginEngine::setEngineEnabled)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.plugins_developer_mode),
|
||||
supportingText = stringResource(Res.string.plugins_developer_mode_d),
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = developerMode,
|
||||
onCheckedChange = PluginEngine::setDeveloperMode,
|
||||
enabled = engineEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Category(Modifier.padding(top = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.plugins_installed),
|
||||
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
)
|
||||
if (installed.isEmpty()) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.plugins_none_installed),
|
||||
leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) },
|
||||
)
|
||||
} else {
|
||||
installed.forEach { manifest ->
|
||||
ListItem(
|
||||
headline = manifest.name,
|
||||
supportingText = manifest.description.ifBlank { manifest.id },
|
||||
leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = PluginEngine.isPluginEnabled(manifest.id),
|
||||
onCheckedChange = { enabled ->
|
||||
PluginEngine.setPluginEnabled(manifest.id, enabled)
|
||||
},
|
||||
enabled = engineEnabled,
|
||||
)
|
||||
},
|
||||
onClick = { navController.navigate(SettingsRoutes.pluginDetail(manifest.id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PluginDetailScreen(pluginId: String) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val plugin = PluginEngine.loadedPlugin(pluginId)
|
||||
val settings = plugin?.instance?.createSettings().orEmpty()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
plugin?.manifest?.name ?: pluginId,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Category(Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.plugins_settings_title),
|
||||
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
)
|
||||
settings.forEach { setting ->
|
||||
when (setting) {
|
||||
is PluginSetting.Header -> {
|
||||
Text(
|
||||
text = setting.text,
|
||||
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
is PluginSetting.Text -> {
|
||||
ListItem(
|
||||
headline = setting.text,
|
||||
supportingText = setting.subtext,
|
||||
)
|
||||
}
|
||||
is PluginSetting.Switch -> {
|
||||
var checked by remember(pluginId, setting.key) {
|
||||
mutableStateOf(
|
||||
PluginEngine.getPluginSettingBoolean(pluginId, setting.key, setting.default),
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headline = setting.text,
|
||||
supportingText = setting.subtext,
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { value ->
|
||||
checked = value
|
||||
PluginEngine.setPluginSetting(pluginId, setting.key, value.toString())
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
is PluginSetting.Input -> {
|
||||
var text by remember(pluginId, setting.key) {
|
||||
mutableStateOf(
|
||||
PluginEngine.getPluginSettingString(
|
||||
pluginId,
|
||||
setting.key,
|
||||
setting.default,
|
||||
),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { value ->
|
||||
text = value
|
||||
PluginEngine.setPluginSetting(pluginId, setting.key, value)
|
||||
},
|
||||
label = { Text(setting.text) },
|
||||
modifier = Modifier
|
||||
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,8 @@ object SettingsRoutes {
|
||||
const val About = "about"
|
||||
const val 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"
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import android.content.Context
|
||||
import dalvik.system.DexClassLoader
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import top.canyie.pine.Pine
|
||||
import top.canyie.pine.callback.MethodHook
|
||||
import java.io.File
|
||||
|
||||
private lateinit var appContext: Context
|
||||
|
||||
fun initAndroidPluginPlatform(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
actual object PluginPlatform {
|
||||
actual val current: PluginPlatform? get() = this
|
||||
|
||||
private val classLoaders = mutableMapOf<String, DexClassLoader>()
|
||||
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
|
||||
|
||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
|
||||
val clazz = Class.forName(target.className)
|
||||
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
|
||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||
val record = Pine.hook(method, object : MethodHook() {
|
||||
override fun beforeCall(callFrame: Pine.CallFrame) {
|
||||
registration.before?.invoke(callFrame.args)?.let { result ->
|
||||
when (result.strategy) {
|
||||
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
|
||||
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { callFrame.args = it }
|
||||
}
|
||||
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterCall(callFrame: Pine.CallFrame) {
|
||||
registration.after?.invoke(callFrame.args, callFrame.result)?.let { result ->
|
||||
when (result.strategy) {
|
||||
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
|
||||
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { callFrame.setResult(it) }
|
||||
}
|
||||
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
pineHooks.getOrPut(registration.pluginId) { mutableListOf() } += record
|
||||
}
|
||||
|
||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||
pineHooks.remove(registration.pluginId)?.forEach { Pine.unhook(it) }
|
||||
}
|
||||
|
||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||
val artifact = manifest.androidArtifact ?: return null
|
||||
val dex = File(pluginDir, artifact)
|
||||
if (!dex.exists()) return null
|
||||
val optimizedDir = File(appContext.codeCacheDir, "plugins_opt").apply { mkdirs() }
|
||||
val loader = DexClassLoader(
|
||||
dex.absolutePath,
|
||||
optimizedDir.absolutePath,
|
||||
null,
|
||||
appContext.classLoader,
|
||||
)
|
||||
classLoaders[manifest.id] = loader
|
||||
val clazz = loader.loadClass(manifest.entryClass)
|
||||
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
|
||||
return LoadedPlugin(manifest, instance, pluginDir)
|
||||
}
|
||||
|
||||
actual fun unloadPlugin(pluginId: String) {
|
||||
pineHooks.remove(pluginId)?.forEach { Pine.unhook(it) }
|
||||
classLoaders.remove(pluginId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import ru.fromchat.plugins.HookResult
|
||||
|
||||
data class SharedHookTarget(
|
||||
val className: String,
|
||||
val methodName: String,
|
||||
val paramTypeNames: Array<String>,
|
||||
)
|
||||
|
||||
object HookRegistry {
|
||||
const val LOGGER_DEBUG = "logger.debug"
|
||||
|
||||
val targets = mapOf(
|
||||
LOGGER_DEBUG to SharedHookTarget(
|
||||
className = "ru.fromchat.Logger",
|
||||
methodName = "d",
|
||||
paramTypeNames = arrayOf("java.lang.String", "java.lang.String"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class SharedHookRegistration(
|
||||
val pluginId: String,
|
||||
val hookId: String,
|
||||
val priority: Int,
|
||||
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
)
|
||||
|
||||
object SharedMethodHookRegistry {
|
||||
private val registrations = mutableListOf<SharedHookRegistration>()
|
||||
|
||||
fun register(registration: SharedHookRegistration) {
|
||||
registrations += registration
|
||||
registrations.sortByDescending { it.priority }
|
||||
PluginPlatform.current?.installSharedHook(registration)
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
val removed = registrations.filter { it.pluginId == pluginId }
|
||||
registrations.removeAll { it.pluginId == pluginId }
|
||||
removed.forEach { PluginPlatform.current?.uninstallSharedHook(it) }
|
||||
}
|
||||
|
||||
fun targetFor(hookId: String): SharedHookTarget? = HookRegistry.targets[hookId]
|
||||
}
|
||||
|
||||
expect object PluginPlatform {
|
||||
val current: PluginPlatform?
|
||||
fun installSharedHook(registration: SharedHookRegistration)
|
||||
fun uninstallSharedHook(registration: SharedHookRegistration)
|
||||
fun loadPlugin(manifest: ru.fromchat.plugins.PluginManifest, pluginDir: String): LoadedPlugin?
|
||||
fun unloadPlugin(pluginId: String)
|
||||
}
|
||||
|
||||
data class LoadedPlugin(
|
||||
val manifest: ru.fromchat.plugins.PluginManifest,
|
||||
val instance: ru.fromchat.plugins.BasePlugin,
|
||||
val pluginDir: String,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
expect object PluginDevServer {
|
||||
fun onDeveloperModeChanged(enabled: Boolean)
|
||||
fun stop()
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import ru.fromchat.plugins.AppEvent
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.PluginHostBridge
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import ru.fromchat.plugins.PluginSettingsReload
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
import java.io.File
|
||||
|
||||
object PluginEngine : PluginHostBridge {
|
||||
private val _engineEnabled = MutableStateFlow(false)
|
||||
val engineEnabled: StateFlow<Boolean> = _engineEnabled.asStateFlow()
|
||||
|
||||
private val _developerMode = MutableStateFlow(false)
|
||||
val developerMode: StateFlow<Boolean> = _developerMode.asStateFlow()
|
||||
|
||||
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
|
||||
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
|
||||
|
||||
private val enabledIds = linkedSetOf<String>()
|
||||
private val loaded = mutableMapOf<String, LoadedPlugin>()
|
||||
private val settings = mutableMapOf<String, MutableMap<String, String>>()
|
||||
private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>()
|
||||
|
||||
var pluginsRootProvider: () -> File = { error("pluginsRootProvider not set") }
|
||||
var appVersionProvider: () -> String = { "0.0.0" }
|
||||
var onSettingsReload: ((String) -> Unit)? = null
|
||||
|
||||
fun init() {
|
||||
PluginSettingsReload.listener = { pluginId -> onSettingsReload?.invoke(pluginId) }
|
||||
refreshInstalledList()
|
||||
loadEnabledState()
|
||||
if (_engineEnabled.value) {
|
||||
enabledIds.forEach { loadPlugin(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setEngineEnabled(enabled: Boolean) {
|
||||
if (_engineEnabled.value == enabled) return
|
||||
_engineEnabled.value = enabled
|
||||
persistEngineState()
|
||||
if (enabled) {
|
||||
enabledIds.forEach { loadPlugin(it) }
|
||||
} else {
|
||||
loaded.keys.toList().forEach { unloadPlugin(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setDeveloperMode(enabled: Boolean) {
|
||||
_developerMode.value = enabled
|
||||
persistEngineState()
|
||||
PluginDevServer.onDeveloperModeChanged(enabled)
|
||||
}
|
||||
|
||||
fun setPluginEnabled(pluginId: String, enabled: Boolean) {
|
||||
if (enabled) {
|
||||
enabledIds += pluginId
|
||||
if (_engineEnabled.value) loadPlugin(pluginId)
|
||||
} else {
|
||||
enabledIds -= pluginId
|
||||
unloadPlugin(pluginId)
|
||||
}
|
||||
persistEnabledState()
|
||||
refreshInstalledList()
|
||||
}
|
||||
|
||||
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
|
||||
|
||||
fun loadedPlugin(pluginId: String): LoadedPlugin? = loaded[pluginId]
|
||||
|
||||
fun getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
|
||||
getSetting(pluginId, key, default)
|
||||
|
||||
fun getPluginSettingString(pluginId: String, key: String, default: String = ""): String =
|
||||
getSettingString(pluginId, key, default)
|
||||
|
||||
fun setPluginSetting(pluginId: String, key: String, value: String) {
|
||||
setSetting(pluginId, key, value)
|
||||
}
|
||||
|
||||
fun refreshInstalledList() {
|
||||
val root = pluginsRootProvider()
|
||||
if (!root.exists()) {
|
||||
root.mkdirs()
|
||||
}
|
||||
val manifests = root.listFiles()?.filter { it.isDirectory }?.mapNotNull { dir ->
|
||||
val manifestFile = dir.resolve("manifest.json")
|
||||
if (!manifestFile.exists()) return@mapNotNull null
|
||||
runCatching { PluginManifestParser.parse(manifestFile.readText()) }.getOrNull()
|
||||
}.orEmpty().sortedBy { it.name.lowercase() }
|
||||
_installed.value = manifests
|
||||
}
|
||||
|
||||
fun installFcPluginArchive(archiveFile: File) {
|
||||
val temp = pluginsRootProvider().resolve(".install-${System.currentTimeMillis()}")
|
||||
temp.mkdirs()
|
||||
projectZipExtract(archiveFile, temp)
|
||||
val manifestFile = temp.walkTopDown().firstOrNull { it.name == "manifest.json" }
|
||||
?: error("manifest.json not found in archive")
|
||||
val manifest = PluginManifestParser.parse(manifestFile.readText())
|
||||
val target = pluginsRootProvider().resolve(manifest.id)
|
||||
if (target.exists()) target.deleteRecursively()
|
||||
manifestFile.parentFile?.copyRecursively(target, overwrite = true)
|
||||
temp.deleteRecursively()
|
||||
refreshInstalledList()
|
||||
}
|
||||
|
||||
fun dispatchAppEvent(event: AppEvent) {
|
||||
loaded.values.forEach { runCatching { it.instance.onAppEvent(event) } }
|
||||
}
|
||||
|
||||
override fun log(pluginId: String, message: String) {
|
||||
ru.fromchat.plugins.host.util.PluginLogger.log(pluginId, message)
|
||||
}
|
||||
|
||||
override fun getSetting(pluginId: String, key: String, default: Boolean): Boolean =
|
||||
settings[pluginId]?.get(key)?.toBooleanStrictOrNull() ?: default
|
||||
|
||||
override fun getSettingString(pluginId: String, key: String, default: String): String =
|
||||
settings[pluginId]?.get(key) ?: default
|
||||
|
||||
override fun setSetting(pluginId: String, key: String, value: String) {
|
||||
val map = settings.getOrPut(pluginId) { loadSettingsFile(pluginId) }
|
||||
map[key] = value
|
||||
saveSettingsFile(pluginId, map)
|
||||
}
|
||||
|
||||
override fun registerSendMessageHook(
|
||||
pluginId: String,
|
||||
priority: Int,
|
||||
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
) {
|
||||
PluginHookDispatcher.registerSendHook(pluginId, priority, handler)
|
||||
}
|
||||
|
||||
override fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||
PluginHookDispatcher.registerFeatureOverride(pluginId, featureId, provider)
|
||||
}
|
||||
|
||||
override fun registerMenuItem(pluginId: String, item: MenuItemData) {
|
||||
PluginHookDispatcher.registerMenuItem(pluginId, item)
|
||||
}
|
||||
|
||||
override fun showBulletin(message: String) {
|
||||
PluginHookDispatcher.showBulletin(message)
|
||||
}
|
||||
|
||||
override fun hookSharedMethod(
|
||||
pluginId: String,
|
||||
hookId: String,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit {
|
||||
val registration = SharedHookRegistration(pluginId, hookId, priority, before, after)
|
||||
SharedMethodHookRegistry.register(registration)
|
||||
val unhook = {
|
||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||
}
|
||||
sharedUnhooks.getOrPut(pluginId) { mutableListOf() } += unhook
|
||||
return unhook
|
||||
}
|
||||
|
||||
private fun loadPlugin(pluginId: String) {
|
||||
if (loaded.containsKey(pluginId)) return
|
||||
val dir = pluginsRootProvider().resolve(pluginId)
|
||||
val manifestFile = dir.resolve("manifest.json")
|
||||
if (!manifestFile.exists()) return
|
||||
val manifest = PluginManifestParser.parse(manifestFile.readText())
|
||||
val platform = PluginPlatform.current ?: return
|
||||
val loadedPlugin = platform.loadPlugin(manifest, dir.absolutePath) ?: return
|
||||
loadedPlugin.instance.attach(manifest.id, this)
|
||||
settings.putIfAbsent(manifest.id, loadSettingsFile(manifest.id))
|
||||
runCatching { loadedPlugin.instance.onPluginLoad() }
|
||||
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
|
||||
loaded[manifest.id] = loadedPlugin
|
||||
}
|
||||
|
||||
private fun unloadPlugin(pluginId: String) {
|
||||
val plugin = loaded.remove(pluginId) ?: return
|
||||
runCatching { plugin.instance.onPluginUnload() }
|
||||
PluginHookDispatcher.unregisterPlugin(pluginId)
|
||||
sharedUnhooks.remove(pluginId)
|
||||
PluginPlatform.current?.unloadPlugin(pluginId)
|
||||
}
|
||||
|
||||
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
|
||||
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
|
||||
if (!file.exists()) return mutableMapOf()
|
||||
return runCatching {
|
||||
file.readLines()
|
||||
.mapNotNull { line ->
|
||||
val idx = line.indexOf('=')
|
||||
if (idx <= 0) null else line.substring(0, idx) to line.substring(idx + 1)
|
||||
}
|
||||
.toMap()
|
||||
.toMutableMap()
|
||||
}.getOrElse { mutableMapOf() }
|
||||
}
|
||||
|
||||
private fun saveSettingsFile(pluginId: String, map: Map<String, String>) {
|
||||
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
|
||||
file.writeText(map.entries.joinToString("\n") { "${it.key}=${it.value}" })
|
||||
}
|
||||
|
||||
private fun persistEngineState() {
|
||||
val file = pluginsRootProvider().resolve("engine_state.properties")
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(
|
||||
buildString {
|
||||
appendLine("engineEnabled=${_engineEnabled.value}")
|
||||
appendLine("developerMode=${_developerMode.value}")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadEnabledState() {
|
||||
val file = pluginsRootProvider().resolve("engine_state.properties")
|
||||
if (file.exists()) {
|
||||
file.readLines().forEach { line ->
|
||||
val parts = line.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
when (parts[0]) {
|
||||
"engineEnabled" -> _engineEnabled.value = parts[1].toBooleanStrictOrNull() == true
|
||||
"developerMode" -> _developerMode.value = parts[1].toBooleanStrictOrNull() == true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val enabledFile = pluginsRootProvider().resolve("enabled_plugins.txt")
|
||||
if (enabledFile.exists()) {
|
||||
enabledIds.clear()
|
||||
enabledIds += enabledFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistEnabledState() {
|
||||
pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun projectZipExtract(archive: File, dest: File) {
|
||||
java.util.zip.ZipFile(archive).use { zip ->
|
||||
zip.entries().asSequence().forEach { entry ->
|
||||
val out = dest.resolve(entry.name)
|
||||
if (entry.isDirectory) {
|
||||
out.mkdirs()
|
||||
} else {
|
||||
out.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input -> out.outputStream().use { input.copyTo(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureBundledPlugins(bundledDir: File) {
|
||||
if (!bundledDir.exists()) return
|
||||
bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive ->
|
||||
val installed = pluginsRootProvider().resolve("hello_world")
|
||||
if (!installed.exists()) {
|
||||
runCatching { installFcPluginArchive(archive) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
|
||||
data class RegisteredSendHook(
|
||||
val pluginId: String,
|
||||
val priority: Int,
|
||||
val handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
)
|
||||
|
||||
object PluginHookDispatcher {
|
||||
private val sendHooks = mutableListOf<RegisteredSendHook>()
|
||||
private val featureOverrides = mutableMapOf<String, MutableList<Pair<String, () -> Boolean?>>>()
|
||||
private val menuItems = mutableListOf<Pair<String, MenuItemData>>()
|
||||
var bulletinHandler: ((String) -> Unit)? = null
|
||||
|
||||
fun registerSendHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>) {
|
||||
sendHooks += RegisteredSendHook(pluginId, priority, handler)
|
||||
sendHooks.sortByDescending { it.priority }
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
sendHooks.removeAll { it.pluginId == pluginId }
|
||||
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
|
||||
menuItems.removeAll { it.first == pluginId }
|
||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||
}
|
||||
|
||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||
featureOverrides.getOrPut(featureId) { mutableListOf() } += pluginId to provider
|
||||
}
|
||||
|
||||
fun registerMenuItem(pluginId: String, item: MenuItemData) {
|
||||
menuItems += pluginId to item
|
||||
}
|
||||
|
||||
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
|
||||
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }.map { it.second }
|
||||
|
||||
fun featureOverrideValue(id: String): Boolean? {
|
||||
featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
|
||||
provider()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun interceptSendMessage(context: SendMessageHookContext): SendMessageHookContext? {
|
||||
var current = context
|
||||
for (hook in sendHooks) {
|
||||
val result = hook.handler(current)
|
||||
when (result.strategy) {
|
||||
HookStrategy.DEFAULT -> Unit
|
||||
HookStrategy.CANCEL -> return null
|
||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { current = it }
|
||||
if (result.strategy == HookStrategy.MODIFY_FINAL) return current
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun showBulletin(message: String) {
|
||||
bulletinHandler?.invoke(message)
|
||||
}
|
||||
}
|
||||
|
||||
object FeatureGate {
|
||||
private val defaults = mutableMapOf<String, () -> Boolean>()
|
||||
var pluginResolver: ((String, Boolean) -> Boolean)? = null
|
||||
|
||||
fun registerDefault(id: String, provider: () -> Boolean) {
|
||||
defaults[id] = provider
|
||||
}
|
||||
|
||||
fun isEnabled(id: String, default: Boolean = true): Boolean {
|
||||
PluginHookDispatcher.featureOverrideValue(id)?.let { return it }
|
||||
return defaults[id]?.invoke() ?: default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
|
||||
object PluginManifestParser {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun parse(raw: String): PluginManifest {
|
||||
val root = json.parseToJsonElement(raw).jsonObject
|
||||
val artifacts = root["artifacts"]?.jsonObject
|
||||
return PluginManifest(
|
||||
id = root.requireString("id"),
|
||||
name = root.requireString("name"),
|
||||
description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0",
|
||||
appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
entryClass = root.requireString("entry_class"),
|
||||
androidArtifact = artifacts?.get("android")?.jsonPrimitive?.contentOrNull,
|
||||
desktopArtifact = artifacts?.get("desktop")?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.requireString(key: String): String =
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?: error("manifest missing $key")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.fromchat.plugins.host.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class PluginBulletin(val message: String, val id: Long)
|
||||
|
||||
object PluginBulletinStore {
|
||||
val bulletins = mutableStateListOf<PluginBulletin>()
|
||||
private var nextId = 0L
|
||||
|
||||
fun show(message: String) {
|
||||
val id = ++nextId
|
||||
bulletins += PluginBulletin(message, id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PluginUiHost(content: @Composable () -> Unit) {
|
||||
val bulletins = remember { PluginBulletinStore.bulletins }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
bulletins.lastOrNull()?.let { bulletin ->
|
||||
LaunchedEffect(bulletin.id) {
|
||||
delay(4_000)
|
||||
bulletins.remove(bulletin)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
tonalElevation = 6.dp,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = bulletin.message,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.fromchat.plugins.host.util
|
||||
|
||||
object PluginLogger {
|
||||
var sink: ((tag: String, message: String) -> Unit)? = null
|
||||
|
||||
fun log(pluginId: String, message: String) {
|
||||
val tag = "Plugin:$pluginId"
|
||||
sink?.invoke(tag, message) ?: println("$tag $message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
|
||||
actual object PluginDevServer {
|
||||
private var serverJob: Job? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
actual fun onDeveloperModeChanged(enabled: Boolean) {
|
||||
if (enabled) start() else stop()
|
||||
}
|
||||
|
||||
actual fun stop() {
|
||||
serverJob?.cancel()
|
||||
serverJob = null
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (serverJob != null) return
|
||||
serverJob = scope.launch {
|
||||
runCatching {
|
||||
ServerSocket(42690, 50, InetAddress.getByName("127.0.0.1")).use { server ->
|
||||
while (true) {
|
||||
val socket = server.accept()
|
||||
scope.launch { handleClient(socket) }
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
PluginEngine.log("devserver", "failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClient(socket: Socket) {
|
||||
socket.use { client ->
|
||||
val reader = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||
val writer = PrintWriter(client.getOutputStream(), true)
|
||||
val line = reader.readLine() ?: return
|
||||
val requestId = Regex("\"#\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1) ?: "0"
|
||||
val command = Regex("\"@\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)
|
||||
when (command) {
|
||||
"ping" -> writer.println("""{"#":"$requestId","pong":true}""")
|
||||
"get_plugins" -> {
|
||||
val plugins = PluginEngine.installed.value.joinToString(",") { manifest ->
|
||||
"""{"id":"${manifest.id}","enabled":${PluginEngine.isPluginEnabled(manifest.id)}}"""
|
||||
}
|
||||
writer.println("""{"#":"$requestId","plugins":[$plugins]}""")
|
||||
}
|
||||
"enable_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
|
||||
PluginEngine.setPluginEnabled(it, true)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
"disable_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
|
||||
PluginEngine.setPluginEnabled(it, false)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
"reload_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let { id ->
|
||||
PluginEngine.setPluginEnabled(id, false)
|
||||
PluginEngine.setPluginEnabled(id, true)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
else -> writer.println("""{"#":"$requestId","error":"unknown command"}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PluginEngine.log(tag: String, message: String) {
|
||||
ru.fromchat.plugins.host.util.PluginLogger.log(tag, message)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import net.bytebuddy.agent.ByteBuddyAgent
|
||||
import net.bytebuddy.implementation.MethodDelegation
|
||||
import net.bytebuddy.implementation.bind.annotation.AllArguments
|
||||
import net.bytebuddy.implementation.bind.annotation.Origin
|
||||
import net.bytebuddy.implementation.bind.annotation.RuntimeType
|
||||
import net.bytebuddy.implementation.bind.annotation.SuperCall
|
||||
import net.bytebuddy.matcher.ElementMatchers
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
import java.net.URLClassLoader
|
||||
import java.util.concurrent.Callable
|
||||
import net.bytebuddy.ByteBuddy
|
||||
|
||||
actual object PluginPlatform {
|
||||
actual val current: PluginPlatform? get() = this
|
||||
|
||||
private val classLoaders = mutableMapOf<String, URLClassLoader>()
|
||||
private val instances = mutableMapOf<String, BasePlugin>()
|
||||
private val hookHandles = mutableMapOf<String, MutableList<SharedHookHandle>>()
|
||||
private var agentInstalled = false
|
||||
|
||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
|
||||
val handle = JvmSharedHookHandle(registration, target)
|
||||
handle.install()
|
||||
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
|
||||
}
|
||||
|
||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
|
||||
}
|
||||
|
||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||
val artifact = manifest.desktopArtifact ?: return null
|
||||
val jar = File(pluginDir, artifact)
|
||||
if (!jar.exists()) return null
|
||||
val loader = URLClassLoader(arrayOf(jar.toURI().toURL()), BasePlugin::class.java.classLoader)
|
||||
classLoaders[manifest.id] = loader
|
||||
val clazz = loader.loadClass(manifest.entryClass)
|
||||
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
|
||||
instances[manifest.id] = instance
|
||||
return LoadedPlugin(manifest, instance, pluginDir)
|
||||
}
|
||||
|
||||
actual fun unloadPlugin(pluginId: String) {
|
||||
hookHandles.remove(pluginId)?.forEach { it.uninstall() }
|
||||
instances.remove(pluginId)
|
||||
classLoaders.remove(pluginId)?.close()
|
||||
}
|
||||
|
||||
private fun ensureAgent() {
|
||||
if (agentInstalled) return
|
||||
runCatching { ByteBuddyAgent.install() }
|
||||
agentInstalled = true
|
||||
}
|
||||
|
||||
private interface SharedHookHandle {
|
||||
val registration: SharedHookRegistration
|
||||
fun install()
|
||||
fun uninstall()
|
||||
}
|
||||
|
||||
private class JvmSharedHookHandle(
|
||||
override val registration: SharedHookRegistration,
|
||||
private val target: SharedHookTarget,
|
||||
) : SharedHookHandle {
|
||||
private var installed = false
|
||||
|
||||
override fun install() {
|
||||
if (installed) return
|
||||
ensureAgent()
|
||||
val clazz = Class.forName(target.className)
|
||||
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
|
||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||
ByteBuddy()
|
||||
.redefine(clazz)
|
||||
.method(ElementMatchers.named(target.methodName))
|
||||
.intercept(MethodDelegation.to(JvmHookInterceptor(registration)))
|
||||
.make()
|
||||
.load(clazz.classLoader)
|
||||
installed = true
|
||||
}
|
||||
|
||||
override fun uninstall() {
|
||||
installed = false
|
||||
}
|
||||
}
|
||||
|
||||
class JvmHookInterceptor(private val registration: SharedHookRegistration) {
|
||||
@RuntimeType
|
||||
fun intercept(
|
||||
@AllArguments args: Array<Any?>,
|
||||
@Origin method: Method,
|
||||
@SuperCall callable: Callable<Any?>,
|
||||
): Any? {
|
||||
registration.before?.let { before ->
|
||||
val result = before(args)
|
||||
when (result.strategy) {
|
||||
HookStrategy.CANCEL -> return null
|
||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> result.value?.let { return@intercept callable.call() }
|
||||
HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
val value = callable.call()
|
||||
registration.after?.let { after ->
|
||||
val result = after(args, value)
|
||||
when (result.strategy) {
|
||||
HookStrategy.CANCEL -> return null
|
||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> return result.value
|
||||
HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ru.fromchat.plugins.sample.hello
|
||||
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.PluginSetting
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
|
||||
class HelloWorldPlugin : BasePlugin() {
|
||||
override fun onPluginLoad() {
|
||||
log("Hello World plugin loaded")
|
||||
showBulletin("Hello World plugin is active")
|
||||
addOnSendMessageHook { context ->
|
||||
onSendMessage(context)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createSettings(): List<PluginSetting> = listOf(
|
||||
PluginSetting.Header("Hello World"),
|
||||
PluginSetting.Input(
|
||||
key = "template",
|
||||
text = "Greeting template",
|
||||
default = "Hello, {name}!",
|
||||
subtext = "Use {name} for the entered name",
|
||||
),
|
||||
PluginSetting.Text(
|
||||
text = "Send .hello Alice in a chat",
|
||||
subtext = "Example: .hello Alice -> Hello, Alice!",
|
||||
),
|
||||
)
|
||||
|
||||
private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
|
||||
val raw = context.text.trim()
|
||||
if (!raw.startsWith(".hello")) return HookResult()
|
||||
val parts = raw.split(" ", limit = 2)
|
||||
val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" }
|
||||
val template = getSettingString("template", "Hello, {name}!")
|
||||
context.text = template.replace("{name}", name)
|
||||
return HookResult(strategy = HookStrategy.MODIFY, value = context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
androidLibrary {
|
||||
namespace = "ru.fromchat.plugins.sdk"
|
||||
compileSdk = 37
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ru.fromchat.plugins
|
||||
|
||||
interface PluginHostBridge {
|
||||
fun log(pluginId: String, message: String)
|
||||
fun getSetting(pluginId: String, key: String, default: Boolean): Boolean
|
||||
fun getSettingString(pluginId: String, key: String, default: String): String
|
||||
fun setSetting(pluginId: String, key: String, value: String)
|
||||
fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>)
|
||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?)
|
||||
fun registerMenuItem(pluginId: String, item: MenuItemData)
|
||||
fun showBulletin(message: String)
|
||||
fun hookSharedMethod(
|
||||
pluginId: String,
|
||||
hookId: String,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit
|
||||
}
|
||||
|
||||
abstract class BasePlugin {
|
||||
lateinit var pluginId: String
|
||||
internal set
|
||||
private lateinit var bridge: PluginHostBridge
|
||||
|
||||
fun attach(id: String, hostBridge: PluginHostBridge) {
|
||||
pluginId = id
|
||||
bridge = hostBridge
|
||||
}
|
||||
|
||||
open fun onPluginLoad() {}
|
||||
open fun onPluginUnload() {}
|
||||
open fun onAppEvent(event: AppEvent) {}
|
||||
open fun createSettings(): List<PluginSetting> = emptyList()
|
||||
|
||||
protected fun log(message: String) = bridge.log(pluginId, message)
|
||||
|
||||
protected fun getSetting(key: String, default: Boolean = false): Boolean =
|
||||
bridge.getSetting(pluginId, key, default)
|
||||
|
||||
protected fun getSettingString(key: String, default: String = ""): String =
|
||||
bridge.getSettingString(pluginId, key, default)
|
||||
|
||||
protected fun setSetting(key: String, value: String, reloadSettings: Boolean = false) {
|
||||
bridge.setSetting(pluginId, key, value)
|
||||
if (reloadSettings) {
|
||||
PluginSettingsReload.request(pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun addOnSendMessageHook(
|
||||
priority: Int = 0,
|
||||
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
) {
|
||||
bridge.registerSendMessageHook(pluginId, priority, handler)
|
||||
}
|
||||
|
||||
protected fun registerFeatureOverride(featureId: String, provider: () -> Boolean?) {
|
||||
bridge.registerFeatureOverride(pluginId, featureId, provider)
|
||||
}
|
||||
|
||||
protected fun addMenuItem(item: MenuItemData) {
|
||||
bridge.registerMenuItem(pluginId, item)
|
||||
}
|
||||
|
||||
protected fun showBulletin(message: String) {
|
||||
bridge.showBulletin(message)
|
||||
}
|
||||
|
||||
protected fun hookShared(
|
||||
hookId: String,
|
||||
priority: Int = 0,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
|
||||
): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after)
|
||||
}
|
||||
|
||||
object PluginSettingsReload {
|
||||
var listener: ((String) -> Unit)? = null
|
||||
fun request(pluginId: String) {
|
||||
listener?.invoke(pluginId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.fromchat.plugins
|
||||
|
||||
enum class AppEvent {
|
||||
START,
|
||||
STOP,
|
||||
PAUSE,
|
||||
RESUME,
|
||||
}
|
||||
|
||||
enum class HookStrategy {
|
||||
DEFAULT,
|
||||
CANCEL,
|
||||
MODIFY,
|
||||
MODIFY_FINAL,
|
||||
}
|
||||
|
||||
data class HookResult<T>(
|
||||
val strategy: HookStrategy = HookStrategy.DEFAULT,
|
||||
val value: T? = null,
|
||||
)
|
||||
|
||||
data class SendMessageHookContext(
|
||||
var text: String,
|
||||
val isDm: Boolean,
|
||||
val recipientId: Int?,
|
||||
)
|
||||
|
||||
enum class MenuItemType {
|
||||
MESSAGE_CONTEXT,
|
||||
CHAT_ACTION,
|
||||
PROFILE_ACTION,
|
||||
SETTINGS_ROW,
|
||||
}
|
||||
|
||||
data class MenuItemData(
|
||||
val menuType: MenuItemType,
|
||||
val text: String,
|
||||
val subtext: String? = null,
|
||||
val priority: Int = 0,
|
||||
val onClickKey: String = "",
|
||||
)
|
||||
|
||||
sealed class PluginSetting {
|
||||
data class Header(val text: String) : PluginSetting()
|
||||
data class Switch(
|
||||
val key: String,
|
||||
val text: String,
|
||||
val default: Boolean = false,
|
||||
val subtext: String? = null,
|
||||
) : PluginSetting()
|
||||
data class Input(
|
||||
val key: String,
|
||||
val text: String,
|
||||
val default: String = "",
|
||||
val subtext: String? = null,
|
||||
) : PluginSetting()
|
||||
data class Text(
|
||||
val text: String,
|
||||
val subtext: String? = null,
|
||||
) : PluginSetting()
|
||||
}
|
||||
|
||||
data class PluginManifest(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val author: String = "",
|
||||
val version: String = "1.0.0",
|
||||
val appVersion: String = "",
|
||||
val sdkVersion: String = "",
|
||||
val entryClass: String,
|
||||
val androidArtifact: String? = null,
|
||||
val desktopArtifact: String? = null,
|
||||
)
|
||||
+9
-1
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user