6 Commits

  • Fix raw hooks: plugin-side hookRawMethod with Advice instrumentation
    Remove the planted isContactsTabVisible() hook target and app-side visibility
    conditionals (that was cheating). Plugins now declare raw JVM targets directly
    via hookRawMethod on existing methods like MainScreenKt.selectMainPage,
    ContactsTabKt.ContactsTab, NavigationBarKt.NavigationBarItem, and Logger.d.
    
    Replace ByteBuddy MethodDelegation (schema-changing) with Advice-based
    ClassReloadingStrategy so hooks actually install at runtime. Fix Logger hook
    signature for Kotlin object instance methods. Add chat banner z-index fix and
    PluginEngine.hostRevision to recompose MainScreen after plugin load.
    
    Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
  • Restyle plugins UI with Material 3 expressive tokens
    Replace hardcoded exteraGram colors with theme-aware cards, SearchBar,
    SettingsDetailTopBar, ExpressiveIconFrame, and tonal action buttons.
    
    Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
  • Redesign plugins UI and demo raw + high-level hooks
    - Match exteraGram-style card layout with master toggle and plugin actions
    - Add usage manifest field, pin/delete/removePlugin engine APIs
    - Hello World plugin hooks Logger.d (raw) and send pipeline (high-level)
    - Bundle updated hello_world.fcplugin; stop debug auto-enable
    
    Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
  • Fix plugin settings UI toggles and debug desktop auto-enable
    Use SwitchListItem for visible engine/plugin switches, add Plugins to
    pre-auth overflow menu, auto-enable hello_world in debug desktop builds.
    
    Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
  • 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>
  • Fix Gradle and Rust cache save failures on parallel release jobs.
    Use scope-specific Gradle cache paths, global release concurrency, Rust v3 key with v1/v2 fallbacks, and ensure Android SDK cache dirs exist before save.
    
    Co-authored-by: Cursor <cursoragent@cursor.com>
51 changed files with 2560 additions and 33 deletions
+18 -1
View File
@@ -9,7 +9,7 @@ inputs:
outputs:
cache-hit:
description: Whether an exact cache key match was restored
value: ${{ steps.cache.outputs.cache-hit }}
value: ${{ steps.cache.outputs.cache-hit == 'true' || steps.cache-android.outputs.cache-hit == 'true' }}
cache-save-scope:
description: Scope to pass to the save action (always this job's scope, not a restored fallback key)
value: ${{ inputs.scope }}
@@ -18,6 +18,23 @@ runs:
using: composite
steps:
- uses: actions/cache/restore@v5
if: inputs.scope == 'android'
id: cache-android
with:
path: |
.gradle
build
app/shared/build
app/android/build
utils/shared/build
utils/android/build
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
restore-keys: |
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-build-jar-
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-
- uses: actions/cache/restore@v5
if: inputs.scope != 'android'
id: cache
with:
path: |
@@ -16,6 +16,9 @@ runs:
~/.cargo/registry
~/.cargo/git
app/desktop/windows-setup/target
key: windows-rust-${{ runner.os }}-v2-${{ hashFiles('app/desktop/windows-setup/Cargo.lock', 'app/desktop/windows-setup/Cargo.toml', 'app/desktop/windows-setup/**/src/**', 'app/desktop/windows-setup/**/build.rs') }}
key: windows-rust-${{ runner.os }}-v3-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
restore-keys: |
windows-rust-${{ runner.os }}-v3-
windows-rust-${{ runner.os }}-v2-
windows-rust-tools-v1-
cargo-registry-${{ runner.os }}-
@@ -9,7 +9,22 @@ inputs:
runs:
using: composite
steps:
- uses: actions/cache/save@v5
- name: Save Android Gradle build cache
if: inputs.scope == 'android'
uses: actions/cache/save@v5
continue-on-error: true
with:
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
path: |
.gradle
build
app/shared/build
app/android/build
utils/shared/build
utils/android/build
- name: Save Gradle build cache
if: inputs.scope != 'android'
uses: actions/cache/save@v5
continue-on-error: true
with:
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
@@ -7,7 +7,7 @@ runs:
- uses: actions/cache/save@v5
continue-on-error: true
with:
key: windows-rust-${{ runner.os }}-v2-${{ hashFiles('app/desktop/windows-setup/Cargo.lock', 'app/desktop/windows-setup/Cargo.toml', 'app/desktop/windows-setup/**/src/**', 'app/desktop/windows-setup/**/build.rs') }}
key: windows-rust-${{ runner.os }}-v3-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
path: |
~/.cargo/registry
~/.cargo/git
+3 -1
View File
@@ -28,7 +28,7 @@ on:
default: "false"
concurrency:
group: release-${{ github.ref }}
group: release
cancel-in-progress: true
env:
@@ -414,6 +414,8 @@ jobs:
id: gradle-build-cache
with:
scope: android
- name: Prepare Android SDK cache dirs
run: mkdir -p ~/.android/build-cache ~/.android/cache
- name: Cache Android SDK extras
uses: actions/cache@v5
with:
+7
View File
@@ -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() }
}
}
}
+2
View File
@@ -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)
@@ -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 }
}
}
@@ -0,0 +1,5 @@
package ru.fromchat.plugins.integration
actual object PluginInstallSupport {
actual fun installFromPicker(): Boolean = false
}
@@ -339,6 +339,27 @@
<string name="debug_tools">Отладка API</string>
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
<string name="plugins_title">Плагины</string>
<string name="plugins_enable_system">Включить систему плагинов</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_no_results">Нет плагинов по вашему запросу.</string>
<string name="plugins_enabled">Включено</string>
<string name="plugins_settings_title">Настройки плагина</string>
<string name="plugins_search">Поиск плагинов</string>
<string name="plugins_add">Добавить плагин</string>
<string name="plugins_usage">Использование:</string>
<string name="plugins_version_author">Версия %1$s • @%2$s</string>
<string name="plugins_delete">Удалить</string>
<string name="plugins_delete_confirm">Удалить «%1$s» и все его данные?</string>
<string name="plugins_share_copied">Информация о плагине скопирована.</string>
<string name="plugins_info_title">О плагинах</string>
<string name="plugins_info_body">Плагины могут менять интерфейс, перехватывать сообщения и подключаться к внутренним методам приложения. Устанавливайте плагины только из проверенных источников.</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,27 @@
<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_enable_system">Enable plugin system</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_no_results">No plugins match your search.</string>
<string name="plugins_enabled">Enabled</string>
<string name="plugins_settings_title">Plugin settings</string>
<string name="plugins_search">Search plugins</string>
<string name="plugins_add">Add plugin</string>
<string name="plugins_usage">Usage:</string>
<string name="plugins_version_author">Version %1$s • @%2$s</string>
<string name="plugins_delete">Delete</string>
<string name="plugins_delete_confirm">Remove \"%1$s\" and all its data?</string>
<string name="plugins_share_copied">Plugin info copied to clipboard.</string>
<string name="plugins_info_title">About plugins</string>
<string name="plugins_info_body">Plugins can modify UI, intercept messages, and hook internal app methods. Only install plugins from sources you trust.</string>
<!-- Settings categories -->
<string name="settings_category_appearance">Appearance</string>
<string name="settings_category_appearance_d">Theme and Material You</string>
@@ -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,
@@ -0,0 +1,5 @@
package ru.fromchat.plugins.integration
expect object PluginInstallSupport {
fun installFromPicker(): Boolean
}
@@ -0,0 +1,79 @@
package ru.fromchat.plugins.integration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.ListItem
import ru.fromchat.plugins.MenuItemType
import ru.fromchat.plugins.host.FeatureGate
import ru.fromchat.plugins.host.PluginHookDispatcher
import ru.fromchat.plugins.host.ui.PluginOverlayStore
import ru.fromchat.ui.components.Text
@Composable
fun PluginOverlayBanner(
slot: String,
modifier: Modifier = Modifier,
) {
val storeOverlays = PluginOverlayStore.overlays
val overlays = storeOverlays.filter { it.slot == slot }
if (overlays.isEmpty()) return
Column(modifier = modifier.fillMaxWidth()) {
overlays.forEach { overlay ->
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
text = overlay.title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = overlay.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f),
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
}
@Composable
fun PluginSettingsMenuItems(
showDividerBeforeFirst: Boolean,
showDividerAfterLast: Boolean,
) {
val items = PluginHookDispatcher.menuItemEntriesFor(MenuItemType.SETTINGS_ROW)
items.forEachIndexed { index, (_, item) ->
ListItem(
headline = item.text,
supportingText = item.subtext,
leadingContent = { androidx.compose.material3.Icon(Icons.Default.Extension, contentDescription = null) },
onClick = { PluginHookDispatcher.dispatchMenuItemClick(item.onClickKey) },
divider = when {
index < items.lastIndex -> true
showDividerAfterLast -> true
else -> false
}.let { divider ->
divider || (index == 0 && showDividerBeforeFirst)
},
)
}
}
fun pluginFeatureEnabled(featureId: String, default: Boolean = true): Boolean =
FeatureGate.isEnabled(featureId, default)
@@ -534,6 +534,7 @@ fun App(
}
ScreenSurface {
ru.fromchat.plugins.host.ui.PluginUiHost {
Box(
Modifier
.fillMaxSize()
@@ -780,6 +781,7 @@ fun App(
}
}
}
}
@Composable
private fun SettingsDetailBackStackObserver(
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.BugReport
@@ -36,6 +37,7 @@ import ru.fromchat.more
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.plugins_title
import ru.fromchat.ui.main.settings.SettingsRoutes
/** Margin between the window edge and the wide [AuthContentFrame] panel. */
@@ -105,6 +107,16 @@ fun PreAuthOverflowMenu() {
navController.navigate(SettingsRoutes.About)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.plugins_title)) },
leadingIcon = {
Icon(Icons.Default.Extension, contentDescription = null)
},
onClick = {
menuExpanded = false
navController.navigate(SettingsRoutes.Plugins)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.logs_title)) },
leadingIcon = {
@@ -55,6 +55,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text
import ru.fromchat.plugins.integration.pluginFeatureEnabled
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -568,6 +569,7 @@ fun ChatInput(
.padding(horizontal = 6.dp, vertical = 0.dp),
verticalAlignment = Alignment.Bottom,
) {
if (pluginFeatureEnabled("chat.emoji_picker")) {
Box(
modifier = Modifier
.padding(vertical = ChatInputIconSlotVerticalInset)
@@ -582,6 +584,7 @@ fun ChatInput(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
TextStyle(
@@ -162,6 +162,7 @@ import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.plugins.integration.PluginOverlayBanner
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.formatLastSeen
@@ -1410,6 +1411,14 @@ fun ChatScreen(
item { Spacer(modifier.height(floatingHeaderClearance)) }
}
PluginOverlayBanner(
slot = "chat.banner",
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(2f)
.padding(top = innerPadding.calculateTopPadding()),
)
val showScrollToBottomFab = !isNearBottom &&
panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material3.Icon
@@ -70,6 +71,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile
import ru.fromchat.api.local.download.resolveSavableMessageImage
import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.plugins.MenuItemType
import ru.fromchat.plugins.host.PluginHookDispatcher
import ru.fromchat.ui.components.Text
data class ContextMenuState(
@@ -275,6 +278,7 @@ fun MessageContextMenu(
onRetrySend(it)
onDismiss()
},
onDismiss = onDismiss,
modifier = modifier
.then(
if (lockedMenuWidthPx > 0) {
@@ -327,6 +331,7 @@ private fun ContextMenuContent(
onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit,
onRetrySend: (Message) -> Unit,
onDismiss: () -> Unit,
isReadOnly: Boolean = false,
modifier: Modifier,
animated: Boolean,
@@ -419,6 +424,16 @@ private fun ContextMenuContent(
)
}
}
PluginHookDispatcher.menuItemEntriesFor(MenuItemType.MESSAGE_CONTEXT).forEach { (_, item) ->
ChatStyleContextMenuItem(
icon = Icons.Rounded.Extension,
text = item.text,
onClick = {
PluginHookDispatcher.dispatchMenuItemClick(item.onClickKey)
onDismiss()
},
)
}
}
}
}
@@ -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 =
@@ -82,7 +82,9 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.ui.profile.ProfileScreen
import androidx.compose.runtime.collectAsState
const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1
@@ -259,6 +261,8 @@ fun MainScreen(
)
}
val pluginHostRevision by PluginEngine.hostRevision.collectAsState()
val chatsLabel = stringResource(Res.string.chats)
val contactsLabel = stringResource(Res.string.contacts)
val settingsLabel = stringResource(Res.string.settings)
@@ -0,0 +1,598 @@
package ru.fromchat.ui.main.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.components.SwitchListItem
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import com.pr0gramm3r101.utils.verticalScroll
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.back
import ru.fromchat.cancel
import ru.fromchat.plugins.PluginManifest
import ru.fromchat.plugins.PluginSetting
import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.plugins.integration.PluginInstallSupport
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.ExpressiveIconFrame
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.Text
import ru.fromchat.plugins_add
import ru.fromchat.plugins_delete
import ru.fromchat.plugins_delete_confirm
import ru.fromchat.plugins_developer_mode
import ru.fromchat.plugins_developer_mode_d
import ru.fromchat.plugins_enable_system
import ru.fromchat.plugins_engine_d
import ru.fromchat.plugins_info_body
import ru.fromchat.plugins_info_title
import ru.fromchat.plugins_no_results
import ru.fromchat.plugins_none_installed
import ru.fromchat.plugins_search
import ru.fromchat.plugins_settings_title
import ru.fromchat.plugins_share_copied
import ru.fromchat.plugins_title
import ru.fromchat.plugins_usage
import ru.fromchat.plugins_version_author
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PluginsScreen() {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val clipboard = supportClipboardManagerImpl
val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
val engineEnabled by PluginEngine.engineEnabled.collectAsState()
val developerMode by PluginEngine.developerMode.collectAsState()
val installed by PluginEngine.installed.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var showInfoDialog by remember { mutableStateOf(false) }
var deleteTarget by remember { mutableStateOf<PluginManifest?>(null) }
var shareNotice by remember { mutableStateOf(false) }
val filtered = remember(installed, searchQuery) {
val sorted = PluginEngine.sortedInstalled()
if (searchQuery.isBlank()) {
sorted
} else {
val query = searchQuery.trim().lowercase()
sorted.filter {
it.name.lowercase().contains(query) ||
it.description.lowercase().contains(query) ||
it.author.lowercase().contains(query) ||
it.id.lowercase().contains(query)
}
}
}
if (showInfoDialog) {
AlertDialog(
onDismissRequest = { showInfoDialog = false },
title = { Text(stringResource(Res.string.plugins_info_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(Res.string.plugins_info_body),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(Res.string.plugins_engine_d),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
SwitchListItem(
headline = stringResource(Res.string.plugins_developer_mode),
supportingText = stringResource(Res.string.plugins_developer_mode_d),
checked = developerMode,
onCheckedChange = PluginEngine::setDeveloperMode,
enabled = engineEnabled,
)
}
},
confirmButton = {
TextButton(onClick = { showInfoDialog = false }) {
Text(stringResource(Res.string.back))
}
},
)
}
deleteTarget?.let { manifest ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(Res.string.plugins_delete)) },
text = { Text(stringResource(Res.string.plugins_delete_confirm, manifest.name)) },
confirmButton = {
TextButton(
onClick = {
PluginEngine.removePlugin(manifest.id)
deleteTarget = null
},
) {
Text(
stringResource(Res.string.plugins_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(Res.string.cancel))
}
},
)
}
if (shareNotice) {
AlertDialog(
onDismissRequest = { shareNotice = false },
text = { Text(stringResource(Res.string.plugins_share_copied)) },
confirmButton = {
TextButton(onClick = { shareNotice = false }) {
Text(stringResource(Res.string.back))
}
},
)
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SettingsDetailTopBar(
title = {
Text(
stringResource(Res.string.plugins_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onBack = { navController.navigateUp() },
scrollBehavior = scrollBehavior,
)
},
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll()
.padding(innerPadding)
.padding(horizontal = 16.dp),
) {
SearchBar(
query = searchQuery,
onQueryChange = { searchQuery = it },
onSearch = {},
placeholder = stringResource(Res.string.plugins_search),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(onClick = { PluginInstallSupport.installFromPicker() }) {
Icon(
Icons.Outlined.Add,
contentDescription = stringResource(Res.string.plugins_add),
)
}
IconButton(onClick = { showInfoDialog = true }) {
Icon(
Icons.Default.Info,
contentDescription = stringResource(Res.string.plugins_info_title),
)
}
}
PluginEngineMasterCard(
checked = engineEnabled,
onCheckedChange = PluginEngine::setEngineEnabled,
)
Spacer(Modifier.height(16.dp))
if (filtered.isEmpty()) {
Text(
text = if (installed.isEmpty()) {
stringResource(Res.string.plugins_none_installed)
} else {
stringResource(Res.string.plugins_no_results)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 24.dp),
)
} else {
filtered.forEach { manifest ->
PluginCard(
manifest = manifest,
engineEnabled = engineEnabled,
pinned = PluginEngine.isPluginPinned(manifest.id),
enabled = PluginEngine.isPluginEnabled(manifest.id),
onEnabledChange = { PluginEngine.setPluginEnabled(manifest.id, it) },
onOpenSettings = {
navController.navigate(SettingsRoutes.pluginDetail(manifest.id))
},
onShare = {
scope.launch {
clipboard.setText("${manifest.name} (${manifest.id}) v${manifest.version}")
shareNotice = true
}
},
onTogglePin = {
PluginEngine.setPluginPinned(manifest.id, !PluginEngine.isPluginPinned(manifest.id))
},
onDelete = { deleteTarget = manifest },
modifier = Modifier.padding(bottom = 12.dp),
)
}
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun PluginEngineMasterCard(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(Res.string.plugins_enable_system),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = stringResource(Res.string.plugins_engine_d),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
modifier = Modifier.padding(top = 4.dp),
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun PluginCard(
manifest: PluginManifest,
engineEnabled: Boolean,
pinned: Boolean,
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
onOpenSettings: () -> Unit,
onShare: () -> Unit,
onTogglePin: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
ExpressiveIconFrame(
icon = Icons.Default.Extension,
containerSize = 48.dp,
iconSize = 24.dp,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
materialPolygon = MaterialShapes.SoftBurst,
)
Switch(
checked = enabled,
onCheckedChange = onEnabledChange,
enabled = engineEnabled,
)
}
Spacer(Modifier.height(12.dp))
Text(
text = manifest.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
val authorLabel = manifest.author.removePrefix("@")
Text(
text = if (authorLabel.isNotBlank()) {
stringResource(
Res.string.plugins_version_author,
manifest.version,
authorLabel,
)
} else {
manifest.version
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
if (manifest.description.isNotBlank()) {
Text(
text = manifest.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 12.dp),
)
}
val usage = manifest.usage.takeIf { it.isNotBlank() }
if (usage != null) {
Text(
text = stringResource(Res.string.plugins_usage),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = usage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(onClick = onShare) {
Icon(Icons.Default.Share, contentDescription = null)
}
FilledTonalIconButton(onClick = onOpenSettings) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
}
FilledTonalIconButton(onClick = onTogglePin) {
Icon(
imageVector = if (pinned) Icons.Default.PushPin else Icons.Outlined.PushPin,
contentDescription = null,
tint = if (pinned) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(Modifier.weight(1f))
IconButton(
onClick = onDelete,
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.plugins_delete),
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PluginDetailScreen(pluginId: String) {
val navController = LocalNavController.current
val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
val plugin = PluginEngine.loadedPlugin(pluginId)
val settings = plugin?.instance?.createSettings().orEmpty()
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background,
topBar = {
SettingsDetailTopBar(
title = {
Text(
plugin?.manifest?.name ?: pluginId,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onBack = { navController.navigateUp() },
scrollBehavior = scrollBehavior,
)
},
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll()
.padding(innerPadding),
) {
if (plugin != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ExpressiveIconFrame(
icon = Icons.Default.Extension,
materialPolygon = MaterialShapes.Cookie6Sided,
)
Spacer(Modifier.height(8.dp))
Text(
text = plugin.manifest.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
val authorLabel = plugin.manifest.author.removePrefix("@")
if (authorLabel.isNotBlank()) {
Text(
text = stringResource(
Res.string.plugins_version_author,
plugin.manifest.version,
authorLabel,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
Category(
modifier = Modifier.padding(top = 8.dp),
margin = PaddingValues(horizontal = 16.dp, vertical = 0.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Text(
text = stringResource(Res.string.plugins_settings_title),
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
settings.forEach { setting ->
when (setting) {
is PluginSetting.Header -> {
Text(
text = setting.text,
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
}
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),
)
}
SwitchListItem(
headline = setting.text,
supportingText = setting.subtext,
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)
.fillMaxWidth(),
)
}
}
}
}
}
}
}
@@ -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
@@ -61,6 +64,9 @@ import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset
import ru.fromchat.ui.main.navigateReplacingMainDetail
import ru.fromchat.plugins.integration.PluginOverlayBanner
import ru.fromchat.plugins.integration.PluginSettingsMenuItems
import ru.fromchat.plugins.integration.pluginFeatureEnabled
val SettingsStepHorizontalPadding = 24.dp
@@ -109,6 +115,8 @@ fun SettingsTab() {
) {
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
PluginOverlayBanner(slot = "settings.banner")
if (isTwoPane) {
Category(Modifier.padding(top = 16.dp)) {
ListItem(
@@ -132,6 +140,7 @@ fun SettingsTab() {
divider = true
)
if (pluginFeatureEnabled("settings.devices")) {
ListItem(
headline = stringResource(Res.string.settings_category_devices),
supportingText = stringResource(Res.string.settings_category_devices_d),
@@ -139,6 +148,7 @@ fun SettingsTab() {
leadingContent = { Icon(Icons.Filled.Devices, null) },
divider = true
)
}
ListItem(
headline = stringResource(Res.string.settings_category_appearance),
@@ -172,11 +182,26 @@ fun SettingsTab() {
divider = true
)
if (pluginFeatureEnabled("settings.logs")) {
ListItem(
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,
)
}
PluginSettingsMenuItems(
showDividerBeforeFirst = true,
showDividerAfterLast = true,
)
ListItem(
headline = stringResource(Res.string.plugins_title),
supportingText = stringResource(Res.string.plugins_engine_d),
onClick = { openDetail(SettingsRoutes.Plugins) },
leadingContent = { Icon(Icons.Filled.Extension, null) },
)
}
}
@@ -0,0 +1,5 @@
package ru.fromchat.plugins.integration
actual object PluginInstallSupport {
actual fun installFromPicker(): Boolean = false
}
@@ -0,0 +1,47 @@
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 }
PluginEngine.init()
installBundledPlugin(classLoader)
PluginEngine.refreshInstalledList()
PluginEngine.dispatchAppEvent(AppEvent.START)
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
}
private fun installBundledPlugin(classLoader: ClassLoader) {
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 {
val bundledDir = temp.parentFile!!.resolve("bundled")
bundledDir.mkdirs()
temp.copyTo(bundledDir.resolve("hello_world.fcplugin"), overwrite = true)
PluginEngine.ensureBundledPlugins(bundledDir)
}.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,23 @@
package ru.fromchat.plugins.integration
import ru.fromchat.plugins.host.PluginEngine
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
actual object PluginInstallSupport {
actual fun installFromPicker(): Boolean {
val dialog = FileDialog(null as Frame?, "Install .fcplugin", FileDialog.LOAD).apply {
file = "*.fcplugin"
isVisible = true
}
val selected = dialog.file ?: return false
val directory = dialog.directory ?: return false
val file = File(directory, selected)
if (!file.exists() || file.extension.lowercase() != "fcplugin") return false
return runCatching {
PluginEngine.installFcPluginArchive(file)
true
}.getOrDefault(false)
}
}
+35
View File
@@ -0,0 +1,35 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.multiplatform.library)
}
kotlin {
android {
namespace = "ru.fromchat.plugins.host"
compileSdk = 37
minSdk = 24
}
jvm()
sourceSets {
commonMain.dependencies {
api(project(":plugins:sdk"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
}
jvmMain.dependencies {
implementation("net.bytebuddy:byte-buddy:1.15.11")
implementation("net.bytebuddy:byte-buddy-agent:1.15.11")
}
androidMain.dependencies {
implementation("top.canyie.pine:core:0.3.0")
}
}
}
@@ -0,0 +1,9 @@
package ru.fromchat.plugins.host
actual object PluginDevServer {
actual fun onDeveloperModeChanged(enabled: Boolean) {
// Android DevServer wiring can mirror JVM; disabled in initial pass.
}
actual fun stop() = Unit
}
@@ -0,0 +1,81 @@
package ru.fromchat.plugins.host
import android.content.Context
import dalvik.system.DexClassLoader
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.PluginManifest
import top.canyie.pine.Pine
import top.canyie.pine.callback.MethodHook
import java.io.File
private lateinit var appContext: Context
fun initAndroidPluginPlatform(context: Context) {
appContext = context.applicationContext
}
actual object PluginPlatform {
actual val current: PluginPlatform? get() = this
private val classLoaders = mutableMapOf<String, DexClassLoader>()
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
actual fun installSharedHook(registration: SharedHookRegistration) {
val target = registration.target
val clazz = Class.forName(target.className)
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(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,15 @@
package ru.fromchat.plugins.host
internal fun resolveJvmParameterType(typeName: String): Class<*> =
when (typeName) {
"boolean" -> java.lang.Boolean.TYPE
"byte" -> java.lang.Byte.TYPE
"char" -> java.lang.Character.TYPE
"short" -> java.lang.Short.TYPE
"int" -> Integer.TYPE
"long" -> java.lang.Long.TYPE
"float" -> java.lang.Float.TYPE
"double" -> java.lang.Double.TYPE
"void" -> java.lang.Void.TYPE
else -> Class.forName(typeName)
}
@@ -0,0 +1,62 @@
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", "java.lang.Throwable"),
),
)
}
data class SharedHookRegistration(
val pluginId: String,
val hookId: String,
val target: SharedHookTarget,
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,379 @@
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 _hostRevision = MutableStateFlow(0)
val hostRevision: StateFlow<Int> = _hostRevision.asStateFlow()
private val enabledIds = linkedSetOf<String>()
private val pinnedIds = 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) }
}
bumpHostRevision()
}
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()
bumpHostRevision()
}
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
fun isPluginPinned(pluginId: String): Boolean = pluginId in pinnedIds
fun setPluginPinned(pluginId: String, pinned: Boolean) {
if (pinned) {
pinnedIds += pluginId
} else {
pinnedIds -= pluginId
}
persistPinnedState()
refreshInstalledList()
}
fun sortedInstalled(): List<PluginManifest> {
val manifests = _installed.value
return manifests.sortedWith(
compareByDescending<PluginManifest> { it.id in pinnedIds }
.thenBy { it.name.lowercase() },
)
}
fun removePlugin(pluginId: String) {
unloadPlugin(pluginId)
enabledIds -= pluginId
pinnedIds -= pluginId
settings.remove(pluginId)
val dir = pluginsRootProvider().resolve(pluginId)
if (dir.exists()) {
dir.deleteRecursively()
}
persistEnabledState()
persistPinnedState()
refreshInstalledList()
}
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 registerUiOverlay(pluginId: String, slot: String, title: String, message: String) {
ru.fromchat.plugins.host.ui.PluginOverlayStore.register(pluginId, slot, title, message)
}
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 target = SharedMethodHookRegistry.targetFor(hookId)
?: run {
log(pluginId, "Unknown shared hook id: $hookId")
return {}
}
return registerSharedHook(pluginId, hookId, target, priority, before, after)
}
override fun hookRawMethod(
pluginId: String,
className: String,
methodName: String,
paramTypeNames: Array<String>,
priority: Int,
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> Unit {
val hookId = "raw:$className#$methodName"
val target = SharedHookTarget(className, methodName, paramTypeNames)
return registerSharedHook(pluginId, hookId, target, priority, before, after)
}
private fun registerSharedHook(
pluginId: String,
hookId: String,
target: SharedHookTarget,
priority: Int,
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> Unit {
val registration = SharedHookRegistration(pluginId, hookId, target, 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
bumpHostRevision()
}
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)
bumpHostRevision()
}
private fun bumpHostRevision() {
_hostRevision.value++
}
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() }
}
val pinnedFile = pluginsRootProvider().resolve("pinned_plugins.txt")
if (pinnedFile.exists()) {
pinnedIds.clear()
pinnedIds += pinnedFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
}
}
private fun persistEnabledState() {
pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n"))
}
private fun persistPinnedState() {
pluginsRootProvider().resolve("pinned_plugins.txt").writeText(pinnedIds.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 manifestInArchive = readManifestFromArchive(archive) ?: return@forEach
val installedDir = pluginsRootProvider().resolve(manifestInArchive.id)
val installedManifest = installedDir.resolve("manifest.json")
val shouldInstall = when {
!installedManifest.exists() -> true
else -> {
val current = runCatching {
PluginManifestParser.parse(installedManifest.readText())
}.getOrNull()
current == null || current.version != manifestInArchive.version
}
}
if (shouldInstall) {
runCatching { installFcPluginArchive(archive) }
}
}
}
private fun readManifestFromArchive(archive: File): PluginManifest? {
return runCatching {
java.util.zip.ZipFile(archive).use { zip ->
val entry = zip.getEntry("manifest.json") ?: return null
zip.getInputStream(entry).bufferedReader().use { PluginManifestParser.parse(it.readText()) }
}
}.getOrNull()
}
}
@@ -0,0 +1,94 @@
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
import ru.fromchat.plugins.host.ui.PluginOverlayStore
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)
PluginOverlayStore.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 menuItemEntriesFor(type: ru.fromchat.plugins.MenuItemType): List<Pair<String, MenuItemData>> =
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
menuItemEntriesFor(type).map { it.second }
fun dispatchMenuItemClick(clickKey: String) {
if (clickKey.isBlank()) return
val owner = menuItems.firstOrNull { it.second.onClickKey == clickKey } ?: return
PluginEngine.loadedPlugin(owner.first)?.instance?.onMenuItemClick(clickKey)
}
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,34 @@
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",
usage = root["usage"]?.jsonPrimitive?.contentOrNull.orEmpty(),
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,26 @@
package ru.fromchat.plugins.host.ui
import androidx.compose.runtime.mutableStateListOf
data class PluginUiOverlay(
val pluginId: String,
val slot: String,
val title: String,
val message: String,
)
object PluginOverlayStore {
val overlays = mutableStateListOf<PluginUiOverlay>()
fun register(pluginId: String, slot: String, title: String, message: String) {
overlays.removeAll { it.pluginId == pluginId && it.slot == slot }
overlays += PluginUiOverlay(pluginId, slot, title, message)
}
fun unregisterPlugin(pluginId: String) {
overlays.removeAll { it.pluginId == pluginId }
}
fun overlaysFor(slot: String): List<PluginUiOverlay> =
overlays.filter { it.slot == slot }
}
@@ -0,0 +1,57 @@
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.firstOrNull()?.let { bulletin ->
LaunchedEffect(bulletin.id) {
delay(4_000)
if (bulletins.firstOrNull()?.id == bulletin.id) {
bulletins.removeAt(0)
}
}
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,102 @@
package ru.fromchat.plugins.host
import net.bytebuddy.ByteBuddy
import net.bytebuddy.agent.ByteBuddyAgent
import net.bytebuddy.asm.Advice
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
import net.bytebuddy.matcher.ElementMatchers
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.PluginManifest
import ru.fromchat.plugins.host.util.PluginLogger
import java.io.File
import java.lang.reflect.Method
import java.net.URLClassLoader
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 handle = JvmSharedHookHandle(registration, registration.target)
handle.install()
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
}
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
HookAdviceBindings.unbind(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
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
private var boundMethod: Method? = null
override fun install() {
if (installed) return
runCatching {
ensureAgent()
val clazz = Class.forName(target.className)
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
HookAdviceBindings.bind(method, registration)
boundMethod = method
ByteBuddy()
.redefine(clazz)
.visit(Advice.to(SharedHookAdvice::class.java).on(ElementMatchers.`is`(method)))
.make()
.load(clazz.classLoader, ClassReloadingStrategy.fromInstalledAgent())
installed = true
}.onFailure {
boundMethod?.let { HookAdviceBindings.unbind(registration) }
boundMethod = null
PluginLogger.log(
registration.pluginId,
"Raw hook install failed for ${target.className}.${target.methodName}: ${it.message}",
)
}
}
override fun uninstall() {
HookAdviceBindings.unbind(registration)
boundMethod = null
installed = false
}
}
}
@@ -0,0 +1,64 @@
package ru.fromchat.plugins.host
import net.bytebuddy.asm.Advice
import ru.fromchat.plugins.HookStrategy
import java.lang.reflect.Method
import java.util.concurrent.ConcurrentHashMap
internal object HookAdviceBindings {
private val byMethodKey = ConcurrentHashMap<String, SharedHookRegistration>()
fun bind(method: Method, registration: SharedHookRegistration) {
byMethodKey[key(method)] = registration
}
fun unbind(registration: SharedHookRegistration) {
byMethodKey.entries.removeIf { it.value == registration }
}
fun registration(method: Method): SharedHookRegistration? = byMethodKey[key(method)]
private fun key(method: Method): String =
buildString {
append(method.declaringClass.name)
append('#')
append(method.name)
append('(')
method.parameterTypes.forEachIndexed { index, type ->
if (index > 0) append(',')
append(type.name)
}
append(')')
}
}
internal class SharedHookAdvice {
companion object {
@JvmStatic
@Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue::class)
fun onEnter(
@Advice.AllArguments arguments: Array<Any?>,
@Advice.Origin method: Method,
): Boolean {
val registration = HookAdviceBindings.registration(method) ?: return false
registration.before?.let { before ->
when (before(arguments).strategy) {
HookStrategy.CANCEL -> return true
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL, HookStrategy.DEFAULT -> Unit
}
}
return false
}
@JvmStatic
@Advice.OnMethodExit(onThrowable = Throwable::class)
fun onExit(
@Advice.AllArguments arguments: Array<Any?>,
@Advice.Origin method: Method,
@Advice.Enter skipOriginal: Boolean,
) {
if (skipOriginal) return
HookAdviceBindings.registration(method)?.after?.invoke(arguments, null)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
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": "Demonstrates high-level send hooks, raw Logger.d hooks, and raw main-nav hooks.",
"author": "FromChat",
"version": "1.0.3",
"usage": "Raw-hooks existing MainScreen/ContactsTab JVM methods (no app hook IDs). Also hides Devices/Logs settings and emoji; injects settings row, message menu item, and chat banner. Send .hello Name to test send hooks.",
"app_version": ">=1.0.0",
"sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
"artifacts": {
"android": "android/plugin.dex",
"desktop": "desktop/plugin.jar"
}
}
""".trimIndent(),
)
val archive = buildDir.resolve("distributions/hello_world.fcplugin")
archive.parentFile.mkdirs()
if (archive.exists()) archive.delete()
val zipProcess = ProcessBuilder("zip", "-r", archive.absolutePath, ".")
.directory(staging)
.inheritIO()
.start()
check(zipProcess.waitFor() == 0) { "zip failed" }
println("Created ${archive.absolutePath}")
}
}
@@ -0,0 +1,198 @@
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.MenuItemData
import ru.fromchat.plugins.MenuItemType
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")
registerUiOverrides()
registerInjectedUi()
registerRawHooks()
addOnSendMessageHook { context ->
onSendMessage(context)
}
hookShared(
hookId = "logger.debug",
before = { args ->
val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult()
val message = args.getOrNull(1) as? String ?: return@hookShared HookResult()
if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) {
showBulletin("Raw hook: intercepted outgoing message pipeline")
}
HookResult()
},
)
}
override fun onMenuItemClick(key: String) {
when (key) {
MENU_SETTINGS_DEMO -> showBulletin("Injected settings row clicked")
MENU_MESSAGE_DEMO -> showBulletin("Injected message menu clicked")
}
}
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 registerRawHooks() {
hookRawMethod(
className = "ru.fromchat.ui.main.MainScreenKt",
methodName = "selectMainPage",
paramTypeNames = arrayOf(
"int",
"com.pr0gramm3r101.utils.WindowWidthSizeClass",
"kotlinx.coroutines.CoroutineScope",
"androidx.compose.foundation.pager.PagerState",
"androidx.navigation.NavController",
),
before = { args ->
val page = (args.getOrNull(0) as? Number)?.toInt() ?: return@hookRawMethod HookResult()
if (page == MAIN_PAGE_CONTACTS) {
if (!contactsNavBlockedNotified) {
contactsNavBlockedNotified = true
showBulletin("Raw hook: blocked Contacts tab navigation")
}
HookResult(strategy = HookStrategy.CANCEL)
} else {
HookResult()
}
},
)
hookRawMethod(
className = "ru.fromchat.ui.main.ContactsTabKt",
methodName = "ContactsTab",
paramTypeNames = arrayOf(
"androidx.compose.runtime.Composer",
"int",
),
before = { _ ->
HookResult(strategy = HookStrategy.CANCEL)
},
)
hookRawMethod(
className = "ru.fromchat.ui.main.MainScreenKt",
methodName = "MainScreen",
paramTypeNames = arrayOf(
"androidx.compose.animation.SharedTransitionScope",
"androidx.compose.animation.AnimatedVisibilityScope",
"androidx.compose.material3.SnackbarHostState",
"boolean",
"int",
"boolean",
"kotlin.jvm.functions.Function1",
"androidx.compose.runtime.Composer",
"int",
"int",
),
before = { _ ->
mainScreenNavItemOrdinal = 0
HookResult()
},
)
hookRawMethod(
className = "androidx.compose.material3.NavigationBarKt",
methodName = "NavigationBarItem",
paramTypeNames = arrayOf(
"androidx.compose.foundation.layout.RowScope",
"boolean",
"kotlin.jvm.functions.Function0",
"kotlin.jvm.functions.Function2",
"androidx.compose.ui.Modifier",
"boolean",
"kotlin.jvm.functions.Function2",
"boolean",
"androidx.compose.material3.NavigationBarItemColors",
"androidx.compose.foundation.interaction.MutableInteractionSource",
"androidx.compose.runtime.Composer",
"int",
"int",
),
before = { _ ->
mainScreenNavItemOrdinal += 1
if (mainScreenNavItemOrdinal == CONTACTS_NAV_ITEM_ORDINAL) {
HookResult(strategy = HookStrategy.CANCEL)
} else {
HookResult()
}
},
)
}
private fun registerUiOverrides() {
registerFeatureOverride(FEATURE_SETTINGS_DEVICES) { false }
registerFeatureOverride(FEATURE_SETTINGS_LOGS) { false }
registerFeatureOverride(FEATURE_CHAT_EMOJI) { false }
}
private fun registerInjectedUi() {
registerUiOverlay(
slot = OVERLAY_CHAT_BANNER,
title = "Hello World plugin UI",
message = "This banner was injected by the plugin engine.",
)
addMenuItem(
MenuItemData(
menuType = MenuItemType.SETTINGS_ROW,
text = "Hello World demo action",
subtext = "Injected settings row from plugin",
priority = 10,
onClickKey = MENU_SETTINGS_DEMO,
),
)
addMenuItem(
MenuItemData(
menuType = MenuItemType.MESSAGE_CONTEXT,
text = "Hello World message action",
subtext = "Injected message menu item",
priority = 10,
onClickKey = MENU_MESSAGE_DEMO,
),
)
}
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)
showBulletin("High-level hook: rewrote .hello command")
return HookResult(strategy = HookStrategy.MODIFY, value = context)
}
private companion object {
const val MAIN_PAGE_CONTACTS = 1
const val CONTACTS_NAV_ITEM_ORDINAL = 2
const val FEATURE_SETTINGS_DEVICES = "settings.devices"
const val FEATURE_SETTINGS_LOGS = "settings.logs"
const val FEATURE_CHAT_EMOJI = "chat.emoji_picker"
const val OVERLAY_CHAT_BANNER = "chat.banner"
const val MENU_SETTINGS_DEMO = "hello_world.settings_demo"
const val MENU_MESSAGE_DEMO = "hello_world.message_demo"
@Volatile
var mainScreenNavItemOrdinal: Int = 0
@Volatile
var contactsNavBlockedNotified: Boolean = false
}
}
+20
View File
@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.multiplatform.library)
}
kotlin {
jvm()
androidLibrary {
namespace = "ru.fromchat.plugins.sdk"
compileSdk = 37
minSdk = 24
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
}
}
}
@@ -0,0 +1,116 @@
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 registerUiOverlay(pluginId: String, slot: String, title: String, message: String)
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
fun hookRawMethod(
pluginId: String,
className: String,
methodName: String,
paramTypeNames: Array<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 onMenuItemClick(key: String) {}
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 registerUiOverlay(slot: String, title: String, message: String) {
bridge.registerUiOverlay(pluginId, slot, title, message)
}
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)
protected fun hookRawMethod(
className: String,
methodName: String,
paramTypeNames: Array<String> = emptyArray(),
priority: Int = 0,
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
): () -> Unit = bridge.hookRawMethod(
pluginId,
className,
methodName,
paramTypeNames,
priority,
before,
after,
)
}
object PluginSettingsReload {
var listener: ((String) -> Unit)? = null
fun request(pluginId: String) {
listener?.invoke(pluginId)
}
}
@@ -0,0 +1,75 @@
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 usage: String = "",
val appVersion: String = "",
val sdkVersion: String = "",
val entryClass: String,
val androidArtifact: String? = null,
val desktopArtifact: String? = null,
)
+9 -1
View File
@@ -42,5 +42,13 @@ dependencyResolutionManagement {
}
}
include(":app:shared", ":utils:shared", ":app:android", ":app:desktop")
include(
":app:shared",
":utils:shared",
":app:android",
":app:desktop",
":plugins:sdk",
":plugins:host",
":plugins:sample-hello",
)