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.
- package/CapacitorPicaNetworkLogger.podspec +2 -1
- package/README.md +100 -77
- package/android/build.gradle +9 -0
- 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/LogRepositoryStore.kt +41 -5
- package/ios/Plugin/InspectorLogger.swift +44 -5
- 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
|
@@ -4,41 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
# capacitor-pica-network-logger
|
|
6
6
|
|
|
7
|
-
Capacitor HTTP
|
|
7
|
+
A Capacitor plugin for logging and inspecting HTTP requests with native inspector UIs on iOS and Android.
|
|
8
8
|
|
|
9
9
|

|
|
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
|
-
-
|
|
21
|
-
- Native inspector UI (
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
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
|
-
|
|
25
|
+
See the [Changelog](CHANGELOG.md) for a full history of changes.
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 {
|
|
69
|
+
import { PicaNetworkLogger } from 'capacitor-pica-network-logger';
|
|
67
70
|
|
|
68
|
-
|
|
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
|
-
|
|
94
|
+
### API
|
|
73
95
|
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
**Vite**
|
|
102
|
+
### Types
|
|
79
103
|
|
|
80
104
|
```ts
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
121
|
+
## Project layout
|
|
107
122
|
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
168
|
+
## License
|
|
145
169
|
|
|
146
|
-
|
|
147
|
-
- The JS wrapper is still authoritative on Android; reflection logging is best-effort.
|
|
170
|
+
MIT
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
|
@@ -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> =
|
|
10
|
-
private var redactJsonFields: Set<String> =
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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> = [
|
|
8
|
-
private var redactJsonFields: Set<String> = [
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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.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
|
}
|