feat(scripting): config interface

- change onLaunched to onDispose
- refactor JSModule extras
This commit is contained in:
rhunk
2023-10-14 12:14:30 +02:00
parent baf8727912
commit b92589fa07
15 changed files with 303 additions and 71 deletions

View File

@ -14,6 +14,8 @@ import coil.ImageLoader
import coil.decode.VideoFrameDecoder import coil.decode.VideoFrameDecoder
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import me.rhunk.snapenhance.bridge.BridgeService import me.rhunk.snapenhance.bridge.BridgeService
@ -81,6 +83,8 @@ class RemoteSideContext(
.components { add(VideoFrameDecoder.Factory()) }.build() .components { add(VideoFrameDecoder.Factory()) }.build()
} }
val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() }
fun reload() { fun reload() {
log.verbose("Loading RemoteSideContext") log.verbose("Loading RemoteSideContext")
runCatching { runCatching {

View File

@ -6,18 +6,20 @@ import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.scripting.IPCListener import me.rhunk.snapenhance.bridge.scripting.IPCListener
import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.common.scripting.ScriptRuntime import me.rhunk.snapenhance.common.scripting.ScriptRuntime
import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.scripting.impl.IPCListeners import me.rhunk.snapenhance.scripting.impl.IPCListeners
import me.rhunk.snapenhance.scripting.impl.RemoteManagerIPC import me.rhunk.snapenhance.scripting.impl.RemoteManagerIPC
import me.rhunk.snapenhance.scripting.impl.ui.InterfaceBuilder import me.rhunk.snapenhance.scripting.impl.RemoteScriptConfig
import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager
import java.io.File
import java.io.InputStream import java.io.InputStream
class RemoteScriptManager( class RemoteScriptManager(
private val context: RemoteSideContext, val context: RemoteSideContext,
) : IScripting.Stub() { ) : IScripting.Stub() {
val runtime = ScriptRuntime(context.androidContext, context.log) val runtime = ScriptRuntime(context.androidContext, context.log)
private val userInterfaces = mutableMapOf<String, MutableMap<String, InterfaceBuilder>>()
private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>() private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>()
private val ipcListeners = IPCListeners() private val ipcListeners = IPCListeners()
@ -40,19 +42,15 @@ class RemoteScriptManager(
fun init() { fun init() {
runtime.buildModuleObject = { module -> runtime.buildModuleObject = { module ->
putConst("ipc", this, RemoteManagerIPC(module.moduleInfo, context.log, ipcListeners)) module.extras["ipc"] = RemoteManagerIPC(module.moduleInfo, context.log, ipcListeners)
putConst("im", this, InterfaceManager(module.moduleInfo, context.log) { name, interfaceBuilder -> module.extras["im"] = InterfaceManager(module.moduleInfo, context.log)
userInterfaces.getOrPut(module.moduleInfo.name) { module.extras["config"] = RemoteScriptConfig(this@RemoteScriptManager, module.moduleInfo, context.log).also {
mutableMapOf() it.load()
}[name] = interfaceBuilder }
})
} }
sync() sync()
enabledScripts.forEach { name -> enabledScripts.forEach { name ->
if (getModuleDataFolder(name) == null) {
context.log.warn("Module data folder not found for $name")
}
loadScript(name) loadScript(name)
} }
} }
@ -62,19 +60,17 @@ class RemoteScriptManager(
runtime.load(name, content) runtime.load(name, content)
} }
fun getScriptInterface(scriptName: String, interfaceName: String)
= userInterfaces[scriptName]?.get(interfaceName)
private fun <R> getScriptInputStream(name: String, callback: (InputStream?) -> R): R { private fun <R> getScriptInputStream(name: String, callback: (InputStream?) -> R): R {
val file = getScriptsFolder()?.findFile(name) ?: return callback(null) val file = getScriptsFolder()?.findFile(name) ?: return callback(null)
return context.androidContext.contentResolver.openInputStream(file.uri)?.use(callback) ?: callback(null) return context.androidContext.contentResolver.openInputStream(file.uri)?.use(callback) ?: callback(null)
} }
private fun getModuleDataFolder(moduleFileName: String): DocumentFile? { fun getModuleDataFolder(moduleFileName: String): File {
val folderName = moduleFileName.substringBeforeLast(".js") return context.androidContext.filesDir.resolve("modules").resolve(moduleFileName).also {
val folder = getScriptsFolder() ?: return null if (!it.exists()) {
return folder.findFile(folderName) ?: folder.createDirectory(folderName) it.mkdirs()
}
}
} }
private fun getScriptsFolder() = runCatching { private fun getScriptsFolder() = runCatching {
@ -114,4 +110,35 @@ class RemoteScriptManager(
context.log.error("Failed to send message for $eventName", it) context.log.error("Failed to send message for $eventName", it)
} }
} }
override fun configTransaction(
module: String?,
action: String,
key: String?,
value: String?,
save: Boolean
): String? {
val scriptConfig = runtime.getModuleByName(module ?: return null)?.extras?.get("config") as? ConfigInterface ?: return null.also {
context.log.warn("Failed to get config interface for $module")
}
val transactionType = ConfigTransactionType.fromKey(action)
return runCatching {
scriptConfig.run {
if (transactionType == ConfigTransactionType.GET) {
return get(key ?: return@runCatching null, value)
}
when (transactionType) {
ConfigTransactionType.SET -> set(key ?: return@runCatching null, value, save)
ConfigTransactionType.SAVE -> save()
ConfigTransactionType.LOAD -> load()
ConfigTransactionType.DELETE -> delete()
else -> {}
}
null
}
}.onFailure {
context.log.error("Failed to perform config transaction", it)
}.getOrDefault("")
}
} }

View File

@ -3,8 +3,8 @@ package me.rhunk.snapenhance.scripting.impl
import android.os.DeadObjectException import android.os.DeadObjectException
import me.rhunk.snapenhance.bridge.scripting.IPCListener import me.rhunk.snapenhance.bridge.scripting.IPCListener
import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.IPCInterface import me.rhunk.snapenhance.common.scripting.impl.IPCInterface
import me.rhunk.snapenhance.common.scripting.Listener import me.rhunk.snapenhance.common.scripting.impl.Listener
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap

View File

@ -0,0 +1,57 @@
package me.rhunk.snapenhance.scripting.impl
import com.google.gson.JsonObject
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.scripting.RemoteScriptManager
import java.io.File
class RemoteScriptConfig(
private val remoteScriptManager: RemoteScriptManager,
moduleInfo: ModuleInfo,
private val logger: AbstractLogger,
) : ConfigInterface() {
private val configFile = File(remoteScriptManager.getModuleDataFolder(moduleInfo.name), "config.json")
private var config = JsonObject()
override fun get(key: String, defaultValue: Any?): String? {
return config[key]?.asString ?: defaultValue?.toString()
}
override fun set(key: String, value: Any?, save: Boolean) {
when (value) {
is Int -> config.addProperty(key, value)
is Double -> config.addProperty(key, value)
is Boolean -> config.addProperty(key, value)
is Long -> config.addProperty(key, value)
is Float -> config.addProperty(key, value)
is Byte -> config.addProperty(key, value)
is Short -> config.addProperty(key, value)
else -> config.addProperty(key, value?.toString())
}
if (save) save()
}
override fun save() {
configFile.writeText(config.toString())
}
override fun load() {
runCatching {
if (!configFile.exists()) {
save()
return@runCatching
}
config = remoteScriptManager.context.gson.fromJson(configFile.readText(), JsonObject::class.java)
}.onFailure {
logger.error("Failed to load config file", it)
save()
}
}
override fun delete() {
configFile.delete()
}
}

View File

@ -12,15 +12,15 @@ import org.mozilla.javascript.annotations.JSFunction
class InterfaceBuilder { class InterfaceBuilder {
val nodes = mutableListOf<Node>() val nodes = mutableListOf<Node>()
var onLaunchedCallback: (() -> Unit)? = null var onDisposeCallback: (() -> Unit)? = null
private fun createNode(type: NodeType, block: Node.() -> Unit): Node { private fun createNode(type: NodeType, block: Node.() -> Unit): Node {
return Node(type).apply(block).also { nodes.add(it) } return Node(type).apply(block).also { nodes.add(it) }
} }
fun onLaunched(block: () -> Unit) { fun onDispose(block: () -> Unit) {
onLaunchedCallback = block onDisposeCallback = block
} }
fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply { fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply {
@ -66,14 +66,25 @@ class InterfaceBuilder {
class InterfaceManager( class InterfaceManager(
private val moduleInfo: ModuleInfo, private val moduleInfo: ModuleInfo,
private val logger: AbstractLogger, private val logger: AbstractLogger
private val registerInterface: (String, InterfaceBuilder) -> Unit,
) { ) {
@JSFunction private val interfaces = mutableMapOf<String, () -> InterfaceBuilder?>()
fun create(name: String, callback: Function) {
logger.info("Creating interface $name for ${moduleInfo.name}") fun buildInterface(name: String): InterfaceBuilder? {
val interfaceBuilder = InterfaceBuilder() return interfaces[name]?.invoke()
callback.call(Context.getCurrentContext(), callback, callback, arrayOf(interfaceBuilder)) }
registerInterface(name, interfaceBuilder)
@JSFunction fun create(name: String, callback: Function) {
interfaces[name] = {
val interfaceBuilder = InterfaceBuilder()
runCatching {
Context.enter()
callback.call(Context.getCurrentContext(), callback, callback, arrayOf(interfaceBuilder))
Context.exit()
interfaceBuilder
}.onFailure {
logger.error("Failed to create interface $name for ${moduleInfo.name}", it)
}.getOrNull()
}
} }
} }

View File

@ -147,8 +147,14 @@ fun ScriptInterface(interfaceBuilder: InterfaceBuilder) {
DrawNode(node) DrawNode(node)
} }
LaunchedEffect(interfaceBuilder) { DisposableEffect(Unit) {
interfaceBuilder.onLaunchedCallback?.invoke() onDispose {
runCatching {
interfaceBuilder.onDisposeCallback?.invoke()
}.onFailure {
AbstractLogger.directError("Error running onDisposed callback", it)
}
}
} }
} }
} }

View File

@ -14,6 +14,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager
import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
@ -88,7 +89,8 @@ class ScriptsSection : Section() {
@Composable @Composable
fun ScriptSettings(script: ModuleInfo) { fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember { val settingsInterface = remember {
context.scriptManager.getScriptInterface(script.name, "settings") val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
(module.extras["im"] as? InterfaceManager)?.buildInterface("settings")
} ?: run { } ?: run {
Text( Text(
text = "This module does not have any settings", text = "This module does not have any settings",
@ -101,7 +103,6 @@ class ScriptsSection : Section() {
ScriptInterface(interfaceBuilder = settingsInterface) ScriptInterface(interfaceBuilder = settingsInterface)
} }
@Composable @Composable
override fun Content() { override fun Content() {
var scriptModules by remember { var scriptModules by remember {

View File

@ -10,4 +10,6 @@ interface IScripting {
void registerIPCListener(String channel, String eventName, IPCListener listener); void registerIPCListener(String channel, String eventName, IPCListener listener);
void sendIPCMessage(String channel, String eventName, in String[] args); void sendIPCMessage(String channel, String eventName, in String[] args);
@nullable String configTransaction(String module, String action, @nullable String key, @nullable String value, boolean save);
} }

View File

@ -18,6 +18,7 @@ class JSModule(
val moduleInfo: ModuleInfo, val moduleInfo: ModuleInfo,
val content: String, val content: String,
) { ) {
val extras = mutableMapOf<String, Any>()
private lateinit var moduleObject: ScriptableObject private lateinit var moduleObject: ScriptableObject
fun load(block: ScriptableObject.() -> Unit) { fun load(block: ScriptableObject.() -> Unit) {
@ -115,8 +116,10 @@ class JSModule(
Undefined.instance Undefined.instance
} }
} }
block(moduleObject) block(moduleObject)
extras.forEach { (key, value) ->
moduleObject.putConst(key, moduleObject, value)
}
evaluateString(moduleObject, content, moduleInfo.name, 1, null) evaluateString(moduleObject, content, moduleInfo.name, 1, null)
} }
} }

View File

@ -25,6 +25,10 @@ open class ScriptRuntime(
} }
} }
fun getModuleByName(name: String): JSModule? {
return modules.values.find { it.moduleInfo.name == name }
}
private fun readModuleInfo(reader: BufferedReader): ModuleInfo { private fun readModuleInfo(reader: BufferedReader): ModuleInfo {
val header = reader.readLine() val header = reader.readLine()
if (!header.startsWith("// ==SE_module==")) { if (!header.startsWith("// ==SE_module==")) {

View File

@ -0,0 +1,74 @@
package me.rhunk.snapenhance.common.scripting.impl
import org.mozilla.javascript.annotations.JSFunction
enum class ConfigTransactionType(
val key: String
) {
GET("get"),
SET("set"),
SAVE("save"),
LOAD("load"),
DELETE("delete");
companion object {
fun fromKey(key: String) = entries.find { it.key == key }
}
}
abstract class ConfigInterface {
@JSFunction fun get(key: String): String? = get(key, null)
@JSFunction abstract fun get(key: String, defaultValue: Any?): String?
@JSFunction fun getInteger(key: String): Int? = getInteger(key, null)
@JSFunction fun getInteger(key: String, defaultValue: Int?): Int? = get(key, defaultValue.toString())?.toIntOrNull() ?: defaultValue
@JSFunction fun getDouble(key: String): Double? = getDouble(key, null)
@JSFunction fun getDouble(key: String, defaultValue: Double?): Double? = get(key, defaultValue.toString())?.toDoubleOrNull() ?: defaultValue
@JSFunction fun getBoolean(key: String): Boolean? = getBoolean(key, null)
@JSFunction fun getBoolean(key: String, defaultValue: Boolean?): Boolean? = get(key, defaultValue.toString())?.toBoolean() ?: defaultValue
@JSFunction fun getLong(key: String): Long? = getLong(key, null)
@JSFunction fun getLong(key: String, defaultValue: Long?): Long? = get(key, defaultValue.toString())?.toLongOrNull() ?: defaultValue
@JSFunction fun getFloat(key: String): Float? = getFloat(key, null)
@JSFunction fun getFloat(key: String, defaultValue: Float?): Float? = get(key, defaultValue.toString())?.toFloatOrNull() ?: defaultValue
@JSFunction fun getByte(key: String): Byte? = getByte(key, null)
@JSFunction fun getByte(key: String, defaultValue: Byte?): Byte? = get(key, defaultValue.toString())?.toByteOrNull() ?: defaultValue
@JSFunction fun getShort(key: String): Short? = getShort(key, null)
@JSFunction fun getShort(key: String, defaultValue: Short?): Short? = get(key, defaultValue.toString())?.toShortOrNull() ?: defaultValue
@JSFunction fun set(key: String, value: Any?) = set(key, value, false)
@JSFunction abstract fun set(key: String, value: Any?, save: Boolean)
@JSFunction fun setInteger(key: String, value: Int?) = setInteger(key, value, false)
@JSFunction fun setInteger(key: String, value: Int?, save: Boolean) = set(key, value, save)
@JSFunction fun setDouble(key: String, value: Double?) = setDouble(key, value, false)
@JSFunction fun setDouble(key: String, value: Double?, save: Boolean) = set(key, value, save)
@JSFunction fun setBoolean(key: String, value: Boolean?) = setBoolean(key, value, false)
@JSFunction fun setBoolean(key: String, value: Boolean?, save: Boolean) = set(key, value, save)
@JSFunction fun setLong(key: String, value: Long?) = setLong(key, value, false)
@JSFunction fun setLong(key: String, value: Long?, save: Boolean) = set(key, value, save)
@JSFunction fun setFloat(key: String, value: Float?) = setFloat(key, value, false)
@JSFunction fun setFloat(key: String, value: Float?, save: Boolean) = set(key, value, save)
@JSFunction fun setByte(key: String, value: Byte?) = setByte(key, value, false)
@JSFunction fun setByte(key: String, value: Byte?, save: Boolean) = set(key, value, save)
@JSFunction fun setShort(key: String, value: Short?) = setShort(key, value, false)
@JSFunction fun setShort(key: String, value: Short?, save: Boolean) = set(key, value, save)
@JSFunction abstract fun save()
@JSFunction abstract fun load()
@JSFunction abstract fun delete()
}

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.common.scripting package me.rhunk.snapenhance.common.scripting.impl
typealias Listener = (Array<out String?>) -> Unit typealias Listener = (Array<out String?>) -> Unit

View File

@ -1,12 +1,11 @@
package me.rhunk.snapenhance.core.scripting package me.rhunk.snapenhance.core.scripting
import android.content.Context import android.content.Context
import me.rhunk.snapenhance.bridge.scripting.IPCListener
import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.IPCInterface
import me.rhunk.snapenhance.common.scripting.Listener
import me.rhunk.snapenhance.common.scripting.ScriptRuntime import me.rhunk.snapenhance.common.scripting.ScriptRuntime
import me.rhunk.snapenhance.core.scripting.impl.CoreIPC
import me.rhunk.snapenhance.core.scripting.impl.CoreScriptConfig
import me.rhunk.snapenhance.core.scripting.impl.ScriptHooker import me.rhunk.snapenhance.core.scripting.impl.ScriptHooker
class CoreScriptRuntime( class CoreScriptRuntime(
@ -18,38 +17,19 @@ class CoreScriptRuntime(
fun connect(scriptingInterface: IScripting) { fun connect(scriptingInterface: IScripting) {
scriptingInterface.apply { scriptingInterface.apply {
buildModuleObject = { module -> buildModuleObject = { module ->
putConst("ipc", this, object: IPCInterface() { module.extras["ipc"] = CoreIPC(this@apply, module.moduleInfo)
override fun onBroadcast(channel: String, eventName: String, listener: Listener) { module.extras["hooker"] = ScriptHooker(module.moduleInfo, logger, androidContext.classLoader).also {
registerIPCListener(channel, eventName, object: IPCListener.Stub() {
override fun onMessage(args: Array<out String?>) {
listener(args)
}
})
}
override fun on(eventName: String, listener: Listener) {
onBroadcast(module.moduleInfo.name, eventName, listener)
}
override fun emit(eventName: String, vararg args: String?) {
broadcast(module.moduleInfo.name, eventName, *args)
}
override fun broadcast(channel: String, eventName: String, vararg args: String?) {
sendIPCMessage(channel, eventName, args)
}
})
putConst("hooker", this, ScriptHooker(module.moduleInfo, logger, androidContext.classLoader).also {
scriptHookers.add(it) scriptHookers.add(it)
}) }
module.extras["config"] = CoreScriptConfig(this@apply, module.moduleInfo)
} }
}
scriptingInterface.enabledScripts.forEach { path -> enabledScripts.forEach { path ->
runCatching { runCatching {
load(path, scriptingInterface.getScriptContent(path)) load(path, scriptingInterface.getScriptContent(path))
}.onFailure { }.onFailure {
logger.error("Failed to load script $path", it) logger.error("Failed to load script $path", it)
}
} }
} }
} }

View File

@ -0,0 +1,32 @@
package me.rhunk.snapenhance.core.scripting.impl
import me.rhunk.snapenhance.bridge.scripting.IPCListener
import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.common.scripting.impl.IPCInterface
import me.rhunk.snapenhance.common.scripting.impl.Listener
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
class CoreIPC(
private val scripting: IScripting,
private val moduleInfo: ModuleInfo
) : IPCInterface() {
override fun onBroadcast(channel: String, eventName: String, listener: Listener) {
scripting.registerIPCListener(channel, eventName, object: IPCListener.Stub() {
override fun onMessage(args: Array<out String?>) {
listener(args)
}
})
}
override fun on(eventName: String, listener: Listener) {
onBroadcast(moduleInfo.name, eventName, listener)
}
override fun emit(eventName: String, vararg args: String?) {
broadcast(moduleInfo.name, eventName, *args)
}
override fun broadcast(channel: String, eventName: String, vararg args: String?) {
scripting.sendIPCMessage(channel, eventName, args)
}
}

View File

@ -0,0 +1,31 @@
package me.rhunk.snapenhance.core.scripting.impl
import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
class CoreScriptConfig(
private val scripting: IScripting,
private val moduleInfo: ModuleInfo
): ConfigInterface() {
override fun get(key: String, defaultValue: Any?): String? {
return scripting.configTransaction(moduleInfo.name, ConfigTransactionType.GET.key, key, defaultValue.toString(), false)
}
override fun set(key: String, value: Any?, save: Boolean) {
scripting.configTransaction(moduleInfo.name, ConfigTransactionType.SET.key, key, value.toString(), save)
}
override fun save() {
scripting.configTransaction(moduleInfo.name, ConfigTransactionType.SAVE.key, null, null, false)
}
override fun load() {
scripting.configTransaction(moduleInfo.name, ConfigTransactionType.LOAD.key, null, null, false)
}
override fun delete() {
scripting.configTransaction(moduleInfo.name, ConfigTransactionType.DELETE.key, null, null, false)
}
}