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.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()
},
)
}
}
}
}
@@ -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)
@@ -64,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
@@ -112,6 +115,8 @@ fun SettingsTab() {
) {
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
PluginOverlayBanner(slot = "settings.banner")
if (isTwoPane) {
Category(Modifier.padding(top = 16.dp)) {
ListItem(
@@ -135,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),
@@ -142,6 +148,7 @@ fun SettingsTab() {
leadingContent = { Icon(Icons.Filled.Devices, null) },
divider = true
)
}
ListItem(
headline = stringResource(Res.string.settings_category_appearance),
@@ -175,6 +182,7 @@ fun SettingsTab() {
divider = true
)
if (pluginFeatureEnabled("settings.logs")) {
ListItem(
headline = stringResource(Res.string.logs_title),
supportingText = stringResource(Res.string.settings_hub_logs_sub),
@@ -182,6 +190,12 @@ fun SettingsTab() {
leadingContent = { Icon(Icons.Outlined.BugReport, null) },
divider = true,
)
}
PluginSettingsMenuItems(
showDividerBeforeFirst = true,
showDividerAfterLast = true,
)
ListItem(
headline = stringResource(Res.string.plugins_title),
@@ -15,8 +15,9 @@ object DesktopPluginBootstrap {
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } }
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
installBundledPlugin(classLoader)
PluginEngine.init()
installBundledPlugin(classLoader)
PluginEngine.refreshInstalledList()
PluginEngine.dispatchAppEvent(AppEvent.START)
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>>()
actual fun installSharedHook(registration: SharedHookRegistration) {
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
val target = registration.target
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 record = Pine.hook(method, object : MethodHook() {
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(
className = "ru.fromchat.Logger",
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(
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?>)?,
@@ -23,6 +23,9 @@ object PluginEngine : PluginHostBridge {
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>()
@@ -51,6 +54,7 @@ object PluginEngine : PluginHostBridge {
} else {
loaded.keys.toList().forEach { unloadPlugin(it) }
}
bumpHostRevision()
}
fun setDeveloperMode(enabled: Boolean) {
@@ -69,6 +73,7 @@ object PluginEngine : PluginHostBridge {
}
persistEnabledState()
refreshInstalledList()
bumpHostRevision()
}
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
@@ -182,6 +187,10 @@ object PluginEngine : PluginHostBridge {
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)
}
@@ -193,7 +202,37 @@ object PluginEngine : PluginHostBridge {
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
): () -> 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)
val unhook = {
SharedMethodHookRegistry.unregisterPlugin(pluginId)
@@ -215,6 +254,7 @@ object PluginEngine : PluginHostBridge {
runCatching { loadedPlugin.instance.onPluginLoad() }
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
loaded[manifest.id] = loadedPlugin
bumpHostRevision()
}
private fun unloadPlugin(pluginId: String) {
@@ -223,6 +263,11 @@ object PluginEngine : PluginHostBridge {
PluginHookDispatcher.unregisterPlugin(pluginId)
sharedUnhooks.remove(pluginId)
PluginPlatform.current?.unloadPlugin(pluginId)
bumpHostRevision()
}
private fun bumpHostRevision() {
_hostRevision.value++
}
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.MenuItemData
import ru.fromchat.plugins.SendMessageHookContext
import ru.fromchat.plugins.host.ui.PluginOverlayStore
data class RegisteredSendHook(
val pluginId: String,
@@ -27,6 +28,7 @@ object PluginHookDispatcher {
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?) {
@@ -37,8 +39,17 @@ object PluginHookDispatcher {
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> =
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? {
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 }
Box(Modifier.fillMaxSize()) {
content()
bulletins.lastOrNull()?.let { bulletin ->
bulletins.firstOrNull()?.let { bulletin ->
LaunchedEffect(bulletin.id) {
delay(4_000)
bulletins.remove(bulletin)
if (bulletins.firstOrNull()?.id == bulletin.id) {
bulletins.removeAt(0)
}
}
Surface(
modifier = Modifier
@@ -1,21 +1,16 @@
package ru.fromchat.plugins.host
import net.bytebuddy.ByteBuddy
import net.bytebuddy.agent.ByteBuddyAgent
import net.bytebuddy.implementation.MethodDelegation
import net.bytebuddy.implementation.bind.annotation.AllArguments
import net.bytebuddy.implementation.bind.annotation.Origin
import net.bytebuddy.implementation.bind.annotation.RuntimeType
import net.bytebuddy.implementation.bind.annotation.SuperCall
import net.bytebuddy.asm.Advice
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
import net.bytebuddy.matcher.ElementMatchers
import ru.fromchat.plugins.BasePlugin
import ru.fromchat.plugins.HookResult
import ru.fromchat.plugins.HookStrategy
import ru.fromchat.plugins.PluginManifest
import ru.fromchat.plugins.host.util.PluginLogger
import java.io.File
import java.lang.reflect.Method
import java.net.URLClassLoader
import java.util.concurrent.Callable
import net.bytebuddy.ByteBuddy
actual object PluginPlatform {
actual val current: PluginPlatform? get() = this
@@ -26,14 +21,14 @@ actual object PluginPlatform {
private var agentInstalled = false
actual fun installSharedHook(registration: SharedHookRegistration) {
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
val handle = JvmSharedHookHandle(registration, target)
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? {
@@ -56,7 +51,7 @@ actual object PluginPlatform {
private fun ensureAgent() {
if (agentInstalled) return
runCatching { ByteBuddyAgent.install() }
ByteBuddyAgent.install()
agentInstalled = true
}
@@ -71,52 +66,37 @@ actual object PluginPlatform {
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 { Class.forName(it) }.toTypedArray()
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
HookAdviceBindings.bind(method, registration)
boundMethod = method
ByteBuddy()
.redefine(clazz)
.method(ElementMatchers.named(target.methodName))
.intercept(MethodDelegation.to(JvmHookInterceptor(registration)))
.visit(Advice.to(SharedHookAdvice::class.java).on(ElementMatchers.`is`(method)))
.make()
.load(clazz.classLoader)
.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
}
}
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",
"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",
"version": "1.0.1",
"usage": "Send .hello Name in any chat to rewrite the message. Raw hook shows a bulletin when a message is queued for sending.",
"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",
@@ -3,6 +3,8 @@ 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
@@ -10,12 +12,15 @@ 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",
after = { args, _ ->
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")) {
@@ -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(
PluginSetting.Header("Hello World"),
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> {
val raw = context.text.trim()
if (!raw.startsWith(".hello")) return HookResult()
@@ -50,4 +178,21 @@ class HelloWorldPlugin : BasePlugin() {
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
}
}
@@ -8,6 +8,7 @@ interface PluginHostBridge {
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,
@@ -16,6 +17,16 @@ interface PluginHostBridge {
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 {
@@ -31,6 +42,7 @@ abstract class BasePlugin {
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)
@@ -63,6 +75,10 @@ abstract class BasePlugin {
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)
}
@@ -73,6 +89,23 @@ abstract class BasePlugin {
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 {