capacitor-pica-network-logger 0.2.3 → 0.2.5

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
@@ -4,41 +4,38 @@
4
4
 
5
5
  # capacitor-pica-network-logger
6
6
 
7
- Capacitor HTTP inspector with debug-only native capture and a JS wrapper that mirrors `@capacitor/http`.
7
+ A Capacitor plugin for logging and inspecting HTTP requests with native inspector UIs on iOS and Android.
8
8
 
9
9
  ![Network logger walkthrough](assets/pica_network_logger.gif)
10
10
 
11
- ## Tech stack
12
-
13
- - Capacitor 7
14
- - Swift (URLProtocol logging on iOS)
15
- - Kotlin (HttpURLConnection reflection logging on Android)
16
- - TypeScript wrapper for `@capacitor/http`
17
-
18
11
  ## Features
19
12
 
20
- - Debug-only request/response logging
21
- - Native inspector UI (standalone Activity/UIViewController)
22
- - Notifications to open inspector
23
- - Redaction + max body size from Capacitor config
24
- - cURL/JSON/HAR exports + copy/share
25
- - Save cURL/JSON/HAR locally
26
- - Copy all logs as HAR
13
+ - Request/response pair logging with automatic correlation
14
+ - Native inspector UI on iOS (UIKit) and Android (Jetpack Compose + Material 3)
15
+ - Body text search with highlighting in the inspector
16
+ - Persistent log storage (SQLite) across inspector sessions
17
+ - Responsive Android layout (compact, medium, expanded breakpoints)
18
+ - Dark/light theme toggle
19
+ - Configurable header and JSON body field redaction
20
+ - Configurable max body size with truncation
21
+ - Local notifications on each completed request (tap to open inspector)
22
+ - Export as cURL, plain text, or HAR 1.2
23
+ - No-op on web -- safe to import in any environment
27
24
 
28
- ## Project layout
25
+ See the [Changelog](CHANGELOG.md) for a full history of changes.
29
26
 
30
- - `src/`: JS wrapper and plugin typings
31
- - `android/`: Capacitor Android plugin + reflection hook
32
- - `ios/`: Capacitor iOS plugin + URLProtocol logger
33
- - `examples/sample-app/`: demo app
27
+ ## Platform requirements
28
+
29
+ | Platform | Minimum version |
30
+ |---|---|
31
+ | Capacitor | 7+ |
32
+ | iOS | 14.0 |
33
+ | Android | API 23 (Android 6.0) |
34
34
 
35
35
  ## Installation
36
36
 
37
37
  ```bash
38
38
  npm install capacitor-pica-network-logger
39
- ```
40
-
41
- ```bash
42
39
  npx cap sync
43
40
  ```
44
41
 
@@ -49,71 +46,86 @@ Add to your app's `capacitor.config.ts`:
49
46
  ```ts
50
47
  plugins: {
51
48
  PicaNetworkLogger: {
52
- enabled: true,
53
- maxBodySize: 131072,
54
- notify: true,
55
- redactHeaders: ["authorization", "cookie"],
56
- redactJsonFields: ["password", "token"]
49
+ maxBodySize: 131072, // max chars per body (default: 128 KB)
50
+ notify: true, // show notifications per request (default: true)
51
+ redactHeaders: ["authorization", "cookie"], // header names to redact (default: none)
52
+ redactJsonFields: ["password", "token"] // JSON field names to redact (default: none)
57
53
  }
58
54
  }
59
55
  ```
60
56
 
57
+ | Option | Type | Default | Description |
58
+ |---|---|---|---|
59
+ | `maxBodySize` | `number` | `131072` | Maximum characters stored per request/response body. Truncated beyond this. |
60
+ | `notify` | `boolean` | `true` | Post a local notification for each completed request. On Android 13+ requests `POST_NOTIFICATIONS` permission. |
61
+ | `redactHeaders` | `string[]` | `[]` | Header names (case-insensitive) whose values are replaced with `[REDACTED]`. |
62
+ | `redactJsonFields` | `string[]` | `[]` | Top-level JSON body field names (case-insensitive) whose values are replaced with `[REDACTED]`. |
63
+
61
64
  ## Usage
62
65
 
63
- Import the wrapper and use it in place of `@capacitor/http`:
66
+ The plugin exposes three methods: `startRequest`, `finishRequest`, and `openInspector`. Use your own HTTP client (e.g. `fetch`) and wrap calls with the logging methods:
64
67
 
65
68
  ```ts
66
- import { CapacitorHttp, PicaNetworkLogger } from 'capacitor-pica-network-logger';
69
+ import { PicaNetworkLogger } from 'capacitor-pica-network-logger';
67
70
 
68
- await CapacitorHttp.get({ url: 'https://example.com' });
71
+ // 1. Log the start of a request
72
+ const { id } = await PicaNetworkLogger.startRequest({
73
+ method: 'GET',
74
+ url: 'https://jsonplaceholder.typicode.com/posts/1',
75
+ headers: { 'Accept': 'application/json' },
76
+ });
77
+
78
+ // 2. Make the actual HTTP call
79
+ const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
80
+ const body = await response.text();
81
+
82
+ // 3. Log the completion
83
+ await PicaNetworkLogger.finishRequest({
84
+ id,
85
+ status: response.status,
86
+ headers: Object.fromEntries(response.headers.entries()),
87
+ body,
88
+ });
89
+
90
+ // 4. Open the inspector UI
69
91
  await PicaNetworkLogger.openInspector();
70
92
  ```
71
93
 
72
- If you want to keep call sites unchanged, alias `@capacitor/http` to this package in your bundler.
94
+ ### API
73
95
 
74
- ### Minimal changes (existing `@capacitor/http` usage)
96
+ | Method | Signature | Description |
97
+ |---|---|---|
98
+ | `startRequest` | `(options: StartRequestOptions) => Promise<{ id: string }>` | Log the start of a request. Returns an `id` for correlation. |
99
+ | `finishRequest` | `(options: FinishRequestOptions) => Promise<void>` | Log the completion of a request (status, headers, body, error). |
100
+ | `openInspector` | `() => Promise<void>` | Open the native inspector UI. |
75
101
 
76
- If you already use `@capacitor/http`, you can keep your code as-is by adding a module alias so imports resolve to this package.
77
-
78
- **Vite**
102
+ ### Types
79
103
 
80
104
  ```ts
81
- // vite.config.ts
82
- import { defineConfig } from 'vite';
83
-
84
- export default defineConfig({
85
- resolve: {
86
- alias: {
87
- '@capacitor/http': 'capacitor-pica-network-logger'
88
- }
89
- }
90
- });
91
- ```
92
-
93
- **Webpack**
105
+ interface StartRequestOptions {
106
+ method: string;
107
+ url: string;
108
+ headers?: Record<string, string>;
109
+ body?: unknown;
110
+ }
94
111
 
95
- ```js
96
- // webpack.config.js
97
- module.exports = {
98
- resolve: {
99
- alias: {
100
- '@capacitor/http': 'capacitor-pica-network-logger'
101
- }
102
- }
103
- };
112
+ interface FinishRequestOptions {
113
+ id: string;
114
+ status?: number;
115
+ headers?: Record<string, string>;
116
+ body?: unknown;
117
+ error?: string;
118
+ }
104
119
  ```
105
120
 
106
- **TypeScript paths (editor/typecheck)**
121
+ ## Project layout
107
122
 
108
- ```json
109
- // tsconfig.json
110
- {
111
- "compilerOptions": {
112
- "paths": {
113
- "@capacitor/http": ["node_modules/capacitor-pica-network-logger"]
114
- }
115
- }
116
- }
123
+ ```
124
+ src/ TypeScript plugin registration and types
125
+ ios/Plugin/ Swift iOS plugin (UIKit inspector, SQLite storage, notifications)
126
+ android/ Kotlin Android plugin (Compose inspector, SQLite storage, notifications)
127
+ examples/ Sample Capacitor app for manual testing
128
+ scripts/ Release script
117
129
  ```
118
130
 
119
131
  ## Build
@@ -133,15 +145,26 @@ npm install
133
145
  npm run dev
134
146
  ```
135
147
 
136
- ## Modifications
148
+ Run on device:
149
+
150
+ ```bash
151
+ # iOS
152
+ cd examples/sample-app && npm run build && npx cap sync ios && npx cap run ios
153
+
154
+ # Android
155
+ cd examples/sample-app && npm run build && npx cap sync android && npx cap run android
156
+ ```
157
+
158
+ ## Release
159
+
160
+ ```bash
161
+ npm run release # prompts for patch/minor/major
162
+ npm run release -- patch # patch bump
163
+ npm run release -- 1.0.0 # explicit version
164
+ ```
137
165
 
138
- - JS wrapper: `src/httpWithLogging.ts`
139
- - Android hook: `android/src/main/java/com/linakis/capacitorpicanetworklogger/ReflectionHook.kt`
140
- - iOS logger: `ios/Plugin/InspectorURLProtocol.swift`
141
- - UI (iOS): `ios/Plugin/InspectorViewController.swift`
142
- - UI (Android): `android/src/main/java/com/linakis/capacitorpicanetworklogger/InspectorActivity.kt`
166
+ The release script updates `CHANGELOG.md`, bumps `package.json`, builds, commits, tags, pushes, publishes to npm, and creates a GitHub Release (if `gh` CLI is available).
143
167
 
144
- ## Notes
168
+ ## License
145
169
 
146
- - Android reflection hook uses `URLStreamHandlerFactory` and will fail if another library set one first.
147
- - The JS wrapper is still authoritative on Android; reflection logging is best-effort.
170
+ MIT
@@ -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
 
@@ -6,8 +6,8 @@ object LogRepositoryStore {
6
6
  private var repository: LogRepository? = null
7
7
  private var appContext: android.content.Context? = null
8
8
  private var maxBodySize: Int = 131072
9
- private var redactHeaders: Set<String> = setOf("authorization", "cookie")
10
- private var redactJsonFields: Set<String> = setOf("password", "token")
9
+ private var redactHeaders: Set<String> = emptySet()
10
+ private var redactJsonFields: Set<String> = emptySet()
11
11
  private var notifyEnabled: Boolean = true
12
12
  private val requestStartTs: MutableMap<String, Long> = mutableMapOf()
13
13
 
@@ -78,9 +78,15 @@ object LogRepositoryStore {
78
78
  data.put("id", id)
79
79
  if (status != null) data.put("status", status)
80
80
  data.put("headers", headers?.redactedHeaders()?.toJsObject())
81
- val truncated = truncate(body?.redactJson())
82
- data.put("responseBody", truncated.value)
83
- data.put("responseBodyTruncated", truncated.truncated)
81
+ val contentType = contentType(headers)
82
+ if (body != null && isBinaryContentType(contentType)) {
83
+ data.put("responseBody", binaryPlaceholder(contentType))
84
+ data.put("responseBodyTruncated", false)
85
+ } else {
86
+ val truncated = truncate(body?.redactJson())
87
+ data.put("responseBody", truncated.value)
88
+ data.put("responseBodyTruncated", truncated.truncated)
89
+ }
84
90
  if (error != null) data.put("error", error)
85
91
  if (protocol != null) data.put("protocol", protocol)
86
92
  if (ssl != null) data.put("ssl", ssl)
@@ -121,6 +127,36 @@ object LogRepositoryStore {
121
127
  }
122
128
 
123
129
  private data class Truncated(val value: String?, val truncated: Boolean)
130
+
131
+ private fun contentType(headers: Map<String, String>?): String? {
132
+ if (headers == null) return null
133
+ for ((key, value) in headers) {
134
+ if (key.lowercase() == "content-type") return value.lowercase()
135
+ }
136
+ return null
137
+ }
138
+
139
+ private val binaryPrefixes = listOf(
140
+ "image/", "video/", "audio/", "application/octet-stream",
141
+ "application/pdf", "application/zip", "application/gzip",
142
+ "font/", "application/vnd.", "application/x-protobuf"
143
+ )
144
+
145
+ private fun isBinaryContentType(contentType: String?): Boolean {
146
+ val ct = contentType ?: return false
147
+ return binaryPrefixes.any { ct.startsWith(it) }
148
+ }
149
+
150
+ private fun binaryPlaceholder(contentType: String?): String {
151
+ val ct = contentType ?: "unknown"
152
+ val mediaType = when {
153
+ ct.startsWith("image/") -> "Image"
154
+ ct.startsWith("video/") -> "Video"
155
+ ct.startsWith("audio/") -> "Audio"
156
+ else -> "Binary"
157
+ }
158
+ return "[$mediaType preview not yet supported ($ct)]"
159
+ }
124
160
  }
125
161
 
126
162
  private fun Map<String, String>.toJsObject(): JSObject {
@@ -4,8 +4,8 @@ class InspectorLogger {
4
4
  static let shared = InspectorLogger()
5
5
  private let repository = LogRepository.shared
6
6
  private var maxBodySize: Int = 131072
7
- private var redactHeaders: Set<String> = ["authorization", "cookie"]
8
- private var redactJsonFields: Set<String> = ["password", "token"]
7
+ private var redactHeaders: Set<String> = []
8
+ private var redactJsonFields: Set<String> = []
9
9
  private var notifyEnabled: Bool = true
10
10
  private var requestMethods: [String: String] = [:]
11
11
  private var requestUrls: [String: String] = [:]
@@ -64,9 +64,15 @@ class InspectorLogger {
64
64
  payload["headers"] = redact(headers)
65
65
  }
66
66
  if let body = body {
67
- let truncated = truncate(redactJson(body))
68
- payload["responseBody"] = truncated.value
69
- payload["responseBodyTruncated"] = truncated.truncated
67
+ let contentType = contentType(from: headers)
68
+ if isBinaryContentType(contentType) {
69
+ payload["responseBody"] = binaryPlaceholder(contentType)
70
+ payload["responseBodyTruncated"] = false
71
+ } else {
72
+ let truncated = truncate(redactJson(body))
73
+ payload["responseBody"] = truncated.value
74
+ payload["responseBodyTruncated"] = truncated.truncated
75
+ }
70
76
  }
71
77
  if let error = error {
72
78
  payload["error"] = error
@@ -154,4 +160,37 @@ class InspectorLogger {
154
160
  func setNotify(enabled: Bool) {
155
161
  notifyEnabled = enabled
156
162
  }
163
+
164
+ private func contentType(from headers: [String: Any]?) -> String? {
165
+ guard let headers = headers else { return nil }
166
+ for (key, value) in headers {
167
+ if key.lowercased() == "content-type", let str = value as? String {
168
+ return str.lowercased()
169
+ }
170
+ }
171
+ return nil
172
+ }
173
+
174
+ private func isBinaryContentType(_ contentType: String?) -> Bool {
175
+ guard let ct = contentType else { return false }
176
+ let binaryPrefixes = ["image/", "video/", "audio/", "application/octet-stream",
177
+ "application/pdf", "application/zip", "application/gzip",
178
+ "font/", "application/vnd.", "application/x-protobuf"]
179
+ return binaryPrefixes.contains { ct.hasPrefix($0) }
180
+ }
181
+
182
+ private func binaryPlaceholder(_ contentType: String?) -> String {
183
+ let ct = contentType ?? "unknown"
184
+ let mediaType: String
185
+ if ct.hasPrefix("image/") {
186
+ mediaType = "Image"
187
+ } else if ct.hasPrefix("video/") {
188
+ mediaType = "Video"
189
+ } else if ct.hasPrefix("audio/") {
190
+ mediaType = "Audio"
191
+ } else {
192
+ mediaType = "Binary"
193
+ }
194
+ return "[\(mediaType) preview not yet supported (\(ct))]"
195
+ }
157
196
  }
@@ -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.5",
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
  }