Files
ProxyIOS/UI/More/MapLocalView.swift
Trey t 148bc3887c Add iPad support, auto-pinning, and comprehensive logging
- Adaptive iPhone/iPad layout with NavigationSplitView sidebar
- Auto-detect SSL-pinned domains, fall back to passthrough
- Certificate install via local HTTP server (Safari profile flow)
- App Group-backed CA, per-domain leaf cert LRU cache
- DB-backed config repository, Darwin notification throttling
- Rules engine, breakpoint rules, pinned domain tracking
- os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui
- Fix dyld framework embed, race conditions, thread safety
2026-04-11 12:52:18 -05:00

246 lines
9.9 KiB
Swift

import SwiftUI
import ProxyCore
import GRDB
struct MapLocalView: View {
@State private var rules: [MapLocalRule] = []
@State private var showAddRule = false
@State private var editingRule: MapLocalRule?
@State private var ruleToDelete: MapLocalRule?
@State private var showDeleteConfirmation = 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
Button {
editingRule = rule
} label: {
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)
}
}
.tint(.primary)
}
.onDelete { indexSet in
if let index = indexSet.first {
ruleToDelete = rules[index]
showDeleteConfirmation = true
}
}
}
}
}
.navigationTitle("Map Local")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showAddRule = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddRule) {
AddMapLocalRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule)
}
.sheet(item: $editingRule) { rule in
AddMapLocalRuleSheet(rulesRepo: rulesRepo, existingRule: rule, isPresented: .constant(true))
}
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: ruleToDelete) { rule in
Button("Delete", role: .destructive) {
if let id = rule.id {
try? rulesRepo.deleteMapLocalRule(id: id)
}
}
}
.task {
observation = rulesRepo.observeMapLocalRules()
.start(in: DatabaseManager.shared.dbPool) { error in
print("Map Local observation error: \(error)")
} onChange: { newRules in
rules = newRules
}
}
}
}
// MARK: - Add Map Local Rule Sheet
private struct AddMapLocalRuleSheet: View {
let rulesRepo: RulesRepository
let existingRule: MapLocalRule?
@Binding var isPresented: Bool
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var urlPattern = ""
@State private var method = "ANY"
@State private var responseStatus = "200"
@State private var responseHeadersJSON = ""
@State private var responseBody = ""
@State private var contentType = "application/json"
private var methods: [String] { ["ANY"] + ProxyConstants.httpMethods }
init(rulesRepo: RulesRepository, existingRule: MapLocalRule? = nil, isPresented: Binding<Bool>) {
self.rulesRepo = rulesRepo
self.existingRule = existingRule
self._isPresented = isPresented
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Name (optional)", text: $name)
TextField("URL Pattern (e.g. */api/*)", text: $urlPattern)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Picker("Method", selection: $method) {
ForEach(methods, id: \.self) { m in
Text(m).tag(m)
}
}
}
Section("Response") {
TextField("Status Code", text: $responseStatus)
.keyboardType(.numberPad)
TextField("Content Type", text: $contentType)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
TextEditor(text: $responseHeadersJSON)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 100)
} header: {
Text("Response Headers (JSON)")
} footer: {
Text("Optional JSON object of headers, for example {\"Cache-Control\":\"no-store\",\"X-Mock\":\"1\"}")
}
Section("Response Body") {
TextEditor(text: $responseBody)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 120)
}
Section {
Button("Save") {
let status = Int(responseStatus) ?? 200
if let existing = existingRule {
let updated = MapLocalRule(
id: existing.id,
name: name.isEmpty ? nil : name,
urlPattern: urlPattern,
method: method,
responseStatus: status,
responseHeaders: normalizedHeadersJSON,
responseBody: responseBody.isEmpty ? nil : responseBody.data(using: .utf8),
responseContentType: contentType.isEmpty ? nil : contentType,
isEnabled: existing.isEnabled,
createdAt: existing.createdAt
)
try? rulesRepo.updateMapLocalRule(updated)
} else {
var rule = MapLocalRule(
name: name.isEmpty ? nil : name,
urlPattern: urlPattern,
method: method,
responseStatus: status,
responseHeaders: normalizedHeadersJSON,
responseBody: responseBody.isEmpty ? nil : responseBody.data(using: .utf8),
responseContentType: contentType.isEmpty ? nil : contentType
)
try? rulesRepo.insertMapLocalRule(&rule)
}
isPresented = false
dismiss()
}
.disabled(urlPattern.isEmpty)
}
}
.navigationTitle(existingRule == nil ? "New Map Local Rule" : "Edit Map Local Rule")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
isPresented = false
dismiss()
}
}
}
.onAppear {
if let rule = existingRule {
name = rule.name ?? ""
urlPattern = rule.urlPattern
method = rule.method
responseStatus = String(rule.responseStatus)
if let body = rule.responseBody, let str = String(data: body, encoding: .utf8) {
responseBody = str
}
responseHeadersJSON = prettyPrintedHeaders(rule.responseHeaders) ?? ""
contentType = rule.responseContentType ?? "application/json"
}
}
}
}
private var normalizedHeadersJSON: String? {
let trimmed = responseHeadersJSON.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let data = trimmed.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let dict = object as? [String: String],
let normalized = try? JSONEncoder().encode(dict),
let json = String(data: normalized, encoding: .utf8) else {
return nil
}
return json
}
private func prettyPrintedHeaders(_ json: String?) -> String? {
guard let json,
let data = json.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
let string = String(data: pretty, encoding: .utf8) else {
return json
}
return string
}
}