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 6b07b07..0000000 Binary files a/app/desktop/src/main/resources/bundled_plugins/hello_world.fcplugin and /dev/null differ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginUiIntegration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginUiIntegration.kt new file mode 100644 index 0000000..3b62a98 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginUiIntegration.kt @@ -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) 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 bf6cc82..c5473da 100644 Binary files a/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin and b/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin differ diff --git a/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt b/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt index a405d6b..ce4f453 100644 --- a/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt +++ b/plugins/host/src/androidMain/kotlin/ru/fromchat/plugins/host/PluginPlatform.android.kt @@ -21,9 +21,9 @@ actual object PluginPlatform { private val pineHooks = mutableMapOf>() 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 {