Skip to content

Commit

Permalink
Switch to event-driven approach
Browse files Browse the repository at this point in the history
Switch to event-driven approach
  • Loading branch information
vyfor authored May 2, 2024
2 parents 640a35b + eec5bde commit 35869de
Show file tree
Hide file tree
Showing 16 changed files with 433 additions and 221 deletions.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ val client = RichClient(CLIENT_ID)

client.connect()

val activity = activity {
client.update {
type = ActivityType.GAME
details = "Exploring Kotlin Native"
state = "Writing code"
Expand Down Expand Up @@ -59,11 +59,28 @@ val activity = activity {
button("Learn more", "https://kotlinlang.org/")
button("Try it yourself", "https://play.kotlinlang.org/")
}
```

### Event handling
```kt
val client = RichClient(CLIENT_ID)

client.on<ReadyEvent> {
update(activity)
}

client.on<ActivityUpdateEvent> {
logger?.info("Updated rich presence")
}

client.on<DisconnectEvent> {
connect(shouldBlock = true) // Attempt to reconnect
}

client.update(activity)
client.connect(shouldBlock = false)
```

### Enable logging
### Logging
```kt
val client = RichClient(CLIENT_ID)
client.logger = ILogger.default()
Expand Down
9 changes: 8 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ plugins {
}

group = "io.github.vyfor"
version = "0.5.3"
version = "0.6.0"

repositories {
mavenCentral()
Expand Down Expand Up @@ -44,6 +44,7 @@ kotlin {
dependencies {
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
}
val commonTest by getting {
Expand Down Expand Up @@ -125,6 +126,12 @@ kotlin {
}
}
}

tasks.withType<Test> {
testLogging {
showStandardStreams = true
}
}
}
}

Expand Down
197 changes: 141 additions & 56 deletions src/commonMain/kotlin/io/github/vyfor/kpresence/RichClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,102 +2,123 @@

package io.github.vyfor.kpresence

import io.github.vyfor.kpresence.event.ActivityUpdateEvent
import io.github.vyfor.kpresence.event.DisconnectEvent
import io.github.vyfor.kpresence.event.Event
import io.github.vyfor.kpresence.event.ReadyEvent
import io.github.vyfor.kpresence.exception.*
import io.github.vyfor.kpresence.ipc.*
import io.github.vyfor.kpresence.logger.ILogger
import io.github.vyfor.kpresence.rpc.Activity
import io.github.vyfor.kpresence.rpc.Packet
import io.github.vyfor.kpresence.rpc.PacketArgs
import io.github.vyfor.kpresence.utils.epochMillis
import io.github.vyfor.kpresence.rpc.*
import io.github.vyfor.kpresence.utils.getProcessId
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

/**
* Manages client connections and activity updates for Discord presence.
* @property clientId The Discord application client ID.
*/
class RichClient(var clientId: Long) {
var connectionState = ConnectionState.DISCONNECTED
private set
class RichClient(
var clientId: Long,
val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)
) {
private val connection = Connection()
private var signal = Mutex(true)
private var lastActivity: Activity? = null

var connectionState = ConnectionState.DISCONNECTED
private set
var onReady: (RichClient.() -> Unit)? = null
var onDisconnect: (RichClient.() -> Unit)? = null
var onActivityUpdate: (RichClient.() -> Unit)? = null
var logger: ILogger? = null

/**
* Establishes a connection to Discord.
* @param callback The callback function to be executed after establishing the connection.
* @return The current Client instance for chaining.
* @param shouldBlock Whether to block the current thread until the connection is established.
* @return The current [RichClient] instance for chaining.
* @throws InvalidClientIdException if the provided client ID is not valid.
* @throws ConnectionException if an error occurs while establishing the connection.
* @throws PipeReadException if an error occurs while reading from the IPC pipe.
* @throws PipeWriteException if an error occurs while writing to the IPC pipe.
*/
fun connect(callback: (RichClient.() -> Unit)? = null): RichClient {
fun connect(shouldBlock: Boolean = true): RichClient {
if (connectionState != ConnectionState.DISCONNECTED) {
logger?.warn("Already connected to Discord. Skipping")
callback?.invoke(this)
return this
}

connection.open()
connectionState = ConnectionState.CONNECTED
logger?.info("Successfully connected to Discord")
logger?.info("Connected to Discord")
handshake()

callback?.invoke(this)

listen()
if (shouldBlock) {
runBlocking {
signal.lock()
}
}

return this
}

/**
* Attempts to reconnect if there is an already active connection.
* @return The current Client instance for chaining.
* @param shouldBlock Whether to block the current thread until the connection is established.
* @return The current [RichClient] instance for chaining.
* @throws InvalidClientIdException if the provided client ID is not valid.
* @throws ConnectionException if an error occurs while establishing the connection.
* @throws PipeReadException if an error occurs while reading from the IPC pipe.
* @throws PipeWriteException if an error occurs while writing to the IPC pipe.
*/
fun reconnect(): RichClient {
fun reconnect(shouldBlock: Boolean = true): RichClient {
if (connectionState != ConnectionState.SENT_HANDSHAKE) {
throw NotConnectedException()
}

shutdown()
connect()

connect(shouldBlock)
return this
}

/**
* Updates the current activity shown on Discord.
* Skips identical presence updates.
* @param activity The activity to display.
* @return The current Client instance for chaining.
* @return The current [RichClient] instance for chaining.
* @throws NotConnectedException if the client is not connected to Discord.
* @throws PipeReadException if an error occurs while reading from the IPC pipe.
* @throws PipeWriteException if an error occurs while writing to the IPC pipe.
* @throws IllegalArgumentException if the validation of the [activity]'s fields fails.
*/
fun update(activity: Activity?): RichClient {
if (connectionState != ConnectionState.SENT_HANDSHAKE) {
throw NotConnectedException()
}

if (lastActivity == activity) {
logger?.info("Received identical presence update. Skipping")
return this
}

lastActivity = activity
sendActivityUpdate()

sendActivityUpdate(activity)

return this
}

/**
* Updates the current activity shown on Discord.
* Skips identical presence updates.
* @param activityBlock A lambda to construct an [Activity].
* @return The current [RichClient] instance for chaining.
* @throws NotConnectedException if the client is not connected to Discord.
* @throws PipeReadException if an error occurs while reading from the IPC pipe.
* @throws PipeWriteException if an error occurs while writing to the IPC pipe.
* @throws IllegalArgumentException if the validation of the [activity]'s fields fails.
*/
fun update(activityBlock: ActivityBuilder.() -> Unit): RichClient {
sendActivityUpdate(ActivityBuilder().apply(activityBlock).build())

return this
}

/**
* Clears the current activity shown on Discord.
* @return The current Client instance for chaining.
* @return The current [RichClient] instance for chaining.
* @throws NotConnectedException if the client is not connected to Discord.
* @throws PipeReadException if an error occurs while reading from the IPC pipe.
* @throws PipeWriteException if an error occurs while writing to the IPC pipe.
Expand All @@ -106,44 +127,110 @@ class RichClient(var clientId: Long) {
if (connectionState != ConnectionState.SENT_HANDSHAKE) {
throw NotConnectedException()
}

update(null)

return this
}

/**
* Shuts down the connection to Discord and cleans up resources.
* @return The current Client instance for chaining.
* @return The current [RichClient] instance for chaining.
*/
fun shutdown(): RichClient {
if (connectionState == ConnectionState.DISCONNECTED) {
logger?.warn("Already disconnected from Discord. Skipping")
logger?.warn("Already disconnected from Discord. Skipping disconnection")
return this
}
// TODO: Send valid payload
connection.write(2, "{\"v\": 1,\"client_id\":\"$clientId\"}")
connection.read()
connection.close()

connection.write(2, null)
connectionState = ConnectionState.DISCONNECTED
logger?.info("Successfully disconnected from Discord")
connection.close()
lastActivity = null
logger?.info("Disconnected from Discord")
onDisconnect?.invoke(this@RichClient)

return this
}

/**
* Registers a callback function for the specified event.
* @param T The type of [Event].
* @param block The callback function to be executed when the event is triggered.
* @return The current [RichClient] instance for chaining.
*/
inline fun <reified T : Event> on(noinline block: RichClient.() -> Unit): RichClient {
when (T::class) {
ReadyEvent::class -> onReady = block
ActivityUpdateEvent::class -> onActivityUpdate = block
DisconnectEvent::class -> onDisconnect = block
}

return this
}

private fun sendActivityUpdate() {
if (connectionState != ConnectionState.SENT_HANDSHAKE) return
private fun sendActivityUpdate(currentActivity: Activity?) {
if (connectionState != ConnectionState.SENT_HANDSHAKE) {
throw NotConnectedException()
}

if (lastActivity == currentActivity) {
logger?.debug("Received identical presence update. Skipping")
return
}
lastActivity = currentActivity

val packet = Json.encodeToString(Packet("SET_ACTIVITY", PacketArgs(getProcessId(), lastActivity), "-"))
logger?.info("Sending presence update with payload: $packet")
logger?.apply {
debug("Sending presence update with payload:")
debug(packet)
}

connection.write(1, packet)
connection.read()
}

private fun handshake() {
connection.write(0, "{\"v\": 1,\"client_id\":\"$clientId\"}")
if (connection.read().decodeToString().contains("Invalid Client ID")) {
throw InvalidClientIdException("'$clientId' is not a valid client ID")
}

private fun listen(): Job {
return coroutineScope.launch {
while (isActive && connectionState != ConnectionState.DISCONNECTED) {
val response = connection.read() ?: continue
logger?.apply {
trace("Received response:")
trace("Message(opcode: ${response.opcode}, data: ${response.data.decodeToString()})")
}
when (response.opcode) {
1 -> {
if (connectionState == ConnectionState.CONNECTED) {
if (response.data.decodeToString().contains("Invalid Client ID")) {
throw InvalidClientIdException("'$clientId' is not a valid client ID")
}

connectionState = ConnectionState.SENT_HANDSHAKE
if (signal.isLocked) signal.unlock()
logger?.debug("Performed initial handshake")
onReady?.invoke(this@RichClient)
continue
}

logger?.debug("Successfully updated presence")
onActivityUpdate?.invoke(this@RichClient)
}
2 -> {
if (connectionState != ConnectionState.DISCONNECTED) {
connectionState = ConnectionState.DISCONNECTED
connection.close()
lastActivity = null
logger?.warn("The connection was forcibly closed")
onDisconnect?.invoke(this@RichClient)
break
}
}
}
}
}
connectionState = ConnectionState.SENT_HANDSHAKE
logger?.info("Performed initial handshake")
}
}

Expand All @@ -152,5 +239,3 @@ enum class ConnectionState {
CONNECTED,
SENT_HANDSHAKE,
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.vyfor.kpresence.event

/**
* Event representing an activity update.
*/
data object ActivityUpdateEvent : Event
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.vyfor.kpresence.event

/**
* Event representing a disconnection event.
*/
data object DisconnectEvent : Event
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package io.github.vyfor.kpresence.event

interface Event
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.vyfor.kpresence.event

/**
* Event indicating that the client is initialized.
*/
data object ReadyEvent : Event
Loading

0 comments on commit 35869de

Please sign in to comment.