From e0431bd001492191e364746ca199971f7f3f4e38 Mon Sep 17 00:00:00 2001 From: Majjo <90888719+NoxRare@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:23:51 +0200 Subject: [PATCH] feat: add Companion Server for live log and network monitoring --- .../config/impl/CompanionServerConfig.kt | 10 + .../common/config/impl/RootConfig.kt | 1 + .../core/features/FeatureManager.kt | 1 + .../features/impl/global/CompanionServer.kt | 440 ++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/CompanionServerConfig.kt create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/CompanionServerConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/CompanionServerConfig.kt new file mode 100644 index 00000000..570df1a4 --- /dev/null +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/CompanionServerConfig.kt @@ -0,0 +1,10 @@ +package me.eternal.purrfectsnap.common.config.impl + +import me.eternal.purrfectsnap.common.config.ConfigContainer +import me.eternal.purrfectsnap.common.config.ConfigFlag + +class CompanionServerConfig : ConfigContainer() { + val enabled = boolean("enabled", false) { requireRestart() } + val port = integer("port", 8484) + val token = string("token", "") { addFlags(ConfigFlag.SENSITIVE); requireRestart() } +} diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/RootConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/RootConfig.kt index 824ec620..3885f851 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/RootConfig.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/RootConfig.kt @@ -19,4 +19,5 @@ class RootConfig : ConfigContainer() { FeatureNotice.UNSTABLE) } val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject } val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch } + val companionServer = container("companion_server", CompanionServerConfig()) { icon = Icons.Default.Monitor } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 0981f20a..c9809680 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -168,6 +168,7 @@ class FeatureManager( CustomTheming(), HideTypingIndicator(), FakeSnapScore(), + CompanionServer(), ) features.values.toList().forEach { feature -> diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt new file mode 100644 index 00000000..8597e0ce --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/CompanionServer.kt @@ -0,0 +1,440 @@ +package me.eternal.purrfectsnap.core.features.impl.global + +import android.util.Log +import com.google.gson.Gson +import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent +import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.hook +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.URLDecoder +import java.security.MessageDigest +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import kotlin.concurrent.thread + +class CompanionServer : Feature("CompanionServer") { + + private val gson = Gson() + private val executor = Executors.newCachedThreadPool() + private val sseClients = CopyOnWriteArrayList() + + // Ring buffers for replay on new connections + private val recentLogs = ArrayDeque>(500) + private val recentNetworkCalls = ArrayDeque>(200) + private val bufferLock = Any() + + // Prevent re-entrant log capture while the server itself logs + private val isCapturing = ThreadLocal() + + override fun init() { + val config = context.config.companionServer + if (!config.enabled.get()) return + + val token = config.token.get().trim() + if (token.isEmpty()) { + context.log.warn("Companion Server: no token set — server disabled", "CompanionServer") + return + } + + val port = config.port.get().coerceIn(1024, 65535) + + hookLogOutput() + subscribeToNetworkEvents() + startServer(port, token) + } + + // ---- Log capture ---- + + private fun hookLogOutput() { + val printLnMethod = Log::class.java.getDeclaredMethod( + "println", Int::class.java, String::class.java, String::class.java + ) + printLnMethod.hook(HookStage.BEFORE) { param -> + if (isCapturing.get() == true) return@hook + isCapturing.set(true) + try { + val priority = param.arg(0) + val tag = param.arg(1) + val message = param.arg(2) + val level = when (priority) { + Log.VERBOSE -> "V" + Log.DEBUG -> "D" + Log.INFO -> "I" + Log.WARN -> "W" + Log.ERROR -> "E" + Log.ASSERT -> "A" + else -> "I" + } + val entry = mapOf( + "ts" to System.currentTimeMillis().toString(), + "level" to level, + "tag" to tag, + "msg" to message + ) + synchronized(bufferLock) { + if (recentLogs.size >= 500) recentLogs.removeFirst() + recentLogs.addLast(entry) + } + broadcast("log", entry) + } finally { + isCapturing.set(false) + } + } + } + + // ---- Network event capture ---- + + private fun subscribeToNetworkEvents() { + context.event.subscribe(NativeUnaryCallEvent::class) { event -> + val entry = mapOf( + "ts" to System.currentTimeMillis().toString(), + "type" to "grpc", + "uri" to event.uri, + "size" to event.buffer.size.toString() + ) + synchronized(bufferLock) { + if (recentNetworkCalls.size >= 200) recentNetworkCalls.removeFirst() + recentNetworkCalls.addLast(entry) + } + broadcast("network", entry) + } + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + val entry = mapOf( + "ts" to System.currentTimeMillis().toString(), + "type" to "http", + "uri" to event.url, + "size" to "0" + ) + synchronized(bufferLock) { + if (recentNetworkCalls.size >= 200) recentNetworkCalls.removeFirst() + recentNetworkCalls.addLast(entry) + } + broadcast("network", entry) + } + } + + // ---- Server ---- + + private fun startServer(port: Int, token: String) { + thread(name = "CompanionServer-Accept", isDaemon = true) { + runCatching { + ServerSocket(port).use { server -> + context.log.info("Companion Server listening on port $port", "CompanionServer") + while (!server.isClosed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + executor.submit { + runCatching { handleClient(socket, token) } + .onFailure { socket.runCatching { close() } } + } + } + } + }.onFailure { + context.log.error("Companion Server fatal: ${it.message}", "CompanionServer") + } + } + } + + private fun handleClient(socket: java.net.Socket, token: String) { + socket.use { + socket.soTimeout = 15_000 + val reader = socket.getInputStream().bufferedReader() + val requestLine = reader.readLine() ?: return + + val headers = mutableMapOf() + var line: String + while (reader.readLine().also { line = it ?: "" }.isNotEmpty()) { + val idx = line.indexOf(':') + if (idx > 0) { + headers[line.substring(0, idx).trim().lowercase()] = + line.substring(idx + 1).trim() + } + } + + val parts = requestLine.split(" ") + if (parts.size < 2) return + val fullPath = parts[1] + val (rawPath, rawQuery) = if ('?' in fullPath) { + val split = fullPath.split("?", limit = 2) + split[0] to split[1] + } else { + fullPath to "" + } + val query = parseQuery(rawQuery) + + // Constant-time auth check + val providedToken = headers["authorization"]?.removePrefix("Bearer ")?.trim() + ?: query["token"] + ?: "" + if (!constantTimeEquals(providedToken, token)) { + respond401(socket) + return + } + + when (rawPath) { + "/events" -> handleSSE(socket) + "/logs" -> serveJson(socket, synchronized(bufferLock) { recentLogs.toList() }) + "/network" -> serveJson(socket, synchronized(bufferLock) { recentNetworkCalls.toList() }) + "/", "/index.html" -> serveDashboard(socket) + else -> respond404(socket) + } + } + } + + // ---- SSE ---- + + private fun handleSSE(socket: java.net.Socket) { + socket.soTimeout = 0 + val writer = PrintWriter(socket.getOutputStream(), true) + writer.print( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache\r\n" + + "X-Accel-Buffering: no\r\n" + + "Connection: keep-alive\r\n\r\n" + ) + writer.flush() + sseClients.add(writer) + try { + while (!socket.isClosed) { + Thread.sleep(20_000) + writer.print(": heartbeat\n\n") + writer.flush() + if (writer.checkError()) break + } + } catch (_: InterruptedException) { + } finally { + sseClients.remove(writer) + } + } + + private fun broadcast(type: String, data: Any) { + if (sseClients.isEmpty()) return + val payload = "event: $type\ndata: ${gson.toJson(data)}\n\n" + val dead = mutableListOf() + for (writer in sseClients) { + runCatching { writer.print(payload); writer.flush() } + .onFailure { dead.add(writer) } + } + sseClients.removeAll(dead) + } + + // ---- Response helpers ---- + + private fun serveJson(socket: java.net.Socket, data: Any) { + val body = gson.toJson(data).toByteArray(Charsets.UTF_8) + val out = socket.getOutputStream() + out.write( + ("HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n").toByteArray() + ) + out.write(body) + out.flush() + } + + private fun serveDashboard(socket: java.net.Socket) { + val body = DASHBOARD_HTML.toByteArray(Charsets.UTF_8) + val out = socket.getOutputStream() + out.write( + ("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n").toByteArray() + ) + out.write(body) + out.flush() + } + + private fun respond401(socket: java.net.Socket) { + val body = "Unauthorized".toByteArray() + socket.getOutputStream().write( + ("HTTP/1.1 401 Unauthorized\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n").toByteArray() + ) + socket.getOutputStream().write(body) + socket.getOutputStream().flush() + } + + private fun respond404(socket: java.net.Socket) { + val body = "Not Found".toByteArray() + socket.getOutputStream().write( + ("HTTP/1.1 404 Not Found\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n").toByteArray() + ) + socket.getOutputStream().write(body) + socket.getOutputStream().flush() + } + + // ---- Utilities ---- + + private fun parseQuery(query: String): Map { + if (query.isEmpty()) return emptyMap() + return query.split("&").mapNotNull { + val idx = it.indexOf('=') + if (idx < 1) null + else URLDecoder.decode(it.substring(0, idx), "UTF-8") to + URLDecoder.decode(it.substring(idx + 1), "UTF-8") + }.toMap() + } + + private fun constantTimeEquals(a: String, b: String): Boolean { + val digest = MessageDigest.getInstance("SHA-256") + return MessageDigest.isEqual( + digest.digest(a.toByteArray(Charsets.UTF_8)), + digest.digest(b.toByteArray(Charsets.UTF_8)) + ) + } + + // ---- Dashboard HTML ---- + // Single self-contained page. No external resources. Token login → SSE stream. + + companion object { + private val DASHBOARD_HTML = """ + + + + +PurrfectSnap Companion + + + +
+
+

PurrfectSnap Companion

+ + + Invalid token or connection failed +
+
+
+

PurrfectSnap Companion

+ ● disconnected + +
+
+
+
+ Logs + + + +
+
+
+
+
+ Network + + + +
+
+
+
+ + +""" + } +}