commit 97aed78894b02f8c19354243d4cfe81d9e384126
parent adf2eff024dc0ba5e2bdaa847b52f16250aeb8f4
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Wed, 22 May 2024 22:39:41 +0200
feat(scripting): module updater
Diffstat:
8 files changed, 173 insertions(+), 151 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
@@ -13,12 +13,13 @@ import me.rhunk.snapenhance.common.scripting.bindings.BindingSide
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.common.scripting.type.readModuleInfo
+import me.rhunk.snapenhance.common.util.ktx.await
import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor
import me.rhunk.snapenhance.scripting.impl.IPCListeners
import me.rhunk.snapenhance.scripting.impl.ManagerIPC
import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig
import me.rhunk.snapenhance.storage.isScriptEnabled
-import me.rhunk.snapenhance.storage.syncScripts
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
@@ -56,23 +57,23 @@ class RemoteScriptManager(
private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>()
private val ipcListeners = IPCListeners()
+ fun getSyncedModules(): List<ModuleInfo> {
+ return cachedModuleInfo.values.toList()
+ }
+
fun sync() {
cachedModuleInfo.clear()
getScriptFileNames().forEach { name ->
runCatching {
getScriptInputStream(name) { stream ->
- stream?.use {
- runtime.getModuleInfo(it).also { info ->
- cachedModuleInfo[name] = info
- }
+ stream?.bufferedReader()?.readModuleInfo()?.also {
+ cachedModuleInfo[name] = it
}
}
}.onFailure {
context.log.error("Failed to load module info for $name", it)
}
}
-
- context.database.syncScripts(cachedModuleInfo.values.toList())
}
fun init() {
@@ -133,7 +134,8 @@ class RemoteScriptManager(
}
fun importFromUrl(
- url: String
+ url: String,
+ filepath: String? = null
): ModuleInfo {
val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute()
if (!response.isSuccessful) {
@@ -142,14 +144,14 @@ class RemoteScriptManager(
response.body.byteStream().use { inputStream ->
val bufferedInputStream = inputStream.buffered()
bufferedInputStream.mark(0)
- val moduleInfo = runtime.readModuleInfo(bufferedInputStream.bufferedReader())
+ val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo()
bufferedInputStream.reset()
- val scriptPath = moduleInfo.name + ".js"
+ 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)?.use { output ->
+ context.androidContext.contentResolver.openOutputStream(scriptFile.uri, "wt")?.use { output ->
bufferedInputStream.copyTo(output)
}
@@ -160,6 +162,26 @@ class RemoteScriptManager(
}
}
+ 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()
+ }
+
+
override fun getEnabledScripts(): List<String> {
return runCatching {
getScriptFileNames().filter {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
@@ -56,14 +56,8 @@ class AppDatabase(
"expirationTimestamp BIGINT",
"length INTEGER"
),
- "scripts" to listOf(
- "id INTEGER PRIMARY KEY AUTOINCREMENT",
- "name VARCHAR NOT NULL",
- "version VARCHAR NOT NULL",
- "displayName VARCHAR",
- "description VARCHAR",
- "author VARCHAR NOT NULL",
- "enabled BOOLEAN"
+ "enabled_scripts" to listOf(
+ "name VARCHAR PRIMARY KEY",
),
"tracker_rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt
@@ -2,72 +2,22 @@ package me.rhunk.snapenhance.storage
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
-import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
-import me.rhunk.snapenhance.common.util.ktx.getInteger
-import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
-fun AppDatabase.getScripts(): List<ModuleInfo> {
- return database.rawQuery("SELECT * FROM scripts ORDER BY id DESC", null).use { cursor ->
- val scripts = mutableListOf<ModuleInfo>()
- while (cursor.moveToNext()) {
- scripts.add(
- ModuleInfo(
- name = cursor.getStringOrNull("name")!!,
- version = cursor.getStringOrNull("version")!!,
- displayName = cursor.getStringOrNull("displayName"),
- description = cursor.getStringOrNull("description"),
- author = cursor.getStringOrNull("author"),
- grantedPermissions = emptyList()
- )
- )
- }
- scripts
- }
-}
-
fun AppDatabase.setScriptEnabled(name: String, enabled: Boolean) {
executeAsync {
- database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf(
- if (enabled) 1 else 0,
- name
- ))
+ if (enabled) {
+ database.execSQL("INSERT OR REPLACE INTO enabled_scripts (name) VALUES (?)", arrayOf(name))
+ } else {
+ database.execSQL("DELETE FROM enabled_scripts WHERE name = ?", arrayOf(name))
+ }
}
}
fun AppDatabase.isScriptEnabled(name: String): Boolean {
- return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor ->
- if (!cursor.moveToFirst()) return@use false
- cursor.getInteger("enabled") == 1
- }
-}
-
-fun AppDatabase.syncScripts(availableScripts: List<ModuleInfo>) {
- runBlocking(executor.asCoroutineDispatcher()) {
- val enabledScripts = getScripts()
- val enabledScriptPaths = enabledScripts.map { it.name }
- val availableScriptPaths = availableScripts.map { it.name }
-
- enabledScripts.forEach { script ->
- if (!availableScriptPaths.contains(script.name)) {
- database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name))
- }
- }
-
- availableScripts.forEach { script ->
- if (!enabledScriptPaths.contains(script.name) || script != enabledScripts.find { it.name == script.name }) {
- database.execSQL(
- "INSERT OR REPLACE INTO scripts (name, version, displayName, description, author, enabled) VALUES (?, ?, ?, ?, ?, ?)",
- arrayOf(
- script.name,
- script.version,
- script.displayName,
- script.description,
- script.author,
- 0
- )
- )
- }
+ return runBlocking(executor.asCoroutineDispatcher()) {
+ database.rawQuery("SELECT * FROM enabled_scripts WHERE name = ?", arrayOf(name)).use {
+ it.moveToNext()
}
}
-}-
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -32,7 +33,7 @@ import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
-import me.rhunk.snapenhance.storage.getScripts
+import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
import me.rhunk.snapenhance.storage.isScriptEnabled
import me.rhunk.snapenhance.storage.setScriptEnabled
import me.rhunk.snapenhance.ui.manager.Routes
@@ -142,6 +143,7 @@ class ScriptingRoot : Routes.Route() {
@Composable
private fun ModuleActions(
script: ModuleInfo,
+ canUpdate: Boolean,
dismiss: () -> Unit
) {
Dialog(
@@ -153,8 +155,28 @@ class ScriptingRoot : Routes.Route() {
.padding(2.dp),
) {
val actions = remember {
- mapOf<Pair<String, ImageVector>, suspend () -> Unit>(
- ("Edit Module" to Icons.Default.Edit) to {
+ mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply {
+ if (canUpdate) {
+ put("Update Module" to Icons.Default.Download) {
+ dismiss()
+ context.shortToast("Updating script ${script.name}...")
+ runCatching {
+ val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception("Module not found")
+ context.scriptManager.unloadScript(modulePath)
+ val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath)
+ context.shortToast("Updated ${script.name} to version ${moduleInfo.version}")
+ context.database.setScriptEnabled(script.name, false)
+ withContext(context.database.executor.asCoroutineDispatcher()) {
+ reloadDispatcher.dispatch()
+ }
+ }.onFailure {
+ context.log.error("Failed to update module", it)
+ context.shortToast("Failed to update module. Check logs for more details")
+ }
+ }
+ }
+
+ put("Edit Module" to Icons.Default.Edit) {
runCatching {
val modulePath = context.scriptManager.getModulePath(script.name)!!
context.androidContext.startActivity(
@@ -170,8 +192,8 @@ class ScriptingRoot : Routes.Route() {
context.log.error("Failed to open module file", it)
context.shortToast("Failed to open module file. Check logs for more details")
}
- },
- ("Clear Module Data" to Icons.Default.Save) to {
+ }
+ put("Clear Module Data" to Icons.Default.Save) {
runCatching {
context.scriptManager.getModuleDataFolder(script.name)
.deleteRecursively()
@@ -181,8 +203,8 @@ class ScriptingRoot : Routes.Route() {
context.log.error("Failed to clear module data", it)
context.shortToast("Failed to clear module data. Check logs for more details")
}
- },
- ("Delete Module" to Icons.Default.DeleteOutline) to {
+ }
+ put("Delete Module" to Icons.Default.DeleteOutline) {
context.scriptManager.apply {
runCatching {
val modulePath = getModulePath(script.name)!!
@@ -197,7 +219,7 @@ class ScriptingRoot : Routes.Route() {
}
}
}
- )
+ }.toMap()
}
LazyColumn(
@@ -243,14 +265,26 @@ class ScriptingRoot : Routes.Route() {
@Composable
fun ModuleItem(script: ModuleInfo) {
- var enabled by rememberAsyncMutableState(defaultValue = false) {
+ var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
context.database.isScriptEnabled(script.name)
}
- var openSettings by remember {
- mutableStateOf(false)
+ var openSettings by remember(script) { mutableStateOf(false) }
+ var openActions by remember { mutableStateOf(false) }
+
+ val dispatcher = rememberAsyncUpdateDispatcher()
+ val reloadCallback = remember { suspend { dispatcher.dispatch() } }
+ val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) {
+ context.scriptManager.checkForUpdate(script)
}
- var openActions by remember {
- mutableStateOf(false)
+
+ LaunchedEffect(Unit) {
+ reloadDispatcher.addCallback(reloadCallback)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ reloadDispatcher.removeCallback(reloadCallback)
+ }
}
Card(
@@ -286,6 +320,9 @@ class ScriptingRoot : Routes.Route() {
) {
Text(text = script.displayName ?: script.name, fontSize = 20.sp)
Text(text = script.description ?: "No description", fontSize = 14.sp)
+ latestUpdate?.let {
+ Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
}
IconButton(onClick = {
openActions = !openActions
@@ -333,7 +370,10 @@ class ScriptingRoot : Routes.Route() {
}
if (openActions) {
- ModuleActions(script) { openActions = false }
+ ModuleActions(
+ script = script,
+ canUpdate = latestUpdate != null,
+ ) { openActions = false }
}
}
@@ -416,7 +456,7 @@ class ScriptingRoot : Routes.Route() {
updateDispatcher = reloadDispatcher
) {
context.scriptManager.sync()
- context.database.getScripts()
+ context.scriptManager.getSyncedModules()
}
val coroutineScope = rememberCoroutineScope()
@@ -477,7 +517,7 @@ class ScriptingRoot : Routes.Route() {
)
}
}
- items(scriptModules.size) { index ->
+ items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
ModuleItem(scriptModules[index])
}
item {
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt
@@ -5,9 +5,8 @@ import android.os.ParcelFileDescriptor
import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.common.BuildConfig
import me.rhunk.snapenhance.common.logger.AbstractLogger
-import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
+import me.rhunk.snapenhance.common.scripting.type.readModuleInfo
import org.mozilla.javascript.ScriptableObject
-import java.io.BufferedReader
import java.io.InputStream
open class ScriptRuntime(
@@ -35,45 +34,6 @@ open class ScriptRuntime(
return modules.values.find { it.moduleInfo.name == name }
}
- fun readModuleInfo(reader: BufferedReader): ModuleInfo {
- val header = reader.readLine()
- if (!header.startsWith("// ==SE_module==")) {
- throw Exception("Invalid module header")
- }
-
- val properties = mutableMapOf<String, String>()
- while (true) {
- val line = reader.readLine()
- if (line.startsWith("// ==/SE_module==")) {
- break
- }
- val split = line.replaceFirst("//", "").split(":")
- if (split.size != 2) {
- throw Exception("Invalid module property")
- }
- properties[split[0].trim()] = split[1].trim()
- }
-
- return ModuleInfo(
- name = properties["name"]?.also {
- if (!it.matches(Regex("[a-z_]+"))) {
- throw Exception("Invalid module name : Only lowercase letters and underscores are allowed")
- }
- } ?: throw Exception("Missing module name"),
- version = properties["version"] ?: throw Exception("Missing module version"),
- displayName = properties["displayName"],
- description = properties["description"],
- author = properties["author"],
- minSnapchatVersion = properties["minSnapchatVersion"]?.toLongOrNull(),
- minSEVersion = properties["minSEVersion"]?.toLongOrNull(),
- grantedPermissions = properties["permissions"]?.split(",")?.map { it.trim() } ?: emptyList(),
- )
- }
-
- fun getModuleInfo(inputStream: InputStream): ModuleInfo {
- return readModuleInfo(inputStream.bufferedReader())
- }
-
fun removeModule(scriptPath: String) {
modules.remove(scriptPath)
}
@@ -94,7 +54,7 @@ open class ScriptRuntime(
fun load(scriptPath: String, content: InputStream): JSModule {
logger.info("Loading module $scriptPath")
val bufferedReader = content.bufferedReader()
- val moduleInfo = readModuleInfo(bufferedReader)
+ val moduleInfo = bufferedReader.readModuleInfo()
if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) {
throw Exception("Module requires a newer version of SnapEnhance (min version: ${moduleInfo.minSEVersion})")
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt
@@ -1,28 +1,57 @@
package me.rhunk.snapenhance.common.scripting.type
+import java.io.BufferedReader
+
data class ModuleInfo(
val name: String,
val version: String,
val displayName: String? = null,
val description: String? = null,
+ val updateUrl: String? = null,
val author: String? = null,
val minSnapchatVersion: Long? = null,
val minSEVersion: Long? = null,
val grantedPermissions: List<String>,
) {
- override fun equals(other: Any?): Boolean {
- if (other !is ModuleInfo) return false
- if (other === this) return true
- return name == other.name &&
- version == other.version &&
- displayName == other.displayName &&
- description == other.description &&
- author == other.author
- }
-
fun ensurePermissionGranted(permission: Permissions) {
if (!grantedPermissions.contains(permission.key)) {
throw AssertionError("Permission $permission is not granted")
}
}
-}-
\ No newline at end of file
+}
+
+fun BufferedReader.readModuleInfo(): ModuleInfo {
+ val header = readLine()
+ if (!header.startsWith("// ==SE_module==")) {
+ throw Exception("Invalid module header")
+ }
+
+ val properties = mutableMapOf<String, String>()
+ while (true) {
+ val line = readLine()
+ if (line.startsWith("// ==/SE_module==")) {
+ break
+ }
+ val split = line.replaceFirst("//", "").split(":", limit = 2)
+ if (split.size != 2) {
+ throw Exception("Invalid module property")
+ }
+ properties[split[0].trim()] = split[1].trim()
+ }
+
+ return ModuleInfo(
+ name = properties["name"]?.also {
+ if (!it.matches(Regex("[a-z_]+"))) {
+ throw Exception("Invalid module name : Only lowercase letters and underscores are allowed")
+ }
+ } ?: throw Exception("Missing module name"),
+ version = properties["version"] ?: throw Exception("Missing module version"),
+ displayName = properties["displayName"],
+ description = properties["description"],
+ updateUrl = properties["updateUrl"],
+ author = properties["author"],
+ minSnapchatVersion = properties["minSnapchatVersion"]?.toLongOrNull(),
+ minSEVersion = properties["minSEVersion"]?.toLongOrNull(),
+ grantedPermissions = properties["permissions"]?.split(",")?.map { it.trim() } ?: emptyList(),
+ )
+}
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt
@@ -61,7 +61,7 @@ fun <T> rememberAsyncMutableState(
defaultValue: T,
updateDispatcher: AsyncUpdateDispatcher? = null,
keys: Array<*> = emptyArray<Any>(),
- getter: () -> T,
+ getter: suspend () -> T,
): MutableState<T> {
return rememberCommonState(
initialState = { mutableStateOf(defaultValue) },
@@ -82,7 +82,7 @@ fun <T> rememberAsyncMutableStateList(
defaultValue: List<T>,
updateDispatcher: AsyncUpdateDispatcher? = null,
keys: Array<*> = emptyArray<Any>(),
- getter: () -> List<T>,
+ getter: suspend () -> List<T>,
): SnapshotStateList<T> {
return rememberCommonState(
initialState = { mutableStateListOf<T>().apply {
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt
@@ -0,0 +1,29 @@
+package me.rhunk.snapenhance.common.util.ktx
+
+import kotlinx.coroutines.CompletionHandler
+import kotlinx.coroutines.suspendCancellableCoroutine
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+import okio.IOException
+import kotlin.coroutines.resumeWithException
+
+suspend inline fun Call.await(): Response {
+ return suspendCancellableCoroutine { continuation ->
+ val callback = object: CompletionHandler, Callback {
+ override fun invoke(cause: Throwable?) {
+ runCatching { cancel() }
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ continuation.resumeWithException(e)
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resumeWith(runCatching { response })
+ }
+ }
+ enqueue(callback)
+ continuation.invokeOnCancellation(callback)
+ }
+}