mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
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:
Binary file not shown.
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package ru.fromchat.plugins.integration
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.pr0gramm3r101.components.ListItem
|
||||||
|
import ru.fromchat.plugins.MenuItemType
|
||||||
|
import ru.fromchat.plugins.host.FeatureGate
|
||||||
|
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||||
|
import ru.fromchat.plugins.host.ui.PluginOverlayStore
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PluginOverlayBanner(
|
||||||
|
slot: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val storeOverlays = PluginOverlayStore.overlays
|
||||||
|
val overlays = storeOverlays.filter { it.slot == slot }
|
||||||
|
if (overlays.isEmpty()) return
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
overlays.forEach { overlay ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = overlay.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = overlay.message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PluginSettingsMenuItems(
|
||||||
|
showDividerBeforeFirst: Boolean,
|
||||||
|
showDividerAfterLast: Boolean,
|
||||||
|
) {
|
||||||
|
val items = PluginHookDispatcher.menuItemEntriesFor(MenuItemType.SETTINGS_ROW)
|
||||||
|
items.forEachIndexed { index, (_, item) ->
|
||||||
|
ListItem(
|
||||||
|
headline = item.text,
|
||||||
|
supportingText = item.subtext,
|
||||||
|
leadingContent = { androidx.compose.material3.Icon(Icons.Default.Extension, contentDescription = null) },
|
||||||
|
onClick = { PluginHookDispatcher.dispatchMenuItemClick(item.onClickKey) },
|
||||||
|
divider = when {
|
||||||
|
index < items.lastIndex -> true
|
||||||
|
showDividerAfterLast -> true
|
||||||
|
else -> false
|
||||||
|
}.let { divider ->
|
||||||
|
divider || (index == 0 && showDividerBeforeFirst)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pluginFeatureEnabled(featureId: String, default: Boolean = true): Boolean =
|
||||||
|
FeatureGate.isEnabled(featureId, default)
|
||||||
@@ -55,6 +55,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.plugins.integration.pluginFeatureEnabled
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -568,6 +569,7 @@ fun ChatInput(
|
|||||||
.padding(horizontal = 6.dp, vertical = 0.dp),
|
.padding(horizontal = 6.dp, vertical = 0.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
|
if (pluginFeatureEnabled("chat.emoji_picker")) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = ChatInputIconSlotVerticalInset)
|
.padding(vertical = ChatInputIconSlotVerticalInset)
|
||||||
@@ -582,6 +584,7 @@ fun ChatInput(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
|
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
|
||||||
TextStyle(
|
TextStyle(
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
|
|||||||
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
|
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||||
|
import ru.fromchat.plugins.integration.PluginOverlayBanner
|
||||||
import ru.fromchat.ui.extraStatusBars
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
import ru.fromchat.utils.formatLastSeen
|
import ru.fromchat.utils.formatLastSeen
|
||||||
@@ -1410,6 +1411,14 @@ fun ChatScreen(
|
|||||||
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PluginOverlayBanner(
|
||||||
|
slot = "chat.banner",
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.zIndex(2f)
|
||||||
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
)
|
||||||
|
|
||||||
val showScrollToBottomFab = !isNearBottom &&
|
val showScrollToBottomFab = !isNearBottom &&
|
||||||
panelState.messages.isNotEmpty() &&
|
panelState.messages.isNotEmpty() &&
|
||||||
!contextMenuState.isOpen
|
!contextMenuState.isOpen
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Close
|
|||||||
import androidx.compose.material.icons.rounded.ContentCopy
|
import androidx.compose.material.icons.rounded.ContentCopy
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
|
import androidx.compose.material.icons.rounded.Extension
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material.icons.rounded.SaveAlt
|
import androidx.compose.material.icons.rounded.SaveAlt
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -70,6 +71,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile
|
|||||||
import ru.fromchat.api.local.download.resolveSavableMessageImage
|
import ru.fromchat.api.local.download.resolveSavableMessageImage
|
||||||
import ru.fromchat.api.local.messages.isQueuedOutbound
|
import ru.fromchat.api.local.messages.isQueuedOutbound
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.plugins.MenuItemType
|
||||||
|
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
data class ContextMenuState(
|
data class ContextMenuState(
|
||||||
@@ -275,6 +278,7 @@ fun MessageContextMenu(
|
|||||||
onRetrySend(it)
|
onRetrySend(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
|
onDismiss = onDismiss,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(
|
.then(
|
||||||
if (lockedMenuWidthPx > 0) {
|
if (lockedMenuWidthPx > 0) {
|
||||||
@@ -327,6 +331,7 @@ private fun ContextMenuContent(
|
|||||||
onSave: (Message) -> Unit,
|
onSave: (Message) -> Unit,
|
||||||
onCancelSend: (Message) -> Unit,
|
onCancelSend: (Message) -> Unit,
|
||||||
onRetrySend: (Message) -> Unit,
|
onRetrySend: (Message) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
animated: Boolean,
|
animated: Boolean,
|
||||||
@@ -419,6 +424,16 @@ private fun ContextMenuContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PluginHookDispatcher.menuItemEntriesFor(MenuItemType.MESSAGE_CONTEXT).forEach { (_, item) ->
|
||||||
|
ChatStyleContextMenuItem(
|
||||||
|
icon = Icons.Rounded.Extension,
|
||||||
|
text = item.text,
|
||||||
|
onClick = {
|
||||||
|
PluginHookDispatcher.dispatchMenuItemClick(item.onClickKey)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
|
|||||||
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
||||||
import ru.fromchat.ui.main.chats.ChatsTab
|
import ru.fromchat.ui.main.chats.ChatsTab
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
|
import ru.fromchat.plugins.host.PluginEngine
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
||||||
const val MAIN_PAGE_CHATS = 0
|
const val MAIN_PAGE_CHATS = 0
|
||||||
const val MAIN_PAGE_CONTACTS = 1
|
const val MAIN_PAGE_CONTACTS = 1
|
||||||
@@ -259,6 +261,8 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pluginHostRevision by PluginEngine.hostRevision.collectAsState()
|
||||||
|
|
||||||
val chatsLabel = stringResource(Res.string.chats)
|
val chatsLabel = stringResource(Res.string.chats)
|
||||||
val contactsLabel = stringResource(Res.string.contacts)
|
val contactsLabel = stringResource(Res.string.contacts)
|
||||||
val settingsLabel = stringResource(Res.string.settings)
|
val settingsLabel = stringResource(Res.string.settings)
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ import ru.fromchat.ui.main.LocalDesktopSettingsNavController
|
|||||||
import ru.fromchat.ui.main.LocalMainChromeInsets
|
import ru.fromchat.ui.main.LocalMainChromeInsets
|
||||||
import ru.fromchat.ui.main.mainPagerBottomInset
|
import ru.fromchat.ui.main.mainPagerBottomInset
|
||||||
import ru.fromchat.ui.main.navigateReplacingMainDetail
|
import ru.fromchat.ui.main.navigateReplacingMainDetail
|
||||||
|
import ru.fromchat.plugins.integration.PluginOverlayBanner
|
||||||
|
import ru.fromchat.plugins.integration.PluginSettingsMenuItems
|
||||||
|
import ru.fromchat.plugins.integration.pluginFeatureEnabled
|
||||||
|
|
||||||
val SettingsStepHorizontalPadding = 24.dp
|
val SettingsStepHorizontalPadding = 24.dp
|
||||||
|
|
||||||
@@ -112,6 +115,8 @@ fun SettingsTab() {
|
|||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||||
|
|
||||||
|
PluginOverlayBanner(slot = "settings.banner")
|
||||||
|
|
||||||
if (isTwoPane) {
|
if (isTwoPane) {
|
||||||
Category(Modifier.padding(top = 16.dp)) {
|
Category(Modifier.padding(top = 16.dp)) {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -135,6 +140,7 @@ fun SettingsTab() {
|
|||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (pluginFeatureEnabled("settings.devices")) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_devices),
|
headline = stringResource(Res.string.settings_category_devices),
|
||||||
supportingText = stringResource(Res.string.settings_category_devices_d),
|
supportingText = stringResource(Res.string.settings_category_devices_d),
|
||||||
@@ -142,6 +148,7 @@ fun SettingsTab() {
|
|||||||
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_appearance),
|
headline = stringResource(Res.string.settings_category_appearance),
|
||||||
@@ -175,6 +182,7 @@ fun SettingsTab() {
|
|||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (pluginFeatureEnabled("settings.logs")) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.logs_title),
|
headline = stringResource(Res.string.logs_title),
|
||||||
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
||||||
@@ -182,6 +190,12 @@ fun SettingsTab() {
|
|||||||
leadingContent = { Icon(Icons.Outlined.BugReport, null) },
|
leadingContent = { Icon(Icons.Outlined.BugReport, null) },
|
||||||
divider = true,
|
divider = true,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginSettingsMenuItems(
|
||||||
|
showDividerBeforeFirst = true,
|
||||||
|
showDividerAfterLast = true,
|
||||||
|
)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.plugins_title),
|
headline = stringResource(Res.string.plugins_title),
|
||||||
|
|||||||
+2
-1
@@ -15,8 +15,9 @@ object DesktopPluginBootstrap {
|
|||||||
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
|
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
|
||||||
PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } }
|
PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } }
|
||||||
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
|
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
|
||||||
installBundledPlugin(classLoader)
|
|
||||||
PluginEngine.init()
|
PluginEngine.init()
|
||||||
|
installBundledPlugin(classLoader)
|
||||||
|
PluginEngine.refreshInstalledList()
|
||||||
PluginEngine.dispatchAppEvent(AppEvent.START)
|
PluginEngine.dispatchAppEvent(AppEvent.START)
|
||||||
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
|
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -21,9 +21,9 @@ actual object PluginPlatform {
|
|||||||
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
|
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
|
||||||
|
|
||||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||||
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
|
val target = registration.target
|
||||||
val clazz = Class.forName(target.className)
|
val clazz = Class.forName(target.className)
|
||||||
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
|
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
|
||||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||||
val record = Pine.hook(method, object : MethodHook() {
|
val record = Pine.hook(method, object : MethodHook() {
|
||||||
override fun beforeCall(callFrame: Pine.CallFrame) {
|
override fun beforeCall(callFrame: Pine.CallFrame) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.fromchat.plugins.host
|
||||||
|
|
||||||
|
internal fun resolveJvmParameterType(typeName: String): Class<*> =
|
||||||
|
when (typeName) {
|
||||||
|
"boolean" -> java.lang.Boolean.TYPE
|
||||||
|
"byte" -> java.lang.Byte.TYPE
|
||||||
|
"char" -> java.lang.Character.TYPE
|
||||||
|
"short" -> java.lang.Short.TYPE
|
||||||
|
"int" -> Integer.TYPE
|
||||||
|
"long" -> java.lang.Long.TYPE
|
||||||
|
"float" -> java.lang.Float.TYPE
|
||||||
|
"double" -> java.lang.Double.TYPE
|
||||||
|
"void" -> java.lang.Void.TYPE
|
||||||
|
else -> Class.forName(typeName)
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ object HookRegistry {
|
|||||||
LOGGER_DEBUG to SharedHookTarget(
|
LOGGER_DEBUG to SharedHookTarget(
|
||||||
className = "ru.fromchat.Logger",
|
className = "ru.fromchat.Logger",
|
||||||
methodName = "d",
|
methodName = "d",
|
||||||
paramTypeNames = arrayOf("java.lang.String", "java.lang.String"),
|
paramTypeNames = arrayOf("java.lang.String", "java.lang.String", "java.lang.Throwable"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ object HookRegistry {
|
|||||||
data class SharedHookRegistration(
|
data class SharedHookRegistration(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val hookId: String,
|
val hookId: String,
|
||||||
|
val target: SharedHookTarget,
|
||||||
val priority: Int,
|
val priority: Int,
|
||||||
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||||
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ object PluginEngine : PluginHostBridge {
|
|||||||
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
|
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
|
||||||
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
|
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
|
||||||
|
|
||||||
|
private val _hostRevision = MutableStateFlow(0)
|
||||||
|
val hostRevision: StateFlow<Int> = _hostRevision.asStateFlow()
|
||||||
|
|
||||||
private val enabledIds = linkedSetOf<String>()
|
private val enabledIds = linkedSetOf<String>()
|
||||||
private val pinnedIds = linkedSetOf<String>()
|
private val pinnedIds = linkedSetOf<String>()
|
||||||
private val loaded = mutableMapOf<String, LoadedPlugin>()
|
private val loaded = mutableMapOf<String, LoadedPlugin>()
|
||||||
@@ -51,6 +54,7 @@ object PluginEngine : PluginHostBridge {
|
|||||||
} else {
|
} else {
|
||||||
loaded.keys.toList().forEach { unloadPlugin(it) }
|
loaded.keys.toList().forEach { unloadPlugin(it) }
|
||||||
}
|
}
|
||||||
|
bumpHostRevision()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDeveloperMode(enabled: Boolean) {
|
fun setDeveloperMode(enabled: Boolean) {
|
||||||
@@ -69,6 +73,7 @@ object PluginEngine : PluginHostBridge {
|
|||||||
}
|
}
|
||||||
persistEnabledState()
|
persistEnabledState()
|
||||||
refreshInstalledList()
|
refreshInstalledList()
|
||||||
|
bumpHostRevision()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
|
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
|
||||||
@@ -182,6 +187,10 @@ object PluginEngine : PluginHostBridge {
|
|||||||
PluginHookDispatcher.registerMenuItem(pluginId, item)
|
PluginHookDispatcher.registerMenuItem(pluginId, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun registerUiOverlay(pluginId: String, slot: String, title: String, message: String) {
|
||||||
|
ru.fromchat.plugins.host.ui.PluginOverlayStore.register(pluginId, slot, title, message)
|
||||||
|
}
|
||||||
|
|
||||||
override fun showBulletin(message: String) {
|
override fun showBulletin(message: String) {
|
||||||
PluginHookDispatcher.showBulletin(message)
|
PluginHookDispatcher.showBulletin(message)
|
||||||
}
|
}
|
||||||
@@ -193,7 +202,37 @@ object PluginEngine : PluginHostBridge {
|
|||||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||||
): () -> Unit {
|
): () -> Unit {
|
||||||
val registration = SharedHookRegistration(pluginId, hookId, priority, before, after)
|
val target = SharedMethodHookRegistry.targetFor(hookId)
|
||||||
|
?: run {
|
||||||
|
log(pluginId, "Unknown shared hook id: $hookId")
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return registerSharedHook(pluginId, hookId, target, priority, before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hookRawMethod(
|
||||||
|
pluginId: String,
|
||||||
|
className: String,
|
||||||
|
methodName: String,
|
||||||
|
paramTypeNames: Array<String>,
|
||||||
|
priority: Int,
|
||||||
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||||
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||||
|
): () -> Unit {
|
||||||
|
val hookId = "raw:$className#$methodName"
|
||||||
|
val target = SharedHookTarget(className, methodName, paramTypeNames)
|
||||||
|
return registerSharedHook(pluginId, hookId, target, priority, before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerSharedHook(
|
||||||
|
pluginId: String,
|
||||||
|
hookId: String,
|
||||||
|
target: SharedHookTarget,
|
||||||
|
priority: Int,
|
||||||
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||||
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||||
|
): () -> Unit {
|
||||||
|
val registration = SharedHookRegistration(pluginId, hookId, target, priority, before, after)
|
||||||
SharedMethodHookRegistry.register(registration)
|
SharedMethodHookRegistry.register(registration)
|
||||||
val unhook = {
|
val unhook = {
|
||||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||||
@@ -215,6 +254,7 @@ object PluginEngine : PluginHostBridge {
|
|||||||
runCatching { loadedPlugin.instance.onPluginLoad() }
|
runCatching { loadedPlugin.instance.onPluginLoad() }
|
||||||
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
|
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
|
||||||
loaded[manifest.id] = loadedPlugin
|
loaded[manifest.id] = loadedPlugin
|
||||||
|
bumpHostRevision()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unloadPlugin(pluginId: String) {
|
private fun unloadPlugin(pluginId: String) {
|
||||||
@@ -223,6 +263,11 @@ object PluginEngine : PluginHostBridge {
|
|||||||
PluginHookDispatcher.unregisterPlugin(pluginId)
|
PluginHookDispatcher.unregisterPlugin(pluginId)
|
||||||
sharedUnhooks.remove(pluginId)
|
sharedUnhooks.remove(pluginId)
|
||||||
PluginPlatform.current?.unloadPlugin(pluginId)
|
PluginPlatform.current?.unloadPlugin(pluginId)
|
||||||
|
bumpHostRevision()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bumpHostRevision() {
|
||||||
|
_hostRevision.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
|
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
|
||||||
|
|||||||
+12
-1
@@ -4,6 +4,7 @@ import ru.fromchat.plugins.HookResult
|
|||||||
import ru.fromchat.plugins.HookStrategy
|
import ru.fromchat.plugins.HookStrategy
|
||||||
import ru.fromchat.plugins.MenuItemData
|
import ru.fromchat.plugins.MenuItemData
|
||||||
import ru.fromchat.plugins.SendMessageHookContext
|
import ru.fromchat.plugins.SendMessageHookContext
|
||||||
|
import ru.fromchat.plugins.host.ui.PluginOverlayStore
|
||||||
|
|
||||||
data class RegisteredSendHook(
|
data class RegisteredSendHook(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
@@ -27,6 +28,7 @@ object PluginHookDispatcher {
|
|||||||
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
|
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
|
||||||
menuItems.removeAll { it.first == pluginId }
|
menuItems.removeAll { it.first == pluginId }
|
||||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||||
|
PluginOverlayStore.unregisterPlugin(pluginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||||
@@ -37,8 +39,17 @@ object PluginHookDispatcher {
|
|||||||
menuItems += pluginId to item
|
menuItems += pluginId to item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun menuItemEntriesFor(type: ru.fromchat.plugins.MenuItemType): List<Pair<String, MenuItemData>> =
|
||||||
|
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }
|
||||||
|
|
||||||
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
|
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
|
||||||
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }.map { it.second }
|
menuItemEntriesFor(type).map { it.second }
|
||||||
|
|
||||||
|
fun dispatchMenuItemClick(clickKey: String) {
|
||||||
|
if (clickKey.isBlank()) return
|
||||||
|
val owner = menuItems.firstOrNull { it.second.onClickKey == clickKey } ?: return
|
||||||
|
PluginEngine.loadedPlugin(owner.first)?.instance?.onMenuItemClick(clickKey)
|
||||||
|
}
|
||||||
|
|
||||||
fun featureOverrideValue(id: String): Boolean? {
|
fun featureOverrideValue(id: String): Boolean? {
|
||||||
featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
|
featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.fromchat.plugins.host.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
|
||||||
|
data class PluginUiOverlay(
|
||||||
|
val pluginId: String,
|
||||||
|
val slot: String,
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object PluginOverlayStore {
|
||||||
|
val overlays = mutableStateListOf<PluginUiOverlay>()
|
||||||
|
|
||||||
|
fun register(pluginId: String, slot: String, title: String, message: String) {
|
||||||
|
overlays.removeAll { it.pluginId == pluginId && it.slot == slot }
|
||||||
|
overlays += PluginUiOverlay(pluginId, slot, title, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterPlugin(pluginId: String) {
|
||||||
|
overlays.removeAll { it.pluginId == pluginId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun overlaysFor(slot: String): List<PluginUiOverlay> =
|
||||||
|
overlays.filter { it.slot == slot }
|
||||||
|
}
|
||||||
@@ -32,10 +32,12 @@ fun PluginUiHost(content: @Composable () -> Unit) {
|
|||||||
val bulletins = remember { PluginBulletinStore.bulletins }
|
val bulletins = remember { PluginBulletinStore.bulletins }
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
content()
|
content()
|
||||||
bulletins.lastOrNull()?.let { bulletin ->
|
bulletins.firstOrNull()?.let { bulletin ->
|
||||||
LaunchedEffect(bulletin.id) {
|
LaunchedEffect(bulletin.id) {
|
||||||
delay(4_000)
|
delay(4_000)
|
||||||
bulletins.remove(bulletin)
|
if (bulletins.firstOrNull()?.id == bulletin.id) {
|
||||||
|
bulletins.removeAt(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
package ru.fromchat.plugins.host
|
package ru.fromchat.plugins.host
|
||||||
|
|
||||||
|
import net.bytebuddy.ByteBuddy
|
||||||
import net.bytebuddy.agent.ByteBuddyAgent
|
import net.bytebuddy.agent.ByteBuddyAgent
|
||||||
import net.bytebuddy.implementation.MethodDelegation
|
import net.bytebuddy.asm.Advice
|
||||||
import net.bytebuddy.implementation.bind.annotation.AllArguments
|
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
|
||||||
import net.bytebuddy.implementation.bind.annotation.Origin
|
|
||||||
import net.bytebuddy.implementation.bind.annotation.RuntimeType
|
|
||||||
import net.bytebuddy.implementation.bind.annotation.SuperCall
|
|
||||||
import net.bytebuddy.matcher.ElementMatchers
|
import net.bytebuddy.matcher.ElementMatchers
|
||||||
import ru.fromchat.plugins.BasePlugin
|
import ru.fromchat.plugins.BasePlugin
|
||||||
import ru.fromchat.plugins.HookResult
|
|
||||||
import ru.fromchat.plugins.HookStrategy
|
|
||||||
import ru.fromchat.plugins.PluginManifest
|
import ru.fromchat.plugins.PluginManifest
|
||||||
|
import ru.fromchat.plugins.host.util.PluginLogger
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.lang.reflect.Method
|
import java.lang.reflect.Method
|
||||||
import java.net.URLClassLoader
|
import java.net.URLClassLoader
|
||||||
import java.util.concurrent.Callable
|
|
||||||
import net.bytebuddy.ByteBuddy
|
|
||||||
|
|
||||||
actual object PluginPlatform {
|
actual object PluginPlatform {
|
||||||
actual val current: PluginPlatform? get() = this
|
actual val current: PluginPlatform? get() = this
|
||||||
@@ -26,14 +21,14 @@ actual object PluginPlatform {
|
|||||||
private var agentInstalled = false
|
private var agentInstalled = false
|
||||||
|
|
||||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||||
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
|
val handle = JvmSharedHookHandle(registration, registration.target)
|
||||||
val handle = JvmSharedHookHandle(registration, target)
|
|
||||||
handle.install()
|
handle.install()
|
||||||
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
|
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||||
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
|
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
|
||||||
|
HookAdviceBindings.unbind(registration)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||||
@@ -56,7 +51,7 @@ actual object PluginPlatform {
|
|||||||
|
|
||||||
private fun ensureAgent() {
|
private fun ensureAgent() {
|
||||||
if (agentInstalled) return
|
if (agentInstalled) return
|
||||||
runCatching { ByteBuddyAgent.install() }
|
ByteBuddyAgent.install()
|
||||||
agentInstalled = true
|
agentInstalled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,52 +66,37 @@ actual object PluginPlatform {
|
|||||||
private val target: SharedHookTarget,
|
private val target: SharedHookTarget,
|
||||||
) : SharedHookHandle {
|
) : SharedHookHandle {
|
||||||
private var installed = false
|
private var installed = false
|
||||||
|
private var boundMethod: Method? = null
|
||||||
|
|
||||||
override fun install() {
|
override fun install() {
|
||||||
if (installed) return
|
if (installed) return
|
||||||
|
runCatching {
|
||||||
ensureAgent()
|
ensureAgent()
|
||||||
val clazz = Class.forName(target.className)
|
val clazz = Class.forName(target.className)
|
||||||
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
|
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
|
||||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||||
|
HookAdviceBindings.bind(method, registration)
|
||||||
|
boundMethod = method
|
||||||
ByteBuddy()
|
ByteBuddy()
|
||||||
.redefine(clazz)
|
.redefine(clazz)
|
||||||
.method(ElementMatchers.named(target.methodName))
|
.visit(Advice.to(SharedHookAdvice::class.java).on(ElementMatchers.`is`(method)))
|
||||||
.intercept(MethodDelegation.to(JvmHookInterceptor(registration)))
|
|
||||||
.make()
|
.make()
|
||||||
.load(clazz.classLoader)
|
.load(clazz.classLoader, ClassReloadingStrategy.fromInstalledAgent())
|
||||||
installed = true
|
installed = true
|
||||||
|
}.onFailure {
|
||||||
|
boundMethod?.let { HookAdviceBindings.unbind(registration) }
|
||||||
|
boundMethod = null
|
||||||
|
PluginLogger.log(
|
||||||
|
registration.pluginId,
|
||||||
|
"Raw hook install failed for ${target.className}.${target.methodName}: ${it.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun uninstall() {
|
override fun uninstall() {
|
||||||
|
HookAdviceBindings.unbind(registration)
|
||||||
|
boundMethod = null
|
||||||
installed = false
|
installed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class JvmHookInterceptor(private val registration: SharedHookRegistration) {
|
|
||||||
@RuntimeType
|
|
||||||
fun intercept(
|
|
||||||
@AllArguments args: Array<Any?>,
|
|
||||||
@Origin method: Method,
|
|
||||||
@SuperCall callable: Callable<Any?>,
|
|
||||||
): Any? {
|
|
||||||
registration.before?.let { before ->
|
|
||||||
val result = before(args)
|
|
||||||
when (result.strategy) {
|
|
||||||
HookStrategy.CANCEL -> return null
|
|
||||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> result.value?.let { return@intercept callable.call() }
|
|
||||||
HookStrategy.DEFAULT -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val value = callable.call()
|
|
||||||
registration.after?.let { after ->
|
|
||||||
val result = after(args, value)
|
|
||||||
when (result.strategy) {
|
|
||||||
HookStrategy.CANCEL -> return null
|
|
||||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> return result.value
|
|
||||||
HookStrategy.DEFAULT -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ru.fromchat.plugins.host
|
||||||
|
|
||||||
|
import net.bytebuddy.asm.Advice
|
||||||
|
import ru.fromchat.plugins.HookStrategy
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
internal object HookAdviceBindings {
|
||||||
|
private val byMethodKey = ConcurrentHashMap<String, SharedHookRegistration>()
|
||||||
|
|
||||||
|
fun bind(method: Method, registration: SharedHookRegistration) {
|
||||||
|
byMethodKey[key(method)] = registration
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind(registration: SharedHookRegistration) {
|
||||||
|
byMethodKey.entries.removeIf { it.value == registration }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registration(method: Method): SharedHookRegistration? = byMethodKey[key(method)]
|
||||||
|
|
||||||
|
private fun key(method: Method): String =
|
||||||
|
buildString {
|
||||||
|
append(method.declaringClass.name)
|
||||||
|
append('#')
|
||||||
|
append(method.name)
|
||||||
|
append('(')
|
||||||
|
method.parameterTypes.forEachIndexed { index, type ->
|
||||||
|
if (index > 0) append(',')
|
||||||
|
append(type.name)
|
||||||
|
}
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SharedHookAdvice {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue::class)
|
||||||
|
fun onEnter(
|
||||||
|
@Advice.AllArguments arguments: Array<Any?>,
|
||||||
|
@Advice.Origin method: Method,
|
||||||
|
): Boolean {
|
||||||
|
val registration = HookAdviceBindings.registration(method) ?: return false
|
||||||
|
registration.before?.let { before ->
|
||||||
|
when (before(arguments).strategy) {
|
||||||
|
HookStrategy.CANCEL -> return true
|
||||||
|
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL, HookStrategy.DEFAULT -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@Advice.OnMethodExit(onThrowable = Throwable::class)
|
||||||
|
fun onExit(
|
||||||
|
@Advice.AllArguments arguments: Array<Any?>,
|
||||||
|
@Advice.Origin method: Method,
|
||||||
|
@Advice.Enter skipOriginal: Boolean,
|
||||||
|
) {
|
||||||
|
if (skipOriginal) return
|
||||||
|
HookAdviceBindings.registration(method)?.after?.invoke(arguments, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,10 @@ tasks.register("packageFcPlugin") {
|
|||||||
{
|
{
|
||||||
"id": "hello_world",
|
"id": "hello_world",
|
||||||
"name": "Hello World",
|
"name": "Hello World",
|
||||||
"description": "Demonstrates high-level send hooks and raw Logger.d hooks.",
|
"description": "Demonstrates high-level send hooks, raw Logger.d hooks, and raw main-nav hooks.",
|
||||||
"author": "FromChat",
|
"author": "FromChat",
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"usage": "Send .hello Name in any chat to rewrite the message. Raw hook shows a bulletin when a message is queued for sending.",
|
"usage": "Raw-hooks existing MainScreen/ContactsTab JVM methods (no app hook IDs). Also hides Devices/Logs settings and emoji; injects settings row, message menu item, and chat banner. Send .hello Name to test send hooks.",
|
||||||
"app_version": ">=1.0.0",
|
"app_version": ">=1.0.0",
|
||||||
"sdk_version": ">=1.0.0",
|
"sdk_version": ">=1.0.0",
|
||||||
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
|
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
|
||||||
|
|||||||
+146
-1
@@ -3,6 +3,8 @@ package ru.fromchat.plugins.sample.hello
|
|||||||
import ru.fromchat.plugins.BasePlugin
|
import ru.fromchat.plugins.BasePlugin
|
||||||
import ru.fromchat.plugins.HookResult
|
import ru.fromchat.plugins.HookResult
|
||||||
import ru.fromchat.plugins.HookStrategy
|
import ru.fromchat.plugins.HookStrategy
|
||||||
|
import ru.fromchat.plugins.MenuItemData
|
||||||
|
import ru.fromchat.plugins.MenuItemType
|
||||||
import ru.fromchat.plugins.PluginSetting
|
import ru.fromchat.plugins.PluginSetting
|
||||||
import ru.fromchat.plugins.SendMessageHookContext
|
import ru.fromchat.plugins.SendMessageHookContext
|
||||||
|
|
||||||
@@ -10,12 +12,15 @@ class HelloWorldPlugin : BasePlugin() {
|
|||||||
override fun onPluginLoad() {
|
override fun onPluginLoad() {
|
||||||
log("Hello World plugin loaded")
|
log("Hello World plugin loaded")
|
||||||
showBulletin("Hello World plugin is active")
|
showBulletin("Hello World plugin is active")
|
||||||
|
registerUiOverrides()
|
||||||
|
registerInjectedUi()
|
||||||
|
registerRawHooks()
|
||||||
addOnSendMessageHook { context ->
|
addOnSendMessageHook { context ->
|
||||||
onSendMessage(context)
|
onSendMessage(context)
|
||||||
}
|
}
|
||||||
hookShared(
|
hookShared(
|
||||||
hookId = "logger.debug",
|
hookId = "logger.debug",
|
||||||
after = { args, _ ->
|
before = { args ->
|
||||||
val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult()
|
val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult()
|
||||||
val message = args.getOrNull(1) as? String ?: return@hookShared HookResult()
|
val message = args.getOrNull(1) as? String ?: return@hookShared HookResult()
|
||||||
if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) {
|
if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) {
|
||||||
@@ -26,6 +31,13 @@ class HelloWorldPlugin : BasePlugin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemClick(key: String) {
|
||||||
|
when (key) {
|
||||||
|
MENU_SETTINGS_DEMO -> showBulletin("Injected settings row clicked")
|
||||||
|
MENU_MESSAGE_DEMO -> showBulletin("Injected message menu clicked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun createSettings(): List<PluginSetting> = listOf(
|
override fun createSettings(): List<PluginSetting> = listOf(
|
||||||
PluginSetting.Header("Hello World"),
|
PluginSetting.Header("Hello World"),
|
||||||
PluginSetting.Input(
|
PluginSetting.Input(
|
||||||
@@ -40,6 +52,122 @@ class HelloWorldPlugin : BasePlugin() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun registerRawHooks() {
|
||||||
|
hookRawMethod(
|
||||||
|
className = "ru.fromchat.ui.main.MainScreenKt",
|
||||||
|
methodName = "selectMainPage",
|
||||||
|
paramTypeNames = arrayOf(
|
||||||
|
"int",
|
||||||
|
"com.pr0gramm3r101.utils.WindowWidthSizeClass",
|
||||||
|
"kotlinx.coroutines.CoroutineScope",
|
||||||
|
"androidx.compose.foundation.pager.PagerState",
|
||||||
|
"androidx.navigation.NavController",
|
||||||
|
),
|
||||||
|
before = { args ->
|
||||||
|
val page = (args.getOrNull(0) as? Number)?.toInt() ?: return@hookRawMethod HookResult()
|
||||||
|
if (page == MAIN_PAGE_CONTACTS) {
|
||||||
|
if (!contactsNavBlockedNotified) {
|
||||||
|
contactsNavBlockedNotified = true
|
||||||
|
showBulletin("Raw hook: blocked Contacts tab navigation")
|
||||||
|
}
|
||||||
|
HookResult(strategy = HookStrategy.CANCEL)
|
||||||
|
} else {
|
||||||
|
HookResult()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hookRawMethod(
|
||||||
|
className = "ru.fromchat.ui.main.ContactsTabKt",
|
||||||
|
methodName = "ContactsTab",
|
||||||
|
paramTypeNames = arrayOf(
|
||||||
|
"androidx.compose.runtime.Composer",
|
||||||
|
"int",
|
||||||
|
),
|
||||||
|
before = { _ ->
|
||||||
|
HookResult(strategy = HookStrategy.CANCEL)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hookRawMethod(
|
||||||
|
className = "ru.fromchat.ui.main.MainScreenKt",
|
||||||
|
methodName = "MainScreen",
|
||||||
|
paramTypeNames = arrayOf(
|
||||||
|
"androidx.compose.animation.SharedTransitionScope",
|
||||||
|
"androidx.compose.animation.AnimatedVisibilityScope",
|
||||||
|
"androidx.compose.material3.SnackbarHostState",
|
||||||
|
"boolean",
|
||||||
|
"int",
|
||||||
|
"boolean",
|
||||||
|
"kotlin.jvm.functions.Function1",
|
||||||
|
"androidx.compose.runtime.Composer",
|
||||||
|
"int",
|
||||||
|
"int",
|
||||||
|
),
|
||||||
|
before = { _ ->
|
||||||
|
mainScreenNavItemOrdinal = 0
|
||||||
|
HookResult()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hookRawMethod(
|
||||||
|
className = "androidx.compose.material3.NavigationBarKt",
|
||||||
|
methodName = "NavigationBarItem",
|
||||||
|
paramTypeNames = arrayOf(
|
||||||
|
"androidx.compose.foundation.layout.RowScope",
|
||||||
|
"boolean",
|
||||||
|
"kotlin.jvm.functions.Function0",
|
||||||
|
"kotlin.jvm.functions.Function2",
|
||||||
|
"androidx.compose.ui.Modifier",
|
||||||
|
"boolean",
|
||||||
|
"kotlin.jvm.functions.Function2",
|
||||||
|
"boolean",
|
||||||
|
"androidx.compose.material3.NavigationBarItemColors",
|
||||||
|
"androidx.compose.foundation.interaction.MutableInteractionSource",
|
||||||
|
"androidx.compose.runtime.Composer",
|
||||||
|
"int",
|
||||||
|
"int",
|
||||||
|
),
|
||||||
|
before = { _ ->
|
||||||
|
mainScreenNavItemOrdinal += 1
|
||||||
|
if (mainScreenNavItemOrdinal == CONTACTS_NAV_ITEM_ORDINAL) {
|
||||||
|
HookResult(strategy = HookStrategy.CANCEL)
|
||||||
|
} else {
|
||||||
|
HookResult()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerUiOverrides() {
|
||||||
|
registerFeatureOverride(FEATURE_SETTINGS_DEVICES) { false }
|
||||||
|
registerFeatureOverride(FEATURE_SETTINGS_LOGS) { false }
|
||||||
|
registerFeatureOverride(FEATURE_CHAT_EMOJI) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerInjectedUi() {
|
||||||
|
registerUiOverlay(
|
||||||
|
slot = OVERLAY_CHAT_BANNER,
|
||||||
|
title = "Hello World plugin UI",
|
||||||
|
message = "This banner was injected by the plugin engine.",
|
||||||
|
)
|
||||||
|
addMenuItem(
|
||||||
|
MenuItemData(
|
||||||
|
menuType = MenuItemType.SETTINGS_ROW,
|
||||||
|
text = "Hello World demo action",
|
||||||
|
subtext = "Injected settings row from plugin",
|
||||||
|
priority = 10,
|
||||||
|
onClickKey = MENU_SETTINGS_DEMO,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
addMenuItem(
|
||||||
|
MenuItemData(
|
||||||
|
menuType = MenuItemType.MESSAGE_CONTEXT,
|
||||||
|
text = "Hello World message action",
|
||||||
|
subtext = "Injected message menu item",
|
||||||
|
priority = 10,
|
||||||
|
onClickKey = MENU_MESSAGE_DEMO,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
|
private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
|
||||||
val raw = context.text.trim()
|
val raw = context.text.trim()
|
||||||
if (!raw.startsWith(".hello")) return HookResult()
|
if (!raw.startsWith(".hello")) return HookResult()
|
||||||
@@ -50,4 +178,21 @@ class HelloWorldPlugin : BasePlugin() {
|
|||||||
showBulletin("High-level hook: rewrote .hello command")
|
showBulletin("High-level hook: rewrote .hello command")
|
||||||
return HookResult(strategy = HookStrategy.MODIFY, value = context)
|
return HookResult(strategy = HookStrategy.MODIFY, value = context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAIN_PAGE_CONTACTS = 1
|
||||||
|
const val CONTACTS_NAV_ITEM_ORDINAL = 2
|
||||||
|
const val FEATURE_SETTINGS_DEVICES = "settings.devices"
|
||||||
|
const val FEATURE_SETTINGS_LOGS = "settings.logs"
|
||||||
|
const val FEATURE_CHAT_EMOJI = "chat.emoji_picker"
|
||||||
|
const val OVERLAY_CHAT_BANNER = "chat.banner"
|
||||||
|
const val MENU_SETTINGS_DEMO = "hello_world.settings_demo"
|
||||||
|
const val MENU_MESSAGE_DEMO = "hello_world.message_demo"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var mainScreenNavItemOrdinal: Int = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var contactsNavBlockedNotified: Boolean = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface PluginHostBridge {
|
|||||||
fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>)
|
fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>)
|
||||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?)
|
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?)
|
||||||
fun registerMenuItem(pluginId: String, item: MenuItemData)
|
fun registerMenuItem(pluginId: String, item: MenuItemData)
|
||||||
|
fun registerUiOverlay(pluginId: String, slot: String, title: String, message: String)
|
||||||
fun showBulletin(message: String)
|
fun showBulletin(message: String)
|
||||||
fun hookSharedMethod(
|
fun hookSharedMethod(
|
||||||
pluginId: String,
|
pluginId: String,
|
||||||
@@ -16,6 +17,16 @@ interface PluginHostBridge {
|
|||||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||||
): () -> Unit
|
): () -> Unit
|
||||||
|
|
||||||
|
fun hookRawMethod(
|
||||||
|
pluginId: String,
|
||||||
|
className: String,
|
||||||
|
methodName: String,
|
||||||
|
paramTypeNames: Array<String>,
|
||||||
|
priority: Int,
|
||||||
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||||
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||||
|
): () -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class BasePlugin {
|
abstract class BasePlugin {
|
||||||
@@ -31,6 +42,7 @@ abstract class BasePlugin {
|
|||||||
open fun onPluginLoad() {}
|
open fun onPluginLoad() {}
|
||||||
open fun onPluginUnload() {}
|
open fun onPluginUnload() {}
|
||||||
open fun onAppEvent(event: AppEvent) {}
|
open fun onAppEvent(event: AppEvent) {}
|
||||||
|
open fun onMenuItemClick(key: String) {}
|
||||||
open fun createSettings(): List<PluginSetting> = emptyList()
|
open fun createSettings(): List<PluginSetting> = emptyList()
|
||||||
|
|
||||||
protected fun log(message: String) = bridge.log(pluginId, message)
|
protected fun log(message: String) = bridge.log(pluginId, message)
|
||||||
@@ -63,6 +75,10 @@ abstract class BasePlugin {
|
|||||||
bridge.registerMenuItem(pluginId, item)
|
bridge.registerMenuItem(pluginId, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun registerUiOverlay(slot: String, title: String, message: String) {
|
||||||
|
bridge.registerUiOverlay(pluginId, slot, title, message)
|
||||||
|
}
|
||||||
|
|
||||||
protected fun showBulletin(message: String) {
|
protected fun showBulletin(message: String) {
|
||||||
bridge.showBulletin(message)
|
bridge.showBulletin(message)
|
||||||
}
|
}
|
||||||
@@ -73,6 +89,23 @@ abstract class BasePlugin {
|
|||||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
|
||||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
|
||||||
): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after)
|
): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after)
|
||||||
|
|
||||||
|
protected fun hookRawMethod(
|
||||||
|
className: String,
|
||||||
|
methodName: String,
|
||||||
|
paramTypeNames: Array<String> = emptyArray(),
|
||||||
|
priority: Int = 0,
|
||||||
|
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
|
||||||
|
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
|
||||||
|
): () -> Unit = bridge.hookRawMethod(
|
||||||
|
pluginId,
|
||||||
|
className,
|
||||||
|
methodName,
|
||||||
|
paramTypeNames,
|
||||||
|
priority,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
object PluginSettingsReload {
|
object PluginSettingsReload {
|
||||||
|
|||||||
Reference in New Issue
Block a user