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