From 1ff4503be9f9d97899d164b272f27fe27c41e091 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 00:17:25 +0000 Subject: [PATCH] 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 --- .../bundled_plugins/hello_world.fcplugin | Bin 3742 -> 0 bytes .../integration/PluginUiIntegration.kt | 79 ++++++++++ .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 29 ++-- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 9 ++ .../ru/fromchat/ui/chat/MessageContextMenu.kt | 15 ++ .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 4 + .../fromchat/ui/main/settings/SettingsTab.kt | 40 +++-- .../integration/DesktopPluginBootstrap.kt | 3 +- .../bundled_plugins/hello_world.fcplugin | Bin 4321 -> 8113 bytes .../plugins/host/PluginPlatform.android.kt | 4 +- .../ru/fromchat/plugins/host/HookJvmTypes.kt | 15 ++ .../ru/fromchat/plugins/host/HookRegistry.kt | 3 +- .../ru/fromchat/plugins/host/PluginEngine.kt | 47 +++++- .../plugins/host/PluginHookDispatcher.kt | 13 +- .../plugins/host/ui/PluginOverlayStore.kt | 26 ++++ .../fromchat/plugins/host/ui/PluginUiHost.kt | 6 +- .../plugins/host/PluginPlatform.jvm.kt | 82 ++++------ .../fromchat/plugins/host/SharedHookAdvice.kt | 64 ++++++++ plugins/sample-hello/build.gradle.kts | 6 +- .../plugins/sample/hello/HelloWorldPlugin.kt | 147 +++++++++++++++++- .../kotlin/ru/fromchat/plugins/BasePlugin.kt | 33 ++++ 21 files changed, 536 insertions(+), 89 deletions(-) delete mode 100644 app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginUiIntegration.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookJvmTypes.kt create mode 100644 plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginOverlayStore.kt create mode 100644 plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/SharedHookAdvice.kt diff --git a/app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin b/app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin deleted file mode 100644 index 6b07b07ecd9af61738ea9ef43e778b4eacf66cfd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3742 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 + 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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 6079c1a..2242e94 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -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,19 +569,21 @@ fun ChatInput( .padding(horizontal = 6.dp, vertical = 0.dp), verticalAlignment = Alignment.Bottom, ) { - Box( - modifier = Modifier - .padding(vertical = ChatInputIconSlotVerticalInset) - .size(ChatInputIconSlotSize) - .clip(CircleShape) - .clickable(onClick = { /* emoji picker to be wired */ }), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.SentimentSatisfied, - contentDescription = cdEmoji, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (pluginFeatureEnabled("chat.emoji_picker")) { + Box( + modifier = Modifier + .padding(vertical = ChatInputIconSlotVerticalInset) + .size(ChatInputIconSlotSize) + .clip(CircleShape) + .clickable(onClick = { /* emoji picker to be wired */ }), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.SentimentSatisfied, + contentDescription = cdEmoji, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } val inputTextStyle = MaterialTheme.typography.bodyLarge.merge( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 3aa635b..c4af5f6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 5242ff1..36947b7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -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() + }, + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 42f0e44..b1b2f6b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt index 320203c..86a6c06 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt @@ -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,13 +140,15 @@ fun SettingsTab() { divider = true ) - ListItem( - headline = stringResource(Res.string.settings_category_devices), - supportingText = stringResource(Res.string.settings_category_devices_d), - onClick = { openDetail(SettingsRoutes.Devices) }, - leadingContent = { Icon(Icons.Filled.Devices, null) }, - divider = true - ) + if (pluginFeatureEnabled("settings.devices")) { + ListItem( + headline = stringResource(Res.string.settings_category_devices), + supportingText = stringResource(Res.string.settings_category_devices_d), + onClick = { openDetail(SettingsRoutes.Devices) }, + leadingContent = { Icon(Icons.Filled.Devices, null) }, + divider = true + ) + } ListItem( headline = stringResource(Res.string.settings_category_appearance), @@ -175,12 +182,19 @@ fun SettingsTab() { divider = true ) - 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) }, - 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) }, + divider = true, + ) + } + + PluginSettingsMenuItems( + showDividerBeforeFirst = true, + showDividerAfterLast = true, ) ListItem( diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt index 2d21dfb..94333fd 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt @@ -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 } } diff --git a/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin b/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin index bf6cc82f6d818bc65c929276414a09682d349d31..c5473da018940305b74ab1d50fac37cd4f375641 100644 GIT binary patch literal 8113 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 DSMmEz literal 4321 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 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) { diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookJvmTypes.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookJvmTypes.kt new file mode 100644 index 0000000..e7b50f7 --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookJvmTypes.kt @@ -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) + } diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt index dcb6d29..7d16a46 100644 --- a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/HookRegistry.kt @@ -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) -> HookResult>)?, val after: ((Array, Any?) -> HookResult)?, diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt index 2d8f004..40c515c 100644 --- a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt @@ -23,6 +23,9 @@ object PluginEngine : PluginHostBridge { private val _installed = MutableStateFlow>(emptyList()) val installed: StateFlow> = _installed.asStateFlow() + private val _hostRevision = MutableStateFlow(0) + val hostRevision: StateFlow = _hostRevision.asStateFlow() + private val enabledIds = linkedSetOf() private val pinnedIds = linkedSetOf() private val loaded = mutableMapOf() @@ -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) -> HookResult>)?, after: ((Array, Any?) -> HookResult)?, ): () -> 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, + priority: Int, + before: ((Array) -> HookResult>)?, + after: ((Array, Any?) -> HookResult)?, + ): () -> 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) -> HookResult>)?, + after: ((Array, Any?) -> HookResult)?, + ): () -> 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 { diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt index 0a4e667..f068aab 100644 --- a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginHookDispatcher.kt @@ -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> = + menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority } + fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List = - 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) -> diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginOverlayStore.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginOverlayStore.kt new file mode 100644 index 0000000..cd859b5 --- /dev/null +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginOverlayStore.kt @@ -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() + + 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 = + overlays.filter { it.slot == slot } +} diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt index eb691da..8ae5781 100644 --- a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt +++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/ui/PluginUiHost.kt @@ -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 diff --git a/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt index fa09473..1a715c9 100644 --- a/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt +++ b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.jvm.kt @@ -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 - ensureAgent() - val clazz = Class.forName(target.className) - val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray() - val method = clazz.getDeclaredMethod(target.methodName, *paramTypes) - ByteBuddy() - .redefine(clazz) - .method(ElementMatchers.named(target.methodName)) - .intercept(MethodDelegation.to(JvmHookInterceptor(registration))) - .make() - .load(clazz.classLoader) - installed = true + 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 } } - - class JvmHookInterceptor(private val registration: SharedHookRegistration) { - @RuntimeType - fun intercept( - @AllArguments args: Array, - @Origin method: Method, - @SuperCall callable: Callable, - ): Any? { - registration.before?.let { before -> - val result = before(args) - when (result.strategy) { - HookStrategy.CANCEL -> return null - HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> result.value?.let { return@intercept callable.call() } - HookStrategy.DEFAULT -> Unit - } - } - val value = callable.call() - registration.after?.let { after -> - val result = after(args, value) - when (result.strategy) { - HookStrategy.CANCEL -> return null - HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> return result.value - HookStrategy.DEFAULT -> Unit - } - } - return value - } - } } diff --git a/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/SharedHookAdvice.kt b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/SharedHookAdvice.kt new file mode 100644 index 0000000..4e22231 --- /dev/null +++ b/plugins/host/src/jvmMain/kotlin/ru/fromchat/plugins/host/SharedHookAdvice.kt @@ -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() + + 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, + @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, + @Advice.Origin method: Method, + @Advice.Enter skipOriginal: Boolean, + ) { + if (skipOriginal) return + HookAdviceBindings.registration(method)?.after?.invoke(arguments, null) + } + } +} diff --git a/plugins/sample-hello/build.gradle.kts b/plugins/sample-hello/build.gradle.kts index d22c9a2..ab551b2 100644 --- a/plugins/sample-hello/build.gradle.kts +++ b/plugins/sample-hello/build.gradle.kts @@ -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", diff --git a/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt b/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt index e8af3a8..4b71fe2 100644 --- a/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt +++ b/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt @@ -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 = 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 { 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 + } } diff --git a/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt index 96ecd73..69a750a 100644 --- a/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt +++ b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/BasePlugin.kt @@ -8,6 +8,7 @@ interface PluginHostBridge { fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult) 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) -> HookResult>)?, after: ((Array, Any?) -> HookResult)?, ): () -> Unit + + fun hookRawMethod( + pluginId: String, + className: String, + methodName: String, + paramTypeNames: Array, + priority: Int, + before: ((Array) -> HookResult>)?, + after: ((Array, Any?) -> HookResult)?, + ): () -> 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 = 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) -> HookResult>)? = null, after: ((Array, Any?) -> HookResult)? = null, ): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after) + + protected fun hookRawMethod( + className: String, + methodName: String, + paramTypeNames: Array = emptyArray(), + priority: Int = 0, + before: ((Array) -> HookResult>)? = null, + after: ((Array, Any?) -> HookResult)? = null, + ): () -> Unit = bridge.hookRawMethod( + pluginId, + className, + methodName, + paramTypeNames, + priority, + before, + after, + ) } object PluginSettingsReload {