RemoteScriptManager.kt (9732B) - raw


      1 package me.rhunk.snapenhance.scripting
      2 
      3 import android.annotation.SuppressLint
      4 import android.net.Uri
      5 import android.os.ParcelFileDescriptor
      6 import androidx.documentfile.provider.DocumentFile
      7 import me.rhunk.snapenhance.RemoteSideContext
      8 import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener
      9 import me.rhunk.snapenhance.bridge.scripting.IPCListener
     10 import me.rhunk.snapenhance.bridge.scripting.IScripting
     11 import me.rhunk.snapenhance.common.scripting.ScriptRuntime
     12 import me.rhunk.snapenhance.common.scripting.bindings.BindingSide
     13 import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface
     14 import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType
     15 import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
     16 import me.rhunk.snapenhance.common.scripting.type.readModuleInfo
     17 import me.rhunk.snapenhance.common.util.ktx.await
     18 import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor
     19 import me.rhunk.snapenhance.scripting.impl.IPCListeners
     20 import me.rhunk.snapenhance.scripting.impl.ManagerIPC
     21 import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig
     22 import me.rhunk.snapenhance.storage.isScriptEnabled
     23 import okhttp3.OkHttpClient
     24 import okhttp3.Request
     25 import java.io.File
     26 import java.io.InputStream
     27 import kotlin.system.exitProcess
     28 
     29 class RemoteScriptManager(
     30     val context: RemoteSideContext,
     31 ) : IScripting.Stub() {
     32     val runtime = ScriptRuntime(context.androidContext, context.log).apply {
     33         scripting = this@RemoteScriptManager
     34     }
     35 
     36     private val okHttpClient by lazy {
     37         OkHttpClient.Builder().build()
     38     }
     39 
     40     private var autoReloadListener: AutoReloadListener? = null
     41     private val autoReloadHandler by lazy {
     42         AutoReloadHandler(context.coroutineScope) {
     43             runCatching {
     44                 autoReloadListener?.restartApp()
     45                 if (context.config.root.scripting.autoReload.getNullable() == "all") {
     46                     exitProcess(1)
     47                 }
     48             }.onFailure {
     49                 context.log.warn("Failed to restart app")
     50                 autoReloadListener = null
     51             }
     52         }.apply {
     53             start()
     54         }
     55     }
     56 
     57     private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>()
     58     private val ipcListeners = IPCListeners()
     59 
     60     fun getSyncedModules(): List<ModuleInfo> {
     61         return cachedModuleInfo.values.toList()
     62     }
     63 
     64     fun sync() {
     65         cachedModuleInfo.clear()
     66         getScriptFileNames().forEach { name ->
     67             runCatching {
     68                 getScriptInputStream(name) { stream ->
     69                     stream?.use {
     70                         cachedModuleInfo[name] = it.bufferedReader().readModuleInfo()
     71                     }
     72                 }
     73             }.onFailure {
     74                 context.log.error("Failed to load module info for $name", it)
     75             }
     76         }
     77     }
     78 
     79     fun init() {
     80         runtime.buildModuleObject = { module ->
     81             putConst("currentSide", this, BindingSide.MANAGER.key)
     82             module.registerBindings(
     83                 ManagerIPC(ipcListeners),
     84                 ManagerScriptConfig(this@RemoteScriptManager)
     85             )
     86         }
     87 
     88         sync()
     89         getEnabledScripts(listOf(BindingSide.MANAGER.key)).forEach { name ->
     90             runCatching {
     91                 loadScript(name)
     92             }.onFailure {
     93                 context.log.error("Failed to load script $name", it)
     94             }
     95         }
     96     }
     97 
     98     fun getModulePath(name: String): String? {
     99         return cachedModuleInfo.entries.find { it.value.name == name }?.key
    100     }
    101 
    102     fun loadScript(path: String) {
    103         val content = getScriptContent(path) ?: return
    104         runtime.load(path, content)
    105         if (context.config.root.scripting.autoReload.getNullable() != null) {
    106             autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return)
    107         }
    108     }
    109 
    110     fun unloadScript(scriptPath: String) {
    111         runtime.unload(scriptPath)
    112     }
    113 
    114     @SuppressLint("Recycle")
    115     private fun <R> getScriptInputStream(name: String, callback: (InputStream?) -> R): R {
    116         val file = getScriptsFolder()?.findFile(name) ?: return callback(null)
    117         return context.androidContext.contentResolver.openInputStream(file.uri)?.let(callback) ?: callback(null)
    118     }
    119 
    120     fun getModuleDataFolder(moduleFileName: String): File {
    121         return context.androidContext.filesDir.resolve("modules").resolve(moduleFileName).also {
    122             if (!it.exists()) {
    123                 it.mkdirs()
    124             }
    125         }
    126     }
    127 
    128     fun getScriptsFolder() = runCatching {
    129         DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get()))
    130     }.getOrNull()
    131 
    132     private fun getScriptFileNames(): List<String> {
    133         return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! }
    134     }
    135 
    136     fun importFromUrl(
    137         url: String,
    138         filepath: String? = null
    139     ): ModuleInfo {
    140         val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute()
    141         if (!response.isSuccessful) {
    142             throw Exception("Failed to fetch script. Code: ${response.code}")
    143         }
    144         response.body.byteStream().use { inputStream ->
    145             val bufferedInputStream = inputStream.buffered()
    146             bufferedInputStream.mark(0)
    147             val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo()
    148             bufferedInputStream.reset()
    149 
    150             val scriptPath = filepath ?: (moduleInfo.name + ".js")
    151             val scriptFile = getScriptsFolder()?.findFile(scriptPath) ?: getScriptsFolder()?.createFile("text/javascript", scriptPath)
    152                 ?: throw Exception("Failed to create script file")
    153 
    154             context.androidContext.contentResolver.openOutputStream(scriptFile.uri, "wt")?.use { output ->
    155                 bufferedInputStream.copyTo(output)
    156             }
    157 
    158             sync()
    159             loadScript(scriptPath)
    160             runtime.removeModule(scriptPath)
    161             return moduleInfo
    162         }
    163     }
    164 
    165     suspend fun checkForUpdate(inputModuleInfo: ModuleInfo): ModuleInfo? {
    166         return runCatching {
    167             context.log.verbose("checking for updates for ${inputModuleInfo.name} ${inputModuleInfo.updateUrl}")
    168             val response = okHttpClient.newCall(Request.Builder().url(inputModuleInfo.updateUrl ?: return@runCatching null).build()).await()
    169             if (!response.isSuccessful) {
    170                 return@runCatching null
    171             }
    172             response.body.byteStream().use { inputStream ->
    173                 val reader = inputStream.buffered().bufferedReader()
    174                 val moduleInfo = reader.readModuleInfo()
    175                 moduleInfo.takeIf {
    176                    it.version != inputModuleInfo.version
    177                 }
    178             }
    179        }.onFailure {
    180            context.log.error("Failed to check for updates", it)
    181        }.getOrNull()
    182     }
    183 
    184     private fun getEnabledScripts(sides: List<String>): List<String> {
    185         return runCatching {
    186             getScriptFileNames().filter { name ->
    187                 cachedModuleInfo[name]?.executionSides?.any { it in sides } ?: true &&
    188                 context.database.isScriptEnabled(cachedModuleInfo[name]?.name ?: return@filter false)
    189             }
    190         }.onFailure {
    191             context.log.error("Failed to get enabled scripts", it)
    192         }.getOrDefault(emptyList())
    193     }
    194 
    195     override fun getEnabledScripts(): List<String> {
    196         return getEnabledScripts(listOf(BindingSide.CORE.key))
    197     }
    198 
    199     override fun getScriptContent(moduleName: String): ParcelFileDescriptor? {
    200         return getScriptInputStream(moduleName) { it?.toParcelFileDescriptor(context.coroutineScope) }
    201     }
    202 
    203     override fun registerIPCListener(channel: String, eventName: String, listener: IPCListener) {
    204         ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(listener)
    205     }
    206 
    207     override fun sendIPCMessage(channel: String, eventName: String, args: Array<out String>): Int {
    208         var dispatchCount = 0
    209         ipcListeners[channel]?.get(eventName)?.toList()?.forEach {
    210             runCatching {
    211                 it.onMessage(args)
    212                 dispatchCount++
    213             }.onFailure {
    214                 context.log.error("Failed to send message for $eventName", it)
    215             }
    216         }
    217         return dispatchCount
    218     }
    219 
    220     override fun configTransaction(
    221         module: String?,
    222         action: String,
    223         key: String?,
    224         value: String?,
    225         save: Boolean
    226     ): String? {
    227         val scriptConfig = runtime.getModuleByName(module ?: return null)?.getBinding(ConfigInterface::class) ?: return null.also {
    228             context.log.warn("Failed to get config interface for $module")
    229         }
    230         val transactionType = ConfigTransactionType.fromKey(action)
    231 
    232         return runCatching {
    233             scriptConfig.run {
    234                 if (transactionType == ConfigTransactionType.GET) {
    235                     return get(key ?: return@runCatching null, value)
    236                 }
    237                 when (transactionType) {
    238                     ConfigTransactionType.SET -> set(key ?: return@runCatching null, value, save)
    239                     ConfigTransactionType.SAVE -> save()
    240                     ConfigTransactionType.LOAD -> load()
    241                     ConfigTransactionType.DELETE -> deleteConfig()
    242                     else -> {}
    243                 }
    244                 null
    245             }
    246         }.onFailure {
    247             context.log.error("Failed to perform config transaction", it)
    248         }.getOrDefault("")
    249     }
    250 
    251     override fun registerAutoReloadListener(listener: AutoReloadListener?) {
    252         autoReloadListener = listener
    253     }
    254 }