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 }