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,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() }
}
}
}
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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