mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(shared): add opt-in diagnostics client with compile-time kill switch
Ship client-side telemetry, crash capture, and bug-report assembly behind a user opt-in, with NoOp transport until Cloudflare is wired. Builds can set vnidrop.diagnostics.included=false to omit the Share-diagnostics UI and disable the telemetry/crash auto-upload stack while keeping bug reports.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSString
|
||||
import platform.Foundation.NSUTF8StringEncoding
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataUsingEncoding
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.writeToFile
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
IosPendingCrashStore(appDataDir)
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private class IosPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val fileManager = NSFileManager.defaultManager
|
||||
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
|
||||
|
||||
override fun write(report: CrashReport) {
|
||||
ensureDirectory()
|
||||
val path = "$directory/${report.id}.crash"
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
val data = (payload as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
|
||||
data.writeToFile(path, atomically = true)
|
||||
}
|
||||
|
||||
override fun list(): List<CrashReport> {
|
||||
ensureDirectory()
|
||||
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.endsWith(".crash") }
|
||||
return names.mapNotNull { name ->
|
||||
val path = "$directory/$name"
|
||||
val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null
|
||||
val text = data.toUtf8String()
|
||||
CrashReportCodec.decode(text)
|
||||
}.sortedByDescending { it.timestampMillis }
|
||||
}
|
||||
|
||||
override fun delete(id: String) {
|
||||
fileManager.removeItemAtPath("$directory/$id.crash", null)
|
||||
}
|
||||
|
||||
private fun ensureDirectory() {
|
||||
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toUtf8String(): String {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ""
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ""
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result.decodeToString()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlin.experimental.ExperimentalNativeApi
|
||||
|
||||
@OptIn(ExperimentalNativeApi::class)
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = setUnhandledExceptionHook { throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
// Terminate like the default hook after capture.
|
||||
terminateWithUnhandledException(throwable)
|
||||
}
|
||||
// Keep a reference so the previous hook is not GC'd unused; we intentionally
|
||||
// replace the default with capture-then-terminate.
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val ignored = previous
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
package com.vnidrop.app.logging
|
||||
|
||||
import platform.Foundation.NSData
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSFileModificationDate
|
||||
import platform.Foundation.NSFileSize
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fwrite
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||
IosPlatformLogStore(appDataDir, policy)
|
||||
@@ -61,6 +63,31 @@ private class IosPlatformLogStore(
|
||||
.sortedByDescending { it.modifiedAtMillis }
|
||||
}
|
||||
|
||||
override fun readLatest(maxBytes: Long): String {
|
||||
if (maxBytes <= 0) return ""
|
||||
ensureDirectory()
|
||||
val paths = listOf(activePath) +
|
||||
(1..policy.maxFiles).map { "$directory/app.$it.log" }
|
||||
val chunks = ArrayList<ByteArray>()
|
||||
var remaining = maxBytes
|
||||
for (path in paths) {
|
||||
if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue
|
||||
val slice = readTail(path, remaining)
|
||||
if (slice.isEmpty()) continue
|
||||
chunks.add(0, slice)
|
||||
remaining -= slice.size.toLong()
|
||||
}
|
||||
if (chunks.isEmpty()) return ""
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
chunk.copyInto(out, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
return out.decodeToString()
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
if (policy.maxFiles == 0) {
|
||||
fileManager.removeItemAtPath(activePath, null)
|
||||
@@ -92,4 +119,31 @@ private class IosPlatformLogStore(
|
||||
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
|
||||
return (date.timeIntervalSince1970 * 1000.0).toLong()
|
||||
}
|
||||
|
||||
private fun readTail(path: String, maxBytes: Long): ByteArray {
|
||||
val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
||||
val all = data.toByteArray()
|
||||
if (all.isEmpty() || maxBytes <= 0) return ByteArray(0)
|
||||
if (all.size.toLong() <= maxBytes) return all
|
||||
val start = all.size - maxBytes.toInt()
|
||||
val slice = all.copyOfRange(start, all.size)
|
||||
val newline = slice.indexOf('\n'.code.toByte())
|
||||
return if (newline in 0 until slice.lastIndex) {
|
||||
slice.copyOfRange(newline + 1, slice.size)
|
||||
} else {
|
||||
slice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ByteArray(0)
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ByteArray(0)
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user