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>
This commit is contained in:
Cursor Agent
2026-09-17 00:17:25 +00:00
Unverified
parent ab4f6fcc53
commit 1ff4503be9
21 changed files with 536 additions and 89 deletions
@@ -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)
@@ -55,6 +55,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.plugins.integration.pluginFeatureEnabled
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -568,19 +569,21 @@ fun ChatInput(
.padding(horizontal = 6.dp, vertical = 0.dp), .padding(horizontal = 6.dp, vertical = 0.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
Box( if (pluginFeatureEnabled("chat.emoji_picker")) {
modifier = Modifier Box(
.padding(vertical = ChatInputIconSlotVerticalInset) modifier = Modifier
.size(ChatInputIconSlotSize) .padding(vertical = ChatInputIconSlotVerticalInset)
.clip(CircleShape) .size(ChatInputIconSlotSize)
.clickable(onClick = { /* emoji picker to be wired */ }), .clip(CircleShape)
contentAlignment = Alignment.Center, .clickable(onClick = { /* emoji picker to be wired */ }),
) { contentAlignment = Alignment.Center,
Icon( ) {
imageVector = Icons.Rounded.SentimentSatisfied, Icon(
contentDescription = cdEmoji, imageVector = Icons.Rounded.SentimentSatisfied,
tint = MaterialTheme.colorScheme.onSurfaceVariant, contentDescription = cdEmoji,
) tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge( val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
@@ -162,6 +162,7 @@ import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.plugins.integration.PluginOverlayBanner
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
@@ -1410,6 +1411,14 @@ fun ChatScreen(
item { Spacer(modifier.height(floatingHeaderClearance)) } item { Spacer(modifier.height(floatingHeaderClearance)) }
} }
PluginOverlayBanner(
slot = "chat.banner",
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(2f)
.padding(top = innerPadding.calculateTopPadding()),
)
val showScrollToBottomFab = !isNearBottom && val showScrollToBottomFab = !isNearBottom &&
panelState.messages.isNotEmpty() && panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen !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.ContentCopy
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit 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.Refresh
import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material3.Icon 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.download.resolveSavableMessageImage
import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.plugins.MenuItemType
import ru.fromchat.plugins.host.PluginHookDispatcher
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
data class ContextMenuState( data class ContextMenuState(
@@ -275,6 +278,7 @@ fun MessageContextMenu(
onRetrySend(it) onRetrySend(it)
onDismiss() onDismiss()
}, },
onDismiss = onDismiss,
modifier = modifier modifier = modifier
.then( .then(
if (lockedMenuWidthPx > 0) { if (lockedMenuWidthPx > 0) {
@@ -327,6 +331,7 @@ private fun ContextMenuContent(
onSave: (Message) -> Unit, onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit, onCancelSend: (Message) -> Unit,
onRetrySend: (Message) -> Unit, onRetrySend: (Message) -> Unit,
onDismiss: () -> Unit,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
modifier: Modifier, modifier: Modifier,
animated: Boolean, 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()
},
)
}
} }
} }
} }
@@ -82,7 +82,9 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsSearchScreen import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import androidx.compose.runtime.collectAsState
const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1 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 chatsLabel = stringResource(Res.string.chats)
val contactsLabel = stringResource(Res.string.contacts) val contactsLabel = stringResource(Res.string.contacts)
val settingsLabel = stringResource(Res.string.settings) val settingsLabel = stringResource(Res.string.settings)
@@ -64,6 +64,9 @@ import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset import ru.fromchat.ui.main.mainPagerBottomInset
import ru.fromchat.ui.main.navigateReplacingMainDetail 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 val SettingsStepHorizontalPadding = 24.dp
@@ -112,6 +115,8 @@ fun SettingsTab() {
) { ) {
Spacer(Modifier.height(innerPadding.calculateTopPadding())) Spacer(Modifier.height(innerPadding.calculateTopPadding()))
PluginOverlayBanner(slot = "settings.banner")
if (isTwoPane) { if (isTwoPane) {
Category(Modifier.padding(top = 16.dp)) { Category(Modifier.padding(top = 16.dp)) {
ListItem( ListItem(
@@ -135,13 +140,15 @@ fun SettingsTab() {
divider = true divider = true
) )
ListItem( if (pluginFeatureEnabled("settings.devices")) {
headline = stringResource(Res.string.settings_category_devices), ListItem(
supportingText = stringResource(Res.string.settings_category_devices_d), headline = stringResource(Res.string.settings_category_devices),
onClick = { openDetail(SettingsRoutes.Devices) }, supportingText = stringResource(Res.string.settings_category_devices_d),
leadingContent = { Icon(Icons.Filled.Devices, null) }, onClick = { openDetail(SettingsRoutes.Devices) },
divider = true leadingContent = { Icon(Icons.Filled.Devices, null) },
) divider = true
)
}
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_appearance), headline = stringResource(Res.string.settings_category_appearance),
@@ -175,12 +182,19 @@ fun SettingsTab() {
divider = true divider = true
) )
ListItem( if (pluginFeatureEnabled("settings.logs")) {
headline = stringResource(Res.string.logs_title), ListItem(
supportingText = stringResource(Res.string.settings_hub_logs_sub), headline = stringResource(Res.string.logs_title),
onClick = { openDetail(SettingsRoutes.Logs) }, supportingText = stringResource(Res.string.settings_hub_logs_sub),
leadingContent = { Icon(Icons.Outlined.BugReport, null) }, onClick = { openDetail(SettingsRoutes.Logs) },
divider = true, leadingContent = { Icon(Icons.Outlined.BugReport, null) },
divider = true,
)
}
PluginSettingsMenuItems(
showDividerBeforeFirst = true,
showDividerAfterLast = true,
) )
ListItem( ListItem(
@@ -15,8 +15,9 @@ object DesktopPluginBootstrap {
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } } PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } }
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version } PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
installBundledPlugin(classLoader)
PluginEngine.init() PluginEngine.init()
installBundledPlugin(classLoader)
PluginEngine.refreshInstalledList()
PluginEngine.dispatchAppEvent(AppEvent.START) PluginEngine.dispatchAppEvent(AppEvent.START)
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled } FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
} }
@@ -21,9 +21,9 @@ actual object PluginPlatform {
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>() private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
actual fun installSharedHook(registration: SharedHookRegistration) { actual fun installSharedHook(registration: SharedHookRegistration) {
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return val target = registration.target
val clazz = Class.forName(target.className) val clazz = Class.forName(target.className)
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray() val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes) val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
val record = Pine.hook(method, object : MethodHook() { val record = Pine.hook(method, object : MethodHook() {
override fun beforeCall(callFrame: Pine.CallFrame) { override fun beforeCall(callFrame: Pine.CallFrame) {
@@ -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)
}
@@ -15,7 +15,7 @@ object HookRegistry {
LOGGER_DEBUG to SharedHookTarget( LOGGER_DEBUG to SharedHookTarget(
className = "ru.fromchat.Logger", className = "ru.fromchat.Logger",
methodName = "d", methodName = "d",
paramTypeNames = arrayOf("java.lang.String", "java.lang.String"), paramTypeNames = arrayOf("java.lang.String", "java.lang.String", "java.lang.Throwable"),
), ),
) )
} }
@@ -23,6 +23,7 @@ object HookRegistry {
data class SharedHookRegistration( data class SharedHookRegistration(
val pluginId: String, val pluginId: String,
val hookId: String, val hookId: String,
val target: SharedHookTarget,
val priority: Int, val priority: Int,
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?, val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?, val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
@@ -23,6 +23,9 @@ object PluginEngine : PluginHostBridge {
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList()) private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow() 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 enabledIds = linkedSetOf<String>()
private val pinnedIds = linkedSetOf<String>() private val pinnedIds = linkedSetOf<String>()
private val loaded = mutableMapOf<String, LoadedPlugin>() private val loaded = mutableMapOf<String, LoadedPlugin>()
@@ -51,6 +54,7 @@ object PluginEngine : PluginHostBridge {
} else { } else {
loaded.keys.toList().forEach { unloadPlugin(it) } loaded.keys.toList().forEach { unloadPlugin(it) }
} }
bumpHostRevision()
} }
fun setDeveloperMode(enabled: Boolean) { fun setDeveloperMode(enabled: Boolean) {
@@ -69,6 +73,7 @@ object PluginEngine : PluginHostBridge {
} }
persistEnabledState() persistEnabledState()
refreshInstalledList() refreshInstalledList()
bumpHostRevision()
} }
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
@@ -182,6 +187,10 @@ object PluginEngine : PluginHostBridge {
PluginHookDispatcher.registerMenuItem(pluginId, item) 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) { override fun showBulletin(message: String) {
PluginHookDispatcher.showBulletin(message) PluginHookDispatcher.showBulletin(message)
} }
@@ -193,7 +202,37 @@ object PluginEngine : PluginHostBridge {
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?, before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?, after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> Unit { ): () -> Unit {
val registration = SharedHookRegistration(pluginId, hookId, priority, before, after) 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) SharedMethodHookRegistry.register(registration)
val unhook = { val unhook = {
SharedMethodHookRegistry.unregisterPlugin(pluginId) SharedMethodHookRegistry.unregisterPlugin(pluginId)
@@ -215,6 +254,7 @@ object PluginEngine : PluginHostBridge {
runCatching { loadedPlugin.instance.onPluginLoad() } runCatching { loadedPlugin.instance.onPluginLoad() }
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") } .onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
loaded[manifest.id] = loadedPlugin loaded[manifest.id] = loadedPlugin
bumpHostRevision()
} }
private fun unloadPlugin(pluginId: String) { private fun unloadPlugin(pluginId: String) {
@@ -223,6 +263,11 @@ object PluginEngine : PluginHostBridge {
PluginHookDispatcher.unregisterPlugin(pluginId) PluginHookDispatcher.unregisterPlugin(pluginId)
sharedUnhooks.remove(pluginId) sharedUnhooks.remove(pluginId)
PluginPlatform.current?.unloadPlugin(pluginId) PluginPlatform.current?.unloadPlugin(pluginId)
bumpHostRevision()
}
private fun bumpHostRevision() {
_hostRevision.value++
} }
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> { private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
@@ -4,6 +4,7 @@ import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.MenuItemData import ru.fromchat.plugins.MenuItemData
import ru.fromchat.plugins.SendMessageHookContext import ru.fromchat.plugins.SendMessageHookContext
import ru.fromchat.plugins.host.ui.PluginOverlayStore
data class RegisteredSendHook( data class RegisteredSendHook(
val pluginId: String, val pluginId: String,
@@ -27,6 +28,7 @@ object PluginHookDispatcher {
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } } featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
menuItems.removeAll { it.first == pluginId } menuItems.removeAll { it.first == pluginId }
SharedMethodHookRegistry.unregisterPlugin(pluginId) SharedMethodHookRegistry.unregisterPlugin(pluginId)
PluginOverlayStore.unregisterPlugin(pluginId)
} }
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) { fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
@@ -37,8 +39,17 @@ object PluginHookDispatcher {
menuItems += pluginId to item 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> = fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }.map { it.second } 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? { fun featureOverrideValue(id: String): Boolean? {
featureOverrides[id]?.asReversed()?.forEach { (_, provider) -> featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
@@ -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 }
}
@@ -32,10 +32,12 @@ fun PluginUiHost(content: @Composable () -> Unit) {
val bulletins = remember { PluginBulletinStore.bulletins } val bulletins = remember { PluginBulletinStore.bulletins }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
content() content()
bulletins.lastOrNull()?.let { bulletin -> bulletins.firstOrNull()?.let { bulletin ->
LaunchedEffect(bulletin.id) { LaunchedEffect(bulletin.id) {
delay(4_000) delay(4_000)
bulletins.remove(bulletin) if (bulletins.firstOrNull()?.id == bulletin.id) {
bulletins.removeAt(0)
}
} }
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -1,21 +1,16 @@
package ru.fromchat.plugins.host package ru.fromchat.plugins.host
import net.bytebuddy.ByteBuddy
import net.bytebuddy.agent.ByteBuddyAgent import net.bytebuddy.agent.ByteBuddyAgent
import net.bytebuddy.implementation.MethodDelegation import net.bytebuddy.asm.Advice
import net.bytebuddy.implementation.bind.annotation.AllArguments import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
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 net.bytebuddy.matcher.ElementMatchers
import ru.fromchat.plugins.BasePlugin import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.PluginManifest import ru.fromchat.plugins.PluginManifest
import ru.fromchat.plugins.host.util.PluginLogger
import java.io.File import java.io.File
import java.lang.reflect.Method import java.lang.reflect.Method
import java.net.URLClassLoader import java.net.URLClassLoader
import java.util.concurrent.Callable
import net.bytebuddy.ByteBuddy
actual object PluginPlatform { actual object PluginPlatform {
actual val current: PluginPlatform? get() = this actual val current: PluginPlatform? get() = this
@@ -26,14 +21,14 @@ actual object PluginPlatform {
private var agentInstalled = false private var agentInstalled = false
actual fun installSharedHook(registration: SharedHookRegistration) { actual fun installSharedHook(registration: SharedHookRegistration) {
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return val handle = JvmSharedHookHandle(registration, registration.target)
val handle = JvmSharedHookHandle(registration, target)
handle.install() handle.install()
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
} }
actual fun uninstallSharedHook(registration: SharedHookRegistration) { actual fun uninstallSharedHook(registration: SharedHookRegistration) {
hookHandles[registration.pluginId]?.removeAll { it.registration == registration } hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
HookAdviceBindings.unbind(registration)
} }
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? { actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
@@ -56,7 +51,7 @@ actual object PluginPlatform {
private fun ensureAgent() { private fun ensureAgent() {
if (agentInstalled) return if (agentInstalled) return
runCatching { ByteBuddyAgent.install() } ByteBuddyAgent.install()
agentInstalled = true agentInstalled = true
} }
@@ -71,52 +66,37 @@ actual object PluginPlatform {
private val target: SharedHookTarget, private val target: SharedHookTarget,
) : SharedHookHandle { ) : SharedHookHandle {
private var installed = false private var installed = false
private var boundMethod: Method? = null
override fun install() { override fun install() {
if (installed) return if (installed) return
ensureAgent() runCatching {
val clazz = Class.forName(target.className) ensureAgent()
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray() val clazz = Class.forName(target.className)
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes) val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
ByteBuddy() val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
.redefine(clazz) HookAdviceBindings.bind(method, registration)
.method(ElementMatchers.named(target.methodName)) boundMethod = method
.intercept(MethodDelegation.to(JvmHookInterceptor(registration))) ByteBuddy()
.make() .redefine(clazz)
.load(clazz.classLoader) .visit(Advice.to(SharedHookAdvice::class.java).on(ElementMatchers.`is`(method)))
installed = true .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() { override fun uninstall() {
HookAdviceBindings.unbind(registration)
boundMethod = null
installed = false 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,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)
}
}
}
+3 -3
View File
@@ -35,10 +35,10 @@ tasks.register("packageFcPlugin") {
{ {
"id": "hello_world", "id": "hello_world",
"name": "Hello World", "name": "Hello World",
"description": "Demonstrates high-level send hooks and raw Logger.d hooks.", "description": "Demonstrates high-level send hooks, raw Logger.d hooks, and raw main-nav hooks.",
"author": "FromChat", "author": "FromChat",
"version": "1.0.1", "version": "1.0.3",
"usage": "Send .hello Name in any chat to rewrite the message. Raw hook shows a bulletin when a message is queued for sending.", "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", "app_version": ">=1.0.0",
"sdk_version": ">=1.0.0", "sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin", "entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
@@ -3,6 +3,8 @@ package ru.fromchat.plugins.sample.hello
import ru.fromchat.plugins.BasePlugin import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.MenuItemData
import ru.fromchat.plugins.MenuItemType
import ru.fromchat.plugins.PluginSetting import ru.fromchat.plugins.PluginSetting
import ru.fromchat.plugins.SendMessageHookContext import ru.fromchat.plugins.SendMessageHookContext
@@ -10,12 +12,15 @@ class HelloWorldPlugin : BasePlugin() {
override fun onPluginLoad() { override fun onPluginLoad() {
log("Hello World plugin loaded") log("Hello World plugin loaded")
showBulletin("Hello World plugin is active") showBulletin("Hello World plugin is active")
registerUiOverrides()
registerInjectedUi()
registerRawHooks()
addOnSendMessageHook { context -> addOnSendMessageHook { context ->
onSendMessage(context) onSendMessage(context)
} }
hookShared( hookShared(
hookId = "logger.debug", hookId = "logger.debug",
after = { args, _ -> before = { args ->
val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult() val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult()
val message = args.getOrNull(1) as? String ?: return@hookShared HookResult() val message = args.getOrNull(1) as? String ?: return@hookShared HookResult()
if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) { if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) {
@@ -26,6 +31,13 @@ class HelloWorldPlugin : BasePlugin() {
) )
} }
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( override fun createSettings(): List<PluginSetting> = listOf(
PluginSetting.Header("Hello World"), PluginSetting.Header("Hello World"),
PluginSetting.Input( PluginSetting.Input(
@@ -40,6 +52,122 @@ class HelloWorldPlugin : BasePlugin() {
), ),
) )
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> { private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
val raw = context.text.trim() val raw = context.text.trim()
if (!raw.startsWith(".hello")) return HookResult() if (!raw.startsWith(".hello")) return HookResult()
@@ -50,4 +178,21 @@ class HelloWorldPlugin : BasePlugin() {
showBulletin("High-level hook: rewrote .hello command") showBulletin("High-level hook: rewrote .hello command")
return HookResult(strategy = HookStrategy.MODIFY, value = context) 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
}
} }
@@ -8,6 +8,7 @@ interface PluginHostBridge {
fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>) fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>)
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?)
fun registerMenuItem(pluginId: String, item: MenuItemData) fun registerMenuItem(pluginId: String, item: MenuItemData)
fun registerUiOverlay(pluginId: String, slot: String, title: String, message: String)
fun showBulletin(message: String) fun showBulletin(message: String)
fun hookSharedMethod( fun hookSharedMethod(
pluginId: String, pluginId: String,
@@ -16,6 +17,16 @@ interface PluginHostBridge {
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?, before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?, after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> Unit ): () -> 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 { abstract class BasePlugin {
@@ -31,6 +42,7 @@ abstract class BasePlugin {
open fun onPluginLoad() {} open fun onPluginLoad() {}
open fun onPluginUnload() {} open fun onPluginUnload() {}
open fun onAppEvent(event: AppEvent) {} open fun onAppEvent(event: AppEvent) {}
open fun onMenuItemClick(key: String) {}
open fun createSettings(): List<PluginSetting> = emptyList() open fun createSettings(): List<PluginSetting> = emptyList()
protected fun log(message: String) = bridge.log(pluginId, message) protected fun log(message: String) = bridge.log(pluginId, message)
@@ -63,6 +75,10 @@ abstract class BasePlugin {
bridge.registerMenuItem(pluginId, item) bridge.registerMenuItem(pluginId, item)
} }
protected fun registerUiOverlay(slot: String, title: String, message: String) {
bridge.registerUiOverlay(pluginId, slot, title, message)
}
protected fun showBulletin(message: String) { protected fun showBulletin(message: String) {
bridge.showBulletin(message) bridge.showBulletin(message)
} }
@@ -73,6 +89,23 @@ abstract class BasePlugin {
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null, before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null, after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after) ): () -> 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 { object PluginSettingsReload {