From 98b3f2bfc9833323f958eed8c214cb93099b3a45 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 17 Sep 2023 12:43:57 +0200 Subject: [PATCH] feat(scripting): hooker module --- app/build.gradle.kts | 1 + .../me/rhunk/snapenhance/RemoteSideContext.kt | 2 +- .../scripting/RemoteScriptManager.kt | 15 +- .../kotlin/me/rhunk/snapenhance/ModContext.kt | 4 +- .../me/rhunk/snapenhance/SnapEnhance.kt | 34 +---- .../me/rhunk/snapenhance/hook/Hooker.kt | 4 +- .../snapenhance/scripting/IPCInterface.kt | 3 +- .../rhunk/snapenhance/scripting/JSModule.kt | 70 +++++----- .../snapenhance/scripting/ScriptRuntime.kt | 9 +- .../scripting/core/CoreScriptRuntime.kt | 64 +++++++++ .../scripting/core/impl/ScriptHooker.kt | 132 ++++++++++++++++++ .../{ScriptKtx.kt => ktx/RhinoKtx.kt} | 8 +- 12 files changed, 260 insertions(+), 86 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/CoreScriptRuntime.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/impl/ScriptHooker.kt rename core/src/main/kotlin/me/rhunk/snapenhance/scripting/{ScriptKtx.kt => ktx/RhinoKtx.kt} (83%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dad5ffa9..138027d7 100644 --- 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 index a4e2d48e..92145ff7 100644 --- 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 index e057748c..7e8df9a6 100644 --- 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) { 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) { 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 index bd735da4..85e029c3 100644 --- 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 feature(featureClass: KClass): 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 index abd7e066..153a8360 100644 --- 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) { - listener(args) - } - }) - } - - override fun emit(eventName: String, args: Array) { - 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 index ed3c5cfc..b2801a0b 100644 --- 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 = 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 index 040ce400..9826de15 100644 --- 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 index 48360b6b..2b4054f6 100644 --- 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("logInfo") { args -> - logger.info(args?.getOrNull(0)?.toString() ?: "null") + 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 } - block(this, moduleObject) + 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?.joinToString(" ") ?: "") + Undefined.instance + } + + 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/ScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptRuntime.kt index f39f5534..366dcd0b 100644 --- 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() 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 new file mode 100644 index 00000000..e4484fcf --- /dev/null +++ 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() + + + 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) { + listener(args) + } + }) + } + + override fun emit(eventName: String, args: Array) { + 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 new file mode 100644 index 00000000..05967bc2 --- /dev/null +++ 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() + + 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(index) + + fun setArg(index: Int, any: Any) = hookAdapter.setArg(index, any) + + fun invokeOriginal() = hookAdapter.invokeOriginal() + fun invokeOriginal(args: Array) = 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() + + // -- 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/ScriptKtx.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt similarity index 83% rename from core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptKtx.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt index 035cd263..c22f19db 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptKtx.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.scripting +package me.rhunk.snapenhance.scripting.ktx import org.mozilla.javascript.Context import org.mozilla.javascript.Function @@ -23,20 +23,20 @@ fun Scriptable.function(name: String): Function? { return this.get(name, this) as? Function } -fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array?) -> Any) { +fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array?) -> Any?) { this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { override fun call( cx: Context?, scope: Scriptable, thisObj: Scriptable, args: Array? - ): Any { + ): Any? { return thisObj.proxy(args) } }) } -fun buildScriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { +fun scriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { return object: ScriptableObject() { override fun getClassName() = name }.apply(f)