ScriptingRootSection.kt (24615B) - raw


      1 package me.rhunk.snapenhance.ui.manager.pages.scripting
      2 
      3 import android.content.Intent
      4 import androidx.compose.foundation.clickable
      5 import androidx.compose.foundation.layout.*
      6 import androidx.compose.foundation.lazy.LazyColumn
      7 import androidx.compose.material.icons.Icons
      8 import androidx.compose.material.icons.filled.*
      9 import androidx.compose.material3.*
     10 import androidx.compose.runtime.*
     11 import androidx.compose.ui.Alignment
     12 import androidx.compose.ui.Modifier
     13 import androidx.compose.ui.focus.FocusRequester
     14 import androidx.compose.ui.focus.focusRequester
     15 import androidx.compose.ui.graphics.vector.ImageVector
     16 import androidx.compose.ui.layout.onGloballyPositioned
     17 import androidx.compose.ui.text.font.FontStyle
     18 import androidx.compose.ui.text.font.FontWeight
     19 import androidx.compose.ui.text.style.TextAlign
     20 import androidx.compose.ui.unit.dp
     21 import androidx.compose.ui.unit.sp
     22 import androidx.navigation.NavBackStackEntry
     23 import kotlinx.coroutines.*
     24 import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
     25 import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
     26 import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
     27 import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
     28 import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
     29 import me.rhunk.snapenhance.common.ui.TopBarActionButton
     30 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
     31 import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
     32 import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard
     33 import me.rhunk.snapenhance.common.util.ktx.openLink
     34 import me.rhunk.snapenhance.storage.isScriptEnabled
     35 import me.rhunk.snapenhance.storage.setScriptEnabled
     36 import me.rhunk.snapenhance.ui.manager.Routes
     37 import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
     38 import me.rhunk.snapenhance.ui.util.Dialog
     39 import me.rhunk.snapenhance.ui.util.chooseFolder
     40 import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
     41 import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
     42 import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
     43 
     44 class ScriptingRootSection : Routes.Route() {
     45     private lateinit var activityLauncherHelper: ActivityLauncherHelper
     46     private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
     47 
     48     override val init: () -> Unit = {
     49         activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
     50     }
     51 
     52     @Composable
     53     private fun ImportRemoteScript(
     54         dismiss: () -> Unit
     55     ) {
     56         Dialog(onDismissRequest = dismiss) {
     57             var url by remember { mutableStateOf("") }
     58             val focusRequester = remember { FocusRequester() }
     59             var isLoading by remember {
     60                 mutableStateOf(false)
     61             }
     62             ElevatedCard(
     63                 modifier = Modifier
     64                     .fillMaxWidth(),
     65             ) {
     66                 Column(
     67                     modifier = Modifier
     68                         .fillMaxWidth()
     69                         .padding(16.dp),
     70                     horizontalAlignment = Alignment.CenterHorizontally
     71                 ) {
     72                     Text(
     73                         text = "Import Script from URL",
     74                         fontSize = 22.sp,
     75                         fontWeight = FontWeight.Bold,
     76                         modifier = Modifier.padding(8.dp),
     77                     )
     78                     Text(
     79                         text = "Warning: Imported scripts can be harmful to your device. Only import scripts from trusted sources.",
     80                         fontSize = 14.sp,
     81                         fontWeight = FontWeight.Light,
     82                         fontStyle = FontStyle.Italic,
     83                         modifier = Modifier.padding(8.dp),
     84                         textAlign = TextAlign.Center,
     85                     )
     86                     TextField(
     87                         value = url,
     88                         onValueChange = {
     89                             url = it
     90                         },
     91                         label = {
     92                             Text(text = "Enter URL here:")
     93                         },
     94                         modifier = Modifier
     95                             .fillMaxWidth()
     96                             .focusRequester(focusRequester)
     97                             .onGloballyPositioned {
     98                                 focusRequester.requestFocus()
     99                             }
    100                     )
    101                     LaunchedEffect(Unit) {
    102                         context.androidContext.getUrlFromClipboard()?.let {
    103                             url = it
    104                         }
    105                     }
    106                     Spacer(modifier = Modifier.height(8.dp))
    107                     Button(
    108                         enabled = url.isNotBlank(),
    109                         onClick = {
    110                             isLoading = true
    111                             context.coroutineScope.launch {
    112                                 runCatching {
    113                                     val moduleInfo = context.scriptManager.importFromUrl(url)
    114                                     context.shortToast("Script ${moduleInfo.name} imported!")
    115                                     reloadDispatcher.dispatch()
    116                                     withContext(Dispatchers.Main) {
    117                                         dismiss()
    118                                     }
    119                                     return@launch
    120                                 }.onFailure {
    121                                     context.log.error("Failed to import script", it)
    122                                     context.shortToast("Failed to import script. ${it.message}. Check logs for more details")
    123                                 }
    124                                 isLoading = false
    125                             }
    126                         },
    127                     ) {
    128                         if (isLoading) {
    129                             CircularProgressIndicator(
    130                                 modifier = Modifier
    131                                     .size(30.dp),
    132                                 strokeWidth = 3.dp,
    133                                 color = MaterialTheme.colorScheme.onPrimary
    134                             )
    135                         } else {
    136                             Text(text = "Import")
    137                         }
    138                     }
    139                 }
    140             }
    141         }
    142     }
    143 
    144 
    145     @Composable
    146     private fun ModuleActions(
    147         script: ModuleInfo,
    148         canUpdate: Boolean,
    149         dismiss: () -> Unit
    150     ) {
    151         Dialog(
    152             onDismissRequest = dismiss,
    153         ) {
    154             ElevatedCard(
    155                 modifier = Modifier
    156                     .fillMaxWidth()
    157                     .padding(2.dp),
    158             ) {
    159                 val actions = remember {
    160                     mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply {
    161                         if (canUpdate) {
    162                             put("Update Module" to Icons.Default.Download) {
    163                                 dismiss()
    164                                 context.shortToast("Updating script ${script.name}...")
    165                                 runCatching {
    166                                     val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception("Module not found")
    167                                     context.scriptManager.unloadScript(modulePath)
    168                                     val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath)
    169                                     context.shortToast("Updated ${script.name} to version ${moduleInfo.version}")
    170                                     context.database.setScriptEnabled(script.name, false)
    171                                     withContext(context.database.executor.asCoroutineDispatcher()) {
    172                                         reloadDispatcher.dispatch()
    173                                     }
    174                                 }.onFailure {
    175                                     context.log.error("Failed to update module", it)
    176                                     context.shortToast("Failed to update module. Check logs for more details")
    177                                 }
    178                             }
    179                         }
    180 
    181                         put("Edit Module" to Icons.Default.Edit) {
    182                             runCatching {
    183                                 val modulePath = context.scriptManager.getModulePath(script.name)!!
    184                                 context.androidContext.startActivity(
    185                                     Intent(Intent.ACTION_VIEW).apply {
    186                                         data = context.scriptManager.getScriptsFolder()!!
    187                                             .findFile(modulePath)!!.uri
    188                                         flags =
    189                                             Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
    190                                     }
    191                                 )
    192                                 dismiss()
    193                             }.onFailure {
    194                                 context.log.error("Failed to open module file", it)
    195                                 context.shortToast("Failed to open module file. Check logs for more details")
    196                             }
    197                         }
    198                         put("Clear Module Data" to Icons.Default.Save) {
    199                             runCatching {
    200                                 context.scriptManager.getModuleDataFolder(script.name)
    201                                     .deleteRecursively()
    202                                 context.shortToast("Module data cleared!")
    203                                 dismiss()
    204                             }.onFailure {
    205                                 context.log.error("Failed to clear module data", it)
    206                                 context.shortToast("Failed to clear module data. Check logs for more details")
    207                             }
    208                         }
    209                         put("Delete Module" to Icons.Default.DeleteOutline) {
    210                             context.scriptManager.apply {
    211                                 runCatching {
    212                                     val modulePath = getModulePath(script.name)!!
    213                                     unloadScript(modulePath)
    214                                     getScriptsFolder()?.findFile(modulePath)?.delete()
    215                                     reloadDispatcher.dispatch()
    216                                     context.shortToast("Deleted script ${script.name}!")
    217                                     dismiss()
    218                                 }.onFailure {
    219                                     context.log.error("Failed to delete module", it)
    220                                     context.shortToast("Failed to delete module. Check logs for more details")
    221                                 }
    222                             }
    223                         }
    224                     }.toMap()
    225                 }
    226 
    227                 LazyColumn(
    228                     modifier = Modifier.fillMaxWidth()
    229                 ) {
    230                     item {
    231                         Text(
    232                             text = "Actions",
    233                             fontSize = 22.sp,
    234                             fontWeight = FontWeight.Bold,
    235                             modifier = Modifier
    236                                 .padding(16.dp)
    237                                 .fillMaxWidth(),
    238                             textAlign = TextAlign.Center,
    239                         )
    240                     }
    241                     items(actions.size) { index ->
    242                         val action = actions.entries.elementAt(index)
    243                         ListItem(
    244                             modifier = Modifier
    245                                 .clickable {
    246                                     context.coroutineScope.launch {
    247                                         action.value()
    248                                         dismiss()
    249                                     }
    250                                 }
    251                                 .fillMaxWidth(),
    252                             leadingContent = {
    253                                 Icon(
    254                                     imageVector = action.key.second,
    255                                     contentDescription = action.key.first
    256                                 )
    257                             },
    258                             headlineContent = {
    259                                 Text(text = action.key.first)
    260                             },
    261                         )
    262                     }
    263                 }
    264             }
    265         }
    266     }
    267 
    268     @Composable
    269     fun ModuleItem(script: ModuleInfo) {
    270         var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
    271             context.database.isScriptEnabled(script.name)
    272         }
    273         var openSettings by remember(script) { mutableStateOf(false) }
    274         var openActions by remember { mutableStateOf(false) }
    275 
    276         val dispatcher = rememberAsyncUpdateDispatcher()
    277         val reloadCallback = remember { suspend { dispatcher.dispatch() } }
    278         val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) {
    279             context.scriptManager.checkForUpdate(script)
    280         }
    281 
    282         LaunchedEffect(Unit) {
    283             reloadDispatcher.addCallback(reloadCallback)
    284         }
    285 
    286         DisposableEffect(Unit) {
    287             onDispose {
    288                 reloadDispatcher.removeCallback(reloadCallback)
    289             }
    290         }
    291 
    292         Card(
    293             modifier = Modifier
    294                 .fillMaxWidth()
    295                 .padding(8.dp),
    296             elevation = CardDefaults.cardElevation()
    297         ) {
    298             Row(
    299                 modifier = Modifier
    300                     .fillMaxWidth()
    301                     .clickable {
    302                         if (!enabled) return@clickable
    303                         openSettings = !openSettings
    304                     }
    305                     .padding(8.dp),
    306                 verticalAlignment = Alignment.CenterVertically
    307             ) {
    308                 if (enabled) {
    309                     Icon(
    310                         imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
    311                         contentDescription = null,
    312                         modifier = Modifier
    313                             .padding(end = 8.dp)
    314                             .size(32.dp),
    315                     )
    316                 }
    317 
    318                 Column(
    319                     modifier = Modifier
    320                         .weight(1f)
    321                         .padding(end = 8.dp)
    322                 ) {
    323                     Text(text = script.displayName ?: script.name, fontSize = 20.sp)
    324                     Text(text = script.description ?: "No description", fontSize = 14.sp)
    325                     latestUpdate?.let {
    326                         Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant)
    327                     }
    328                 }
    329                 IconButton(onClick = {
    330                     openActions = !openActions
    331                 }) {
    332                     Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
    333                 }
    334                 Switch(
    335                     checked = enabled,
    336                     onCheckedChange = { isChecked ->
    337                         openSettings = false
    338                         context.coroutineScope.launch(Dispatchers.IO) {
    339                             runCatching {
    340                                 val modulePath = context.scriptManager.getModulePath(script.name)!!
    341                                 context.scriptManager.unloadScript(modulePath)
    342                                 if (isChecked) {
    343                                     context.scriptManager.loadScript(modulePath)
    344                                     context.scriptManager.runtime.getModuleByName(script.name)
    345                                         ?.callFunction("module.onSnapEnhanceLoad")
    346                                     context.shortToast("Loaded script ${script.name}")
    347                                 } else {
    348                                     context.shortToast("Unloaded script ${script.name}")
    349                                 }
    350 
    351                                 context.database.setScriptEnabled(script.name, isChecked)
    352                                 withContext(Dispatchers.Main) {
    353                                     enabled = isChecked
    354                                 }
    355                             }.onFailure { throwable ->
    356                                 withContext(Dispatchers.Main) {
    357                                     enabled = !isChecked
    358                                 }
    359                                 ("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also {
    360                                     context.log.error(it, throwable)
    361                                     context.shortToast(it)
    362                                 }
    363                             }
    364                         }
    365                     }
    366                 )
    367             }
    368 
    369             if (openSettings) {
    370                 ScriptSettings(script)
    371             }
    372         }
    373 
    374         if (openActions) {
    375             ModuleActions(
    376                 script = script,
    377                 canUpdate = latestUpdate != null,
    378             ) { openActions = false }
    379         }
    380     }
    381 
    382     override val floatingActionButton: @Composable () -> Unit = {
    383         var showImportDialog by remember {
    384             mutableStateOf(false)
    385         }
    386         if (showImportDialog) {
    387             ImportRemoteScript {
    388                 showImportDialog = false
    389             }
    390         }
    391 
    392         Column(
    393             verticalArrangement = Arrangement.spacedBy(8.dp),
    394             horizontalAlignment = Alignment.End,
    395         ) {
    396             ExtendedFloatingActionButton(
    397                 onClick = {
    398                     if (context.scriptManager.getScriptsFolder() == null) {
    399                         return@ExtendedFloatingActionButton
    400                     }
    401                     showImportDialog = true
    402                 },
    403                 icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
    404                 text = {
    405                     Text(text = "Import from URL")
    406                 },
    407             )
    408             ExtendedFloatingActionButton(
    409                 onClick = {
    410                     context.scriptManager.getScriptsFolder()?.let {
    411                         context.androidContext.openLink(it.uri.toString())
    412                     }
    413                 },
    414                 icon = {
    415                     Icon(
    416                         imageVector = Icons.Default.FolderOpen,
    417                         contentDescription = "Folder"
    418                     )
    419                 },
    420                 text = {
    421                     Text(text = "Open Scripts Folder")
    422                 },
    423             )
    424         }
    425     }
    426 
    427 
    428     @Composable
    429     fun ScriptSettings(script: ModuleInfo) {
    430         val settingsInterface = remember {
    431             val module =
    432                 context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
    433             (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
    434         }
    435 
    436         if (settingsInterface == null) {
    437             Text(
    438                 text = "This module does not have any settings",
    439                 style = MaterialTheme.typography.bodySmall,
    440                 modifier = Modifier.padding(8.dp)
    441             )
    442         } else {
    443             ScriptInterface(interfaceBuilder = settingsInterface)
    444         }
    445     }
    446 
    447     override val content: @Composable (NavBackStackEntry) -> Unit = {
    448         val scriptingFolder by rememberAsyncMutableState(
    449             defaultValue = null,
    450             updateDispatcher = reloadDispatcher
    451         ) {
    452             context.scriptManager.getScriptsFolder()
    453         }
    454         val scriptModules by rememberAsyncMutableState(
    455             defaultValue = emptyList(),
    456             updateDispatcher = reloadDispatcher
    457         ) {
    458             context.scriptManager.sync()
    459             context.scriptManager.getSyncedModules()
    460         }
    461 
    462         val coroutineScope = rememberCoroutineScope()
    463 
    464         var refreshing by remember {
    465             mutableStateOf(false)
    466         }
    467 
    468         LaunchedEffect(Unit) {
    469             refreshing = true
    470             withContext(Dispatchers.IO) {
    471                 reloadDispatcher.dispatch()
    472                 refreshing = false
    473             }
    474         }
    475 
    476         val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
    477             refreshing = true
    478             coroutineScope.launch(Dispatchers.IO) {
    479                 reloadDispatcher.dispatch()
    480                 refreshing = false
    481             }
    482         })
    483 
    484         Box(
    485             modifier = Modifier.fillMaxSize()
    486         ) {
    487             LazyColumn(
    488                 modifier = Modifier
    489                     .fillMaxSize()
    490                     .pullRefresh(pullRefreshState),
    491                 horizontalAlignment = Alignment.CenterHorizontally
    492             ) {
    493                 item {
    494                     if (scriptingFolder == null && !refreshing) {
    495                         Text(
    496                             text = "No scripts folder selected",
    497                             style = MaterialTheme.typography.bodySmall,
    498                             modifier = Modifier.padding(8.dp)
    499                         )
    500                         Spacer(modifier = Modifier.height(8.dp))
    501                         Button(onClick = {
    502                             activityLauncherHelper.chooseFolder {
    503                                 context.config.root.scripting.moduleFolder.set(it)
    504                                 context.config.writeConfig()
    505                                 coroutineScope.launch {
    506                                     reloadDispatcher.dispatch()
    507                                 }
    508                             }
    509                         }) {
    510                             Text(text = "Select folder")
    511                         }
    512                     } else if (scriptModules.isEmpty()) {
    513                         Text(
    514                             text = "No scripts found",
    515                             style = MaterialTheme.typography.bodySmall,
    516                             modifier = Modifier.padding(8.dp)
    517                         )
    518                     }
    519                 }
    520                 items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
    521                     ModuleItem(scriptModules[index])
    522                 }
    523                 item {
    524                     Spacer(modifier = Modifier.height(200.dp))
    525                 }
    526             }
    527 
    528             PullRefreshIndicator(
    529                 refreshing = refreshing,
    530                 state = pullRefreshState,
    531                 modifier = Modifier.align(Alignment.TopCenter)
    532             )
    533         }
    534 
    535         var scriptingWarning by remember {
    536             mutableStateOf(context.sharedPreferences.run {
    537                 getBoolean("scripting_warning", true).also {
    538                     edit().putBoolean("scripting_warning", false).apply()
    539                 }
    540             })
    541         }
    542 
    543         if (scriptingWarning) {
    544             var timeout by remember {
    545                 mutableIntStateOf(10)
    546             }
    547 
    548             LaunchedEffect(Unit) {
    549                 while (timeout > 0) {
    550                     delay(1000)
    551                     timeout--
    552                 }
    553             }
    554 
    555             AlertDialog(onDismissRequest = {
    556                 if (timeout == 0) {
    557                     scriptingWarning = false
    558                 }
    559             }, title = {
    560                 Text(text = context.translation["manager.dialogs.scripting_warning.title"])
    561             }, text = {
    562                 Text(text = context.translation["manager.dialogs.scripting_warning.content"])
    563             }, confirmButton = {
    564                 TextButton(
    565                     onClick = {
    566                         scriptingWarning = false
    567                     },
    568                     enabled = timeout == 0
    569                 ) {
    570                     Text(text = "OK " + if (timeout > 0) "($timeout)" else "")
    571                 }
    572             })
    573         }
    574     }
    575 
    576     override val topBarActions: @Composable() (RowScope.() -> Unit) = {
    577         TopBarActionButton(
    578             onClick = {
    579                 context.androidContext.openLink("https://github.com/SnapEnhance/scripting-docs")
    580             },
    581             icon = Icons.Default.CollectionsBookmark,
    582             text = "Documentation",
    583         )
    584     }
    585 }