capacitor-pica-network-logger 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorPicaNetworkLogger.podspec +2 -1
- package/README.md +4 -0
- package/android/build.gradle +9 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/linakis/capacitorpicanetworklogger/InspectorNotifications.kt +38 -7
- package/android/src/main/java/com/linakis/capacitorpicanetworklogger/InspectorScreen.kt +95 -27
- package/android/src/main/java/com/linakis/capacitorpicanetworklogger/LogRepository.kt +170 -1
- package/android/src/main/java/com/linakis/capacitorpicanetworklogger/PicaNetworkLoggerPlugin.kt +3 -2
- package/ios/Plugin/InspectorViewController.swift +89 -15
- package/ios/Plugin/LogRepository.swift +194 -0
- package/package.json +4 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Pod::Spec.new do |s|
|
|
2
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
2
3
|
s.name = 'CapacitorPicaNetworkLogger'
|
|
3
|
-
s.version = '
|
|
4
|
+
s.version = package['version']
|
|
4
5
|
s.summary = 'Capacitor HTTP inspector'
|
|
5
6
|
s.license = 'MIT'
|
|
6
7
|
s.author = { 'Nikos Linakis' => 'nikos@linakis.net' }
|
package/README.md
CHANGED
|
@@ -19,12 +19,16 @@ Capacitor HTTP inspector with debug-only native capture and a JS wrapper that mi
|
|
|
19
19
|
|
|
20
20
|
- Debug-only request/response logging
|
|
21
21
|
- Native inspector UI (standalone Activity/UIViewController)
|
|
22
|
+
- Body text search with highlighting in the inspector
|
|
23
|
+
- Persistent log storage across inspector sessions
|
|
22
24
|
- Notifications to open inspector
|
|
23
25
|
- Redaction + max body size from Capacitor config
|
|
24
26
|
- cURL/JSON/HAR exports + copy/share
|
|
25
27
|
- Save cURL/JSON/HAR locally
|
|
26
28
|
- Copy all logs as HAR
|
|
27
29
|
|
|
30
|
+
See the [Changelog](CHANGELOG.md) for a full history of changes.
|
|
31
|
+
|
|
28
32
|
## Project layout
|
|
29
33
|
|
|
30
34
|
- `src/`: JS wrapper and plugin typings
|
package/android/build.gradle
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<activity
|
|
5
5
|
android:name="com.linakis.capacitorpicanetworklogger.InspectorActivity"
|
|
6
6
|
android:exported="false"
|
|
7
|
+
android:taskAffinity="com.linakis.capacitorpicanetworklogger.inspector"
|
|
7
8
|
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar" />
|
|
8
9
|
</application>
|
|
9
10
|
</manifest>
|
package/android/src/main/java/com/linakis/capacitorpicanetworklogger/InspectorNotifications.kt
CHANGED
|
@@ -9,37 +9,68 @@ import android.os.Build
|
|
|
9
9
|
import androidx.core.app.NotificationCompat
|
|
10
10
|
|
|
11
11
|
object InspectorNotifications {
|
|
12
|
-
private const val
|
|
13
|
-
private const val
|
|
12
|
+
private const val CHANNEL_ID = "pica_network_inspector"
|
|
13
|
+
private const val CHANNEL_NAME = "Network Logger"
|
|
14
|
+
private const val GROUP_KEY = "pica_network_inspector_group"
|
|
15
|
+
private const val SUMMARY_ID = 0
|
|
14
16
|
|
|
15
17
|
fun show(context: Context, method: String, url: String, status: Int?) {
|
|
16
18
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
19
|
+
|
|
17
20
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
18
|
-
val channel = NotificationChannel(
|
|
21
|
+
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
|
|
19
22
|
channel.setSound(null, null)
|
|
20
23
|
channel.enableVibration(false)
|
|
21
24
|
manager.createNotificationChannel(channel)
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
val intent = Intent(context, InspectorActivity::class.java).apply {
|
|
25
|
-
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
28
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
26
29
|
}
|
|
30
|
+
|
|
31
|
+
val requestCode = (System.currentTimeMillis() % 10000).toInt()
|
|
27
32
|
val pendingIntent = PendingIntent.getActivity(
|
|
28
33
|
context,
|
|
29
|
-
|
|
34
|
+
requestCode,
|
|
30
35
|
intent,
|
|
31
36
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
32
37
|
)
|
|
33
38
|
|
|
34
39
|
val title = if (status != null) "$method $status" else method
|
|
35
|
-
val notification = NotificationCompat.Builder(context,
|
|
40
|
+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
36
41
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
|
37
42
|
.setContentTitle(title)
|
|
38
43
|
.setContentText(url)
|
|
39
44
|
.setContentIntent(pendingIntent)
|
|
40
45
|
.setAutoCancel(true)
|
|
46
|
+
.setSilent(true)
|
|
47
|
+
.setGroup(GROUP_KEY)
|
|
48
|
+
.build()
|
|
49
|
+
|
|
50
|
+
manager.notify(requestCode, notification)
|
|
51
|
+
|
|
52
|
+
// Summary notification for the group
|
|
53
|
+
val summaryIntent = Intent(context, InspectorActivity::class.java).apply {
|
|
54
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
55
|
+
}
|
|
56
|
+
val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
57
|
+
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
|
58
|
+
.setContentTitle(CHANNEL_NAME)
|
|
59
|
+
.setContentText("Tap to open inspector")
|
|
60
|
+
.setContentIntent(
|
|
61
|
+
PendingIntent.getActivity(
|
|
62
|
+
context,
|
|
63
|
+
0,
|
|
64
|
+
summaryIntent,
|
|
65
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
.setAutoCancel(true)
|
|
69
|
+
.setSilent(true)
|
|
70
|
+
.setGroup(GROUP_KEY)
|
|
71
|
+
.setGroupSummary(true)
|
|
41
72
|
.build()
|
|
42
73
|
|
|
43
|
-
manager.notify(
|
|
74
|
+
manager.notify(SUMMARY_ID, summaryNotification)
|
|
44
75
|
}
|
|
45
76
|
}
|
|
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.SpanStyle
|
|
|
69
69
|
import androidx.compose.ui.text.TextStyle
|
|
70
70
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
71
71
|
import androidx.compose.ui.text.font.FontWeight
|
|
72
|
+
import androidx.compose.ui.text.style.TextOverflow
|
|
72
73
|
import androidx.compose.ui.text.withStyle
|
|
73
74
|
import androidx.compose.ui.unit.dp
|
|
74
75
|
import androidx.compose.ui.unit.sp
|
|
@@ -415,39 +416,45 @@ private fun TransactionList(
|
|
|
415
416
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
|
416
417
|
) {
|
|
417
418
|
Column(modifier = Modifier.padding(12.dp)) {
|
|
419
|
+
// Row 1: pills + date/time
|
|
418
420
|
Row(
|
|
419
421
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
420
|
-
verticalAlignment = Alignment.CenterVertically
|
|
422
|
+
verticalAlignment = Alignment.CenterVertically,
|
|
423
|
+
modifier = Modifier.fillMaxWidth()
|
|
421
424
|
) {
|
|
422
425
|
StatusChip(status = entry.resStatus)
|
|
423
426
|
MethodChip(method = entry.method)
|
|
427
|
+
Spacer(modifier = Modifier.weight(1f))
|
|
424
428
|
Text(
|
|
425
|
-
text =
|
|
426
|
-
style = MaterialTheme.typography.
|
|
427
|
-
color = MaterialTheme.colorScheme.
|
|
429
|
+
text = formatTime(entry.startTs),
|
|
430
|
+
style = MaterialTheme.typography.labelSmall,
|
|
431
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
428
432
|
)
|
|
429
433
|
}
|
|
434
|
+
// Row 2: full-width path (unlimited lines)
|
|
430
435
|
Text(
|
|
431
|
-
text =
|
|
432
|
-
style = MaterialTheme.typography.
|
|
433
|
-
color = MaterialTheme.colorScheme.
|
|
436
|
+
text = path.ifBlank { entry.url },
|
|
437
|
+
style = MaterialTheme.typography.bodyMedium,
|
|
438
|
+
color = MaterialTheme.colorScheme.onSurface,
|
|
439
|
+
modifier = Modifier.padding(top = 6.dp)
|
|
434
440
|
)
|
|
441
|
+
// Row 3: host + duration/size
|
|
435
442
|
Row(
|
|
436
|
-
modifier = Modifier
|
|
437
|
-
|
|
443
|
+
modifier = Modifier
|
|
444
|
+
.padding(top = 6.dp)
|
|
445
|
+
.fillMaxWidth(),
|
|
446
|
+
verticalAlignment = Alignment.CenterVertically
|
|
438
447
|
) {
|
|
439
448
|
Text(
|
|
440
|
-
|
|
441
|
-
style = MaterialTheme.typography.
|
|
442
|
-
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
style = MaterialTheme.typography.labelSmall,
|
|
447
|
-
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
449
|
+
text = host,
|
|
450
|
+
style = MaterialTheme.typography.bodySmall,
|
|
451
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
452
|
+
maxLines = 1,
|
|
453
|
+
overflow = TextOverflow.Ellipsis,
|
|
454
|
+
modifier = Modifier.weight(1f)
|
|
448
455
|
)
|
|
449
456
|
Text(
|
|
450
|
-
formatSize(entry.resBody?.length ?: 0),
|
|
457
|
+
text = "${entry.durationMs ?: 0} ms • ${formatSize(entry.resBody?.length ?: 0)}",
|
|
451
458
|
style = MaterialTheme.typography.labelSmall,
|
|
452
459
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
453
460
|
)
|
|
@@ -473,6 +480,7 @@ private fun TransactionDetail(
|
|
|
473
480
|
return
|
|
474
481
|
}
|
|
475
482
|
var tab by remember { mutableIntStateOf(0) }
|
|
483
|
+
var bodyQuery by remember { mutableStateOf("") }
|
|
476
484
|
Column(
|
|
477
485
|
modifier = modifier
|
|
478
486
|
.padding(12.dp)
|
|
@@ -481,6 +489,20 @@ private fun TransactionDetail(
|
|
|
481
489
|
Spacer(modifier = Modifier.height(8.dp))
|
|
482
490
|
OverviewSection(entry)
|
|
483
491
|
Spacer(modifier = Modifier.height(12.dp))
|
|
492
|
+
OutlinedTextField(
|
|
493
|
+
value = bodyQuery,
|
|
494
|
+
onValueChange = { bodyQuery = it },
|
|
495
|
+
label = { Text("Search body") },
|
|
496
|
+
modifier = Modifier.fillMaxWidth(),
|
|
497
|
+
trailingIcon = {
|
|
498
|
+
if (bodyQuery.isNotEmpty()) {
|
|
499
|
+
IconButton(onClick = { bodyQuery = "" }) {
|
|
500
|
+
Icon(Icons.Filled.Close, contentDescription = "Clear")
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
Spacer(modifier = Modifier.height(8.dp))
|
|
484
506
|
TabRow(
|
|
485
507
|
selectedTabIndex = tab,
|
|
486
508
|
containerColor = MaterialTheme.colorScheme.surface,
|
|
@@ -491,8 +513,8 @@ private fun TransactionDetail(
|
|
|
491
513
|
}
|
|
492
514
|
Spacer(modifier = Modifier.height(8.dp))
|
|
493
515
|
when (tab) {
|
|
494
|
-
0 -> RequestTab(entry = entry)
|
|
495
|
-
1 -> ResponseTab(entry = entry)
|
|
516
|
+
0 -> RequestTab(entry = entry, bodyQuery = bodyQuery)
|
|
517
|
+
1 -> ResponseTab(entry = entry, bodyQuery = bodyQuery)
|
|
496
518
|
}
|
|
497
519
|
}
|
|
498
520
|
}
|
|
@@ -511,27 +533,27 @@ private fun OverviewSection(entry: LogEntry) {
|
|
|
511
533
|
}
|
|
512
534
|
|
|
513
535
|
@Composable
|
|
514
|
-
private fun RequestTab(entry: LogEntry) {
|
|
536
|
+
private fun RequestTab(entry: LogEntry, bodyQuery: String) {
|
|
515
537
|
val headers = parseHeaders(entry.reqHeadersJson)
|
|
516
538
|
val body = formatBody(entry.reqBody)
|
|
517
539
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
518
540
|
HeadersSectionCard(title = "Request Headers", headers = headers)
|
|
519
|
-
SectionCard(title = "Request Body", body = body)
|
|
541
|
+
SectionCard(title = "Request Body", body = body, highlightQuery = bodyQuery, isBody = true)
|
|
520
542
|
}
|
|
521
543
|
}
|
|
522
544
|
|
|
523
545
|
@Composable
|
|
524
|
-
private fun ResponseTab(entry: LogEntry) {
|
|
546
|
+
private fun ResponseTab(entry: LogEntry, bodyQuery: String) {
|
|
525
547
|
val headers = parseHeaders(entry.resHeadersJson)
|
|
526
548
|
val body = formatBody(entry.resBody)
|
|
527
549
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
528
550
|
HeadersSectionCard(title = "Response Headers", headers = headers)
|
|
529
|
-
SectionCard(title = "Response Body", body = body)
|
|
551
|
+
SectionCard(title = "Response Body", body = body, highlightQuery = bodyQuery, isBody = true)
|
|
530
552
|
}
|
|
531
553
|
}
|
|
532
554
|
|
|
533
555
|
@Composable
|
|
534
|
-
private fun SectionCard(title: String, body: String) {
|
|
556
|
+
private fun SectionCard(title: String, body: String, highlightQuery: String = "", isBody: Boolean = false) {
|
|
535
557
|
Column(modifier = Modifier.fillMaxWidth()) {
|
|
536
558
|
Text(
|
|
537
559
|
title,
|
|
@@ -546,7 +568,18 @@ private fun SectionCard(title: String, body: String) {
|
|
|
546
568
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
|
547
569
|
) {
|
|
548
570
|
Column(modifier = Modifier.padding(12.dp)) {
|
|
549
|
-
|
|
571
|
+
val displayBody = body.ifBlank { "-" }
|
|
572
|
+
val annotated = if (isBody && displayBody != "-") {
|
|
573
|
+
highlightMatches(
|
|
574
|
+
text = displayBody,
|
|
575
|
+
query = highlightQuery,
|
|
576
|
+
highlightColor = MaterialTheme.colorScheme.tertiaryContainer,
|
|
577
|
+
textColor = MaterialTheme.colorScheme.onSurface
|
|
578
|
+
)
|
|
579
|
+
} else {
|
|
580
|
+
AnnotatedString(displayBody)
|
|
581
|
+
}
|
|
582
|
+
Text(annotated, color = MaterialTheme.colorScheme.onSurface)
|
|
550
583
|
}
|
|
551
584
|
}
|
|
552
585
|
}
|
|
@@ -708,7 +741,7 @@ private fun Chip(label: String, background: Color, contentColor: Color) {
|
|
|
708
741
|
|
|
709
742
|
private fun formatTime(epochMillis: Long): String {
|
|
710
743
|
val date = java.util.Date(epochMillis)
|
|
711
|
-
val formatter = java.text.SimpleDateFormat("HH:mm:ss
|
|
744
|
+
val formatter = java.text.SimpleDateFormat("dd MMM HH:mm:ss", java.util.Locale.getDefault())
|
|
712
745
|
return formatter.format(date)
|
|
713
746
|
}
|
|
714
747
|
|
|
@@ -751,6 +784,41 @@ private fun formatBody(body: String?): String {
|
|
|
751
784
|
}
|
|
752
785
|
}
|
|
753
786
|
|
|
787
|
+
private fun highlightMatches(
|
|
788
|
+
text: String,
|
|
789
|
+
query: String,
|
|
790
|
+
highlightColor: Color,
|
|
791
|
+
textColor: Color
|
|
792
|
+
): AnnotatedString {
|
|
793
|
+
val trimmed = query.trim()
|
|
794
|
+
if (trimmed.length < 2) return AnnotatedString(text)
|
|
795
|
+
val lowerText = text.lowercase()
|
|
796
|
+
val lowerQuery = trimmed.lowercase()
|
|
797
|
+
var start = 0
|
|
798
|
+
return buildAnnotatedString {
|
|
799
|
+
while (start < text.length) {
|
|
800
|
+
val index = lowerText.indexOf(lowerQuery, startIndex = start)
|
|
801
|
+
if (index == -1) {
|
|
802
|
+
append(text.substring(start))
|
|
803
|
+
break
|
|
804
|
+
}
|
|
805
|
+
if (index > start) {
|
|
806
|
+
append(text.substring(start, index))
|
|
807
|
+
}
|
|
808
|
+
withStyle(
|
|
809
|
+
SpanStyle(
|
|
810
|
+
background = highlightColor,
|
|
811
|
+
color = textColor,
|
|
812
|
+
fontWeight = FontWeight.SemiBold
|
|
813
|
+
)
|
|
814
|
+
) {
|
|
815
|
+
append(text.substring(index, index + lowerQuery.length))
|
|
816
|
+
}
|
|
817
|
+
start = index + lowerQuery.length
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
754
822
|
private fun parseHeaders(headersJson: String?): Map<String, String> {
|
|
755
823
|
if (headersJson.isNullOrBlank()) return emptyMap()
|
|
756
824
|
return try {
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
package com.linakis.capacitorpicanetworklogger
|
|
2
2
|
|
|
3
3
|
import com.getcapacitor.JSObject
|
|
4
|
+
import java.io.File
|
|
4
5
|
import java.util.concurrent.ConcurrentHashMap
|
|
5
6
|
|
|
6
7
|
class LogRepository {
|
|
7
8
|
private val logsById: MutableMap<String, LogEntry> = ConcurrentHashMap()
|
|
9
|
+
private var database: LogDatabase? = null
|
|
8
10
|
|
|
9
11
|
fun attach(@Suppress("UNUSED_PARAMETER") context: android.content.Context) {
|
|
10
|
-
|
|
12
|
+
if (database != null) return
|
|
13
|
+
val dbFile = File(context.applicationContext.filesDir, "cap-pica-logger.sqlite")
|
|
14
|
+
database = LogDatabase(dbFile)
|
|
15
|
+
database?.readAll()?.forEach { entry ->
|
|
16
|
+
logsById[entry.id] = entry
|
|
17
|
+
}
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
fun startRequest(data: JSObject) {
|
|
@@ -36,6 +43,7 @@ class LogRepository {
|
|
|
36
43
|
correlationId = correlationId
|
|
37
44
|
)
|
|
38
45
|
logsById[id] = entry
|
|
46
|
+
database?.upsert(entry)
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
fun finishRequest(data: JSObject) {
|
|
@@ -70,6 +78,7 @@ class LogRepository {
|
|
|
70
78
|
entry.error = error
|
|
71
79
|
entry.errorMessage = errorMessage
|
|
72
80
|
logsById[id] = entry
|
|
81
|
+
database?.upsert(entry)
|
|
73
82
|
|
|
74
83
|
val notifyMethod = entry.method
|
|
75
84
|
val notifyUrl = entry.url
|
|
@@ -97,6 +106,166 @@ class LogRepository {
|
|
|
97
106
|
|
|
98
107
|
fun clear() {
|
|
99
108
|
logsById.clear()
|
|
109
|
+
database?.clear()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private class LogDatabase(private val file: File) {
|
|
114
|
+
private val driver = DatabaseDriver(file)
|
|
115
|
+
|
|
116
|
+
init {
|
|
117
|
+
driver.open()
|
|
118
|
+
driver.exec(
|
|
119
|
+
"""
|
|
120
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
startTs INTEGER,
|
|
123
|
+
durationMs INTEGER,
|
|
124
|
+
method TEXT,
|
|
125
|
+
url TEXT,
|
|
126
|
+
host TEXT,
|
|
127
|
+
path TEXT,
|
|
128
|
+
query TEXT,
|
|
129
|
+
reqHeadersJson TEXT,
|
|
130
|
+
reqBody TEXT,
|
|
131
|
+
reqBodyTruncated INTEGER,
|
|
132
|
+
resStatus INTEGER,
|
|
133
|
+
resHeadersJson TEXT,
|
|
134
|
+
resBody TEXT,
|
|
135
|
+
resBodyTruncated INTEGER,
|
|
136
|
+
protocol TEXT,
|
|
137
|
+
ssl INTEGER,
|
|
138
|
+
error INTEGER,
|
|
139
|
+
errorMessage TEXT,
|
|
140
|
+
platform TEXT,
|
|
141
|
+
correlationId TEXT
|
|
142
|
+
);
|
|
143
|
+
""".trimIndent()
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fun upsert(entry: LogEntry) {
|
|
148
|
+
driver.exec(
|
|
149
|
+
"""
|
|
150
|
+
INSERT OR REPLACE INTO logs (
|
|
151
|
+
id, startTs, durationMs, method, url, host, path, query, reqHeadersJson, reqBody,
|
|
152
|
+
reqBodyTruncated, resStatus, resHeadersJson, resBody, resBodyTruncated, protocol,
|
|
153
|
+
ssl, error, errorMessage, platform, correlationId
|
|
154
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
155
|
+
""".trimIndent(),
|
|
156
|
+
listOf(
|
|
157
|
+
entry.id,
|
|
158
|
+
entry.startTs,
|
|
159
|
+
entry.durationMs,
|
|
160
|
+
entry.method,
|
|
161
|
+
entry.url,
|
|
162
|
+
entry.host,
|
|
163
|
+
entry.path,
|
|
164
|
+
entry.query,
|
|
165
|
+
entry.reqHeadersJson,
|
|
166
|
+
entry.reqBody,
|
|
167
|
+
if (entry.reqBodyTruncated) 1 else 0,
|
|
168
|
+
entry.resStatus,
|
|
169
|
+
entry.resHeadersJson,
|
|
170
|
+
entry.resBody,
|
|
171
|
+
if (entry.resBodyTruncated) 1 else 0,
|
|
172
|
+
entry.protocol,
|
|
173
|
+
if (entry.ssl) 1 else 0,
|
|
174
|
+
if (entry.error) 1 else 0,
|
|
175
|
+
entry.errorMessage,
|
|
176
|
+
entry.platform,
|
|
177
|
+
entry.correlationId
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fun readAll(): List<LogEntry> {
|
|
183
|
+
val rows = driver.query(
|
|
184
|
+
"""
|
|
185
|
+
SELECT id, startTs, durationMs, method, url, host, path, query, reqHeadersJson, reqBody,
|
|
186
|
+
reqBodyTruncated, resStatus, resHeadersJson, resBody, resBodyTruncated, protocol,
|
|
187
|
+
ssl, error, errorMessage, platform, correlationId
|
|
188
|
+
FROM logs
|
|
189
|
+
""".trimIndent()
|
|
190
|
+
)
|
|
191
|
+
return rows.mapNotNull { row ->
|
|
192
|
+
val id = row[0] as? String ?: return@mapNotNull null
|
|
193
|
+
val startTs = (row[1] as? Number)?.toLong() ?: return@mapNotNull null
|
|
194
|
+
val method = row[3] as? String ?: return@mapNotNull null
|
|
195
|
+
val url = row[4] as? String ?: return@mapNotNull null
|
|
196
|
+
val platform = row[19] as? String ?: return@mapNotNull null
|
|
197
|
+
val entry = LogEntry(
|
|
198
|
+
id = id,
|
|
199
|
+
startTs = startTs,
|
|
200
|
+
method = method,
|
|
201
|
+
url = url,
|
|
202
|
+
host = row[5] as? String,
|
|
203
|
+
path = row[6] as? String,
|
|
204
|
+
query = row[7] as? String,
|
|
205
|
+
reqHeadersJson = row[8] as? String,
|
|
206
|
+
reqBody = row[9] as? String,
|
|
207
|
+
reqBodyTruncated = (row[10] as? Number)?.toInt() == 1,
|
|
208
|
+
platform = platform,
|
|
209
|
+
correlationId = row[20] as? String
|
|
210
|
+
)
|
|
211
|
+
entry.durationMs = (row[2] as? Number)?.toLong()
|
|
212
|
+
entry.resStatus = (row[11] as? Number)?.toLong()
|
|
213
|
+
entry.resHeadersJson = row[12] as? String
|
|
214
|
+
entry.resBody = row[13] as? String
|
|
215
|
+
entry.resBodyTruncated = (row[14] as? Number)?.toInt() == 1
|
|
216
|
+
entry.protocol = row[15] as? String
|
|
217
|
+
entry.ssl = (row[16] as? Number)?.toInt() == 1
|
|
218
|
+
entry.error = (row[17] as? Number)?.toInt() == 1
|
|
219
|
+
entry.errorMessage = row[18] as? String
|
|
220
|
+
entry
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fun clear() {
|
|
225
|
+
driver.exec("DELETE FROM logs")
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private class DatabaseDriver(private val file: File) {
|
|
230
|
+
private var database: android.database.sqlite.SQLiteDatabase? = null
|
|
231
|
+
|
|
232
|
+
fun open() {
|
|
233
|
+
if (database != null) return
|
|
234
|
+
database = android.database.sqlite.SQLiteDatabase.openOrCreateDatabase(file, null)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fun exec(sql: String, args: List<Any?> = emptyList()) {
|
|
238
|
+
val db = database ?: return
|
|
239
|
+
if (args.isEmpty()) {
|
|
240
|
+
db.execSQL(sql)
|
|
241
|
+
} else {
|
|
242
|
+
db.execSQL(sql, args.toTypedArray())
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fun query(sql: String): List<List<Any?>> {
|
|
247
|
+
val db = database ?: return emptyList()
|
|
248
|
+
val cursor = db.rawQuery(sql, null)
|
|
249
|
+
val rows = mutableListOf<List<Any?>>()
|
|
250
|
+
cursor.use {
|
|
251
|
+
val count = cursor.columnCount
|
|
252
|
+
while (cursor.moveToNext()) {
|
|
253
|
+
val row = ArrayList<Any?>(count)
|
|
254
|
+
for (i in 0 until count) {
|
|
255
|
+
row.add(
|
|
256
|
+
when (cursor.getType(i)) {
|
|
257
|
+
android.database.Cursor.FIELD_TYPE_NULL -> null
|
|
258
|
+
android.database.Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
|
|
259
|
+
android.database.Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(i)
|
|
260
|
+
android.database.Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
|
|
261
|
+
else -> cursor.getString(i)
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
rows.add(row)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return rows
|
|
100
269
|
}
|
|
101
270
|
}
|
|
102
271
|
|
package/android/src/main/java/com/linakis/capacitorpicanetworklogger/PicaNetworkLoggerPlugin.kt
CHANGED
|
@@ -8,6 +8,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|
|
8
8
|
import android.content.pm.PackageManager
|
|
9
9
|
import android.os.Build
|
|
10
10
|
import androidx.core.app.ActivityCompat
|
|
11
|
+
import org.json.JSONObject
|
|
11
12
|
|
|
12
13
|
@CapacitorPlugin(name = "PicaNetworkLogger")
|
|
13
14
|
class PicaNetworkLoggerPlugin : Plugin() {
|
|
@@ -58,7 +59,7 @@ class PicaNetworkLoggerPlugin : Plugin() {
|
|
|
58
59
|
val headers = call.getObject("headers")?.let { obj ->
|
|
59
60
|
obj.keys().asSequence().associateWith { key -> obj.getString(key) ?: "" }
|
|
60
61
|
}
|
|
61
|
-
val body = call.
|
|
62
|
+
val body = call.data.opt("body")?.let { if ( it == JSONObject.NULL) null else it.toString() }
|
|
62
63
|
val id = java.util.UUID.randomUUID().toString()
|
|
63
64
|
LogRepositoryStore.logStart(id, method, url, headers, body)
|
|
64
65
|
val ret = JSObject()
|
|
@@ -73,7 +74,7 @@ class PicaNetworkLoggerPlugin : Plugin() {
|
|
|
73
74
|
val headers = call.getObject("headers")?.let { obj ->
|
|
74
75
|
obj.keys().asSequence().associateWith { key -> obj.getString(key) ?: "" }
|
|
75
76
|
}
|
|
76
|
-
val body = call.
|
|
77
|
+
val body = call.data.opt("body")?.let { if ( it == JSONObject.NULL) null else it.toString() }
|
|
77
78
|
val error = call.getString("error")
|
|
78
79
|
LogRepositoryStore.logFinish(id, status, headers, body, error, null, null)
|
|
79
80
|
call.resolve()
|
|
@@ -116,10 +116,12 @@ final class InspectorLogCell: UITableViewCell {
|
|
|
116
116
|
private let container = UIView()
|
|
117
117
|
private let titleLabel = UILabel()
|
|
118
118
|
private let hostLabel = UILabel()
|
|
119
|
-
private let
|
|
119
|
+
private let dateLabel = UILabel()
|
|
120
|
+
private let detailLabel = UILabel()
|
|
120
121
|
private let statusChip = InspectorChipLabel()
|
|
121
122
|
private let methodChip = InspectorChipLabel()
|
|
122
123
|
private let headerRow = UIStackView()
|
|
124
|
+
private let bottomRow = UIStackView()
|
|
123
125
|
|
|
124
126
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
125
127
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
@@ -132,26 +134,52 @@ final class InspectorLogCell: UITableViewCell {
|
|
|
132
134
|
container.layer.masksToBounds = true
|
|
133
135
|
contentView.addSubview(container)
|
|
134
136
|
|
|
137
|
+
// Row 1: [statusChip] [methodChip] --- dateLabel
|
|
135
138
|
headerRow.axis = .horizontal
|
|
136
139
|
headerRow.spacing = 8
|
|
137
140
|
headerRow.alignment = .center
|
|
138
141
|
headerRow.translatesAutoresizingMaskIntoConstraints = false
|
|
139
142
|
|
|
143
|
+
dateLabel.font = UIFont.systemFont(ofSize: 11, weight: .medium)
|
|
144
|
+
dateLabel.numberOfLines = 1
|
|
145
|
+
dateLabel.textAlignment = .right
|
|
146
|
+
dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
147
|
+
dateLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
148
|
+
|
|
149
|
+
let headerSpacer = UIView()
|
|
150
|
+
headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
151
|
+
|
|
152
|
+
headerRow.addArrangedSubview(statusChip)
|
|
153
|
+
headerRow.addArrangedSubview(methodChip)
|
|
154
|
+
headerRow.addArrangedSubview(headerSpacer)
|
|
155
|
+
headerRow.addArrangedSubview(dateLabel)
|
|
156
|
+
|
|
157
|
+
// Row 2: path (full width, unlimited lines)
|
|
140
158
|
titleLabel.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
|
141
|
-
titleLabel.numberOfLines =
|
|
142
|
-
|
|
159
|
+
titleLabel.numberOfLines = 0
|
|
160
|
+
|
|
161
|
+
// Row 3: hostLabel --- detailLabel (duration • size)
|
|
162
|
+
bottomRow.axis = .horizontal
|
|
163
|
+
bottomRow.spacing = 8
|
|
164
|
+
bottomRow.alignment = .center
|
|
165
|
+
bottomRow.translatesAutoresizingMaskIntoConstraints = false
|
|
143
166
|
|
|
144
167
|
hostLabel.font = UIFont.systemFont(ofSize: 13, weight: .regular)
|
|
145
168
|
hostLabel.numberOfLines = 1
|
|
169
|
+
hostLabel.lineBreakMode = .byTruncatingTail
|
|
170
|
+
hostLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
171
|
+
hostLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
146
172
|
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
detailLabel.font = UIFont.systemFont(ofSize: 11, weight: .medium)
|
|
174
|
+
detailLabel.numberOfLines = 1
|
|
175
|
+
detailLabel.textAlignment = .right
|
|
176
|
+
detailLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
177
|
+
detailLabel.setContentHuggingPriority(.required, for: .horizontal)
|
|
149
178
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
headerRow.addArrangedSubview(titleLabel)
|
|
179
|
+
bottomRow.addArrangedSubview(hostLabel)
|
|
180
|
+
bottomRow.addArrangedSubview(detailLabel)
|
|
153
181
|
|
|
154
|
-
let stack = UIStackView(arrangedSubviews: [headerRow,
|
|
182
|
+
let stack = UIStackView(arrangedSubviews: [headerRow, titleLabel, bottomRow])
|
|
155
183
|
stack.axis = .vertical
|
|
156
184
|
stack.spacing = 6
|
|
157
185
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -177,7 +205,8 @@ final class InspectorLogCell: UITableViewCell {
|
|
|
177
205
|
container.backgroundColor = InspectorTheme.cardBackground(isDark)
|
|
178
206
|
titleLabel.textColor = InspectorTheme.textPrimary(isDark)
|
|
179
207
|
hostLabel.textColor = InspectorTheme.textSecondary(isDark)
|
|
180
|
-
|
|
208
|
+
dateLabel.textColor = InspectorTheme.textSecondary(isDark)
|
|
209
|
+
detailLabel.textColor = InspectorTheme.textSecondary(isDark)
|
|
181
210
|
|
|
182
211
|
let statusText = log.resStatus.map { "\($0)" } ?? "-"
|
|
183
212
|
statusChip.configure(text: statusText, background: InspectorTheme.statusColor(log.resStatus), content: InspectorTheme.contentColor(for: InspectorTheme.statusColor(log.resStatus)))
|
|
@@ -186,10 +215,10 @@ final class InspectorLogCell: UITableViewCell {
|
|
|
186
215
|
let path = log.path ?? URL(string: log.url)?.path ?? ""
|
|
187
216
|
titleLabel.text = path.isEmpty ? log.url : path
|
|
188
217
|
hostLabel.text = log.host ?? URL(string: log.url)?.host ?? ""
|
|
189
|
-
|
|
218
|
+
dateLabel.text = InspectorTheme.formatTime(log.startTs)
|
|
190
219
|
let duration = "\(log.durationMs ?? 0) ms"
|
|
191
220
|
let size = InspectorTheme.formatSize(log.resBody?.count ?? 0)
|
|
192
|
-
|
|
221
|
+
detailLabel.text = [duration, size].joined(separator: " • ")
|
|
193
222
|
}
|
|
194
223
|
}
|
|
195
224
|
|
|
@@ -231,6 +260,7 @@ final class InspectorDetailViewController: UIViewController {
|
|
|
231
260
|
private let segmented = UISegmentedControl(items: ["Request", "Response"])
|
|
232
261
|
private let scrollView = UIScrollView()
|
|
233
262
|
private let stack = UIStackView()
|
|
263
|
+
private var bodySearchQuery: String = ""
|
|
234
264
|
|
|
235
265
|
init(log: LogEntry, isDark: Bool) {
|
|
236
266
|
self.log = log
|
|
@@ -253,6 +283,16 @@ final class InspectorDetailViewController: UIViewController {
|
|
|
253
283
|
segmented.translatesAutoresizingMaskIntoConstraints = false
|
|
254
284
|
view.addSubview(segmented)
|
|
255
285
|
|
|
286
|
+
let searchField = UITextField()
|
|
287
|
+
searchField.translatesAutoresizingMaskIntoConstraints = false
|
|
288
|
+
searchField.placeholder = "Search body"
|
|
289
|
+
searchField.borderStyle = .roundedRect
|
|
290
|
+
searchField.autocorrectionType = .no
|
|
291
|
+
searchField.autocapitalizationType = .none
|
|
292
|
+
searchField.clearButtonMode = .whileEditing
|
|
293
|
+
searchField.addTarget(self, action: #selector(bodySearchChanged(_:)), for: .editingChanged)
|
|
294
|
+
view.addSubview(searchField)
|
|
295
|
+
|
|
256
296
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
257
297
|
view.addSubview(scrollView)
|
|
258
298
|
|
|
@@ -265,7 +305,10 @@ final class InspectorDetailViewController: UIViewController {
|
|
|
265
305
|
segmented.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
|
266
306
|
segmented.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
267
307
|
segmented.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
268
|
-
|
|
308
|
+
searchField.topAnchor.constraint(equalTo: segmented.bottomAnchor, constant: 12),
|
|
309
|
+
searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
310
|
+
searchField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
311
|
+
scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 12),
|
|
269
312
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
270
313
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
271
314
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
@@ -283,6 +326,11 @@ final class InspectorDetailViewController: UIViewController {
|
|
|
283
326
|
refreshContent()
|
|
284
327
|
}
|
|
285
328
|
|
|
329
|
+
@objc private func bodySearchChanged(_ sender: UITextField) {
|
|
330
|
+
bodySearchQuery = sender.text ?? ""
|
|
331
|
+
refreshContent()
|
|
332
|
+
}
|
|
333
|
+
|
|
286
334
|
@objc private func share() {
|
|
287
335
|
let sheet = UIAlertController(title: "Share", message: nil, preferredStyle: .actionSheet)
|
|
288
336
|
sheet.addAction(UIAlertAction(title: "Share cURL", style: .default) { [weak self] _ in
|
|
@@ -384,7 +432,7 @@ final class InspectorDetailViewController: UIViewController {
|
|
|
384
432
|
} else if isHeader {
|
|
385
433
|
bodyLabel.attributedText = attributedHeaders(body)
|
|
386
434
|
} else {
|
|
387
|
-
bodyLabel.
|
|
435
|
+
bodyLabel.attributedText = highlightedBody(body)
|
|
388
436
|
}
|
|
389
437
|
bodyLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
390
438
|
card.addSubview(bodyLabel)
|
|
@@ -400,6 +448,28 @@ final class InspectorDetailViewController: UIViewController {
|
|
|
400
448
|
return wrapper
|
|
401
449
|
}
|
|
402
450
|
|
|
451
|
+
private func highlightedBody(_ body: String) -> NSAttributedString {
|
|
452
|
+
let text = body.isEmpty ? "-" : body
|
|
453
|
+
let baseFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
|
454
|
+
let baseColor = InspectorTheme.textPrimary(isDark)
|
|
455
|
+
let result = NSMutableAttributedString(string: text, attributes: [.font: baseFont, .foregroundColor: baseColor])
|
|
456
|
+
let query = bodySearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
457
|
+
if query.count < 2 { return result }
|
|
458
|
+
let lowerText = text.lowercased()
|
|
459
|
+
let lowerQuery = query.lowercased()
|
|
460
|
+
var searchRange = lowerText.startIndex..<lowerText.endIndex
|
|
461
|
+
while let range = lowerText.range(of: lowerQuery, options: [], range: searchRange) {
|
|
462
|
+
let nsRange = NSRange(range, in: text)
|
|
463
|
+
result.addAttributes([
|
|
464
|
+
.backgroundColor: InspectorTheme.highlightColor(isDark),
|
|
465
|
+
.foregroundColor: InspectorTheme.textPrimary(isDark),
|
|
466
|
+
.font: UIFont.systemFont(ofSize: 13, weight: .semibold)
|
|
467
|
+
], range: nsRange)
|
|
468
|
+
searchRange = range.upperBound..<lowerText.endIndex
|
|
469
|
+
}
|
|
470
|
+
return result
|
|
471
|
+
}
|
|
472
|
+
|
|
403
473
|
private func prettyJsonString(_ value: Any) -> String? {
|
|
404
474
|
guard JSONSerialization.isValidJSONObject(value),
|
|
405
475
|
let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]) else {
|
|
@@ -566,6 +636,10 @@ enum InspectorTheme {
|
|
|
566
636
|
return dark ? UIColor(red: 0.71, green: 0.74, blue: 0.76, alpha: 1) : UIColor(red: 0.29, green: 0.34, blue: 0.32, alpha: 1)
|
|
567
637
|
}
|
|
568
638
|
|
|
639
|
+
static func highlightColor(_ dark: Bool) -> UIColor {
|
|
640
|
+
return dark ? UIColor(red: 0.27, green: 0.36, blue: 0.16, alpha: 1) : UIColor(red: 0.98, green: 0.93, blue: 0.62, alpha: 1)
|
|
641
|
+
}
|
|
642
|
+
|
|
569
643
|
static func statusColor(_ status: Int64?) -> UIColor {
|
|
570
644
|
guard let status = status else {
|
|
571
645
|
return UIColor(red: 0.36, green: 0.38, blue: 0.44, alpha: 1)
|
|
@@ -614,7 +688,7 @@ enum InspectorTheme {
|
|
|
614
688
|
static func formatTime(_ epochMillis: Int64) -> String {
|
|
615
689
|
let date = Date(timeIntervalSince1970: TimeInterval(epochMillis) / 1000)
|
|
616
690
|
let formatter = DateFormatter()
|
|
617
|
-
formatter.dateFormat = "HH:mm:ss"
|
|
691
|
+
formatter.dateFormat = "dd MMM HH:mm:ss"
|
|
618
692
|
return formatter.string(from: date)
|
|
619
693
|
}
|
|
620
694
|
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import SQLite3
|
|
3
|
+
|
|
4
|
+
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
2
5
|
|
|
3
6
|
class LogRepository {
|
|
4
7
|
static let shared = LogRepository()
|
|
5
8
|
|
|
6
9
|
private var logsById: [String: LogEntry] = [:]
|
|
7
10
|
private let lock = NSLock()
|
|
11
|
+
private var db: OpaquePointer?
|
|
12
|
+
private let dbPath: String?
|
|
13
|
+
|
|
14
|
+
init() {
|
|
15
|
+
dbPath = LogRepository.databasePath()
|
|
16
|
+
if let path = dbPath {
|
|
17
|
+
openDatabase(path)
|
|
18
|
+
createTableIfNeeded()
|
|
19
|
+
loadPersistedLogs()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
deinit {
|
|
24
|
+
if let db = db {
|
|
25
|
+
sqlite3_close(db)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
8
28
|
|
|
9
29
|
func startRequest(_ data: [String: Any]) {
|
|
10
30
|
guard let id = data["id"] as? String,
|
|
@@ -34,6 +54,8 @@ class LogRepository {
|
|
|
34
54
|
lock.lock()
|
|
35
55
|
logsById[id] = entry
|
|
36
56
|
lock.unlock()
|
|
57
|
+
|
|
58
|
+
upsert(entry)
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
func finishRequest(_ data: [String: Any]) {
|
|
@@ -62,6 +84,8 @@ class LogRepository {
|
|
|
62
84
|
entry.errorMessage = errorMessage
|
|
63
85
|
logsById[id] = entry
|
|
64
86
|
lock.unlock()
|
|
87
|
+
|
|
88
|
+
upsert(entry)
|
|
65
89
|
}
|
|
66
90
|
|
|
67
91
|
func getLogs() -> [[String: Any]] {
|
|
@@ -87,6 +111,176 @@ class LogRepository {
|
|
|
87
111
|
lock.lock()
|
|
88
112
|
logsById.removeAll()
|
|
89
113
|
lock.unlock()
|
|
114
|
+
|
|
115
|
+
deleteAll()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private static func databasePath() -> String? {
|
|
119
|
+
let fileManager = FileManager.default
|
|
120
|
+
guard let baseUrl = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
121
|
+
return nil
|
|
122
|
+
}
|
|
123
|
+
let directory = baseUrl.appendingPathComponent("CapacitorPicaNetworkLogger", isDirectory: true)
|
|
124
|
+
if !fileManager.fileExists(atPath: directory.path) {
|
|
125
|
+
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
126
|
+
}
|
|
127
|
+
return directory.appendingPathComponent("logs.sqlite").path
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private func openDatabase(_ path: String) {
|
|
131
|
+
if sqlite3_open(path, &db) != SQLITE_OK {
|
|
132
|
+
db = nil
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private func createTableIfNeeded() {
|
|
137
|
+
guard let db = db else { return }
|
|
138
|
+
let sql = """
|
|
139
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
140
|
+
id TEXT PRIMARY KEY,
|
|
141
|
+
startTs INTEGER,
|
|
142
|
+
durationMs INTEGER,
|
|
143
|
+
method TEXT,
|
|
144
|
+
url TEXT,
|
|
145
|
+
host TEXT,
|
|
146
|
+
path TEXT,
|
|
147
|
+
query TEXT,
|
|
148
|
+
reqHeadersJson TEXT,
|
|
149
|
+
reqBody TEXT,
|
|
150
|
+
reqBodyTruncated INTEGER,
|
|
151
|
+
resStatus INTEGER,
|
|
152
|
+
resHeadersJson TEXT,
|
|
153
|
+
resBody TEXT,
|
|
154
|
+
resBodyTruncated INTEGER,
|
|
155
|
+
protocol TEXT,
|
|
156
|
+
ssl INTEGER,
|
|
157
|
+
error INTEGER,
|
|
158
|
+
errorMessage TEXT,
|
|
159
|
+
platform TEXT,
|
|
160
|
+
correlationId TEXT
|
|
161
|
+
);
|
|
162
|
+
"""
|
|
163
|
+
sqlite3_exec(db, sql, nil, nil, nil)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func loadPersistedLogs() {
|
|
167
|
+
guard let db = db else { return }
|
|
168
|
+
let sql = """
|
|
169
|
+
SELECT id, startTs, durationMs, method, url, host, path, query, reqHeadersJson, reqBody,
|
|
170
|
+
reqBodyTruncated, resStatus, resHeadersJson, resBody, resBodyTruncated, protocol,
|
|
171
|
+
ssl, error, errorMessage, platform, correlationId
|
|
172
|
+
FROM logs
|
|
173
|
+
"""
|
|
174
|
+
var statement: OpaquePointer?
|
|
175
|
+
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) != SQLITE_OK {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
defer { sqlite3_finalize(statement) }
|
|
179
|
+
|
|
180
|
+
while sqlite3_step(statement) == SQLITE_ROW {
|
|
181
|
+
guard let id = columnText(statement, index: 0),
|
|
182
|
+
let method = columnText(statement, index: 3),
|
|
183
|
+
let url = columnText(statement, index: 4),
|
|
184
|
+
let platform = columnText(statement, index: 19) else {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
let startTs = sqlite3_column_int64(statement, 1)
|
|
188
|
+
let entry = LogEntry(
|
|
189
|
+
id: id,
|
|
190
|
+
startTs: startTs,
|
|
191
|
+
method: method,
|
|
192
|
+
url: url,
|
|
193
|
+
host: columnText(statement, index: 5),
|
|
194
|
+
path: columnText(statement, index: 6),
|
|
195
|
+
query: columnText(statement, index: 7),
|
|
196
|
+
reqHeadersJson: columnText(statement, index: 8),
|
|
197
|
+
reqBody: columnText(statement, index: 9),
|
|
198
|
+
reqBodyTruncated: sqlite3_column_int64(statement, 10) != 0,
|
|
199
|
+
platform: platform,
|
|
200
|
+
correlationId: columnText(statement, index: 20)
|
|
201
|
+
)
|
|
202
|
+
if sqlite3_column_type(statement, 2) != SQLITE_NULL {
|
|
203
|
+
entry.durationMs = sqlite3_column_int64(statement, 2)
|
|
204
|
+
}
|
|
205
|
+
if sqlite3_column_type(statement, 11) != SQLITE_NULL {
|
|
206
|
+
entry.resStatus = sqlite3_column_int64(statement, 11)
|
|
207
|
+
}
|
|
208
|
+
entry.resHeadersJson = columnText(statement, index: 12)
|
|
209
|
+
entry.resBody = columnText(statement, index: 13)
|
|
210
|
+
entry.resBodyTruncated = sqlite3_column_int64(statement, 14) != 0
|
|
211
|
+
entry.protocol = columnText(statement, index: 15)
|
|
212
|
+
entry.ssl = sqlite3_column_int64(statement, 16) != 0
|
|
213
|
+
entry.error = sqlite3_column_int64(statement, 17) != 0
|
|
214
|
+
entry.errorMessage = columnText(statement, index: 18)
|
|
215
|
+
|
|
216
|
+
logsById[id] = entry
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func upsert(_ entry: LogEntry) {
|
|
221
|
+
guard let db = db else { return }
|
|
222
|
+
let sql = """
|
|
223
|
+
INSERT OR REPLACE INTO logs (
|
|
224
|
+
id, startTs, durationMs, method, url, host, path, query, reqHeadersJson, reqBody,
|
|
225
|
+
reqBodyTruncated, resStatus, resHeadersJson, resBody, resBodyTruncated, protocol,
|
|
226
|
+
ssl, error, errorMessage, platform, correlationId
|
|
227
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
228
|
+
"""
|
|
229
|
+
var statement: OpaquePointer?
|
|
230
|
+
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) != SQLITE_OK {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
defer { sqlite3_finalize(statement) }
|
|
234
|
+
|
|
235
|
+
sqlite3_bind_text(statement, 1, entry.id, -1, sqliteTransient)
|
|
236
|
+
sqlite3_bind_int64(statement, 2, entry.startTs)
|
|
237
|
+
bindOptionalInt64(statement, index: 3, value: entry.durationMs)
|
|
238
|
+
sqlite3_bind_text(statement, 4, entry.method, -1, sqliteTransient)
|
|
239
|
+
sqlite3_bind_text(statement, 5, entry.url, -1, sqliteTransient)
|
|
240
|
+
bindOptionalText(statement, index: 6, value: entry.host)
|
|
241
|
+
bindOptionalText(statement, index: 7, value: entry.path)
|
|
242
|
+
bindOptionalText(statement, index: 8, value: entry.query)
|
|
243
|
+
bindOptionalText(statement, index: 9, value: entry.reqHeadersJson)
|
|
244
|
+
bindOptionalText(statement, index: 10, value: entry.reqBody)
|
|
245
|
+
sqlite3_bind_int(statement, 11, entry.reqBodyTruncated ? 1 : 0)
|
|
246
|
+
bindOptionalInt64(statement, index: 12, value: entry.resStatus)
|
|
247
|
+
bindOptionalText(statement, index: 13, value: entry.resHeadersJson)
|
|
248
|
+
bindOptionalText(statement, index: 14, value: entry.resBody)
|
|
249
|
+
sqlite3_bind_int(statement, 15, entry.resBodyTruncated ? 1 : 0)
|
|
250
|
+
bindOptionalText(statement, index: 16, value: entry.protocol)
|
|
251
|
+
sqlite3_bind_int(statement, 17, entry.ssl ? 1 : 0)
|
|
252
|
+
sqlite3_bind_int(statement, 18, entry.error ? 1 : 0)
|
|
253
|
+
bindOptionalText(statement, index: 19, value: entry.errorMessage)
|
|
254
|
+
sqlite3_bind_text(statement, 20, entry.platform, -1, sqliteTransient)
|
|
255
|
+
bindOptionalText(statement, index: 21, value: entry.correlationId)
|
|
256
|
+
|
|
257
|
+
sqlite3_step(statement)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private func deleteAll() {
|
|
261
|
+
guard let db = db else { return }
|
|
262
|
+
sqlite3_exec(db, "DELETE FROM logs", nil, nil, nil)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private func columnText(_ statement: OpaquePointer?, index: Int32) -> String? {
|
|
266
|
+
guard let cString = sqlite3_column_text(statement, index) else { return nil }
|
|
267
|
+
return String(cString: cString)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private func bindOptionalText(_ statement: OpaquePointer?, index: Int32, value: String?) {
|
|
271
|
+
if let value = value {
|
|
272
|
+
sqlite3_bind_text(statement, index, value, -1, sqliteTransient)
|
|
273
|
+
} else {
|
|
274
|
+
sqlite3_bind_null(statement, index)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private func bindOptionalInt64(_ statement: OpaquePointer?, index: Int32, value: Int64?) {
|
|
279
|
+
if let value = value {
|
|
280
|
+
sqlite3_bind_int64(statement, index, value)
|
|
281
|
+
} else {
|
|
282
|
+
sqlite3_bind_null(statement, index)
|
|
283
|
+
}
|
|
90
284
|
}
|
|
91
285
|
|
|
92
286
|
private func jsonString(_ value: Any?) -> String? {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capacitor-pica-network-logger",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Capacitor HTTP network logger with debug-only native capture",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,12 +32,14 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
|
35
|
-
"clean": "rm -rf dist"
|
|
35
|
+
"clean": "rm -rf dist",
|
|
36
|
+
"release": "node scripts/release.js"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@capacitor/core": "^7.0.0"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
42
|
+
"@inquirer/prompts": "^8.2.1",
|
|
41
43
|
"typescript": "^5.4.5"
|
|
42
44
|
}
|
|
43
45
|
}
|