feat(scripting): integrated ui

This commit is contained in:
rhunk
2023-12-25 23:10:31 +01:00
parent 37becec350
commit 72c9b92a3e
16 changed files with 141 additions and 76 deletions

View File

@ -703,6 +703,10 @@
"name": "Auto Reload",
"description": "Automatically reloads scripts when they change"
},
"integrated_ui": {
"name": "Integrated UI",
"description": "Allows scripts to add custom UI components to Snapchat"
},
"disable_log_anonymization": {
"name": "Disable Log Anonymization",
"description": "Disables the anonymization of logs"

View File

@ -7,5 +7,6 @@ class Scripting : ConfigContainer() {
val developerMode = boolean("developer_mode", false) { requireRestart() }
val moduleFolder = string("module_folder", "modules") { addFlags(ConfigFlag.FOLDER); requireRestart() }
val autoReload = unique("auto_reload", "snapchat_only", "all")
val integratedUI = boolean("integrated_ui", false) { requireRestart() }
val disableLogAnonymization = boolean("disable_log_anonymization", false) { requireRestart() }
}

View File

@ -11,6 +11,7 @@ import me.rhunk.snapenhance.common.scripting.ktx.scriptable
import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.scripting.type.Permissions
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
import org.mozilla.javascript.Function
import org.mozilla.javascript.NativeJavaObject
import org.mozilla.javascript.ScriptableObject
@ -53,6 +54,7 @@ class JSModule(
registerBindings(
JavaInterfaces(),
InterfaceManager(),
)
moduleObject.putFunction("setField") { args ->

View File

@ -0,0 +1,11 @@
package me.rhunk.snapenhance.common.scripting.ui
import me.rhunk.snapenhance.common.scripting.bindings.BindingSide
enum class EnumScriptInterface(
val key: String,
val side: BindingSide
) {
SETTINGS("settings", BindingSide.MANAGER),
FRIEND_FEED_CONTEXT_MENU("friendFeedContextMenu", BindingSide.CORE),
}

View File

@ -0,0 +1,116 @@
package me.rhunk.snapenhance.common.scripting.ui
import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding
import me.rhunk.snapenhance.common.scripting.bindings.BindingSide
import me.rhunk.snapenhance.common.scripting.ktx.contextScope
import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject
import me.rhunk.snapenhance.common.scripting.ui.components.Node
import me.rhunk.snapenhance.common.scripting.ui.components.NodeType
import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionNode
import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionType
import me.rhunk.snapenhance.common.scripting.ui.components.impl.RowColumnNode
import org.mozilla.javascript.Function
import org.mozilla.javascript.annotations.JSFunction
class InterfaceBuilder {
val nodes = mutableListOf<Node>()
var onDisposeCallback: (() -> Unit)? = null
private fun createNode(type: NodeType, block: Node.() -> Unit): Node {
return Node(type).apply(block).also { nodes.add(it) }
}
fun onDispose(block: () -> Unit) {
nodes.add(ActionNode(ActionType.DISPOSE, callback = block))
}
fun onLaunched(block: () -> Unit) {
onLaunched(Unit, block)
}
fun onLaunched(key: Any, block: () -> Unit) {
nodes.add(ActionNode(ActionType.LAUNCHED, key, block))
}
fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply {
children.addAll(InterfaceBuilder().apply(block).nodes)
}.also { nodes.add(it) }
fun column(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.COLUMN).apply {
children.addAll(InterfaceBuilder().apply(block).nodes)
}.also { nodes.add(it) }
fun text(text: String) = createNode(NodeType.TEXT) {
label(text)
}
fun switch(state: Boolean?, callback: (Boolean) -> Unit) = createNode(NodeType.SWITCH) {
attributes["state"] = state
attributes["callback"] = callback
}
fun button(label: String, callback: () -> Unit) = createNode(NodeType.BUTTON) {
label(label)
attributes["callback"] = callback
}
fun slider(min: Int, max: Int, step: Int, value: Int, callback: (Int) -> Unit) = createNode(
NodeType.SLIDER
) {
attributes["value"] = value
attributes["min"] = min
attributes["max"] = max
attributes["step"] = step
attributes["callback"] = callback
}
fun list(label: String, items: List<String>, callback: (String) -> Unit) = createNode(NodeType.LIST) {
label(label)
attributes["items"] = items
attributes["callback"] = callback
}
}
class InterfaceManager : AbstractBinding("interface-manager", BindingSide.COMMON) {
private val interfaces = mutableMapOf<String, (args: Map<String, Any?>) -> InterfaceBuilder?>()
fun buildInterface(scriptInterface: EnumScriptInterface, args: Map<String, Any?> = emptyMap()): InterfaceBuilder? {
return runCatching {
interfaces[scriptInterface.key]?.invoke(args)
}.onFailure {
context.runtime.logger.error("Failed to build interface ${scriptInterface.key} for ${context.moduleInfo.name}", it)
}.getOrNull()
}
override fun onDispose() {
interfaces.clear()
}
fun hasInterface(scriptInterfaces: EnumScriptInterface): Boolean {
return interfaces.containsKey(scriptInterfaces.key)
}
@Suppress("unused")
@JSFunction fun create(name: String, callback: Function) {
interfaces[name] = { args ->
val interfaceBuilder = InterfaceBuilder()
runCatching {
contextScope {
callback.call(this, callback, callback, arrayOf(interfaceBuilder, scriptableObject {
args.forEach { (key, value) ->
putConst(key,this, value)
}
}))
}
interfaceBuilder
}.onFailure {
context.runtime.logger.error("Failed to create interface $name for ${context.moduleInfo.name}", it)
}.getOrNull()
}
}
override fun getObject() = this
}

View File

@ -0,0 +1,181 @@
package me.rhunk.snapenhance.common.scripting.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.ui.components.Node
import me.rhunk.snapenhance.common.scripting.ui.components.NodeType
import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionNode
import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionType
import kotlin.math.abs
@Composable
@Suppress("UNCHECKED_CAST")
private fun DrawNode(node: Node) {
val coroutineScope = rememberCoroutineScope()
val cachedAttributes = remember { mutableStateMapOf(*node.attributes.toList().toTypedArray()) }
node.uiChangeDetection = { key, value ->
coroutineScope.launch {
cachedAttributes[key] = value
}
}
DisposableEffect(Unit) {
onDispose {
node.uiChangeDetection = { _, _ -> }
}
}
val arrangement = cachedAttributes["arrangement"]
val alignment = cachedAttributes["alignment"]
val spacing = cachedAttributes["spacing"]?.toString()?.toInt()?.let { abs(it) }
val rowColumnModifier = Modifier
.then(if (cachedAttributes["fillMaxWidth"] as? Boolean == true) Modifier.fillMaxWidth() else Modifier)
.then(if (cachedAttributes["fillMaxHeight"] as? Boolean == true) Modifier.fillMaxHeight() else Modifier)
.padding(
(cachedAttributes["padding"]
?.toString()
?.toInt()
?.let { abs(it) } ?: 2).dp)
fun runCallbackSafe(callback: () -> Unit) {
runCatching {
callback()
}.onFailure {
AbstractLogger.directError("Error running callback", it)
}
}
@Composable
fun NodeLabel() {
Text(
text = cachedAttributes["label"] as String,
fontSize = (cachedAttributes["fontSize"]?.toString()?.toInt() ?: 14).sp,
color = (cachedAttributes["color"] as? Long)?.let { Color(it) } ?: Color.Unspecified
)
}
when (node.type) {
NodeType.ACTION -> {
when ((node as ActionNode).actionType) {
ActionType.LAUNCHED -> {
LaunchedEffect(node.key) {
runCallbackSafe {
node.callback()
}
}
}
ActionType.DISPOSE -> {
DisposableEffect(Unit) {
onDispose {
runCallbackSafe {
node.callback()
}
}
}
}
}
}
NodeType.COLUMN -> {
Column(
verticalArrangement = arrangement as? Arrangement.Vertical ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.Top,
horizontalAlignment = alignment as? Alignment.Horizontal ?: Alignment.Start,
modifier = rowColumnModifier
) {
node.children.forEach { child ->
DrawNode(child)
}
}
}
NodeType.ROW -> {
Row(
horizontalArrangement = arrangement as? Arrangement.Horizontal ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.SpaceBetween,
verticalAlignment = alignment as? Alignment.Vertical ?: Alignment.CenterVertically,
modifier = rowColumnModifier
) {
node.children.forEach { child ->
DrawNode(child)
}
}
}
NodeType.TEXT -> NodeLabel()
NodeType.SWITCH -> {
var switchState by remember {
mutableStateOf(cachedAttributes["state"] as Boolean)
}
Switch(
checked = switchState,
onCheckedChange = { state ->
runCallbackSafe {
switchState = state
node.setAttribute("state", state)
(cachedAttributes["callback"] as? (Boolean) -> Unit)?.let { it(state) }
}
}
)
}
NodeType.SLIDER -> {
var sliderValue by remember {
mutableFloatStateOf((cachedAttributes["value"] as Int).toFloat())
}
Slider(
value = sliderValue,
onValueChange = { value ->
runCallbackSafe {
sliderValue = value
node.setAttribute("value", value.toInt())
(cachedAttributes["callback"] as? (Int) -> Unit)?.let { it(value.toInt()) }
}
},
valueRange = (cachedAttributes["min"] as Int).toFloat()..(cachedAttributes["max"] as Int).toFloat(),
steps = cachedAttributes["step"] as Int,
)
}
NodeType.BUTTON -> {
OutlinedButton(onClick = {
runCallbackSafe {
(cachedAttributes["callback"] as? () -> Unit)?.let { it() }
}
}) {
NodeLabel()
}
}
else -> {}
}
}
@Composable
fun ScriptInterface(interfaceBuilder: InterfaceBuilder) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
interfaceBuilder.nodes.forEach { node ->
DrawNode(node)
}
DisposableEffect(Unit) {
onDispose {
runCatching {
interfaceBuilder.onDisposeCallback?.invoke()
}.onFailure {
AbstractLogger.directError("Error running onDisposed callback", it)
}
}
}
}
}

View File

@ -0,0 +1,52 @@
package me.rhunk.snapenhance.common.scripting.ui.components
open class Node(
val type: NodeType,
) {
lateinit var uiChangeDetection: (key: String, value: Any?) -> Unit
val children = mutableListOf<Node>()
val attributes = object: HashMap<String, Any?>() {
override fun put(key: String, value: Any?): Any? {
return super.put(key, value).also {
if (::uiChangeDetection.isInitialized) {
uiChangeDetection(key, value)
}
}
}
}
fun setAttribute(key: String, value: Any?) {
attributes[key] = value
}
fun fillMaxWidth(): Node {
attributes["fillMaxWidth"] = true
return this
}
fun fillMaxHeight(): Node {
attributes["fillMaxHeight"] = true
return this
}
fun label(text: String): Node {
attributes["label"] = text
return this
}
fun padding(padding: Int): Node {
attributes["padding"] = padding
return this
}
fun fontSize(size: Int): Node {
attributes["fontSize"] = size
return this
}
fun color(color: Long): Node {
attributes["color"] = color
return this
}
}

View File

@ -0,0 +1,12 @@
package me.rhunk.snapenhance.common.scripting.ui.components
enum class NodeType {
ROW,
COLUMN,
TEXT,
SWITCH,
BUTTON,
SLIDER,
LIST,
ACTION
}

View File

@ -0,0 +1,15 @@
package me.rhunk.snapenhance.common.scripting.ui.components.impl
import me.rhunk.snapenhance.common.scripting.ui.components.Node
import me.rhunk.snapenhance.common.scripting.ui.components.NodeType
enum class ActionType {
LAUNCHED,
DISPOSE
}
class ActionNode(
val actionType: ActionType,
val key: Any = Unit,
val callback: () -> Unit
): Node(NodeType.ACTION)

View File

@ -0,0 +1,47 @@
package me.rhunk.snapenhance.common.scripting.ui.components.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.Alignment
import me.rhunk.snapenhance.common.scripting.ui.components.Node
import me.rhunk.snapenhance.common.scripting.ui.components.NodeType
class RowColumnNode(
type: NodeType,
) : Node(type) {
companion object {
private val arrangements = mapOf(
"start" to Arrangement.Start,
"end" to Arrangement.End,
"top" to Arrangement.Top,
"bottom" to Arrangement.Bottom,
"center" to Arrangement.Center,
"spaceBetween" to Arrangement.SpaceBetween,
"spaceAround" to Arrangement.SpaceAround,
"spaceEvenly" to Arrangement.SpaceEvenly,
)
private val alignments = mapOf(
"start" to Alignment.Start,
"end" to Alignment.End,
"top" to Alignment.Top,
"bottom" to Alignment.Bottom,
"centerVertically" to Alignment.CenterVertically,
"centerHorizontally" to Alignment.CenterHorizontally,
)
}
fun arrangement(arrangement: String): RowColumnNode {
attributes["arrangement"] = arrangements[arrangement] ?: throw IllegalArgumentException("Invalid arrangement")
return this
}
fun alignment(alignment: String): RowColumnNode {
attributes["alignment"] = alignments[alignment] ?: throw IllegalArgumentException("Invalid alignment")
return this
}
fun spacedBy(spacing: Int): RowColumnNode {
attributes["spacing"] = spacing
return this
}
}