From 5751ff8d507d9b22071339f834202e704bdaabf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20Sol=C3=A9?= Date: Fri, 22 Feb 2019 11:42:32 +0100 Subject: [PATCH] Add Actor-based RPC handler (#18) --- build.gradle.kts | 2 +- gradle.properties | 13 +- readme.md | 8 +- .../kotlin/be/bluexin/drpc4k/jna/RPCActor.kt | 260 ++++++++++++++++++ .../be/bluexin/drpc4k/jna/RPCHandler.kt | 2 +- src/test/kotlin/JnaExampleActor.kt | 76 +++++ 6 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/be/bluexin/drpc4k/jna/RPCActor.kt create mode 100644 src/test/kotlin/JnaExampleActor.kt diff --git a/build.gradle.kts b/build.gradle.kts index fe50f4a..66489bf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation("org.slf4j", "slf4j-api") implementation("io.github.microutils", "kotlin-logging", prop("kotlinLoggingVersion")) - compileOnly("org.slf4j", "slf4j-simple", prop("slf4jVersion")) + testRuntime("org.slf4j", "slf4j-simple", prop("slf4jVersion")) shade("net.java.dev.jna", "jna", prop("jnaVersion")) shadeInPlace(files("libs")) diff --git a/gradle.properties b/gradle.properties index 9cfc496..9871c92 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,20 +1,17 @@ #Plugins versions -kotlin_version=1.3.0 +kotlin_version=1.3.11 undercouch_dl_version=3.4.3 bintray_version=1.8.4 artifactory_version=4.7.5 - #Dependencies versions -discord_rpc_version=3.3.0 -coroutinesVersion=1.0.0 +discord_rpc_version=3.4.0 +coroutinesVersion=1.1.0 slf4jVersion=1.7.25 kotlinLoggingVersion=1.6.10 -jnaVersion=4.5.0 - +jnaVersion=5.2.0 #Build variables -build_number=8 +build_number=9 version_number=0 - #Build settings org.gradle.caching=true org.gradle.parallel=true diff --git a/readme.md b/readme.md index a8c8f52..e63043a 100644 --- a/readme.md +++ b/readme.md @@ -27,7 +27,7 @@ Then add the dependency: ```groovy dependencies { /* project dependencies */ - compile("be.bluexin:drpc4k:0.8") + compile("be.bluexin:drpc4k:0.9") } ``` Maven : @@ -35,7 +35,7 @@ Maven : be.bluexin drpc4k - 0.8 + 0.9 pom ``` @@ -46,6 +46,6 @@ You can also directly download it from [Bintray](https://bintray.com/bluexin/blu ## Rich Presence -To use Discord Rich Presence, the easiest way is to use the wrapper class [be.bluexin.drpc4k.jna.RPCHandler](src/main/kotlin/be/bluexin/drpc4k/jna/RPCHandler.kt). +To use Discord Rich Presence, the easiest way is to use the Actor wrapper [be.bluexin.drpc4k.jna.RPCActor](src/main/kotlin/be/bluexin/drpc4k/jna/RPCActor.kt). It will handle everything using lightweight Kotlin Coroutines. -An example usage can be found at [src/test/kotlin/JnaExample.kt](src/test/kotlin/JnaExample.kt). +An example usage can be found at [src/test/kotlin/JnaExampleActor.kt](src/test/kotlin/JnaExampleActor.kt). diff --git a/src/main/kotlin/be/bluexin/drpc4k/jna/RPCActor.kt b/src/main/kotlin/be/bluexin/drpc4k/jna/RPCActor.kt new file mode 100644 index 0000000..254b68f --- /dev/null +++ b/src/main/kotlin/be/bluexin/drpc4k/jna/RPCActor.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2019 Arnaud 'Bluexin' Solé + * + * This file is part of drpc4k. + * + * drpc4k is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * drpc4k is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with drpc4k. If not, see . + */ + +package be.bluexin.drpc4k.jna + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.actor +import mu.KotlinLogging +import kotlin.coroutines.CoroutineContext + +/** + * Start a new Discord RPC Actor in the current [this] [CoroutineScope]. + * + * The newly created actor will receive messages of type [RPCInputMessage] and send [RPCOutputMessage] to the + * specified [output]. + * + * A typical usage looks like this : + * ``` + * val rpcOutput = Channel(capacity = Channel.UNLIMITED) + * val rpcInput = rpcActor(rpcOutput) + * // Connect to the client via RPC + * rpcInput.send(RPCInputMessage.Connect(myClientKey)) + * // Update rich presence + * rpcInput.send(RPCInputMessage.UpdatePresence(myPresence)) + * // Set up receiving of updates from the RPC actor + * launch { + * for (msg in rpcOutput) with(msg) { + * when (this) { + * is RPCOutputMessage.Ready -> with(user) { logger.info("Logged in as $username#$discriminator") } + * is RPCOutputMessage.Disconnected -> logger.warn("Disconnected: #$errorCode $message") + * is RPCOutputMessage.Errored -> logger.error("Error: #$errorCode $message") + * } + * } + * } + * ... + * // Disconnect from the client + * rpcInput.close() + * ``` + * + * Note that because we use a [Channel.UNLIMITED] capacity channel, it is safe to use non-suspending [Channel.offer] + * instead of the suspending [Channel.send]. + * + * @param output Channel the RPC Actor will be sending [RPCOutputMessage] update messages to. + * @param context additional to [CoroutineScope.coroutineContext] context of the coroutine. + * @see CoroutineScope.actor for more technical information. + */ +@ObsoleteCoroutinesApi +@ExperimentalCoroutinesApi +fun CoroutineScope.rpcActor(output: SendChannel, context: CoroutineContext = this.coroutineContext): + SendChannel = actor(context = context, capacity = Channel.UNLIMITED, start = CoroutineStart.LAZY) { + RPCActor(this, channel, output).start() +} + +/** + * Superclass for messages sent to the RPC actor. + */ +@Suppress("MemberVisibilityCanBePrivate") +sealed class RPCInputMessage { + /** + * Make the RPC actor connect to the client. + * + * @param clientId your app's client ID. + * @param autoRegister whether Discord should register your app for automatic launch (untested! Probably broken because Java). + * @param steamId your app's Steam ID, if any. + * @param refreshRate the rate in milliseconds at which this handler will run callbacks and send info to discord. + */ + data class Connect( + val clientId: String, + val autoRegister: Boolean = false, + val steamId: String? = null, + val refreshRate: Long = 500L + ) : RPCInputMessage() + + /** + * Update the user's Rich Presence. + * The presence will be cached if used before the app has connected, and automatically sent once ready. + * + * @see DiscordRichPresence for all available options. + */ + data class UpdatePresence(val presence: DiscordRichPresence) : RPCInputMessage() +} + +/** + * Superclass for messages sent by the RPC actor. + */ +@Suppress("MemberVisibilityCanBePrivate") +sealed class RPCOutputMessage { + /** + * Sent when the RPC actor has logged in, and is ready to be accessed. + */ + data class Ready(val user: DiscordUser) : RPCOutputMessage() + + /** + * Sent when the RPC actor has been disconnected. + * + * @param errorCode the error code causing disconnection. + * @param message the message for disconnection. + */ + data class Disconnected(val errorCode: Int, val message: String) : RPCOutputMessage() + + /** + * Sent when the RPC actor has detected an error. + * + * @param errorCode the error code causing the error. + * @param message the message for the error. + */ + data class Errored(val errorCode: Int, val message: String) : RPCOutputMessage() + + /** + * Sent when the someone accepted a game invitation. + * + * @param joinSecret the game invitation secret. + */ + data class JoinGame(val joinSecret: String) : RPCOutputMessage() + + /** + * Sent when the someone accepted a game spectating invitation. + * + * @param spectateSecret the game spectating secret. + */ + data class Spectate(val spectateSecret: String) : RPCOutputMessage() + + /** + * Sent when the someone requested to join the game. + * + * @param user the requester. + */ + data class JoinRequest(val user: DiscordUser) : RPCOutputMessage() +} + +/** + * RPC Actor implementation. + * + * @param scope the scope for this actor to act in. + * @param input the actor's input channel. + * @param output the actor's output channel. + */ +@ExperimentalCoroutinesApi +private class RPCActor( + private val scope: CoroutineScope, + private val input: ReceiveChannel, + private val output: SendChannel) { + + private val logger = KotlinLogging.logger { } + + private var connected = false + private var initialized = false + private lateinit var user: DiscordUser + private var queuedPresence: DiscordRichPresence? = null + + /** + * Start the actor. + */ + suspend fun start() { + for (m in input) onReceive(m) + } + + private suspend fun onReceive(msg: RPCInputMessage) { + when (msg) { + is RPCInputMessage.Connect -> with(msg) { connect(clientId, autoRegister, steamId, refreshRate) } + is RPCInputMessage.UpdatePresence -> if (initialized) DiscordRpc.Discord_UpdatePresence(msg.presence) else queuedPresence = msg.presence + } + } + + /** + * Connect the actor to the RPC Client. + * + * @see DiscordRpc.Discord_Initialize + */ + private suspend fun connect(clientId: String, autoRegister: Boolean = false, steamId: String? = null, refreshRate: Long = 500L) { + try { + DiscordRpc.Discord_Initialize(clientId, handlers, autoRegister, steamId) + initialized = true + if (queuedPresence != null) { + DiscordRpc.Discord_UpdatePresence(queuedPresence!!) + queuedPresence = null + } + while (!input.isClosedForReceive) { + var m = input.poll() + while (m != null) { + onReceive(m) + m = input.poll() + } + DiscordRpc.Discord_RunCallbacks() + delay(refreshRate) + } + } catch (e: CancellationException) { + } catch (e: Throwable) { + output.send(RPCOutputMessage.Errored(-1, "Unknown error caused by: ${e.message}")) + } finally { + output.send(RPCOutputMessage.Disconnected(0, "Discord RPC Thread closed.")) + output.close() + connected = false + scope.coroutineContext.cancelChildren() + try { + DiscordRpc.Discord_Shutdown() + } catch (e: Throwable) { + } + } + } + + private val handlers = DiscordEventHandlers { + onReady { + user = it + connected = true + scope.launch { + output.send(RPCOutputMessage.Ready(it)) + } + } + onDisconnected { errorCode, message -> + logger.warn("Disconnected: #$errorCode (${message.takeIf { message.isNotEmpty() } + ?: "No message provided"})") + connected = false + scope.launch { + output.send(RPCOutputMessage.Disconnected(errorCode, message)) + } + } + onErrored { errorCode, message -> + logger.error("Error: #$errorCode (${message.takeIf { message.isNotEmpty() } ?: "No message provided"})") + connected = false + scope.launch { + output.send(RPCOutputMessage.Errored(errorCode, message)) + } + } + onJoinGame { + scope.launch { + output.send(RPCOutputMessage.JoinGame(it)) + } + } + onSpectateGame { + scope.launch { + output.send(RPCOutputMessage.Spectate(it)) + } + } + onJoinRequest { + scope.launch { + output.send(RPCOutputMessage.JoinRequest(it)) + } + } + } +} diff --git a/src/main/kotlin/be/bluexin/drpc4k/jna/RPCHandler.kt b/src/main/kotlin/be/bluexin/drpc4k/jna/RPCHandler.kt index ccab6df..e0eecd4 100644 --- a/src/main/kotlin/be/bluexin/drpc4k/jna/RPCHandler.kt +++ b/src/main/kotlin/be/bluexin/drpc4k/jna/RPCHandler.kt @@ -34,7 +34,7 @@ import java.util.concurrent.atomic.AtomicBoolean * * @author Bluexin */ -@Suppress("MemberVisibilityCanPrivate", "unused") +@Suppress("MemberVisibilityCanBePrivate", "unused") object RPCHandler { private val logger = KotlinLogging.logger {} diff --git a/src/test/kotlin/JnaExampleActor.kt b/src/test/kotlin/JnaExampleActor.kt new file mode 100644 index 0000000..de7b62c --- /dev/null +++ b/src/test/kotlin/JnaExampleActor.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2019 Arnaud 'Bluexin' Solé + * + * This file is part of drpc4k. + * + * drpc4k is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * drpc4k is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with drpc4k. If not, see . + */ + +import be.bluexin.drpc4k.jna.DiscordRichPresence +import be.bluexin.drpc4k.jna.RPCInputMessage +import be.bluexin.drpc4k.jna.RPCOutputMessage +import be.bluexin.drpc4k.jna.rpcActor +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import mu.KotlinLogging +import kotlin.random.Random + +private val logger = KotlinLogging.logger { } + +@ObsoleteCoroutinesApi +@ExperimentalCoroutinesApi +fun main(args: Array) = runBlocking { + if (args.isEmpty()) { + logger.error("Missing Client ID") + return@runBlocking + } + + val rpcOutput = Channel(capacity = Channel.UNLIMITED) + val rpcInput = rpcActor(rpcOutput) + + val presence = DiscordRichPresence { + details = "Raid: Kill Migas" + state = "Recruiting" + partyId = "Awesome Party ID" + partySize = Random.nextInt(20) + 1 + partyMax = 24 + setDuration(1200L) + smallImageKey = "ia_sakura_water" + largeImageKey = "ia_sakura_water" + smallImageText = "OwO smol" + largeImageText = "OwO big" + joinSecret = "anawesomesecret" + spectateSecret = "anawesomesecret2" + } + + launch { + for (msg in rpcOutput) with(msg) { + when (this) { + is RPCOutputMessage.Ready -> with(user) { logger.info("Logged in as $username#$discriminator") } + is RPCOutputMessage.Disconnected -> logger.warn("Disconnected: #$errorCode (${message.takeIf { message.isNotEmpty() } + ?: "No message provided"})") + is RPCOutputMessage.Errored -> logger.error("Error: #$errorCode (${message.takeIf { message.isNotEmpty() } + ?: "No message provided"})") + } + } + } + + rpcInput.send(RPCInputMessage.Connect(args[0])) + rpcInput.send(RPCInputMessage.UpdatePresence(presence)) + + delay(10000) + + rpcInput.close() + delay(500) +}