commit 98b3f2bfc9833323f958eed8c214cb93099b3a45
parent e79aba81653d50c218a37f9d5e1975cfd49f3d69
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun, 17 Sep 2023 12:43:57 +0200

feat(scripting): hooker module

Diffstat:
Mapp/build.gradle.kts | 1+
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 2+-
Mapp/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt | 15+++++++++------
Mcore/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt | 4++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt | 34++--------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt | 4+---
Mcore/src/main/kotlin/me/rhunk/snapenhance/scripting/IPCInterface.kt | 3++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/scripting/JSModule.kt | 68++++++++++++++++++++++++++++++++++++--------------------------------
Dcore/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptKtx.kt | 44--------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptRuntime.kt | 9+++++----
Acore/src/main/kotlin/me/rhunk/snapenhance/scripting/core/CoreScriptRuntime.kt | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/src/main/kotlin/me/rhunk/snapenhance/scripting/core/impl/ScriptHooker.kt | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt | 44++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 301 insertions(+), 125 deletions(-)

diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -102,6 +102,7 @@ dependencies { implementation(libs.coil.video) implementation(libs.ffmpeg.kit) implementation(libs.osmdroid.android) + implementation(libs.rhino) debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -97,7 +97,7 @@ class RemoteSideContext( } scriptManager.runtime.eachModule { - callOnManagerLoad(androidContext) + callFunction("module.onManagerLoad",androidContext) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -13,9 +13,8 @@ import java.io.InputStream class RemoteScriptManager( private val context: RemoteSideContext, ) : IScripting.Stub() { - val runtime = ScriptRuntime(context.log).apply { - ipcManager = IRemoteIPC() - } + val runtime = ScriptRuntime(context.log) + val remoteIpc = IRemoteIPC() private fun getScriptFolder() = DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get())) @@ -41,6 +40,10 @@ class RemoteScriptManager( } fun init() { + runtime.buildModuleObject = { + putConst("ipc", this, remoteIpc) + } + sync() enabledScripts.forEach { path -> val content = getScriptContent(path) ?: return@forEach @@ -76,12 +79,12 @@ class RemoteScriptManager( } override fun registerIPCListener(eventName: String, listener: IPCListener) { - runtime.ipcManager.on(eventName, object: Listener { + remoteIpc.on(eventName, object: Listener { override fun invoke(args: Array<out String?>) { try { listener.onMessage(args) } catch (e: DeadObjectException) { - (runtime.ipcManager as IRemoteIPC).removeListener(eventName, this) + remoteIpc.removeListener(eventName, this) } catch (t: Throwable) { context.log.error("Failed to invoke $eventName", t) } @@ -91,7 +94,7 @@ class RemoteScriptManager( override fun sendIPCMessage(eventName: String, args: Array<out String>) { runCatching { - runtime.ipcManager.emit(eventName, *args) + remoteIpc.emit(eventName, args) }.onFailure { context.log.error("Failed to send message for $eventName", it) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -25,7 +25,7 @@ import me.rhunk.snapenhance.manager.impl.ActionManager import me.rhunk.snapenhance.manager.impl.FeatureManager import me.rhunk.snapenhance.nativelib.NativeConfig import me.rhunk.snapenhance.nativelib.NativeLib -import me.rhunk.snapenhance.scripting.ScriptRuntime +import me.rhunk.snapenhance.scripting.core.CoreScriptRuntime import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import kotlin.reflect.KClass @@ -60,7 +60,7 @@ class ModContext { val messageSender = MessageSender(this) val classCache get() = SnapEnhance.classCache val resources: Resources get() = androidContext.resources - val scriptRuntime by lazy { ScriptRuntime(log) } + val scriptRuntime by lazy { CoreScriptRuntime(log, androidContext.classLoader) } fun <T : Feature> feature(featureClass: KClass<T>): T { return features.get(featureClass)!! diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -117,37 +117,7 @@ class SnapEnhance { if (!mappings.isMappingsLoaded()) return features.init() syncRemote() - - bridgeClient.getScriptingInterface().apply { - registerReloadListener(object: ReloadListener.Stub() { - override fun reloadScript(path: String, content: String) { - scriptRuntime.reload(path, content) - } - }) - - scriptRuntime.ipcManager = object: IPCInterface() { - override fun on(eventName: String, listener: Listener) { - registerIPCListener(eventName, object: IPCListener.Stub() { - override fun onMessage(args: Array<out String?>) { - listener(args) - } - }) - } - - override fun emit(eventName: String, args: Array<out String?>) { - sendIPCMessage(eventName, args) - } - } - - enabledScripts.forEach { path -> - runCatching { - scriptRuntime.load(path, getScriptContent(path)) - }.onFailure { - log.error("Failed to load script $path", it) - } - } - } - + scriptRuntime.connect(bridgeClient.getScriptingInterface()) } }.also { time -> appContext.log.verbose("init took $time") @@ -160,7 +130,7 @@ class SnapEnhance { with(appContext) { features.onActivityCreate() actionManager.init() - scriptRuntime.eachModule { callOnSnapActivity(mainActivity!!) } + scriptRuntime.eachModule { callFunction("module.onSnapActivity", mainActivity!!) } } }.also { time -> appContext.log.verbose("onActivityCreate took $time") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt @@ -58,9 +58,7 @@ object Hooker { clazz: Class<*>, stage: HookStage, consumer: (HookAdapter) -> Unit - ) { - XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) - } + ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) fun hookConstructor( clazz: Class<*>, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/IPCInterface.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/IPCInterface.kt @@ -6,6 +6,7 @@ abstract class IPCInterface { abstract fun on(eventName: String, listener: Listener) abstract fun emit(eventName: String, vararg args: String?) - + + @Suppress("unused") fun emit(eventName: String) = emit(eventName, *emptyArray()) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/JSModule.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/JSModule.kt @@ -1,9 +1,12 @@ package me.rhunk.snapenhance.scripting -import android.app.Activity import me.rhunk.snapenhance.core.logger.AbstractLogger +import me.rhunk.snapenhance.scripting.ktx.contextScope +import me.rhunk.snapenhance.scripting.ktx.putFunction +import me.rhunk.snapenhance.scripting.ktx.scriptableObject import me.rhunk.snapenhance.scripting.type.ModuleInfo -import org.mozilla.javascript.Context +import org.mozilla.javascript.Function +import org.mozilla.javascript.NativeJavaObject import org.mozilla.javascript.ScriptableObject import org.mozilla.javascript.Undefined @@ -14,11 +17,11 @@ class JSModule( lateinit var logger: AbstractLogger private lateinit var moduleObject: ScriptableObject - fun load(block: Context.(ScriptableObject) -> Unit) { + fun load(block: ScriptableObject.() -> Unit) { contextScope { moduleObject = initSafeStandardObjects() - moduleObject.putConst("module", moduleObject, buildScriptableObject { - putConst("info", this, buildScriptableObject { + moduleObject.putConst("module", moduleObject, scriptableObject { + putConst("info", this, scriptableObject { putConst("name", this, moduleInfo.name) putConst("version", this, moduleInfo.version) putConst("description", this, moduleInfo.description) @@ -29,46 +32,47 @@ class JSModule( }) }) + moduleObject.putFunction("setField") { args -> + val obj = args?.get(0) as? NativeJavaObject ?: return@putFunction Undefined.instance + val name = args[1].toString() + val value = args[2] + val field = obj.unwrap().javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance + field.isAccessible = true + field.set(obj.unwrap(), value) + Undefined.instance + } + + moduleObject.putFunction("getField") { args -> + val obj = args?.get(0) as? NativeJavaObject ?: return@putFunction Undefined.instance + val name = args[1].toString() + val field = obj.unwrap().javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance + field.isAccessible = true + field.get(obj.unwrap()) + } + moduleObject.putFunction("logInfo") { args -> - logger.info(args?.getOrNull(0)?.toString() ?: "null") + logger.info(args?.joinToString(" ") ?: "") Undefined.instance } - block(this, moduleObject) + block(moduleObject) evaluateString(moduleObject, content, moduleInfo.name, 1, null) } } fun unload() { - contextScope { - moduleObject.scriptable("module")?.function("onUnload")?.call( - this, - moduleObject, - moduleObject, - null - ) - } + callFunction("module.onUnload") } - fun callOnSnapActivity(activity: Activity) { + fun callFunction(name: String, vararg args: Any?) { contextScope { - moduleObject.scriptable("module")?.function("onSnapActivity")?.call( - this, - moduleObject, - moduleObject, - arrayOf(activity) - ) - } - } + name.split(".").also { split -> + val function = split.dropLast(1).fold(moduleObject) { obj, key -> + obj.get(key, obj) as? ScriptableObject ?: return@contextScope + }.get(split.last(), moduleObject) as? Function ?: return@contextScope - fun callOnManagerLoad(context: android.content.Context) { - contextScope { - moduleObject.scriptable("module")?.function("onManagerLoad")?.call( - this, - moduleObject, - moduleObject, - arrayOf(context) - ) + function.call(this, moduleObject, moduleObject, args) + } } } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptKtx.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptKtx.kt @@ -1,43 +0,0 @@ -package me.rhunk.snapenhance.scripting - -import org.mozilla.javascript.Context -import org.mozilla.javascript.Function -import org.mozilla.javascript.Scriptable -import org.mozilla.javascript.ScriptableObject - -fun contextScope(f: Context.() -> Unit) { - val context = Context.enter() - context.optimizationLevel = -1 - try { - context.f() - } finally { - Context.exit() - } -} - -fun Scriptable.scriptable(name: String): Scriptable? { - return this.get(name, this) as? Scriptable -} - -fun Scriptable.function(name: String): Function? { - return this.get(name, this) as? Function -} - -fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any>?) -> Any) { - this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { - override fun call( - cx: Context?, - scope: Scriptable, - thisObj: Scriptable, - args: Array<out Any>? - ): Any { - return thisObj.proxy(args) - } - }) -} - -fun buildScriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { - return object: ScriptableObject() { - override fun getClassName() = name - }.apply(f) -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptRuntime.kt @@ -2,14 +2,15 @@ package me.rhunk.snapenhance.scripting import me.rhunk.snapenhance.core.logger.AbstractLogger import me.rhunk.snapenhance.scripting.type.ModuleInfo +import org.mozilla.javascript.ScriptableObject import java.io.BufferedReader import java.io.ByteArrayInputStream import java.io.InputStream -class ScriptRuntime( - private val logger: AbstractLogger, +open class ScriptRuntime( + protected val logger: AbstractLogger, ) { - lateinit var ipcManager: IPCInterface + var buildModuleObject: ScriptableObject.(JSModule) -> Unit = {} private val modules = mutableMapOf<String, JSModule>() fun eachModule(f: JSModule.() -> Unit) { @@ -76,7 +77,7 @@ class ScriptRuntime( ).apply { logger = this@ScriptRuntime.logger load { - it.putConst("ipc", it, ipcManager) + buildModuleObject(this, this@apply) } modules[path] = this } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/CoreScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/CoreScriptRuntime.kt @@ -0,0 +1,64 @@ +package me.rhunk.snapenhance.scripting.core + +import me.rhunk.snapenhance.bridge.scripting.IPCListener +import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.bridge.scripting.ReloadListener +import me.rhunk.snapenhance.core.logger.AbstractLogger +import me.rhunk.snapenhance.scripting.IPCInterface +import me.rhunk.snapenhance.scripting.Listener +import me.rhunk.snapenhance.scripting.ScriptRuntime +import me.rhunk.snapenhance.scripting.core.impl.ScriptHooker +import me.rhunk.snapenhance.scripting.ktx.putFunction + +class CoreScriptRuntime( + logger: AbstractLogger, + private val classLoader: ClassLoader +): ScriptRuntime(logger) { + private lateinit var ipcInterface: IPCInterface + + private val scriptHookers = mutableListOf<ScriptHooker>() + + + fun connect(scriptingInterface: IScripting) { + scriptingInterface.apply { + registerReloadListener(object: ReloadListener.Stub() { + override fun reloadScript(path: String, content: String) { + reload(path, content) + } + }) + + ipcInterface = object: IPCInterface() { + override fun on(eventName: String, listener: Listener) { + registerIPCListener(eventName, object: IPCListener.Stub() { + override fun onMessage(args: Array<out String?>) { + listener(args) + } + }) + } + + override fun emit(eventName: String, args: Array<out String?>) { + sendIPCMessage(eventName, args) + } + } + } + + buildModuleObject = { module -> + putConst("ipc", this, ipcInterface) + putFunction("findClass") { + val className = it?.get(0).toString() + classLoader.loadClass(className) + } + putConst("hooker", this, ScriptHooker(module.moduleInfo, logger, classLoader).also { + scriptHookers.add(it) + }) + } + + scriptingInterface.enabledScripts.forEach { path -> + runCatching { + load(path, scriptingInterface.getScriptContent(path)) + }.onFailure { + logger.error("Failed to load script $path", it) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/impl/ScriptHooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/impl/ScriptHooker.kt @@ -0,0 +1,132 @@ +package me.rhunk.snapenhance.scripting.core.impl + +import me.rhunk.snapenhance.core.logger.AbstractLogger +import me.rhunk.snapenhance.hook.HookAdapter +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.hook.hook +import me.rhunk.snapenhance.hook.hookConstructor +import me.rhunk.snapenhance.scripting.type.ModuleInfo +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter +import java.lang.reflect.Member + + +class ScriptHookCallback( + private val hookAdapter: HookAdapter +) { + var result + @JSGetter("result") get() = hookAdapter.getResult() + @JSSetter("result") set(result) = hookAdapter.setResult(result) + + val thisObject + @JSGetter("thisObject") get() = hookAdapter.nullableThisObject<Any>() + + val method + @JSGetter("method") get() = hookAdapter.method() + + val args + @JSGetter("args") get() = hookAdapter.args().toList() + + fun cancel() = hookAdapter.setResult(null) + + fun arg(index: Int) = hookAdapter.argNullable<Any>(index) + + fun setArg(index: Int, any: Any) = hookAdapter.setArg(index, any) + + fun invokeOriginal() = hookAdapter.invokeOriginal() + fun invokeOriginal(args: Array<Any>) = hookAdapter.invokeOriginal(args) +} + + +typealias HookCallback = (ScriptHookCallback) -> Unit +typealias HookUnhook = () -> Unit + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class ScriptHooker( + private val moduleInfo: ModuleInfo, + private val logger: AbstractLogger, + private val classLoader: ClassLoader +) { + private val hooks = mutableListOf<HookUnhook>() + + // -- search for class members + + private fun findClassSafe(className: String): Class<*>? { + return runCatching { + classLoader.loadClass(className) + }.onFailure { + logger.warn("Failed to load class $className") + }.getOrNull() + } + + private fun getHookStageFromString(stage: String): HookStage { + return when (stage) { + "before" -> HookStage.BEFORE + "after" -> HookStage.AFTER + else -> throw IllegalArgumentException("Invalid stage: $stage") + } + } + + fun findMethod(clazz: Class<*>, methodName: String): Member? { + return clazz.declaredMethods.find { it.name == methodName } + } + + fun findMethodWithParameters(clazz: Class<*>, methodName: String, vararg types: String): Member? { + return clazz.declaredMethods.find { method -> method.name == methodName && method.parameterTypes.map { it.name }.toTypedArray() contentEquals types } + } + + fun findMethod(className: String, methodName: String): Member? { + return findClassSafe(className)?.let { findMethod(it, methodName) } + } + + fun findMethodWithParameters(className: String, methodName: String, vararg types: String): Member? { + return findClassSafe(className)?.let { findMethodWithParameters(it, methodName, *types) } + } + + fun findConstructor(clazz: Class<*>, vararg types: String): Member? { + return clazz.declaredConstructors.find { constructor -> constructor.parameterTypes.map { it.name }.toTypedArray() contentEquals types } + } + + fun findConstructorParameters(className: String, vararg types: String): Member? { + return findClassSafe(className)?.let { findConstructor(it, *types) } + } + + // -- hooking + + fun hook(method: Member, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = Hooker.hook(method, getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.unhook() + }.also { hooks.add(it) } + } + + fun hookAllMethods(clazz: Class<*>, methodName: String, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = clazz.hook(methodName, getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.forEach { it.unhook() } + }.also { hooks.add(it) } + } + + fun hookAllConstructors(clazz: Class<*>, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = clazz.hookConstructor(getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.forEach { it.unhook() } + }.also { hooks.add(it) } + } + + fun hookAllMethods(className: String, methodName: String, stage: String, callback: HookCallback) + = findClassSafe(className)?.let { hookAllMethods(it, methodName, stage, callback) } + + fun hookAllConstructors(className: String, stage: String, callback: HookCallback) + = findClassSafe(className)?.let { hookAllConstructors(it, stage, callback) } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt @@ -0,0 +1,43 @@ +package me.rhunk.snapenhance.scripting.ktx + +import org.mozilla.javascript.Context +import org.mozilla.javascript.Function +import org.mozilla.javascript.Scriptable +import org.mozilla.javascript.ScriptableObject + +fun contextScope(f: Context.() -> Unit) { + val context = Context.enter() + context.optimizationLevel = -1 + try { + context.f() + } finally { + Context.exit() + } +} + +fun Scriptable.scriptable(name: String): Scriptable? { + return this.get(name, this) as? Scriptable +} + +fun Scriptable.function(name: String): Function? { + return this.get(name, this) as? Function +} + +fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any>?) -> Any?) { + this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { + override fun call( + cx: Context?, + scope: Scriptable, + thisObj: Scriptable, + args: Array<out Any>? + ): Any? { + return thisObj.proxy(args) + } + }) +} + +fun scriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { + return object: ScriptableObject() { + override fun getClassName() = name + }.apply(f) +}+ \ No newline at end of file