mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Add Kotlin plugin system (SDK, host, hooks, settings UI)
Implement exteraGram-style plugin architecture for Android and desktop: - plugins/sdk: BasePlugin API, hook types, settings DSL - plugins/host: PluginEngine, typed send hooks, FeatureGate, HookRegistry - JVM ByteBuddy and Android Pine hook backends - DevServer on port 42690 (JVM) - Plugins settings screens and PluginUiHost bulletin overlay - hello_world sample plugin packaged as .fcplugin - ProGuard keepnames for ru.fromchat.** with shrinking enabled Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.fromchat.plugins.host"
|
||||
compileSdk = 37
|
||||
minSdk = 24
|
||||
}
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":plugins:sdk"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation("net.bytebuddy:byte-buddy:1.15.11")
|
||||
implementation("net.bytebuddy:byte-buddy-agent:1.15.11")
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation("top.canyie.pine:core:0.3.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
actual object PluginDevServer {
|
||||
actual fun onDeveloperModeChanged(enabled: Boolean) {
|
||||
// Android DevServer wiring can mirror JVM; disabled in initial pass.
|
||||
}
|
||||
|
||||
actual fun stop() = Unit
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import android.content.Context
|
||||
import dalvik.system.DexClassLoader
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import top.canyie.pine.Pine
|
||||
import top.canyie.pine.callback.MethodHook
|
||||
import java.io.File
|
||||
|
||||
private lateinit var appContext: Context
|
||||
|
||||
fun initAndroidPluginPlatform(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
actual object PluginPlatform {
|
||||
actual val current: PluginPlatform? get() = this
|
||||
|
||||
private val classLoaders = mutableMapOf<String, DexClassLoader>()
|
||||
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
|
||||
|
||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
|
||||
val clazz = Class.forName(target.className)
|
||||
val paramTypes = target.paramTypeNames.map { Class.forName(it) }.toTypedArray()
|
||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||
val record = Pine.hook(method, object : MethodHook() {
|
||||
override fun beforeCall(callFrame: Pine.CallFrame) {
|
||||
registration.before?.invoke(callFrame.args)?.let { result ->
|
||||
when (result.strategy) {
|
||||
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
|
||||
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { callFrame.args = it }
|
||||
}
|
||||
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterCall(callFrame: Pine.CallFrame) {
|
||||
registration.after?.invoke(callFrame.args, callFrame.result)?.let { result ->
|
||||
when (result.strategy) {
|
||||
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
|
||||
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { callFrame.setResult(it) }
|
||||
}
|
||||
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
pineHooks.getOrPut(registration.pluginId) { mutableListOf() } += record
|
||||
}
|
||||
|
||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||
pineHooks.remove(registration.pluginId)?.forEach { Pine.unhook(it) }
|
||||
}
|
||||
|
||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||
val artifact = manifest.androidArtifact ?: return null
|
||||
val dex = File(pluginDir, artifact)
|
||||
if (!dex.exists()) return null
|
||||
val optimizedDir = File(appContext.codeCacheDir, "plugins_opt").apply { mkdirs() }
|
||||
val loader = DexClassLoader(
|
||||
dex.absolutePath,
|
||||
optimizedDir.absolutePath,
|
||||
null,
|
||||
appContext.classLoader,
|
||||
)
|
||||
classLoaders[manifest.id] = loader
|
||||
val clazz = loader.loadClass(manifest.entryClass)
|
||||
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
|
||||
return LoadedPlugin(manifest, instance, pluginDir)
|
||||
}
|
||||
|
||||
actual fun unloadPlugin(pluginId: String) {
|
||||
pineHooks.remove(pluginId)?.forEach { Pine.unhook(it) }
|
||||
classLoaders.remove(pluginId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import ru.fromchat.plugins.HookResult
|
||||
|
||||
data class SharedHookTarget(
|
||||
val className: String,
|
||||
val methodName: String,
|
||||
val paramTypeNames: Array<String>,
|
||||
)
|
||||
|
||||
object HookRegistry {
|
||||
const val LOGGER_DEBUG = "logger.debug"
|
||||
|
||||
val targets = mapOf(
|
||||
LOGGER_DEBUG to SharedHookTarget(
|
||||
className = "ru.fromchat.Logger",
|
||||
methodName = "d",
|
||||
paramTypeNames = arrayOf("java.lang.String", "java.lang.String"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class SharedHookRegistration(
|
||||
val pluginId: String,
|
||||
val hookId: String,
|
||||
val priority: Int,
|
||||
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
)
|
||||
|
||||
object SharedMethodHookRegistry {
|
||||
private val registrations = mutableListOf<SharedHookRegistration>()
|
||||
|
||||
fun register(registration: SharedHookRegistration) {
|
||||
registrations += registration
|
||||
registrations.sortByDescending { it.priority }
|
||||
PluginPlatform.current?.installSharedHook(registration)
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
val removed = registrations.filter { it.pluginId == pluginId }
|
||||
registrations.removeAll { it.pluginId == pluginId }
|
||||
removed.forEach { PluginPlatform.current?.uninstallSharedHook(it) }
|
||||
}
|
||||
|
||||
fun targetFor(hookId: String): SharedHookTarget? = HookRegistry.targets[hookId]
|
||||
}
|
||||
|
||||
expect object PluginPlatform {
|
||||
val current: PluginPlatform?
|
||||
fun installSharedHook(registration: SharedHookRegistration)
|
||||
fun uninstallSharedHook(registration: SharedHookRegistration)
|
||||
fun loadPlugin(manifest: ru.fromchat.plugins.PluginManifest, pluginDir: String): LoadedPlugin?
|
||||
fun unloadPlugin(pluginId: String)
|
||||
}
|
||||
|
||||
data class LoadedPlugin(
|
||||
val manifest: ru.fromchat.plugins.PluginManifest,
|
||||
val instance: ru.fromchat.plugins.BasePlugin,
|
||||
val pluginDir: String,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
expect object PluginDevServer {
|
||||
fun onDeveloperModeChanged(enabled: Boolean)
|
||||
fun stop()
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import ru.fromchat.plugins.AppEvent
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.PluginHostBridge
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import ru.fromchat.plugins.PluginSettingsReload
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
import java.io.File
|
||||
|
||||
object PluginEngine : PluginHostBridge {
|
||||
private val _engineEnabled = MutableStateFlow(false)
|
||||
val engineEnabled: StateFlow<Boolean> = _engineEnabled.asStateFlow()
|
||||
|
||||
private val _developerMode = MutableStateFlow(false)
|
||||
val developerMode: StateFlow<Boolean> = _developerMode.asStateFlow()
|
||||
|
||||
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
|
||||
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
|
||||
|
||||
private val enabledIds = linkedSetOf<String>()
|
||||
private val loaded = mutableMapOf<String, LoadedPlugin>()
|
||||
private val settings = mutableMapOf<String, MutableMap<String, String>>()
|
||||
private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>()
|
||||
|
||||
var pluginsRootProvider: () -> File = { error("pluginsRootProvider not set") }
|
||||
var appVersionProvider: () -> String = { "0.0.0" }
|
||||
var onSettingsReload: ((String) -> Unit)? = null
|
||||
|
||||
fun init() {
|
||||
PluginSettingsReload.listener = { pluginId -> onSettingsReload?.invoke(pluginId) }
|
||||
refreshInstalledList()
|
||||
loadEnabledState()
|
||||
if (_engineEnabled.value) {
|
||||
enabledIds.forEach { loadPlugin(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setEngineEnabled(enabled: Boolean) {
|
||||
if (_engineEnabled.value == enabled) return
|
||||
_engineEnabled.value = enabled
|
||||
persistEngineState()
|
||||
if (enabled) {
|
||||
enabledIds.forEach { loadPlugin(it) }
|
||||
} else {
|
||||
loaded.keys.toList().forEach { unloadPlugin(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setDeveloperMode(enabled: Boolean) {
|
||||
_developerMode.value = enabled
|
||||
persistEngineState()
|
||||
PluginDevServer.onDeveloperModeChanged(enabled)
|
||||
}
|
||||
|
||||
fun setPluginEnabled(pluginId: String, enabled: Boolean) {
|
||||
if (enabled) {
|
||||
enabledIds += pluginId
|
||||
if (_engineEnabled.value) loadPlugin(pluginId)
|
||||
} else {
|
||||
enabledIds -= pluginId
|
||||
unloadPlugin(pluginId)
|
||||
}
|
||||
persistEnabledState()
|
||||
refreshInstalledList()
|
||||
}
|
||||
|
||||
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
|
||||
|
||||
fun loadedPlugin(pluginId: String): LoadedPlugin? = loaded[pluginId]
|
||||
|
||||
fun getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
|
||||
getSetting(pluginId, key, default)
|
||||
|
||||
fun getPluginSettingString(pluginId: String, key: String, default: String = ""): String =
|
||||
getSettingString(pluginId, key, default)
|
||||
|
||||
fun setPluginSetting(pluginId: String, key: String, value: String) {
|
||||
setSetting(pluginId, key, value)
|
||||
}
|
||||
|
||||
fun refreshInstalledList() {
|
||||
val root = pluginsRootProvider()
|
||||
if (!root.exists()) {
|
||||
root.mkdirs()
|
||||
}
|
||||
val manifests = root.listFiles()?.filter { it.isDirectory }?.mapNotNull { dir ->
|
||||
val manifestFile = dir.resolve("manifest.json")
|
||||
if (!manifestFile.exists()) return@mapNotNull null
|
||||
runCatching { PluginManifestParser.parse(manifestFile.readText()) }.getOrNull()
|
||||
}.orEmpty().sortedBy { it.name.lowercase() }
|
||||
_installed.value = manifests
|
||||
}
|
||||
|
||||
fun installFcPluginArchive(archiveFile: File) {
|
||||
val temp = pluginsRootProvider().resolve(".install-${System.currentTimeMillis()}")
|
||||
temp.mkdirs()
|
||||
projectZipExtract(archiveFile, temp)
|
||||
val manifestFile = temp.walkTopDown().firstOrNull { it.name == "manifest.json" }
|
||||
?: error("manifest.json not found in archive")
|
||||
val manifest = PluginManifestParser.parse(manifestFile.readText())
|
||||
val target = pluginsRootProvider().resolve(manifest.id)
|
||||
if (target.exists()) target.deleteRecursively()
|
||||
manifestFile.parentFile?.copyRecursively(target, overwrite = true)
|
||||
temp.deleteRecursively()
|
||||
refreshInstalledList()
|
||||
}
|
||||
|
||||
fun dispatchAppEvent(event: AppEvent) {
|
||||
loaded.values.forEach { runCatching { it.instance.onAppEvent(event) } }
|
||||
}
|
||||
|
||||
override fun log(pluginId: String, message: String) {
|
||||
ru.fromchat.plugins.host.util.PluginLogger.log(pluginId, message)
|
||||
}
|
||||
|
||||
override fun getSetting(pluginId: String, key: String, default: Boolean): Boolean =
|
||||
settings[pluginId]?.get(key)?.toBooleanStrictOrNull() ?: default
|
||||
|
||||
override fun getSettingString(pluginId: String, key: String, default: String): String =
|
||||
settings[pluginId]?.get(key) ?: default
|
||||
|
||||
override fun setSetting(pluginId: String, key: String, value: String) {
|
||||
val map = settings.getOrPut(pluginId) { loadSettingsFile(pluginId) }
|
||||
map[key] = value
|
||||
saveSettingsFile(pluginId, map)
|
||||
}
|
||||
|
||||
override fun registerSendMessageHook(
|
||||
pluginId: String,
|
||||
priority: Int,
|
||||
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
) {
|
||||
PluginHookDispatcher.registerSendHook(pluginId, priority, handler)
|
||||
}
|
||||
|
||||
override fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||
PluginHookDispatcher.registerFeatureOverride(pluginId, featureId, provider)
|
||||
}
|
||||
|
||||
override fun registerMenuItem(pluginId: String, item: MenuItemData) {
|
||||
PluginHookDispatcher.registerMenuItem(pluginId, item)
|
||||
}
|
||||
|
||||
override fun showBulletin(message: String) {
|
||||
PluginHookDispatcher.showBulletin(message)
|
||||
}
|
||||
|
||||
override fun hookSharedMethod(
|
||||
pluginId: String,
|
||||
hookId: String,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit {
|
||||
val registration = SharedHookRegistration(pluginId, hookId, priority, before, after)
|
||||
SharedMethodHookRegistry.register(registration)
|
||||
val unhook = {
|
||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||
}
|
||||
sharedUnhooks.getOrPut(pluginId) { mutableListOf() } += unhook
|
||||
return unhook
|
||||
}
|
||||
|
||||
private fun loadPlugin(pluginId: String) {
|
||||
if (loaded.containsKey(pluginId)) return
|
||||
val dir = pluginsRootProvider().resolve(pluginId)
|
||||
val manifestFile = dir.resolve("manifest.json")
|
||||
if (!manifestFile.exists()) return
|
||||
val manifest = PluginManifestParser.parse(manifestFile.readText())
|
||||
val platform = PluginPlatform.current ?: return
|
||||
val loadedPlugin = platform.loadPlugin(manifest, dir.absolutePath) ?: return
|
||||
loadedPlugin.instance.attach(manifest.id, this)
|
||||
settings.putIfAbsent(manifest.id, loadSettingsFile(manifest.id))
|
||||
runCatching { loadedPlugin.instance.onPluginLoad() }
|
||||
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
|
||||
loaded[manifest.id] = loadedPlugin
|
||||
}
|
||||
|
||||
private fun unloadPlugin(pluginId: String) {
|
||||
val plugin = loaded.remove(pluginId) ?: return
|
||||
runCatching { plugin.instance.onPluginUnload() }
|
||||
PluginHookDispatcher.unregisterPlugin(pluginId)
|
||||
sharedUnhooks.remove(pluginId)
|
||||
PluginPlatform.current?.unloadPlugin(pluginId)
|
||||
}
|
||||
|
||||
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
|
||||
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
|
||||
if (!file.exists()) return mutableMapOf()
|
||||
return runCatching {
|
||||
file.readLines()
|
||||
.mapNotNull { line ->
|
||||
val idx = line.indexOf('=')
|
||||
if (idx <= 0) null else line.substring(0, idx) to line.substring(idx + 1)
|
||||
}
|
||||
.toMap()
|
||||
.toMutableMap()
|
||||
}.getOrElse { mutableMapOf() }
|
||||
}
|
||||
|
||||
private fun saveSettingsFile(pluginId: String, map: Map<String, String>) {
|
||||
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
|
||||
file.writeText(map.entries.joinToString("\n") { "${it.key}=${it.value}" })
|
||||
}
|
||||
|
||||
private fun persistEngineState() {
|
||||
val file = pluginsRootProvider().resolve("engine_state.properties")
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(
|
||||
buildString {
|
||||
appendLine("engineEnabled=${_engineEnabled.value}")
|
||||
appendLine("developerMode=${_developerMode.value}")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadEnabledState() {
|
||||
val file = pluginsRootProvider().resolve("engine_state.properties")
|
||||
if (file.exists()) {
|
||||
file.readLines().forEach { line ->
|
||||
val parts = line.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
when (parts[0]) {
|
||||
"engineEnabled" -> _engineEnabled.value = parts[1].toBooleanStrictOrNull() == true
|
||||
"developerMode" -> _developerMode.value = parts[1].toBooleanStrictOrNull() == true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val enabledFile = pluginsRootProvider().resolve("enabled_plugins.txt")
|
||||
if (enabledFile.exists()) {
|
||||
enabledIds.clear()
|
||||
enabledIds += enabledFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistEnabledState() {
|
||||
pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun projectZipExtract(archive: File, dest: File) {
|
||||
java.util.zip.ZipFile(archive).use { zip ->
|
||||
zip.entries().asSequence().forEach { entry ->
|
||||
val out = dest.resolve(entry.name)
|
||||
if (entry.isDirectory) {
|
||||
out.mkdirs()
|
||||
} else {
|
||||
out.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input -> out.outputStream().use { input.copyTo(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureBundledPlugins(bundledDir: File) {
|
||||
if (!bundledDir.exists()) return
|
||||
bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive ->
|
||||
val installed = pluginsRootProvider().resolve("hello_world")
|
||||
if (!installed.exists()) {
|
||||
runCatching { installFcPluginArchive(archive) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
|
||||
data class RegisteredSendHook(
|
||||
val pluginId: String,
|
||||
val priority: Int,
|
||||
val handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
)
|
||||
|
||||
object PluginHookDispatcher {
|
||||
private val sendHooks = mutableListOf<RegisteredSendHook>()
|
||||
private val featureOverrides = mutableMapOf<String, MutableList<Pair<String, () -> Boolean?>>>()
|
||||
private val menuItems = mutableListOf<Pair<String, MenuItemData>>()
|
||||
var bulletinHandler: ((String) -> Unit)? = null
|
||||
|
||||
fun registerSendHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>) {
|
||||
sendHooks += RegisteredSendHook(pluginId, priority, handler)
|
||||
sendHooks.sortByDescending { it.priority }
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
sendHooks.removeAll { it.pluginId == pluginId }
|
||||
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
|
||||
menuItems.removeAll { it.first == pluginId }
|
||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||
}
|
||||
|
||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||
featureOverrides.getOrPut(featureId) { mutableListOf() } += pluginId to provider
|
||||
}
|
||||
|
||||
fun registerMenuItem(pluginId: String, item: MenuItemData) {
|
||||
menuItems += pluginId to item
|
||||
}
|
||||
|
||||
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
|
||||
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }.map { it.second }
|
||||
|
||||
fun featureOverrideValue(id: String): Boolean? {
|
||||
featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
|
||||
provider()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun interceptSendMessage(context: SendMessageHookContext): SendMessageHookContext? {
|
||||
var current = context
|
||||
for (hook in sendHooks) {
|
||||
val result = hook.handler(current)
|
||||
when (result.strategy) {
|
||||
HookStrategy.DEFAULT -> Unit
|
||||
HookStrategy.CANCEL -> return null
|
||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { current = it }
|
||||
if (result.strategy == HookStrategy.MODIFY_FINAL) return current
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun showBulletin(message: String) {
|
||||
bulletinHandler?.invoke(message)
|
||||
}
|
||||
}
|
||||
|
||||
object FeatureGate {
|
||||
private val defaults = mutableMapOf<String, () -> Boolean>()
|
||||
var pluginResolver: ((String, Boolean) -> Boolean)? = null
|
||||
|
||||
fun registerDefault(id: String, provider: () -> Boolean) {
|
||||
defaults[id] = provider
|
||||
}
|
||||
|
||||
fun isEnabled(id: String, default: Boolean = true): Boolean {
|
||||
PluginHookDispatcher.featureOverrideValue(id)?.let { return it }
|
||||
return defaults[id]?.invoke() ?: default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
|
||||
object PluginManifestParser {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun parse(raw: String): PluginManifest {
|
||||
val root = json.parseToJsonElement(raw).jsonObject
|
||||
val artifacts = root["artifacts"]?.jsonObject
|
||||
return PluginManifest(
|
||||
id = root.requireString("id"),
|
||||
name = root.requireString("name"),
|
||||
description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0",
|
||||
appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
entryClass = root.requireString("entry_class"),
|
||||
androidArtifact = artifacts?.get("android")?.jsonPrimitive?.contentOrNull,
|
||||
desktopArtifact = artifacts?.get("desktop")?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.requireString(key: String): String =
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?: error("manifest missing $key")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.fromchat.plugins.host.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class PluginBulletin(val message: String, val id: Long)
|
||||
|
||||
object PluginBulletinStore {
|
||||
val bulletins = mutableStateListOf<PluginBulletin>()
|
||||
private var nextId = 0L
|
||||
|
||||
fun show(message: String) {
|
||||
val id = ++nextId
|
||||
bulletins += PluginBulletin(message, id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PluginUiHost(content: @Composable () -> Unit) {
|
||||
val bulletins = remember { PluginBulletinStore.bulletins }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
bulletins.lastOrNull()?.let { bulletin ->
|
||||
LaunchedEffect(bulletin.id) {
|
||||
delay(4_000)
|
||||
bulletins.remove(bulletin)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
tonalElevation = 6.dp,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = bulletin.message,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.fromchat.plugins.host.util
|
||||
|
||||
object PluginLogger {
|
||||
var sink: ((tag: String, message: String) -> Unit)? = null
|
||||
|
||||
fun log(pluginId: String, message: String) {
|
||||
val tag = "Plugin:$pluginId"
|
||||
sink?.invoke(tag, message) ?: println("$tag $message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
|
||||
actual object PluginDevServer {
|
||||
private var serverJob: Job? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
actual fun onDeveloperModeChanged(enabled: Boolean) {
|
||||
if (enabled) start() else stop()
|
||||
}
|
||||
|
||||
actual fun stop() {
|
||||
serverJob?.cancel()
|
||||
serverJob = null
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (serverJob != null) return
|
||||
serverJob = scope.launch {
|
||||
runCatching {
|
||||
ServerSocket(42690, 50, InetAddress.getByName("127.0.0.1")).use { server ->
|
||||
while (true) {
|
||||
val socket = server.accept()
|
||||
scope.launch { handleClient(socket) }
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
PluginEngine.log("devserver", "failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClient(socket: Socket) {
|
||||
socket.use { client ->
|
||||
val reader = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||
val writer = PrintWriter(client.getOutputStream(), true)
|
||||
val line = reader.readLine() ?: return
|
||||
val requestId = Regex("\"#\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1) ?: "0"
|
||||
val command = Regex("\"@\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)
|
||||
when (command) {
|
||||
"ping" -> writer.println("""{"#":"$requestId","pong":true}""")
|
||||
"get_plugins" -> {
|
||||
val plugins = PluginEngine.installed.value.joinToString(",") { manifest ->
|
||||
"""{"id":"${manifest.id}","enabled":${PluginEngine.isPluginEnabled(manifest.id)}}"""
|
||||
}
|
||||
writer.println("""{"#":"$requestId","plugins":[$plugins]}""")
|
||||
}
|
||||
"enable_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
|
||||
PluginEngine.setPluginEnabled(it, true)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
"disable_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
|
||||
PluginEngine.setPluginEnabled(it, false)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
"reload_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let { id ->
|
||||
PluginEngine.setPluginEnabled(id, false)
|
||||
PluginEngine.setPluginEnabled(id, true)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
else -> writer.println("""{"#":"$requestId","error":"unknown command"}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PluginEngine.log(tag: String, message: String) {
|
||||
ru.fromchat.plugins.host.util.PluginLogger.log(tag, message)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
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.matcher.ElementMatchers
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
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
|
||||
|
||||
private val classLoaders = mutableMapOf<String, URLClassLoader>()
|
||||
private val instances = mutableMapOf<String, BasePlugin>()
|
||||
private val hookHandles = mutableMapOf<String, MutableList<SharedHookHandle>>()
|
||||
private var agentInstalled = false
|
||||
|
||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||
val target = SharedMethodHookRegistry.targetFor(registration.hookId) ?: return
|
||||
val handle = JvmSharedHookHandle(registration, target)
|
||||
handle.install()
|
||||
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
|
||||
}
|
||||
|
||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
|
||||
}
|
||||
|
||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||
val artifact = manifest.desktopArtifact ?: return null
|
||||
val jar = File(pluginDir, artifact)
|
||||
if (!jar.exists()) return null
|
||||
val loader = URLClassLoader(arrayOf(jar.toURI().toURL()), BasePlugin::class.java.classLoader)
|
||||
classLoaders[manifest.id] = loader
|
||||
val clazz = loader.loadClass(manifest.entryClass)
|
||||
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
|
||||
instances[manifest.id] = instance
|
||||
return LoadedPlugin(manifest, instance, pluginDir)
|
||||
}
|
||||
|
||||
actual fun unloadPlugin(pluginId: String) {
|
||||
hookHandles.remove(pluginId)?.forEach { it.uninstall() }
|
||||
instances.remove(pluginId)
|
||||
classLoaders.remove(pluginId)?.close()
|
||||
}
|
||||
|
||||
private fun ensureAgent() {
|
||||
if (agentInstalled) return
|
||||
runCatching { ByteBuddyAgent.install() }
|
||||
agentInstalled = true
|
||||
}
|
||||
|
||||
private interface SharedHookHandle {
|
||||
val registration: SharedHookRegistration
|
||||
fun install()
|
||||
fun uninstall()
|
||||
}
|
||||
|
||||
private class JvmSharedHookHandle(
|
||||
override val registration: SharedHookRegistration,
|
||||
private val target: SharedHookTarget,
|
||||
) : SharedHookHandle {
|
||||
private var installed = false
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
override fun uninstall() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user