capacitor-pica-network-logger 0.2.3 → 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.
@@ -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 = '0.2.3'
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
@@ -11,6 +11,15 @@ android {
11
11
  defaultConfig {
12
12
  minSdk 23
13
13
  }
14
+
15
+ compileOptions {
16
+ sourceCompatibility JavaVersion.VERSION_21
17
+ targetCompatibility JavaVersion.VERSION_21
18
+ }
19
+ }
20
+
21
+ kotlin {
22
+ jvmToolchain(21)
14
23
  }
15
24
 
16
25
  dependencies {
@@ -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 = path.ifBlank { entry.url },
426
- style = MaterialTheme.typography.bodyMedium,
427
- color = MaterialTheme.colorScheme.onSurface
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 = host,
432
- style = MaterialTheme.typography.bodySmall,
433
- color = MaterialTheme.colorScheme.onSurfaceVariant
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.padding(top = 6.dp),
437
- horizontalArrangement = Arrangement.spacedBy(12.dp)
443
+ modifier = Modifier
444
+ .padding(top = 6.dp)
445
+ .fillMaxWidth(),
446
+ verticalAlignment = Alignment.CenterVertically
438
447
  ) {
439
448
  Text(
440
- formatTime(entry.startTs),
441
- style = MaterialTheme.typography.labelSmall,
442
- color = MaterialTheme.colorScheme.onSurfaceVariant
443
- )
444
- Text(
445
- "${entry.durationMs ?: 0} ms",
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
- Text(body.ifBlank { "-" }, color = MaterialTheme.colorScheme.onSurface)
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.SSS", java.util.Locale.getDefault())
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
- // No-op: in-memory storage
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
 
@@ -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 metaLabel = UILabel()
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 = 1
142
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
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
- metaLabel.font = UIFont.systemFont(ofSize: 11, weight: .medium)
148
- metaLabel.numberOfLines = 1
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
- headerRow.addArrangedSubview(statusChip)
151
- headerRow.addArrangedSubview(methodChip)
152
- headerRow.addArrangedSubview(titleLabel)
179
+ bottomRow.addArrangedSubview(hostLabel)
180
+ bottomRow.addArrangedSubview(detailLabel)
153
181
 
154
- let stack = UIStackView(arrangedSubviews: [headerRow, hostLabel, metaLabel])
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
- metaLabel.textColor = InspectorTheme.textSecondary(isDark)
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
- let time = InspectorTheme.formatTime(log.startTs)
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
- metaLabel.text = [time, duration, size].joined(separator: " • ")
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
- scrollView.topAnchor.constraint(equalTo: segmented.bottomAnchor, constant: 12),
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.text = body
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",
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
  }