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
This commit is contained in:
@@ -3,7 +3,6 @@ import ProxyCore
|
||||
|
||||
struct AdvancedSettingsView: View {
|
||||
@State private var hideSystemTraffic = IPCManager.shared.hideSystemTraffic
|
||||
@State private var showImagePreview = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -15,12 +14,6 @@ struct AdvancedSettingsView: View {
|
||||
} 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")
|
||||
}
|
||||
|
||||
19
UI/More/AppLockView.swift
Normal file
19
UI/More/AppLockView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
struct AppLockView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
List {
|
||||
Section {
|
||||
Toggle("App Lock", isOn: $appState.isAppLockEnabled)
|
||||
} footer: {
|
||||
Text("When enabled, the app will require Face ID, Touch ID, or your device passcode to unlock each time you open it.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Lock")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct AppSettingsView: View {
|
||||
@State private var analyticsEnabled = false
|
||||
@State private var crashReportingEnabled = true
|
||||
@AppStorage("analyticsEnabled") private var analyticsEnabled = false
|
||||
@AppStorage("crashReportingEnabled") private var crashReportingEnabled = true
|
||||
@State private var showClearCacheConfirmation = false
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
@@ -23,14 +32,14 @@ struct AppSettingsView: View {
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0.0")
|
||||
LabeledContent("Build", value: "1")
|
||||
LabeledContent("Version", value: appVersion)
|
||||
LabeledContent("Build", value: buildNumber)
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Settings")
|
||||
.confirmationDialog("Clear Cache", isPresented: $showClearCacheConfirmation) {
|
||||
Button("Clear Cache", role: .destructive) {
|
||||
// TODO: Clear URL cache, image cache, etc.
|
||||
URLCache.shared.removeAllCachedResponses()
|
||||
}
|
||||
} message: {
|
||||
Text("This will clear all cached data.")
|
||||
|
||||
@@ -6,6 +6,9 @@ struct BlockListView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBlockListEnabled
|
||||
@State private var entries: [BlockListEntry] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingEntry: BlockListEntry?
|
||||
@State private var entryToDelete: BlockListEntry?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -32,22 +35,26 @@ struct BlockListView: View {
|
||||
.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)
|
||||
Button {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = entries[index].id {
|
||||
try? rulesRepo.deleteBlockEntry(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
entryToDelete = entries[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +74,18 @@ struct BlockListView: View {
|
||||
try? rulesRepo.insertBlockEntry(&entry)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingEntry) { entry in
|
||||
NewBlockRuleView(existingEntry: entry) { updated in
|
||||
try? rulesRepo.updateBlockEntry(updated)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = entry.id {
|
||||
try? rulesRepo.deleteBlockEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeBlockListEntries()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
@@ -81,6 +100,7 @@ struct BlockListView: View {
|
||||
// MARK: - New Block Rule
|
||||
|
||||
struct NewBlockRuleView: View {
|
||||
let existingEntry: BlockListEntry?
|
||||
let onSave: (BlockListEntry) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@@ -90,6 +110,11 @@ struct NewBlockRuleView: View {
|
||||
@State private var blockAction: BlockAction = .blockAndHide
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(existingEntry: BlockListEntry? = nil, onSave: @escaping (BlockListEntry) -> Void) {
|
||||
self.existingEntry = existingEntry
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -118,7 +143,7 @@ struct NewBlockRuleView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Block Rule")
|
||||
.navigationTitle(existingEntry == nil ? "New Block Rule" : "Edit Block Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -127,11 +152,14 @@ struct NewBlockRuleView: View {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let entry = BlockListEntry(
|
||||
id: existingEntry?.id,
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
includeSubpaths: includeSubpaths,
|
||||
blockAction: blockAction
|
||||
blockAction: blockAction,
|
||||
isEnabled: existingEntry?.isEnabled ?? true,
|
||||
createdAt: existingEntry?.createdAt ?? Date().timeIntervalSince1970
|
||||
)
|
||||
onSave(entry)
|
||||
dismiss()
|
||||
@@ -139,6 +167,15 @@ struct NewBlockRuleView: View {
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let entry = existingEntry {
|
||||
name = entry.name ?? ""
|
||||
urlPattern = entry.urlPattern
|
||||
method = entry.method
|
||||
includeSubpaths = entry.includeSubpaths
|
||||
blockAction = entry.action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ struct BreakpointRulesView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBreakpointEnabled
|
||||
@State private var rules: [BreakpointRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingRule: BreakpointRule?
|
||||
@State private var ruleToDelete: BreakpointRule?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
@State private var selectedTab = 0
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
@@ -25,42 +29,66 @@ struct BreakpointRulesView: View {
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
Section {
|
||||
Picker("Tab", selection: $selectedTab) {
|
||||
Text("Rules").tag(0)
|
||||
Text("Waiting").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
if selectedTab == 0 {
|
||||
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
|
||||
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)
|
||||
HStack(spacing: 8) {
|
||||
if rule.interceptRequest {
|
||||
Text("Request")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
if rule.interceptResponse {
|
||||
Text("Response")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
if let index = indexSet.first {
|
||||
ruleToDelete = rules[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section("Waiting") {
|
||||
EmptyStateView(
|
||||
icon: "pause.circle",
|
||||
title: "No Breakpoint Rules",
|
||||
subtitle: "Tap + to create a new breakpoint rule."
|
||||
icon: "clock",
|
||||
title: "No Waiting Breakpoints",
|
||||
subtitle: "Breakpoints will appear here when a request is paused."
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,17 +101,15 @@ struct BreakpointRulesView: View {
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
AddBreakpointRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule)
|
||||
}
|
||||
.sheet(item: $editingRule) { rule in
|
||||
AddBreakpointRuleSheet(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.deleteBreakpointRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,3 +123,101 @@ struct BreakpointRulesView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Breakpoint Rule Sheet
|
||||
|
||||
private struct AddBreakpointRuleSheet: View {
|
||||
let rulesRepo: RulesRepository
|
||||
let existingRule: BreakpointRule?
|
||||
@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 interceptRequest = true
|
||||
@State private var interceptResponse = true
|
||||
|
||||
private var methods: [String] { ["ANY"] + ProxyConstants.httpMethods }
|
||||
|
||||
init(rulesRepo: RulesRepository, existingRule: BreakpointRule? = 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 {
|
||||
Toggle("Intercept Request", isOn: $interceptRequest)
|
||||
Toggle("Intercept Response", isOn: $interceptResponse)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save") {
|
||||
if let existing = existingRule {
|
||||
let updated = BreakpointRule(
|
||||
id: existing.id,
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
interceptRequest: interceptRequest,
|
||||
interceptResponse: interceptResponse,
|
||||
isEnabled: existing.isEnabled,
|
||||
createdAt: existing.createdAt
|
||||
)
|
||||
try? rulesRepo.updateBreakpointRule(updated)
|
||||
} else {
|
||||
var rule = BreakpointRule(
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
interceptRequest: interceptRequest,
|
||||
interceptResponse: interceptResponse
|
||||
)
|
||||
try? rulesRepo.insertBreakpointRule(&rule)
|
||||
}
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle(existingRule == nil ? "New Breakpoint Rule" : "Edit Breakpoint 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
|
||||
interceptRequest = rule.interceptRequest
|
||||
interceptResponse = rule.interceptResponse
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,30 @@ import ProxyCore
|
||||
|
||||
struct CertificateView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showRegenerateConfirmation = false
|
||||
@State private var isInstallingCert = false
|
||||
@State private var certServer: CertificateInstallServer?
|
||||
|
||||
private var dateFormatter: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: appState.isCertificateTrusted ? "checkmark.shield.fill" : "exclamationmark.shield")
|
||||
Image(systemName: CertificateManager.shared.hasCA ? "checkmark.shield.fill" : "exclamationmark.shield")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(appState.isCertificateTrusted ? .green : .orange)
|
||||
.foregroundStyle(CertificateManager.shared.hasCA ? .green : .orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(appState.isCertificateTrusted
|
||||
? "Certificate is installed & trusted!"
|
||||
: "Certificate not installed")
|
||||
Text(CertificateManager.shared.hasCA
|
||||
? "Certificate Generated"
|
||||
: "Certificate not generated")
|
||||
.font(.headline)
|
||||
Text("Required for HTTPS decryption")
|
||||
Text("The app owns the shared CA used for HTTPS decryption.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -27,24 +36,173 @@ struct CertificateView: View {
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("CA Certificate", value: "Proxy CA (\(UIDevice.current.name))")
|
||||
LabeledContent("Generated", value: "-")
|
||||
LabeledContent("Expires", value: "-")
|
||||
LabeledContent("Generated", value: formattedDate(CertificateManager.shared.caGeneratedDate))
|
||||
LabeledContent("Expires", value: formattedDate(CertificateManager.shared.caExpirationDate))
|
||||
LabeledContent("Fingerprint", value: abbreviatedFingerprint(CertificateManager.shared.caFingerprint))
|
||||
}
|
||||
|
||||
Section("Runtime") {
|
||||
LabeledContent("Extension Loaded Same CA", value: appState.hasSharedCertificate ? "Yes" : "No")
|
||||
LabeledContent("HTTPS Inspection Verified", value: appState.isHTTPSInspectionVerified ? "Yes" : "Not Yet")
|
||||
if let domain = appState.runtimeStatus.lastSuccessfulMITMDomain {
|
||||
LabeledContent("Last Verified Domain", value: domain)
|
||||
}
|
||||
if let lastError = appState.lastRuntimeError {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Latest Error")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Install Certificate") {
|
||||
// TODO: Phase 3 - Export and open cert installation
|
||||
Button {
|
||||
installCertificate()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isInstallingCert {
|
||||
ProgressView()
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text("Install Certificate to Settings")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isInstallingCert || !CertificateManager.shared.hasCA)
|
||||
} footer: {
|
||||
Text("Downloads the CA certificate in Safari. After downloading, install it from Settings > General > VPN & Device Management, then enable trust in Settings > General > About > Certificate Trust Settings.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Regenerate Certificate", role: .destructive) {
|
||||
// TODO: Phase 3 - Generate new CA
|
||||
showRegenerateConfirmation = true
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Certificate")
|
||||
.confirmationDialog("Regenerate Certificate?", isPresented: $showRegenerateConfirmation) {
|
||||
Button("Regenerate", role: .destructive) {
|
||||
CertificateManager.shared.regenerateCA()
|
||||
appState.isCertificateInstalled = CertificateManager.shared.hasCA
|
||||
}
|
||||
} message: {
|
||||
Text("This will create a new CA certificate. You will need to reinstall and trust it on your device.")
|
||||
}
|
||||
.onAppear {
|
||||
appState.isCertificateInstalled = CertificateManager.shared.hasCA
|
||||
}
|
||||
.onDisappear {
|
||||
certServer?.stop()
|
||||
certServer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ date: Date?) -> String {
|
||||
guard let date else { return "N/A" }
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private func abbreviatedFingerprint(_ fingerprint: String?) -> String {
|
||||
guard let fingerprint else { return "N/A" }
|
||||
if fingerprint.count <= 16 { return fingerprint }
|
||||
return "\(fingerprint.prefix(8))...\(fingerprint.suffix(8))"
|
||||
}
|
||||
|
||||
private func installCertificate() {
|
||||
guard let derBytes = CertificateManager.shared.exportCACertificateDER() else { return }
|
||||
|
||||
isInstallingCert = true
|
||||
|
||||
// Start a local HTTP server that serves the certificate
|
||||
let server = CertificateInstallServer(certDER: Data(derBytes))
|
||||
certServer = server
|
||||
|
||||
server.start { port in
|
||||
Task { @MainActor in
|
||||
// Open Safari to our local server so the certificate can be downloaded and installed.
|
||||
if let url = URL(string: "http://localhost:\(port)/ProxyCA.cer") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
isInstallingCert = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local HTTP server for certificate installation
|
||||
|
||||
import Network
|
||||
|
||||
final class CertificateInstallServer: @unchecked Sendable {
|
||||
private let certDER: Data
|
||||
private var listener: NWListener?
|
||||
private let queue = DispatchQueue(label: "cert-install-server")
|
||||
|
||||
init(certDER: Data) {
|
||||
self.certDER = certDER
|
||||
}
|
||||
|
||||
func start(onReady: @escaping @Sendable (UInt16) -> Void) {
|
||||
do {
|
||||
let params = NWParameters.tcp
|
||||
listener = try NWListener(using: params, on: .any)
|
||||
|
||||
listener?.stateUpdateHandler = { state in
|
||||
if case .ready = state, let port = self.listener?.port?.rawValue {
|
||||
DispatchQueue.main.async {
|
||||
onReady(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.newConnectionHandler = { [weak self] connection in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
|
||||
listener?.start(queue: queue)
|
||||
} catch {
|
||||
print("[CertInstall] Failed to start server: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
}
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
|
||||
// Read the HTTP request (we don't really need to parse it)
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, _, _ in
|
||||
guard let self else { return }
|
||||
|
||||
// Respond with the certificate as a mobileconfig-style download
|
||||
let body = self.certDER
|
||||
let response = """
|
||||
HTTP/1.1 200 OK\r
|
||||
Content-Type: application/x-x509-ca-cert\r
|
||||
Content-Disposition: attachment; filename="ProxyCA.cer"\r
|
||||
Content-Length: \(body.count)\r
|
||||
Connection: close\r
|
||||
\r\n
|
||||
"""
|
||||
|
||||
var responseData = Data(response.utf8)
|
||||
responseData.append(body)
|
||||
|
||||
connection.send(content: responseData, completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
// Stop the server after serving — one-shot
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ struct DNSSpoofingView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isDNSSpoofingEnabled
|
||||
@State private var rules: [DNSSpoofRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingRule: DNSSpoofRule?
|
||||
@State private var ruleToDelete: DNSSpoofRule?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -34,24 +37,28 @@ struct DNSSpoofingView: View {
|
||||
)
|
||||
} 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)
|
||||
Button {
|
||||
editingRule = rule
|
||||
} label: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteDNSSpoofRule(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
ruleToDelete = rules[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,17 +73,15 @@ struct DNSSpoofingView: View {
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
AddDNSSpoofRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule)
|
||||
}
|
||||
.sheet(item: $editingRule) { rule in
|
||||
AddDNSSpoofRuleSheet(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.deleteDNSSpoofRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,3 +95,76 @@ struct DNSSpoofingView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add DNS Spoof Rule Sheet
|
||||
|
||||
private struct AddDNSSpoofRuleSheet: View {
|
||||
let rulesRepo: RulesRepository
|
||||
let existingRule: DNSSpoofRule?
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var sourceDomain = ""
|
||||
@State private var targetDomain = ""
|
||||
|
||||
init(rulesRepo: RulesRepository, existingRule: DNSSpoofRule? = nil, isPresented: Binding<Bool>) {
|
||||
self.rulesRepo = rulesRepo
|
||||
self.existingRule = existingRule
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Source Domain (e.g. api.example.com)", text: $sourceDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Target Domain (e.g. dev.example.com)", text: $targetDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save") {
|
||||
if let existing = existingRule {
|
||||
let updated = DNSSpoofRule(
|
||||
id: existing.id,
|
||||
sourceDomain: sourceDomain,
|
||||
targetDomain: targetDomain,
|
||||
isEnabled: existing.isEnabled,
|
||||
createdAt: existing.createdAt
|
||||
)
|
||||
try? rulesRepo.updateDNSSpoofRule(updated)
|
||||
} else {
|
||||
var rule = DNSSpoofRule(
|
||||
sourceDomain: sourceDomain,
|
||||
targetDomain: targetDomain
|
||||
)
|
||||
try? rulesRepo.insertDNSSpoofRule(&rule)
|
||||
}
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
.disabled(sourceDomain.isEmpty || targetDomain.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle(existingRule == nil ? "New DNS Rule" : "Edit DNS Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let rule = existingRule {
|
||||
sourceDomain = rule.sourceDomain
|
||||
targetDomain = rule.targetDomain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ 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()
|
||||
@@ -32,22 +35,26 @@ struct MapLocalView: View {
|
||||
)
|
||||
} 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)
|
||||
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
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteMapLocalRule(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
ruleToDelete = rules[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,17 +69,15 @@ struct MapLocalView: View {
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,3 +91,155 @@ struct MapLocalView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ struct MoreView: View {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Setup Guide")
|
||||
Text(appState.isVPNConnected ? "Ready to Intercept" : "Setup Required")
|
||||
Text(setupStatusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
.foregroundStyle(setupStatusColor)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
.foregroundStyle(setupStatusColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,6 @@ struct MoreView: View {
|
||||
Label("Block List", systemImage: "xmark.shield")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BreakpointRulesView()
|
||||
} label: {
|
||||
Label("Breakpoint", systemImage: "pause.circle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MapLocalView()
|
||||
} label: {
|
||||
@@ -66,6 +60,12 @@ struct MoreView: View {
|
||||
} label: {
|
||||
Label("DNS Spoofing", systemImage: "network")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
PinnedDomainsView()
|
||||
} label: {
|
||||
Label("Pinned Domains", systemImage: "pin.slash")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Settings") {
|
||||
@@ -80,8 +80,31 @@ struct MoreView: View {
|
||||
} label: {
|
||||
Label("App Settings", systemImage: "gearshape")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AppLockView()
|
||||
} label: {
|
||||
Label("App Lock", systemImage: "lock.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("More")
|
||||
}
|
||||
|
||||
private var setupStatusText: String {
|
||||
if appState.isHTTPSInspectionVerified {
|
||||
return "HTTPS Verified"
|
||||
}
|
||||
if appState.isVPNConnected && appState.hasSharedCertificate {
|
||||
return "Ready to Capture"
|
||||
}
|
||||
return "Setup Required"
|
||||
}
|
||||
|
||||
private var setupStatusColor: Color {
|
||||
if appState.isVPNConnected && appState.hasSharedCertificate {
|
||||
return .green
|
||||
}
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
80
UI/More/PinnedDomainsView.swift
Normal file
80
UI/More/PinnedDomainsView.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct PinnedDomainsView: View {
|
||||
@State private var domains: [PinnedDomain] = []
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
@State private var showClearConfirmation = false
|
||||
|
||||
private let repo = PinnedDomainRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("SSL Pinning Detection")
|
||||
.font(.headline)
|
||||
Text("Domains listed here were automatically detected as using SSL pinning. MITM interception is skipped for these domains — they use passthrough instead so the app still works.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Detected Domains (\(domains.count))") {
|
||||
if domains.isEmpty {
|
||||
Text("No pinned domains detected yet")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(domains) { domain in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(domain.domain)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let reason = domain.reason {
|
||||
Text(reason)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text("Detected \(Date(timeIntervalSince1970: domain.detectedAt).formatted(.relative(presentation: .named)))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
for index in offsets {
|
||||
try? repo.unpin(domain: domains[index].domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pinned Domains")
|
||||
.toolbar {
|
||||
if !domains.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
showClearConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear All Pinned Domains?", isPresented: $showClearConfirmation) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
try? repo.deleteAll()
|
||||
}
|
||||
} message: {
|
||||
Text("This will allow MITM interception to be attempted again for all domains. Pinned domains will be re-detected automatically if they still use SSL pinning.")
|
||||
}
|
||||
.task {
|
||||
observation = repo.observeAll()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Pinned domains observation error: \(error)")
|
||||
} onChange: { newDomains in
|
||||
withAnimation { domains = newDomains }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ struct SSLProxyingListView: View {
|
||||
@State private var entries: [SSLProxyingEntry] = []
|
||||
@State private var showAddInclude = false
|
||||
@State private var showAddExclude = false
|
||||
@State private var editingEntry: SSLProxyingEntry?
|
||||
@State private var entryToDelete: SSLProxyingEntry?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -41,13 +44,17 @@ struct SSLProxyingListView: View {
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(includeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
Button {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
Text(entry.domainPattern)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = includeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
entryToDelete = includeEntries[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,13 +67,17 @@ struct SSLProxyingListView: View {
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(excludeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
Button {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
Text(entry.domainPattern)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = excludeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
entryToDelete = excludeEntries[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +92,7 @@ struct SSLProxyingListView: View {
|
||||
Divider()
|
||||
Button("Clear All Rules", role: .destructive) {
|
||||
try? rulesRepo.deleteAllSSLEntries()
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
@@ -91,12 +103,37 @@ struct SSLProxyingListView: View {
|
||||
DomainEntrySheet(title: "New Include Entry", isInclude: true) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: true)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
IPCManager.shared.isSSLProxyingEnabled = true
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddExclude) {
|
||||
DomainEntrySheet(title: "New Exclude Entry", isInclude: false) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: false)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingEntry) { entry in
|
||||
DomainEntrySheet(
|
||||
title: entry.isInclude ? "Edit Include Entry" : "Edit Exclude Entry",
|
||||
isInclude: entry.isInclude,
|
||||
existingEntry: entry
|
||||
) { pattern in
|
||||
var updated = entry
|
||||
updated.domainPattern = pattern
|
||||
try? rulesRepo.updateSSLEntry(updated)
|
||||
if updated.isInclude {
|
||||
IPCManager.shared.isSSLProxyingEnabled = true
|
||||
}
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = entry.id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -115,11 +152,19 @@ struct SSLProxyingListView: View {
|
||||
struct DomainEntrySheet: View {
|
||||
let title: String
|
||||
let isInclude: Bool
|
||||
let existingEntry: SSLProxyingEntry?
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var domainPattern = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(title: String, isInclude: Bool, existingEntry: SSLProxyingEntry? = nil, onSave: @escaping (String) -> Void) {
|
||||
self.title = title
|
||||
self.isInclude = isInclude
|
||||
self.existingEntry = existingEntry
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -145,6 +190,11 @@ struct DomainEntrySheet: View {
|
||||
.disabled(domainPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let entry = existingEntry {
|
||||
domainPattern = entry.domainPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ struct SetupGuideView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var isReady: Bool {
|
||||
appState.isVPNConnected && appState.isCertificateTrusted
|
||||
appState.isVPNConnected && appState.hasSharedCertificate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -15,9 +15,9 @@ struct SetupGuideView: View {
|
||||
Image(systemName: isReady ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(isReady ? "Ready to Intercept" : "Setup Required")
|
||||
Text(isReady ? "Ready to Capture" : "Setup Required")
|
||||
.font(.headline)
|
||||
Text(isReady ? "All systems are configured correctly" : "Complete the steps below to start")
|
||||
Text(isReady ? "The tunnel and shared certificate are configured" : "Complete the steps below to start")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
@@ -44,43 +44,42 @@ struct SetupGuideView: View {
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
)
|
||||
NavigationLink {
|
||||
CertificateView()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: appState.hasSharedCertificate ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(appState.hasSharedCertificate ? .green : .secondary)
|
||||
|
||||
Divider()
|
||||
VStack(alignment: .leading) {
|
||||
Text("Shared Certificate Available")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(appState.hasSharedCertificate ? .green : .primary)
|
||||
Text(certificateSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let lastError = appState.lastRuntimeError {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Latest Runtime Error", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.orange)
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.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)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -90,6 +89,19 @@ struct SetupGuideView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var certificateSubtitle: String {
|
||||
if !appState.isCertificateInstalled {
|
||||
return "Generate and install the Proxy CA certificate."
|
||||
}
|
||||
if !appState.hasSharedCertificate {
|
||||
return "The app has a CA, but the extension has not loaded the same one yet."
|
||||
}
|
||||
if appState.isHTTPSInspectionVerified {
|
||||
return "HTTPS inspection has been verified on live traffic."
|
||||
}
|
||||
return "Include a domain in SSL Proxying, trust the CA in Settings, then retry the request."
|
||||
}
|
||||
|
||||
private func stepRow(title: String, subtitle: String, isComplete: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
Reference in New Issue
Block a user