package me.eternal.purrfect.scripting import android.annotation.SuppressLint import android.net.Uri import android.os.ParcelFileDescriptor import androidx.documentfile.provider.DocumentFile import me.eternal.purrfect.RemoteSideContext import me.eternal.purrfect.bridge.scripting.AutoReloadListener import me.eternal.purrfect.bridge.scripting.IPCListener import me.eternal.purrfect.bridge.scripting.IScripting import me.eternal.purrfect.common.scripting.ScriptRuntime import me.eternal.purrfect.common.scripting.bindings.BindingSide import me.eternal.purrfect.common.scripting.impl.ConfigInterface import me.eternal.purrfect.common.scripting.impl.ConfigTransactionType import me.eternal.purrfect.common.scripting.type.ModuleInfo import me.eternal.purrfect.common.scripting.type.readModuleInfo import me.eternal.purrfect.common.util.ktx.await import me.eternal.purrfect.common.util.ktx.toParcelFileDescriptor import me.eternal.purrfect.scripting.impl.IPCListeners import me.eternal.purrfect.scripting.impl.ManagerIPC import me.eternal.purrfect.scripting.impl.ManagerScriptConfig import me.eternal.purrfect.storage.isScriptEnabled import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.InputStream import kotlin.system.exitProcess class RemoteScriptManager( val context: RemoteSideContext, ) : IScripting.Stub() { val runtime = ScriptRuntime( config = { context.config.root }, androidContext = context.androidContext, logger = context.log ).apply { scripting = this@RemoteScriptManager } private val okHttpClient by lazy { OkHttpClient.Builder().build() } private var autoReloadListener: AutoReloadListener? = null private val autoReloadHandler by lazy { AutoReloadHandler(context.coroutineScope) { runCatching { autoReloadListener?.restartApp() if (context.config.root.scripting.autoReload.getNullable() == "all") { exitProcess(1) } }.onFailure { context.log.warn("Failed to restart app") autoReloadListener = null } }.apply { start() } } private val cachedModuleInfo = mutableMapOf() private val ipcListeners = IPCListeners() fun getSyncedModules(): List { return cachedModuleInfo.values.toList() } fun sync() { cachedModuleInfo.clear() getScriptFileNames().forEach { name -> runCatching { getScriptInputStream(name) { stream -> stream?.use { cachedModuleInfo[name] = it.bufferedReader().readModuleInfo() } } }.onFailure { context.log.error("Failed to load module info for $name", it) } } } fun init() { runtime.buildModuleObject = { module -> putConst("currentSide", this, BindingSide.MANAGER.key) module.registerBindings( ManagerIPC(ipcListeners), ManagerScriptConfig(this@RemoteScriptManager) ) } sync() getEnabledScripts(listOf(BindingSide.MANAGER.key)).forEach { name -> runCatching { loadScript(name) }.onFailure { context.log.error("Failed to load script $name", it) } } } fun getModulePath(name: String): String? { return cachedModuleInfo.entries.find { it.value.name == name }?.key } fun loadScript(path: String) { val content = getScriptContent(path) ?: return runtime.load(path, content) if (context.config.root.scripting.autoReload.getNullable() != null) { autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return) } } fun unloadScript(scriptPath: String) { runtime.unload(scriptPath) } @SuppressLint("Recycle") private fun getScriptInputStream(name: String, callback: (InputStream?) -> R): R { val file = getScriptsFolder()?.findFile(name) ?: return callback(null) return context.androidContext.contentResolver.openInputStream(file.uri)?.let(callback) ?: callback(null) } fun getModuleDataFolder(moduleFileName: String): File { return context.androidContext.filesDir.resolve("modules").resolve(moduleFileName).also { if (!it.exists()) { it.mkdirs() } } } fun getScriptsFolder() = runCatching { DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get())) }.getOrNull() private fun getScriptFileNames(): List { return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! } } fun importFromUrl( url: String, filepath: String? = null ): ModuleInfo { val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() if (!response.isSuccessful) { throw Exception("Failed to fetch script. Code: ${response.code}") } val inputStream = response.body?.byteStream() ?: throw Exception("Response body is null or cannot be read") inputStream.use { val bufferedInputStream = it.buffered() bufferedInputStream.mark(0) val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo() bufferedInputStream.reset() val scriptPath = filepath ?: (moduleInfo.name + ".js") val scriptFile = getScriptsFolder()?.findFile(scriptPath) ?: getScriptsFolder()?.createFile("text/javascript", scriptPath) ?: throw Exception("Failed to create script file") context.androidContext.contentResolver.openOutputStream(scriptFile.uri, "wt")?.use { output -> bufferedInputStream.copyTo(output) } sync() loadScript(scriptPath) runtime.removeModule(scriptPath) return moduleInfo } } suspend fun checkForUpdate(inputModuleInfo: ModuleInfo): ModuleInfo? { return runCatching { context.log.verbose("checking for updates for ${inputModuleInfo.name} ${inputModuleInfo.updateUrl}") val response = okHttpClient.newCall(Request.Builder().url(inputModuleInfo.updateUrl ?: return@runCatching null).build()).await() if (!response.isSuccessful) { return@runCatching null } response.body?.byteStream()?.use { inputStream -> val reader = inputStream.buffered().bufferedReader() val moduleInfo = reader.readModuleInfo() moduleInfo.takeIf { it.version != inputModuleInfo.version } } }.onFailure { context.log.error("Failed to check for updates", it) }.getOrNull() } private fun getEnabledScripts(sides: List): List { return runCatching { getScriptFileNames().filter { name -> cachedModuleInfo[name]?.executionSides?.any { it in sides } ?: true && context.database.isScriptEnabled(cachedModuleInfo[name]?.name ?: return@filter false) } }.onFailure { context.log.error("Failed to get enabled scripts", it) }.getOrDefault(emptyList()) } override fun getEnabledScripts(): List { return getEnabledScripts(listOf(BindingSide.CORE.key)) } override fun getScriptContent(moduleName: String): ParcelFileDescriptor? { return getScriptInputStream(moduleName) { it?.toParcelFileDescriptor(context.coroutineScope) } } override fun registerIPCListener(channel: String, eventName: String, listener: IPCListener) { ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(listener) } override fun sendIPCMessage(channel: String, eventName: String, args: Array): Int { var dispatchCount = 0 ipcListeners[channel]?.get(eventName)?.toList()?.forEach { runCatching { it.onMessage(args) dispatchCount++ }.onFailure { context.log.error("Failed to send message for $eventName", it) } } return dispatchCount } override fun configTransaction( module: String?, action: String, key: String?, value: String?, save: Boolean ): String? { 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) 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 -> deleteConfig() else -> {} } null } }.onFailure { context.log.error("Failed to perform config transaction", it) }.getOrDefault("") } override fun registerAutoReloadListener(listener: AutoReloadListener?) { autoReloadListener = listener } }