Initial project setup - Phases 1-3 complete
This commit is contained in:
55
UI/Compose/CURLImportView.swift
Normal file
55
UI/Compose/CURLImportView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct CURLImportView: View {
|
||||
let onImport: (ParsedCURLRequest) -> Void
|
||||
|
||||
@State private var curlText = ""
|
||||
@State private var errorMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Paste a cURL command to import as a request.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextEditor(text: $curlText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
)
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button("Import") {
|
||||
if let parsed = CURLParser.parse(curlText) {
|
||||
onImport(parsed)
|
||||
} else {
|
||||
errorMessage = "Invalid cURL command. Make sure it starts with 'curl'."
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(curlText.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Import cURL")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
180
UI/Compose/ComposeEditorView.swift
Normal file
180
UI/Compose/ComposeEditorView.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct ComposeEditorView: View {
|
||||
let requestId: Int64
|
||||
|
||||
@State private var method = "GET"
|
||||
@State private var url = ""
|
||||
@State private var headersText = ""
|
||||
@State private var queryText = ""
|
||||
@State private var bodyText = ""
|
||||
@State private var selectedTab: EditorTab = .headers
|
||||
@State private var isSending = false
|
||||
@State private var responseStatus: Int?
|
||||
@State private var responseBody: String?
|
||||
|
||||
private let composeRepo = ComposeRepository()
|
||||
|
||||
enum EditorTab: String, CaseIterable {
|
||||
case headers = "Headers"
|
||||
case query = "Query"
|
||||
case body = "Body"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Method + URL
|
||||
HStack(spacing: 12) {
|
||||
Menu {
|
||||
ForEach(ProxyConstants.httpMethods, id: \.self) { m in
|
||||
Button(m) { method = m }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(method)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
TextField("Tap to edit URL", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.subheadline)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Tabs
|
||||
Picker("Tab", selection: $selectedTab) {
|
||||
ForEach(EditorTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Tab Content
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch selectedTab {
|
||||
case .headers:
|
||||
headerEditor
|
||||
case .query:
|
||||
queryEditor
|
||||
case .body:
|
||||
bodyEditor
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// Response
|
||||
if let responseBody {
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(responseBody)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
.background(Color(.systemGray6))
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Request")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await sendRequest() }
|
||||
} label: {
|
||||
Text("Send")
|
||||
.font(.headline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(url.isEmpty || isSending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if headersText.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "list.bullet.rectangle",
|
||||
title: "No Headers",
|
||||
subtitle: "Tap 'Edit Headers' to add headers."
|
||||
)
|
||||
}
|
||||
|
||||
Button("Edit Headers") {
|
||||
// TODO: Open header editor sheet
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private var queryEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextEditor(text: $queryText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
)
|
||||
Text("Format: key=value, one per line")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var bodyEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextEditor(text: $bodyText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequest() async {
|
||||
guard let requestURL = URL(string: url) else { return }
|
||||
|
||||
isSending = true
|
||||
defer { isSending = false }
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = method
|
||||
if !bodyText.isEmpty {
|
||||
request.httpBody = bodyText.data(using: .utf8)
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
responseStatus = httpResponse.statusCode
|
||||
}
|
||||
if let string = String(data: data, encoding: .utf8) {
|
||||
responseBody = string
|
||||
} else {
|
||||
responseBody = "\(data.count) bytes (binary)"
|
||||
}
|
||||
} catch {
|
||||
responseBody = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
133
UI/Compose/ComposeListView.swift
Normal file
133
UI/Compose/ComposeListView.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct ComposeListView: View {
|
||||
@State private var requests: [ComposeRequest] = []
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var showTemplatePicker = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let composeRepo = ComposeRepository()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if requests.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "square.and.pencil",
|
||||
title: "No Requests",
|
||||
subtitle: "Create a new request to get started.",
|
||||
actionTitle: "New Request"
|
||||
) {
|
||||
createEmptyRequest()
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(requests) { request in
|
||||
NavigationLink(value: request.id) {
|
||||
HStack {
|
||||
MethodBadge(method: request.method)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(request.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let url = request.url, !url.isEmpty {
|
||||
Text(url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = requests[index].id {
|
||||
try? composeRepo.delete(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Int64?.self) { id in
|
||||
if let id {
|
||||
ComposeEditorView(requestId: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Compose")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.disabled(requests.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("Empty Request") { createEmptyRequest() }
|
||||
Button("GET with Query") { createTemplate(method: "GET", name: "GET with Query") }
|
||||
Button("POST with JSON") { createTemplate(method: "POST", name: "POST with JSON", contentType: "application/json") }
|
||||
Button("POST with Form") { createTemplate(method: "POST", name: "POST with Form", contentType: "application/x-www-form-urlencoded") }
|
||||
Divider()
|
||||
Button("Import from cURL") { showTemplatePicker = true }
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear History", isPresented: $showClearConfirmation) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
try? composeRepo.deleteAll()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete all compose requests.")
|
||||
}
|
||||
.sheet(isPresented: $showTemplatePicker) {
|
||||
CURLImportView { parsed in
|
||||
var request = ComposeRequest(
|
||||
name: "Imported Request",
|
||||
method: parsed.method,
|
||||
url: parsed.url,
|
||||
headers: encodeHeaders(parsed.headers),
|
||||
body: parsed.body
|
||||
)
|
||||
try? composeRepo.insert(&request)
|
||||
showTemplatePicker = false
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = composeRepo.observeRequests()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Compose observation error: \(error)")
|
||||
} onChange: { newRequests in
|
||||
withAnimation {
|
||||
requests = newRequests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createEmptyRequest() {
|
||||
var request = ComposeRequest()
|
||||
try? composeRepo.insert(&request)
|
||||
}
|
||||
|
||||
private func createTemplate(method: String, name: String, contentType: String? = nil) {
|
||||
var headers: String?
|
||||
if let contentType {
|
||||
headers = encodeHeaders([(key: "Content-Type", value: contentType)])
|
||||
}
|
||||
var request = ComposeRequest(name: name, method: method, headers: headers)
|
||||
try? composeRepo.insert(&request)
|
||||
}
|
||||
|
||||
private func encodeHeaders(_ headers: [(key: String, value: String)]) -> String? {
|
||||
var dict: [String: String] = [:]
|
||||
for h in headers { dict[h.key] = h.value }
|
||||
guard let data = try? JSONEncoder().encode(dict) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
27
UI/More/AdvancedSettingsView.swift
Normal file
27
UI/More/AdvancedSettingsView.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct AdvancedSettingsView: View {
|
||||
@State private var hideSystemTraffic = IPCManager.shared.hideSystemTraffic
|
||||
@State private var showImagePreview = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Hide iOS System Traffic", isOn: $hideSystemTraffic)
|
||||
.onChange(of: hideSystemTraffic) { _, newValue in
|
||||
IPCManager.shared.hideSystemTraffic = newValue
|
||||
}
|
||||
} footer: {
|
||||
Text("Hide traffic from Apple system services like push notifications, iCloud sync, and analytics.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show Image Preview", isOn: $showImagePreview)
|
||||
} footer: {
|
||||
Text("Display thumbnail previews for image responses in the traffic list.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Advanced")
|
||||
}
|
||||
}
|
||||
39
UI/More/AppSettingsView.swift
Normal file
39
UI/More/AppSettingsView.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppSettingsView: View {
|
||||
@State private var analyticsEnabled = false
|
||||
@State private var crashReportingEnabled = true
|
||||
@State private var showClearCacheConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Analytics", isOn: $analyticsEnabled)
|
||||
Toggle("Crash Reporting", isOn: $crashReportingEnabled)
|
||||
} footer: {
|
||||
Text("Help improve the app by sharing anonymous usage data.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Clear App Cache", role: .destructive) {
|
||||
showClearCacheConfirmation = true
|
||||
}
|
||||
} footer: {
|
||||
Text("Remove all cached data. This does not delete captured traffic.")
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0.0")
|
||||
LabeledContent("Build", value: "1")
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Settings")
|
||||
.confirmationDialog("Clear Cache", isPresented: $showClearCacheConfirmation) {
|
||||
Button("Clear Cache", role: .destructive) {
|
||||
// TODO: Clear URL cache, image cache, etc.
|
||||
}
|
||||
} message: {
|
||||
Text("This will clear all cached data.")
|
||||
}
|
||||
}
|
||||
}
|
||||
144
UI/More/BlockListView.swift
Normal file
144
UI/More/BlockListView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct BlockListView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBlockListEnabled
|
||||
@State private var entries: [BlockListEntry] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "Block List",
|
||||
description: "Block requests matching these rules. Blocked requests will be dropped or hidden based on the action.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isBlockListEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if entries.isEmpty {
|
||||
Text("No block rules")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(entries) { entry in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.name ?? entry.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(entry.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.action.displayName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = entries[index].id {
|
||||
try? rulesRepo.deleteBlockEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Block List")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NewBlockRuleView { entry in
|
||||
var entry = entry
|
||||
try? rulesRepo.insertBlockEntry(&entry)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeBlockListEntries()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Block list observation error: \(error)")
|
||||
} onChange: { newEntries in
|
||||
entries = newEntries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - New Block Rule
|
||||
|
||||
struct NewBlockRuleView: View {
|
||||
let onSave: (BlockListEntry) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var urlPattern = ""
|
||||
@State private var method = "ANY"
|
||||
@State private var includeSubpaths = true
|
||||
@State private var blockAction: BlockAction = .blockAndHide
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name (optional)", text: $name)
|
||||
TextField("URL Pattern", text: $urlPattern)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Method", selection: $method) {
|
||||
Text("ANY").tag("ANY")
|
||||
ForEach(ProxyConstants.httpMethods, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
Toggle("Include Subpaths", isOn: $includeSubpaths)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Block Action", selection: $blockAction) {
|
||||
ForEach(BlockAction.allCases, id: \.self) { action in
|
||||
Text(action.displayName).tag(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Block Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let entry = BlockListEntry(
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
includeSubpaths: includeSubpaths,
|
||||
blockAction: blockAction
|
||||
)
|
||||
onSave(entry)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
UI/More/BreakpointRulesView.swift
Normal file
99
UI/More/BreakpointRulesView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct BreakpointRulesView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBreakpointEnabled
|
||||
@State private var rules: [BreakpointRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "Breakpoint",
|
||||
description: "Pause and modify HTTP requests and responses in real-time before they reach the server or the app.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isBreakpointEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "pause.circle",
|
||||
title: "No Breakpoint Rules",
|
||||
subtitle: "Tap + to create a new breakpoint rule."
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name ?? rule.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(rule.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
if rule.interceptRequest {
|
||||
Text("Request")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
if rule.interceptResponse {
|
||||
Text("Response")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteBreakpointRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Breakpoint")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// TODO: Add breakpoint rule creation form
|
||||
Text("Breakpoint rule creation")
|
||||
}
|
||||
.navigationTitle("New Breakpoint Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddRule = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeBreakpointRules()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Breakpoint observation error: \(error)")
|
||||
} onChange: { newRules in
|
||||
rules = newRules
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
UI/More/CertificateView.swift
Normal file
50
UI/More/CertificateView.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct CertificateView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: appState.isCertificateTrusted ? "checkmark.shield.fill" : "exclamationmark.shield")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(appState.isCertificateTrusted ? .green : .orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(appState.isCertificateTrusted
|
||||
? "Certificate is installed & trusted!"
|
||||
: "Certificate not installed")
|
||||
.font(.headline)
|
||||
Text("Required for HTTPS decryption")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("CA Certificate", value: "Proxy CA (\(UIDevice.current.name))")
|
||||
LabeledContent("Generated", value: "-")
|
||||
LabeledContent("Expires", value: "-")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Install Certificate") {
|
||||
// TODO: Phase 3 - Export and open cert installation
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Regenerate Certificate", role: .destructive) {
|
||||
// TODO: Phase 3 - Generate new CA
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Certificate")
|
||||
}
|
||||
}
|
||||
92
UI/More/DNSSpoofingView.swift
Normal file
92
UI/More/DNSSpoofingView.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct DNSSpoofingView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isDNSSpoofingEnabled
|
||||
@State private var rules: [DNSSpoofRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "DNS Spoofing",
|
||||
description: "Redirect domain resolution to a different target. Useful for routing production domains to development servers.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isDNSSpoofingEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "network",
|
||||
title: "No DNS Spoofing Rules",
|
||||
subtitle: "Tap + to create a new rule."
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(rule.sourceDomain)
|
||||
.font(.subheadline)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(rule.targetDomain)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteDNSSpoofRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("DNS Spoofing")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// TODO: Add DNS spoof rule creation form
|
||||
Text("DNS Spoofing rule creation")
|
||||
}
|
||||
.navigationTitle("New DNS Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddRule = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeDNSSpoofRules()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("DNS Spoof observation error: \(error)")
|
||||
} onChange: { newRules in
|
||||
rules = newRules
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
UI/More/MapLocalView.swift
Normal file
88
UI/More/MapLocalView.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct MapLocalView: View {
|
||||
@State private var rules: [MapLocalRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Map Local")
|
||||
.font(.headline)
|
||||
Text("Intercept requests and replace the response with local content. Define custom mock responses for matched URLs.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "doc.on.doc",
|
||||
title: "No Map Local Rules",
|
||||
subtitle: "Tap + to create a new rule."
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name ?? rule.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(rule.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Status: \(rule.responseStatus)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteMapLocalRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Map Local")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// TODO: Add map local rule creation form
|
||||
Text("Map Local rule creation")
|
||||
}
|
||||
.navigationTitle("New Map Local Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddRule = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeMapLocalRules()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Map Local observation error: \(error)")
|
||||
} onChange: { newRules in
|
||||
rules = newRules
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
UI/More/MoreView.swift
Normal file
87
UI/More/MoreView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct MoreView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink {
|
||||
SetupGuideView()
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Setup Guide")
|
||||
Text(appState.isVPNConnected ? "Ready to Intercept" : "Setup Required")
|
||||
.font(.caption)
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
CertificateView()
|
||||
} label: {
|
||||
Label("Certificate", systemImage: "lock.shield")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Rules") {
|
||||
NavigationLink {
|
||||
SSLProxyingListView()
|
||||
} label: {
|
||||
Label("SSL Proxying List", systemImage: "lock.fill")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BlockListView()
|
||||
} label: {
|
||||
Label("Block List", systemImage: "xmark.shield")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BreakpointRulesView()
|
||||
} label: {
|
||||
Label("Breakpoint", systemImage: "pause.circle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MapLocalView()
|
||||
} label: {
|
||||
Label("Map Local", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
NoCachingView()
|
||||
} label: {
|
||||
Label("No Caching", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
DNSSpoofingView()
|
||||
} label: {
|
||||
Label("DNS Spoofing", systemImage: "network")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Settings") {
|
||||
NavigationLink {
|
||||
AdvancedSettingsView()
|
||||
} label: {
|
||||
Label("Advanced", systemImage: "gearshape.2")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AppSettingsView()
|
||||
} label: {
|
||||
Label("App Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("More")
|
||||
}
|
||||
}
|
||||
42
UI/More/NoCachingView.swift
Normal file
42
UI/More/NoCachingView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct NoCachingView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isNoCachingEnabled
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "No Caching",
|
||||
description: "Bypass all caching layers to always see the latest server response. Strips cache headers from requests and responses.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isNoCachingEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("How it works") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Request modifications:")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("Removes If-Modified-Since, If-None-Match\nAdds Pragma: no-cache, Cache-Control: no-cache")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Response modifications:")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("Removes Expires, Last-Modified, ETag\nAdds Expires: 0, Cache-Control: no-cache")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("No Caching")
|
||||
}
|
||||
}
|
||||
150
UI/More/SSLProxyingListView.swift
Normal file
150
UI/More/SSLProxyingListView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct SSLProxyingListView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isSSLProxyingEnabled
|
||||
@State private var entries: [SSLProxyingEntry] = []
|
||||
@State private var showAddInclude = false
|
||||
@State private var showAddExclude = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var includeEntries: [SSLProxyingEntry] {
|
||||
entries.filter(\.isInclude)
|
||||
}
|
||||
|
||||
var excludeEntries: [SSLProxyingEntry] {
|
||||
entries.filter { !$0.isInclude }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "SSL Proxying",
|
||||
description: "Decrypt HTTPS traffic from included domains. Excluded domains are always passed through.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isSSLProxyingEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Include") {
|
||||
if includeEntries.isEmpty {
|
||||
Text("No include entries")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(includeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = includeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Exclude") {
|
||||
if excludeEntries.isEmpty {
|
||||
Text("No exclude entries")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(excludeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = excludeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("SSL Proxying")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("Add Include Entry") { showAddInclude = true }
|
||||
Button("Add Exclude Entry") { showAddExclude = true }
|
||||
Divider()
|
||||
Button("Clear All Rules", role: .destructive) {
|
||||
try? rulesRepo.deleteAllSSLEntries()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddInclude) {
|
||||
DomainEntrySheet(title: "New Include Entry", isInclude: true) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: true)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddExclude) {
|
||||
DomainEntrySheet(title: "New Exclude Entry", isInclude: false) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: false)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeSSLEntries()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("SSL observation error: \(error)")
|
||||
} onChange: { newEntries in
|
||||
entries = newEntries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Domain Entry Sheet
|
||||
|
||||
struct DomainEntrySheet: View {
|
||||
let title: String
|
||||
let isInclude: Bool
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var domainPattern = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Domain Pattern", text: $domainPattern)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} footer: {
|
||||
Text("Supports wildcards: * (zero or more) and ? (single character). Example: *.example.com")
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
onSave(domainPattern)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(domainPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
UI/More/SetupGuideView.swift
Normal file
116
UI/More/SetupGuideView.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct SetupGuideView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var isReady: Bool {
|
||||
appState.isVPNConnected && appState.isCertificateTrusted
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
// Status Banner
|
||||
HStack {
|
||||
Image(systemName: isReady ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(isReady ? "Ready to Intercept" : "Setup Required")
|
||||
.font(.headline)
|
||||
Text(isReady ? "All systems are configured correctly" : "Complete the steps below to start")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.background(isReady ? Color.green : Color.orange, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text("Follow these two steps to start capturing network traffic on your device.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Step 1: VPN
|
||||
stepRow(
|
||||
title: "VPN Extension Enabled",
|
||||
subtitle: appState.isVPNConnected
|
||||
? "VPN is running and capturing traffic"
|
||||
: "Tap to enable VPN",
|
||||
isComplete: appState.isVPNConnected,
|
||||
action: {
|
||||
Task { await appState.toggleVPN() }
|
||||
}
|
||||
)
|
||||
|
||||
// Step 2: Certificate
|
||||
stepRow(
|
||||
title: "Certificate Installed & Trusted",
|
||||
subtitle: appState.isCertificateTrusted
|
||||
? "HTTPS traffic can now be decrypted"
|
||||
: "Install and trust the CA certificate",
|
||||
isComplete: appState.isCertificateTrusted,
|
||||
action: {
|
||||
// TODO: Phase 3 - Open certificate installation flow
|
||||
}
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Help section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Need Help?")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("Watch Video Tutorial")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.forward")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "book.fill")
|
||||
.foregroundStyle(.blue)
|
||||
Text("Read Documentation")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.forward")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Setup Guide")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func stepRow(title: String, subtitle: String, isComplete: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: isComplete ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(isComplete ? .green : .secondary)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(isComplete ? .green : .primary)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
46
UI/Pin/PinView.swift
Normal file
46
UI/Pin/PinView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct PinView: View {
|
||||
@State private var pinnedRequests: [CapturedTraffic] = []
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let trafficRepo = TrafficRepository()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if pinnedRequests.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "pin.slash",
|
||||
title: "No Pinned Requests",
|
||||
subtitle: "Pin requests from the Home tab to save them here for quick access."
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(pinnedRequests) { request in
|
||||
NavigationLink(value: request.id) {
|
||||
TrafficRowView(traffic: request)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Int64?.self) { id in
|
||||
if let id {
|
||||
RequestDetailView(trafficId: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pin")
|
||||
.task {
|
||||
observation = trafficRepo.observePinnedTraffic()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Pin observation error: \(error)")
|
||||
} onChange: { pinned in
|
||||
withAnimation {
|
||||
pinnedRequests = pinned
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
UI/SharedComponents/EmptyStateView.swift
Normal file
37
UI/SharedComponents/EmptyStateView.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let actionTitle, let action {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
34
UI/SharedComponents/FilterChipsView.swift
Normal file
34
UI/SharedComponents/FilterChipsView.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FilterChip: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
var isSelected: Bool = false
|
||||
}
|
||||
|
||||
struct FilterChipsView: View {
|
||||
@Binding var chips: [FilterChip]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach($chips) { $chip in
|
||||
Button {
|
||||
chip.isSelected.toggle()
|
||||
} label: {
|
||||
Text(chip.label)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
chip.isSelected ? Color.accentColor : Color(.systemGray5),
|
||||
in: Capsule()
|
||||
)
|
||||
.foregroundStyle(chip.isSelected ? .white : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
UI/SharedComponents/KeyValueRow.swift
Normal file
18
UI/SharedComponents/KeyValueRow.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
struct KeyValueRow: View {
|
||||
let key: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(key)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
27
UI/SharedComponents/MethodBadge.swift
Normal file
27
UI/SharedComponents/MethodBadge.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MethodBadge: View {
|
||||
let method: String
|
||||
|
||||
var color: Color {
|
||||
switch method.uppercased() {
|
||||
case "GET": .green
|
||||
case "POST": .blue
|
||||
case "PUT": .orange
|
||||
case "PATCH": .purple
|
||||
case "DELETE": .red
|
||||
case "HEAD": .gray
|
||||
case "OPTIONS": .teal
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(method.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
45
UI/SharedComponents/StatusBadge.swift
Normal file
45
UI/SharedComponents/StatusBadge.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusBadge: View {
|
||||
let statusCode: Int?
|
||||
|
||||
var color: Color {
|
||||
guard let code = statusCode else { return .secondary }
|
||||
switch code {
|
||||
case 200..<300: return .green
|
||||
case 300..<400: return .blue
|
||||
case 400..<500: return .yellow
|
||||
case 500..<600: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var text: String {
|
||||
guard let code = statusCode else { return "..." }
|
||||
switch code {
|
||||
case 200: return "200 OK"
|
||||
case 201: return "201 Created"
|
||||
case 204: return "204 No Content"
|
||||
case 301: return "301 Moved"
|
||||
case 302: return "302 Found"
|
||||
case 304: return "304 Not Modified"
|
||||
case 400: return "400 Bad Request"
|
||||
case 401: return "401 Unauthorized"
|
||||
case 403: return "403 Forbidden"
|
||||
case 404: return "404 Not Found"
|
||||
case 500: return "500 Server Error"
|
||||
case 502: return "502 Bad Gateway"
|
||||
case 503: return "503 Unavailable"
|
||||
default: return "\(code)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
20
UI/SharedComponents/ToggleHeaderView.swift
Normal file
20
UI/SharedComponents/ToggleHeaderView.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleHeaderView: View {
|
||||
let title: String
|
||||
let description: String
|
||||
@Binding var isEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(title, isOn: $isEnabled)
|
||||
.font(.headline)
|
||||
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user