Initial project setup - Phases 1-3 complete
This commit is contained in:
82
UI/Home/DomainDetailView.swift
Normal file
82
UI/Home/DomainDetailView.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct DomainDetailView: View {
|
||||
let domain: String
|
||||
|
||||
@State private var requests: [CapturedTraffic] = []
|
||||
@State private var searchText = ""
|
||||
@State private var filterChips: [FilterChip] = [
|
||||
FilterChip(label: "JSON"),
|
||||
FilterChip(label: "Form"),
|
||||
FilterChip(label: "HTTP"),
|
||||
FilterChip(label: "HTTPS"),
|
||||
]
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let trafficRepo = TrafficRepository()
|
||||
|
||||
var filteredRequests: [CapturedTraffic] {
|
||||
var result = requests
|
||||
|
||||
if !searchText.isEmpty {
|
||||
result = result.filter { $0.url.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
let activeFilters = filterChips.filter(\.isSelected).map(\.label)
|
||||
if !activeFilters.isEmpty {
|
||||
result = result.filter { request in
|
||||
for filter in activeFilters {
|
||||
switch filter {
|
||||
case "JSON":
|
||||
if request.responseContentType?.contains("json") == true { return true }
|
||||
case "Form":
|
||||
if request.requestContentType?.contains("form") == true { return true }
|
||||
case "HTTP":
|
||||
if request.scheme == "http" { return true }
|
||||
case "HTTPS":
|
||||
if request.scheme == "https" { return true }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
FilterChipsView(chips: $filterChips)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
List {
|
||||
ForEach(filteredRequests) { request in
|
||||
NavigationLink(value: request.id) {
|
||||
TrafficRowView(traffic: request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
.navigationTitle(domain)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(for: Int64?.self) { id in
|
||||
if let id {
|
||||
RequestDetailView(trafficId: id)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = trafficRepo.observeTraffic(forDomain: domain)
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Observation error: \(error)")
|
||||
} onChange: { newRequests in
|
||||
withAnimation {
|
||||
requests = newRequests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
UI/Home/HomeView.swift
Normal file
104
UI/Home/HomeView.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var domains: [DomainGroup] = []
|
||||
@State private var searchText = ""
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let trafficRepo = TrafficRepository()
|
||||
|
||||
var filteredDomains: [DomainGroup] {
|
||||
if searchText.isEmpty { return domains }
|
||||
return domains.filter { $0.domain.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !appState.isVPNConnected && domains.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("VPN Not Connected", systemImage: "bolt.slash")
|
||||
} description: {
|
||||
Text("Enable the VPN to start capturing network traffic.")
|
||||
} actions: {
|
||||
Button("Enable VPN") {
|
||||
Task { await appState.toggleVPN() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else if domains.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Traffic", systemImage: "network.slash")
|
||||
} description: {
|
||||
Text("Waiting for network requests. Open Safari or another app to generate traffic.")
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredDomains) { group in
|
||||
NavigationLink(value: group) {
|
||||
HStack {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(group.domain)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(group.requestCount)")
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Filter Domains")
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(for: DomainGroup.self) { group in
|
||||
DomainDetailView(domain: group.domain)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.disabled(domains.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await appState.toggleVPN() }
|
||||
} label: {
|
||||
Image(systemName: appState.isVPNConnected ? "bolt.fill" : "bolt.slash")
|
||||
.foregroundStyle(appState.isVPNConnected ? .yellow : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear All Domains", isPresented: $showClearConfirmation) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
try? trafficRepo.deleteAll()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete all captured traffic.")
|
||||
}
|
||||
.task {
|
||||
startObservation()
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservation() {
|
||||
observation = trafficRepo.observeDomainGroups()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("[HomeView] Observation error: \(error)")
|
||||
} onChange: { newDomains in
|
||||
withAnimation {
|
||||
domains = newDomains
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
UI/Home/RequestDetailView.swift
Normal file
193
UI/Home/RequestDetailView.swift
Normal file
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct RequestDetailView: View {
|
||||
let trafficId: Int64
|
||||
|
||||
@State private var traffic: CapturedTraffic?
|
||||
@State private var selectedSegment: Segment = .request
|
||||
|
||||
private let trafficRepo = TrafficRepository()
|
||||
|
||||
enum Segment: String, CaseIterable {
|
||||
case request = "Request"
|
||||
case response = "Response"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let traffic {
|
||||
VStack(spacing: 0) {
|
||||
Picker("Segment", selection: $selectedSegment) {
|
||||
ForEach(Segment.allCases, id: \.self) { segment in
|
||||
Text(segment.rawValue).tag(segment)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
switch selectedSegment {
|
||||
case .request:
|
||||
requestContent(traffic)
|
||||
case .response:
|
||||
responseContent(traffic)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(traffic?.domain ?? "Request")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let traffic {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
try? trafficRepo.togglePin(id: trafficId, isPinned: !traffic.isPinned)
|
||||
self.traffic?.isPinned.toggle()
|
||||
} label: {
|
||||
Image(systemName: traffic.isPinned ? "pin.fill" : "pin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
traffic = try? trafficRepo.traffic(byId: trafficId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Request Content
|
||||
|
||||
@ViewBuilder
|
||||
private func requestContent(_ traffic: CapturedTraffic) -> some View {
|
||||
// General
|
||||
DisclosureGroup("General") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
KeyValueRow(key: "URL", value: traffic.url)
|
||||
KeyValueRow(key: "Method", value: traffic.method)
|
||||
KeyValueRow(key: "Scheme", value: traffic.scheme)
|
||||
KeyValueRow(key: "Time", value: traffic.startDate.formatted(.dateTime))
|
||||
if let duration = traffic.durationMs {
|
||||
KeyValueRow(key: "Duration", value: "\(duration) ms")
|
||||
}
|
||||
if let status = traffic.statusCode {
|
||||
KeyValueRow(key: "Status", value: "\(status) \(traffic.statusText ?? "")")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Headers
|
||||
let requestHeaders = traffic.decodedRequestHeaders
|
||||
if !requestHeaders.isEmpty {
|
||||
DisclosureGroup("Headers (\(requestHeaders.count))") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(requestHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
KeyValueRow(key: key, value: value)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Query Parameters
|
||||
let queryParams = traffic.decodedQueryParameters
|
||||
if !queryParams.isEmpty {
|
||||
DisclosureGroup("Query (\(queryParams.count))") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(queryParams.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
KeyValueRow(key: key, value: value)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
if let body = traffic.requestBody, !body.isEmpty {
|
||||
DisclosureGroup("Body (\(formatBytes(body.count)))") {
|
||||
bodyView(data: body, contentType: traffic.requestContentType)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response Content
|
||||
|
||||
@ViewBuilder
|
||||
private func responseContent(_ traffic: CapturedTraffic) -> some View {
|
||||
if let status = traffic.statusCode {
|
||||
// Status
|
||||
HStack {
|
||||
StatusBadge(statusCode: status)
|
||||
Text(traffic.statusText ?? "")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// Headers
|
||||
let responseHeaders = traffic.decodedResponseHeaders
|
||||
if !responseHeaders.isEmpty {
|
||||
DisclosureGroup("Headers (\(responseHeaders.count))") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(responseHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||
KeyValueRow(key: key, value: value)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
if let body = traffic.responseBody, !body.isEmpty {
|
||||
DisclosureGroup("Body (\(formatBytes(body.count)))") {
|
||||
bodyView(data: body, contentType: traffic.responseContentType)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
if traffic.statusCode == nil {
|
||||
EmptyStateView(
|
||||
icon: "clock",
|
||||
title: "Waiting for Response",
|
||||
subtitle: "The response has not been received yet."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body View
|
||||
|
||||
@ViewBuilder
|
||||
private func bodyView(data: Data, contentType: String?) -> some View {
|
||||
if let contentType, contentType.contains("json"),
|
||||
let json = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted),
|
||||
let string = String(data: pretty, encoding: .utf8) {
|
||||
ScrollView(.horizontal) {
|
||||
Text(string)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} else if let string = String(data: data, encoding: .utf8) {
|
||||
Text(string)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text("\(data.count) bytes (binary)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int) -> String {
|
||||
if bytes < 1024 { return "\(bytes) B" }
|
||||
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
|
||||
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
|
||||
}
|
||||
}
|
||||
50
UI/Home/TrafficRowView.swift
Normal file
50
UI/Home/TrafficRowView.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct TrafficRowView: View {
|
||||
let traffic: CapturedTraffic
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
MethodBadge(method: traffic.method)
|
||||
StatusBadge(statusCode: traffic.statusCode)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(traffic.startDate, format: .dateTime.hour().minute().second().secondFraction(.fractional(3)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(traffic.formattedDuration)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(traffic.url)
|
||||
.font(.caption)
|
||||
.lineLimit(3)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if traffic.requestBodySize > 0 {
|
||||
Label(formatBytes(traffic.requestBodySize), systemImage: "arrow.up.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
if traffic.responseBodySize > 0 {
|
||||
Label(formatBytes(traffic.responseBodySize), systemImage: "arrow.down.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int) -> String {
|
||||
if bytes < 1024 { return "\(bytes) B" }
|
||||
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
|
||||
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user