feat: add Companion Server for live log and network monitoring
This commit is contained in:
@@ -168,6 +168,7 @@ class FeatureManager(
|
||||
CustomTheming(),
|
||||
HideTypingIndicator(),
|
||||
FakeSnapScore(),
|
||||
CompanionServer(),
|
||||
)
|
||||
|
||||
features.values.toList().forEach { feature ->
|
||||
|
||||
@@ -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<PrintWriter>()
|
||||
|
||||
// Ring buffers for replay on new connections
|
||||
private val recentLogs = ArrayDeque<Map<String, String>>(500)
|
||||
private val recentNetworkCalls = ArrayDeque<Map<String, String>>(200)
|
||||
private val bufferLock = Any()
|
||||
|
||||
// Prevent re-entrant log capture while the server itself logs
|
||||
private val isCapturing = ThreadLocal<Boolean>()
|
||||
|
||||
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<Int>(0)
|
||||
val tag = param.arg<String>(1)
|
||||
val message = param.arg<String>(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<String, String>()
|
||||
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<PrintWriter>()
|
||||
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<String, String> {
|
||||
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 = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PurrfectSnap Companion</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:monospace;background:#0d0d0d;color:#d0d0d0;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
||||
#login{position:fixed;inset:0;background:#0d0d0d;display:flex;align-items:center;justify-content:center;z-index:99}
|
||||
#login-box{display:flex;flex-direction:column;gap:10px;width:280px}
|
||||
#login-box h2{font-size:14px;color:#fff;margin-bottom:4px}
|
||||
#login-box input{padding:8px;background:#1a1a1a;border:1px solid #444;color:#fff;font-family:monospace;font-size:13px;border-radius:3px;outline:none}
|
||||
#login-box input:focus{border-color:#666}
|
||||
#login-box button{padding:8px;background:#222;border:1px solid #555;color:#ccc;cursor:pointer;border-radius:3px;font-size:13px}
|
||||
#login-box button:hover{background:#333}
|
||||
#login-err{color:#ef5350;font-size:11px;display:none}
|
||||
#bar{padding:6px 10px;background:#111;border-bottom:1px solid #222;display:flex;align-items:center;gap:14px;flex-shrink:0}
|
||||
#bar h1{font-size:13px;color:#bbb;font-weight:normal}
|
||||
#dot{font-size:11px}
|
||||
#dot.on{color:#4caf50}
|
||||
#dot.off{color:#ef5350}
|
||||
#counts{font-size:10px;color:#555;margin-left:auto}
|
||||
#main{display:flex;flex:1;overflow:hidden}
|
||||
.pane{flex:1;display:flex;flex-direction:column;border-right:1px solid #1a1a1a;overflow:hidden}
|
||||
.pane:last-child{border-right:none}
|
||||
.pane-bar{padding:4px 8px;background:#111;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||
.pane-bar span{font-size:11px;color:#888;min-width:50px}
|
||||
.pane-bar input{flex:1;background:#0d0d0d;border:1px solid #2a2a2a;color:#bbb;font-family:monospace;font-size:11px;padding:2px 5px;border-radius:2px;outline:none}
|
||||
.pane-bar input:focus{border-color:#444}
|
||||
.pane-bar button{font-size:10px;padding:2px 7px;background:#181818;border:1px solid #333;color:#888;cursor:pointer;border-radius:2px}
|
||||
.pane-bar button:hover{background:#222}
|
||||
.pane-bar label{font-size:10px;color:#555;white-space:nowrap}
|
||||
.entries{flex:1;overflow-y:auto;padding:2px 0;font-size:11px;line-height:1.6}
|
||||
.row{padding:0 6px;white-space:pre-wrap;word-break:break-all}
|
||||
.row:hover{background:#141414}
|
||||
.V{color:#555}.D{color:#5c8fbd}.I{color:#bbb}.W{color:#c9944a}.E{color:#c45252}.A{color:#c45252;font-weight:bold}
|
||||
.grpc{color:#9c6db5}.http{color:#3ba8b8}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login">
|
||||
<div id="login-box">
|
||||
<h2>PurrfectSnap Companion</h2>
|
||||
<input type="password" id="tok" placeholder="Access token" autofocus/>
|
||||
<button onclick="connect()">Connect</button>
|
||||
<span id="login-err">Invalid token or connection failed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bar">
|
||||
<h1>PurrfectSnap Companion</h1>
|
||||
<span id="dot" class="off">● disconnected</span>
|
||||
<span id="counts"></span>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div class="pane">
|
||||
<div class="pane-bar">
|
||||
<span>Logs</span>
|
||||
<input id="lf" type="text" placeholder="filter…" oninput="filter('le','lf')"/>
|
||||
<button onclick="clr('le')">Clear</button>
|
||||
<label><input type="checkbox" id="ls" checked> scroll</label>
|
||||
</div>
|
||||
<div class="entries" id="le"></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-bar">
|
||||
<span>Network</span>
|
||||
<input id="nf" type="text" placeholder="filter…" oninput="filter('ne','nf')"/>
|
||||
<button onclick="clr('ne')">Clear</button>
|
||||
<label><input type="checkbox" id="ns" checked> scroll</label>
|
||||
</div>
|
||||
<div class="entries" id="ne"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var tok='',lc=0,nc=0,es=null;
|
||||
function ts(e){return new Date(+e).toISOString().slice(11,23)}
|
||||
function q(id){return document.getElementById(id)}
|
||||
function connect(){
|
||||
tok=q('tok').value.trim();
|
||||
if(!tok)return;
|
||||
fetch('/?token='+encodeURIComponent(tok))
|
||||
.then(function(r){
|
||||
if(r.status===401){q('login-err').style.display='block';return;}
|
||||
q('login').style.display='none';
|
||||
loadHistory();
|
||||
startSSE();
|
||||
})
|
||||
.catch(function(){q('login-err').style.display='block';});
|
||||
}
|
||||
function loadHistory(){
|
||||
fetch('/logs?token='+encodeURIComponent(tok)).then(function(r){return r.json();}).then(function(a){a.forEach(function(e){addLog(e,false);});});
|
||||
fetch('/network?token='+encodeURIComponent(tok)).then(function(r){return r.json();}).then(function(a){a.forEach(function(e){addNet(e,false);});});
|
||||
}
|
||||
function startSSE(){
|
||||
es=new EventSource('/events?token='+encodeURIComponent(tok));
|
||||
es.onopen=function(){q('dot').textContent='● connected';q('dot').className='on';};
|
||||
es.onerror=function(){q('dot').textContent='● disconnected';q('dot').className='off';};
|
||||
es.addEventListener('log',function(e){addLog(JSON.parse(e.data),true);});
|
||||
es.addEventListener('network',function(e){addNet(JSON.parse(e.data),true);});
|
||||
}
|
||||
function addLog(e,live){
|
||||
lc++;
|
||||
var el=document.createElement('div');
|
||||
el.className='row '+(e.level||'I');
|
||||
el.textContent='['+ts(e.ts)+'] '+(e.level||'I')+' '+(e.tag||'')+': '+(e.msg||'');
|
||||
el.dataset.t=el.textContent.toLowerCase();
|
||||
var f=q('lf').value.toLowerCase();
|
||||
if(f&&!el.dataset.t.includes(f))el.style.display='none';
|
||||
q('le').appendChild(el);
|
||||
if(live&&q('ls').checked)el.scrollIntoView();
|
||||
upd();
|
||||
}
|
||||
function addNet(e,live){
|
||||
nc++;
|
||||
var el=document.createElement('div');
|
||||
el.className='row '+(e.type||'grpc');
|
||||
var sz=e.size&&e.size!=='0'?' ('+e.size+' B)':'';
|
||||
el.textContent='['+ts(e.ts)+'] '+(e.type||'').toUpperCase()+' '+(e.uri||e.url||'')+sz;
|
||||
el.dataset.t=el.textContent.toLowerCase();
|
||||
var f=q('nf').value.toLowerCase();
|
||||
if(f&&!el.dataset.t.includes(f))el.style.display='none';
|
||||
q('ne').appendChild(el);
|
||||
if(live&&q('ns').checked)el.scrollIntoView();
|
||||
upd();
|
||||
}
|
||||
function filter(listId,filterId){
|
||||
var f=q(filterId).value.toLowerCase();
|
||||
q(listId).querySelectorAll('.row').forEach(function(el){
|
||||
el.style.display=(!f||el.dataset.t.includes(f))?'':'none';
|
||||
});
|
||||
}
|
||||
function clr(id){q(id).innerHTML='';}
|
||||
function upd(){q('counts').textContent=lc+' logs | '+nc+' calls';}
|
||||
q('tok').addEventListener('keydown',function(e){if(e.key==='Enter')connect();});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user