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 }