Initial project setup - Phases 1-3 complete

This commit is contained in:
Trey t
2026-04-06 11:28:40 -05:00
commit c77e506db5
293 changed files with 14233 additions and 0 deletions

View 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
View 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
}
}
}
}

View 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)
}
}

View 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)
}
}