Files
ProxyIOS/UI/Compose/ComposeEditorView.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

334 lines
12 KiB
Swift

import SwiftUI
import ProxyCore
struct ComposeEditorView: View {
let requestId: Int64
@State private var method = "GET"
@State private var url = ""
@State private var headers: [(key: String, value: String)] = []
@State private var queryParams: [(key: String, value: String)] = []
@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?
@State private var showHeaderEditor = false
@State private var showQueryEditor = false
@State private var didLoad = false
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 responseStatus != nil || responseBody != nil {
Divider()
VStack(alignment: .leading, spacing: 8) {
if let status = responseStatus {
HStack {
Text("Response")
.font(.caption.weight(.semibold))
StatusBadge(statusCode: status)
}
.padding(.horizontal)
.padding(.top, 8)
}
if let body = responseBody {
ScrollView {
Text(body)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
.frame(maxHeight: 200)
NavigationLink {
FullResponseView(statusCode: responseStatus, responseBody: body)
} label: {
Label("View Full Response", systemImage: "arrow.up.left.and.arrow.down.right")
.font(.caption)
}
.padding(.horizontal)
.padding(.bottom, 8)
}
}
.background(Color(.systemGray6))
}
}
.navigationTitle("Edit Request")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await sendRequest() }
} label: {
if isSending {
ProgressView()
} else {
Text("Send")
.font(.headline)
}
}
.buttonStyle(.borderedProminent)
.disabled(url.isEmpty || isSending)
}
}
.sheet(isPresented: $showHeaderEditor) {
HeaderEditorSheet(headers: headers) { updated in
headers = updated
}
}
.sheet(isPresented: $showQueryEditor) {
QueryEditorSheet(parameters: queryParams) { updated in
queryParams = updated
}
}
.task {
guard !didLoad else { return }
didLoad = true
loadFromDB()
}
}
// MARK: - Tab Views
private var headerEditor: some View {
VStack(alignment: .leading, spacing: 8) {
if headers.isEmpty {
EmptyStateView(
icon: "list.bullet.rectangle",
title: "No Headers",
subtitle: "Tap 'Edit Headers' to add headers."
)
} else {
ForEach(headers.indices, id: \.self) { index in
HStack {
Text(headers[index].key)
.font(.subheadline.weight(.medium))
Spacer()
Text(headers[index].value)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.vertical, 2)
}
}
Button("Edit Headers") {
showHeaderEditor = true
}
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
}
}
private var queryEditor: some View {
VStack(alignment: .leading, spacing: 8) {
if queryParams.isEmpty {
EmptyStateView(
icon: "questionmark.circle",
title: "No Query Parameters",
subtitle: "Tap 'Edit Parameters' to add query parameters."
)
} else {
ForEach(queryParams.indices, id: \.self) { index in
HStack {
Text(queryParams[index].key)
.font(.subheadline.weight(.medium))
Spacer()
Text(queryParams[index].value)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.vertical, 2)
}
}
Button("Edit Parameters") {
showQueryEditor = true
}
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
}
}
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))
)
}
}
// MARK: - DB Load
private func loadFromDB() {
do {
if let request = try composeRepo.fetch(id: requestId) {
method = request.method
url = request.url ?? ""
bodyText = request.body ?? ""
headers = decodeKeyValues(request.headers)
queryParams = decodeKeyValues(request.queryParameters)
responseStatus = request.responseStatus
if let data = request.responseBody, let str = String(data: data, encoding: .utf8) {
responseBody = str
}
}
} catch {
print("Failed to load compose request: \(error)")
}
}
// MARK: - Send
private func sendRequest() async {
// Build URL with query parameters
guard var components = URLComponents(string: url) else { return }
if !queryParams.isEmpty {
var items = components.queryItems ?? []
for param in queryParams {
items.append(URLQueryItem(name: param.key, value: param.value))
}
components.queryItems = items
}
guard let requestURL = components.url else { return }
isSending = true
defer { isSending = false }
var request = URLRequest(url: requestURL)
request.httpMethod = method
for header in headers {
request.setValue(header.value, forHTTPHeaderField: header.key)
}
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)"
responseStatus = nil
}
// Save to DB
saveToDB()
}
private func saveToDB() {
do {
if var request = try composeRepo.fetch(id: requestId) {
request.method = method
request.url = url
request.headers = encodeKeyValues(headers)
request.queryParameters = encodeKeyValues(queryParams)
request.body = bodyText.isEmpty ? nil : bodyText
request.responseStatus = responseStatus
request.responseBody = responseBody?.data(using: .utf8)
request.lastSentAt = Date().timeIntervalSince1970
try composeRepo.update(request)
}
} catch {
print("Failed to save compose request: \(error)")
}
}
// MARK: - Helpers
private func encodeKeyValues(_ pairs: [(key: String, value: String)]) -> String? {
guard !pairs.isEmpty else { return nil }
var dict: [String: String] = [:]
for pair in pairs { dict[pair.key] = pair.value }
guard let data = try? JSONEncoder().encode(dict) else { return nil }
return String(data: data, encoding: .utf8)
}
private func decodeKeyValues(_ json: String?) -> [(key: String, value: String)] {
guard let json, let data = json.data(using: .utf8),
let dict = try? JSONDecoder().decode([String: String].self, from: data) else {
return []
}
return dict.map { (key: $0.key, value: $0.value) }.sorted { $0.key < $1.key }
}
}