mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-12 13:17:42 +02:00
feat(scripting): integrated ui
This commit is contained in:
@ -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"
|
||||
|
@ -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() }
|
||||
}
|
@ -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 ->
|
||||
|
@ -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),
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package me.rhunk.snapenhance.common.scripting.ui.components
|
||||
|
||||
enum class NodeType {
|
||||
ROW,
|
||||
COLUMN,
|
||||
TEXT,
|
||||
SWITCH,
|
||||
BUTTON,
|
||||
SLIDER,
|
||||
LIST,
|
||||
ACTION
|
||||
}
|
@ -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)
|
@ -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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user