Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to event-driven approach #15

Merged
merged 15 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading