feat(scripting): module system

This commit is contained in:
rhunk
2023-12-24 17:29:19 +01:00
parent 7d6978f961
commit 392cd95dac
22 changed files with 315 additions and 129 deletions

View File

@@ -118,7 +118,7 @@ class RemoteSideContext(
}
scriptManager.runtime.eachModule {
callFunction("module.onManagerLoad", androidContext)
callFunction("module.onSnapEnhanceLoad", androidContext)
}
}

View File

@@ -268,7 +268,7 @@ class ModDatabase(
version = cursor.getStringOrNull("version")!!,
description = cursor.getStringOrNull("description"),
author = cursor.getStringOrNull("author"),
grantPermissions = null
grantedPermissions = emptyList()
)
)
}

View File

@@ -11,8 +11,8 @@ 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.scripting.impl.IPCListeners
import me.rhunk.snapenhance.scripting.impl.RemoteManagerIPC
import me.rhunk.snapenhance.scripting.impl.RemoteScriptConfig
import me.rhunk.snapenhance.scripting.impl.ManagerIPC
import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig
import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager
import java.io.File
import java.io.InputStream
@@ -21,7 +21,9 @@ import kotlin.system.exitProcess
class RemoteScriptManager(
val context: RemoteSideContext,
) : IScripting.Stub() {
val runtime = ScriptRuntime(context.androidContext, context.log)
val runtime = ScriptRuntime(context.androidContext, context.log).apply {
scripting = this@RemoteScriptManager
}
private var autoReloadListener: AutoReloadListener? = null
private val autoReloadHandler by lazy {
@@ -61,11 +63,11 @@ class RemoteScriptManager(
fun init() {
runtime.buildModuleObject = { module ->
module.extras["ipc"] = RemoteManagerIPC(module.moduleInfo, context.log, ipcListeners)
module.extras["im"] = InterfaceManager(module.moduleInfo, context.log)
module.extras["config"] = RemoteScriptConfig(this@RemoteScriptManager, module.moduleInfo, context.log).also {
it.load()
}
module.registerBindings(
ManagerIPC(ipcListeners),
InterfaceManager(),
ManagerScriptConfig(this@RemoteScriptManager)
)
}
sync()
@@ -74,12 +76,20 @@ class RemoteScriptManager(
}
}
fun loadScript(name: String) {
val content = getScriptContent(name) ?: return
fun getModulePath(name: String): String? {
return cachedModuleInfo.entries.find { it.value.name == name }?.key
}
fun loadScript(path: String) {
val content = getScriptContent(path) ?: return
if (context.config.root.scripting.autoReload.getNullable() != null) {
autoReloadHandler.addFile(getScriptsFolder()?.findFile(name) ?: return)
autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return)
}
runtime.load(name, content)
runtime.load(path, content)
}
fun unloadScript(scriptPath: String) {
runtime.unload(scriptPath)
}
private fun <R> getScriptInputStream(name: String, callback: (InputStream?) -> R): R {
@@ -140,7 +150,7 @@ class RemoteScriptManager(
value: String?,
save: Boolean
): String? {
val scriptConfig = runtime.getModuleByName(module ?: return null)?.extras?.get("config") as? ConfigInterface ?: return null.also {
val scriptConfig = runtime.getModuleByName(module ?: return null)?.getBinding(ConfigInterface::class) ?: return null.also {
context.log.warn("Failed to get config interface for $module")
}
val transactionType = ConfigTransactionType.fromKey(action)
@@ -154,7 +164,7 @@ class RemoteScriptManager(
ConfigTransactionType.SET -> set(key ?: return@runCatching null, value, save)
ConfigTransactionType.SAVE -> save()
ConfigTransactionType.LOAD -> load()
ConfigTransactionType.DELETE -> delete()
ConfigTransactionType.DELETE -> deleteConfig()
else -> {}
}
null

View File

@@ -2,17 +2,13 @@ package me.rhunk.snapenhance.scripting.impl
import android.os.DeadObjectException
import me.rhunk.snapenhance.bridge.scripting.IPCListener
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.impl.IPCInterface
import me.rhunk.snapenhance.common.scripting.impl.Listener
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import java.util.concurrent.ConcurrentHashMap
typealias IPCListeners = ConcurrentHashMap<String, MutableMap<String, MutableSet<IPCListener>>> // channel, eventName -> listeners
class RemoteManagerIPC(
private val moduleInfo: ModuleInfo,
private val logger: AbstractLogger,
class ManagerIPC(
private val ipcListeners: IPCListeners = ConcurrentHashMap(),
) : IPCInterface() {
companion object {
@@ -20,22 +16,22 @@ class RemoteManagerIPC(
}
override fun on(eventName: String, listener: Listener) {
onBroadcast(moduleInfo.name, eventName, listener)
onBroadcast(context.moduleInfo.name, eventName, listener)
}
override fun emit(eventName: String, vararg args: String?) {
emit(moduleInfo.name, eventName, *args)
emit(context.moduleInfo.name, eventName, *args)
}
override fun onBroadcast(channel: String, eventName: String, listener: Listener) {
ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(object: IPCListener.Stub() {
override fun onMessage(args: Array<out String?>) {
try {
listener(args)
listener(args.toList())
} catch (doe: DeadObjectException) {
ipcListeners[channel]?.get(eventName)?.remove(this)
} catch (t: Throwable) {
logger.error("Failed to receive message for channel: $channel, event: $eventName", t, TAG)
context.runtime.logger.error("Failed to receive message for channel: $channel, event: $eventName", t, TAG)
}
}
})
@@ -48,7 +44,7 @@ class RemoteManagerIPC(
} catch (doe: DeadObjectException) {
ipcListeners[channel]?.get(eventName)?.remove(it)
} catch (t: Throwable) {
logger.error("Failed to send message for channel: $channel, event: $eventName", t, TAG)
context.runtime.logger.error("Failed to send message for channel: $channel, event: $eventName", t, TAG)
}
}
}

View File

@@ -1,18 +1,14 @@
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,
class ManagerScriptConfig(
private val remoteScriptManager: RemoteScriptManager
) : ConfigInterface() {
private val configFile = File(remoteScriptManager.getModuleDataFolder(moduleInfo.name), "config.json")
private val configFile by lazy { File(remoteScriptManager.getModuleDataFolder(context.moduleInfo.name), "config.json") }
private var config = JsonObject()
override fun get(key: String, defaultValue: Any?): String? {
@@ -46,12 +42,16 @@ class RemoteScriptConfig(
}
config = remoteScriptManager.context.gson.fromJson(configFile.readText(), JsonObject::class.java)
}.onFailure {
logger.error("Failed to load config file", it)
context.runtime.logger.error("Failed to load config file", it)
save()
}
}
override fun delete() {
override fun deleteConfig() {
configFile.delete()
}
override fun onInit() {
load()
}
}

View File

@@ -1,13 +1,13 @@
package me.rhunk.snapenhance.scripting.impl.ui
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding
import me.rhunk.snapenhance.common.scripting.bindings.BindingSide
import me.rhunk.snapenhance.common.scripting.ktx.contextScope
import me.rhunk.snapenhance.scripting.impl.ui.components.Node
import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType
import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionNode
import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionType
import me.rhunk.snapenhance.scripting.impl.ui.components.impl.RowColumnNode
import org.mozilla.javascript.Context
import org.mozilla.javascript.Function
import org.mozilla.javascript.annotations.JSFunction
@@ -73,27 +73,31 @@ class InterfaceBuilder {
class InterfaceManager(
private val moduleInfo: ModuleInfo,
private val logger: AbstractLogger
) {
class InterfaceManager : AbstractBinding("interface-manager", BindingSide.MANAGER) {
private val interfaces = mutableMapOf<String, () -> InterfaceBuilder?>()
fun buildInterface(name: String): InterfaceBuilder? {
return interfaces[name]?.invoke()
}
override fun onDispose() {
interfaces.clear()
}
@Suppress("unused")
@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()
contextScope {
callback.call(this, callback, callback, arrayOf(interfaceBuilder))
}
interfaceBuilder
}.onFailure {
logger.error("Failed to create interface $name for ${moduleInfo.name}", it)
context.runtime.logger.error("Failed to create interface $name for ${context.moduleInfo.name}", it)
}.getOrNull()
}
}
override fun getObject() = this
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
@@ -14,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -70,12 +72,27 @@ class ScriptsSection : Section() {
}
Switch(
checked = enabled,
onCheckedChange = {
context.modDatabase.setScriptEnabled(script.name, it)
if (it) {
context.scriptManager.loadScript(script.name)
onCheckedChange = { isChecked ->
context.modDatabase.setScriptEnabled(script.name, isChecked)
enabled = isChecked
runCatching {
val modulePath = context.scriptManager.getModulePath(script.name)!!
context.scriptManager.unloadScript(modulePath)
if (isChecked) {
context.scriptManager.loadScript(modulePath)
context.scriptManager.runtime.getModuleByName(script.name)
?.callFunction("module.onSnapEnhanceLoad")
context.shortToast("Loaded script ${script.name}")
} else {
context.shortToast("Unloaded script ${script.name}")
}
}.onFailure { throwable ->
enabled = !isChecked
("Failed to ${if (isChecked) "enable" else "disable"} script").let {
context.log.error(it, throwable)
context.shortToast(it)
}
}
enabled = it
}
)
}
@@ -130,7 +147,7 @@ class ScriptsSection : Section() {
val settingsInterface = remember {
val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
runCatching {
(module.extras["im"] as? InterfaceManager)?.buildInterface("settings")
(module.getBinding(InterfaceManager::class))?.buildInterface("settings")
}.onFailure {
settingsError = it
}.getOrNull()
@@ -228,4 +245,18 @@ class ScriptsSection : Section() {
)
}
}
@Composable
override fun TopBarActions(rowScope: RowScope) {
rowScope.apply {
IconButton(onClick = {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
data = "https://github.com/SnapEnhance/docs".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
})
}) {
Icon(imageVector = Icons.Default.LibraryBooks, contentDescription = "Documentation")
}
}
}
}