- 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
246 lines
9.9 KiB
Swift
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
|
|
}
|
|
}
|