All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.copperleaf.ballast.debugger.BallastDebuggerClientConnection.kt Maven / Gradle / Ivy

There is a newer version: 3.0.2
Show newest version
package com.copperleaf.ballast.debugger

import com.benasher44.uuid.uuid4
import com.copperleaf.ballast.BallastInterceptorScope
import com.copperleaf.ballast.BallastNotification
import com.copperleaf.ballast.Queued
import com.copperleaf.ballast.associate
import com.copperleaf.ballast.debugger.models.BallastApplicationState
import com.copperleaf.ballast.debugger.models.BallastDebuggerAction
import com.copperleaf.ballast.debugger.models.BallastDebuggerEvent
import com.copperleaf.ballast.debugger.models.BallastViewModelState
import com.copperleaf.ballast.debugger.models.debuggerEventJson
import com.copperleaf.ballast.debugger.models.getActualValue
import com.copperleaf.ballast.debugger.models.serialize
import com.copperleaf.ballast.debugger.models.updateConnection
import com.copperleaf.ballast.debugger.models.updateViewModel
import com.copperleaf.ballast.debugger.models.updateWithDebuggerEvent
import com.copperleaf.ballast.debugger.utils.now
import io.github.copper_leaf.ballast_debugger.BALLAST_VERSION
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.features.json.serializer.KotlinxSerializer
import io.ktor.client.features.websocket.DefaultClientWebSocketSession
import io.ktor.client.features.websocket.WebSockets
import io.ktor.client.features.websocket.webSocket
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.datetime.LocalDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.ZERO
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime

@ExperimentalTime
public class BallastDebuggerClientConnection(
    engineFactory: HttpClientEngineFactory,
    private val applicationCoroutineScope: CoroutineScope,
    private val host: String = "127.0.0.1", // 10.0.2.2 on Android
    private val port: Int = 9684,
    private val ballastVersion: String = BALLAST_VERSION,
    private val connectionId: String = generateUuid(),
    block: HttpClientConfig.() -> Unit = {}
) {
    public companion object {
        public const val CONNECTION_ID_HEADER: String = "x-ballast-connection-id"
        public const val BALLAST_VERSION_HEADER: String = "x-ballast-version"
        private fun generateUuid(): String {
            return uuid4().toString()
        }
    }

    private val client: HttpClient = HttpClient(engineFactory) {
        install(WebSockets)
        install(JsonFeature) {
            serializer = KotlinxSerializer()
        }
        block()
    }

    private class BallastDebuggerEventWrapper(
        val notification: BallastNotification<*, *, *>?,
        val debuggerEvent: BallastDebuggerEvent?,
        val updateConnectionState: Boolean,
    )

    private val outgoingMessages = Channel(Channel.UNLIMITED, BufferOverflow.SUSPEND)
    private val incomingActions = MutableSharedFlow()
    private var waitForEvent = CompletableDeferred()

    private var applicationState: BallastApplicationState = BallastApplicationState()
    private val uuids: MutableMap> = mutableMapOf()

    public fun connect(): Job {
        var failedAttempts = 0
        val job = applicationCoroutineScope.launch(
            start = CoroutineStart.UNDISPATCHED,
            context = Dispatchers.Default,
        ) {
            println("Starting connection")
            while (true) {
                val currentTimeoutValue = when (failedAttempts) {
                    0 -> ZERO
                    in 0..10 -> 1.seconds
                    in 11..20 -> 5.seconds
                    in 21..30 -> 30.seconds
                    else -> Int.MAX_VALUE.seconds
                }
                failedAttempts++
                waitForEvent = CompletableDeferred()

                try {
                    coroutineScope {
                        attemptConnection(currentTimeoutValue) {
                            println("Connected to Ballast debugger: $connectionId")
                            failedAttempts = 0
                        }
                    }
                } catch (e: CancellationException) {
                    throw e
                } catch (t: Throwable) {
                    t.printStackTrace()
                }
            }
        }
        return job
    }

    private suspend fun attemptConnection(currentTimeoutValue: Duration, onSuccessfulConnection: ()->Unit) {
        // either wait for a given timeout to reconnect, or if a new event comes in connect immediately
        withTimeoutOrNull(currentTimeoutValue) {
            waitForEvent.await()
        }

        client.webSocket(
            method = HttpMethod.Get,
            request = {
                url("ws", host, port, "/ballast/debugger") {
                    parameters[CONNECTION_ID_HEADER] = connectionId
                    parameters[BALLAST_VERSION_HEADER] = ballastVersion
                }
                header(CONNECTION_ID_HEADER, connectionId)
                header(BALLAST_VERSION_HEADER, ballastVersion)
            }
        ) {
            onSuccessfulConnection()
            joinAll(
                heartbeat(),
                processOutgoing(),
                processIncoming(),
            )
        }
    }

    internal fun  BallastInterceptorScope.connectViewModel(
        notifications: Flow>,
    ) {
        val processIncomingJob = applicationCoroutineScope.launch {
            incomingActions
                .mapNotNull { action ->
                    if (action.viewModelName == hostViewModelName) {
                        val isForThisViewModel = applicationState.connections
                            .firstOrNull { it.connectionId == [email protected] }
                            ?.viewModels
                            ?.firstOrNull { it.viewModelName == hostViewModelName }

                        if (isForThisViewModel != null) {
                            action to isForThisViewModel
                        } else {
                            null
                        }
                    } else {
                        null
                    }
                }
                .collect { (action, thisViewModel) -> handleAction(action, thisViewModel) }
        }
        applicationCoroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
            notifications
                .collect(::acceptNotification)
        }.invokeOnCompletion { processIncomingJob.cancel() }
    }

// Impl
// ---------------------------------------------------------------------------------------------------------------------

    private suspend fun acceptNotification(notification: BallastNotification<*, *, *>) {
        outgoingMessages.send(
            BallastDebuggerEventWrapper(
                notification = notification,
                debuggerEvent = null,
                updateConnectionState = true,
            )
        )
        waitForEvent.complete(Unit)
    }

    private fun getUuid(notification: BallastNotification<*, *, *>): Pair {
        return notification.associate(
            cache = uuids,
            computeValueForSubject = { generateUuid() to LocalDateTime.now() },
        )
    }

    /**
     * send a heartbeat from this connection every 5 seconds, so the UI can show when each connection is still alive
     */
    private fun DefaultClientWebSocketSession.heartbeat(): Job {
        val session = this

        return flow {
            while (true) {
                emit(Unit)
            }
        }
            .onEach { delay(5000) }
            .onEach {
                session.send(
                    debuggerEventJson
                        .encodeToString(
                            BallastDebuggerEvent.serializer(),
                            BallastDebuggerEvent.Heartbeat(
                                connectionId = connectionId,
                                connectionBallastVersion = ballastVersion,
                            )
                        )
                        .let { Frame.Text(it) }
                )
            }
            .launchIn(this)
    }

    /**
     * send messages from the channel to the websocket
     */
    private fun DefaultClientWebSocketSession.processOutgoing(): Job {
        val session = this
        return outgoingMessages
            .receiveAsFlow()
            .onEach { message ->
                val event = if (message.notification != null) {
                    check(message.debuggerEvent == null) { "Must provide a notification or a debugger event, not both" }

                    val (uuid, timestamp) = getUuid(message.notification)
                    message.notification.serialize(connectionId, uuid, firstSeen = timestamp, now = LocalDateTime.now())
                } else if (message.debuggerEvent != null) {
                    message.debuggerEvent
                } else {
                    error("Must provide a notification or a debugger event, not both")
                }

                if (message.updateConnectionState) {
                    // update the local cache with the same event the client UI has, so we can request refreshed data if
                    // something has been dropped
                    applicationState = applicationState.updateConnection(connectionId) {
                        updateViewModel(event.viewModelName) {
                            // on the client, we can get the actual value from the original notification, and cache it
                            // so that it can be restored later if requested
                            val actualValue = message.notification?.getActualValue()
                            updateWithDebuggerEvent(event, actualValue)
                        }
                    }
                }

                // send the message through the websocket to the client UI, where it will be processed in the same way
                session.send(
                    debuggerEventJson
                        .encodeToString(BallastDebuggerEvent.serializer(), event)
                        .let { Frame.Text(it) }
                )
            }
            .launchIn(this)
    }

    /**
     * read messages sent from the debugger UI to the app
     */
    private fun DefaultClientWebSocketSession.processIncoming(): Job {
        return incoming
            .receiveAsFlow()
            .filterIsInstance()
            .onEach { frame ->
                val text = frame.readText()

                debuggerEventJson
                    .decodeFromString(BallastDebuggerAction.serializer(), text)
                    .let { incomingActions.emit(it) }
            }
            .launchIn(this)
    }

    @Suppress("UNCHECKED_CAST")
    private suspend fun  BallastInterceptorScope.handleAction(
        action: BallastDebuggerAction,
        thisViewModel: BallastViewModelState,
    ) {
        return when (action) {
            is BallastDebuggerAction.RequestViewModelRefresh -> {
                val currentViewModelHistory = thisViewModel.fullHistory.reversed()

                outgoingMessages.send(
                    BallastDebuggerEventWrapper(
                        notification = null,
                        debuggerEvent = BallastDebuggerEvent.RefreshViewModelStart(
                            connectionId,
                            action.viewModelName,
                        ),
                        updateConnectionState = false,
                    )
                )

                currentViewModelHistory.forEach {
                    outgoingMessages.send(
                        BallastDebuggerEventWrapper(
                            notification = null,
                            debuggerEvent = it,
                            updateConnectionState = false,
                        )
                    )
                }

                outgoingMessages.send(
                    BallastDebuggerEventWrapper(
                        notification = null,
                        debuggerEvent = BallastDebuggerEvent.RefreshViewModelComplete(
                            connectionId,
                            action.viewModelName,
                        ),
                        updateConnectionState = false,
                    )
                )

                waitForEvent.complete(Unit)

                Unit
            }
            is BallastDebuggerAction.RequestResendInput -> {
                val inputToResend = thisViewModel
                    .inputs
                    .firstOrNull { it.uuid == action.inputUuid }
                    ?.actualInput as? Inputs

                if (inputToResend != null) {
                    sendToQueue(Queued.HandleInput(null, inputToResend))
                } else {
                }
            }
            is BallastDebuggerAction.RequestRestoreState -> {
                val stateToRestore = thisViewModel
                    .states
                    .firstOrNull { it.uuid == action.stateUuid }
                    ?.actualState as? State

                if (stateToRestore != null) {
                    sendToQueue(Queued.RestoreState(null, stateToRestore))
                } else {
                }
            }
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy