- 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
175 lines
7.1 KiB
Swift
175 lines
7.1 KiB
Swift
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?
|
|
@State private var didInitializeObservers = false
|
|
@State private var refreshSequence = 0
|
|
|
|
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)
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
addToSSLProxyingList(domain: group.domain)
|
|
} label: {
|
|
Label("Add to SSL Proxying", systemImage: "lock.shield")
|
|
}
|
|
Button {
|
|
addToBlockList(domain: group.domain)
|
|
} label: {
|
|
Label("Add to Block List", systemImage: "xmark.shield")
|
|
}
|
|
Button(role: .destructive) {
|
|
try? trafficRepo.deleteForDomain(group.domain)
|
|
} label: {
|
|
Label("Delete Domain", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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.")
|
|
}
|
|
.onAppear {
|
|
ProxyLogger.ui.info("HomeView: onAppear vpnConnected=\(appState.isVPNConnected) domains=\(domains.count)")
|
|
}
|
|
.onDisappear {
|
|
ProxyLogger.ui.info("HomeView: onDisappear domains=\(domains.count)")
|
|
}
|
|
.task {
|
|
guard !didInitializeObservers else {
|
|
ProxyLogger.ui.info("HomeView: task rerun ignored; observers already initialized")
|
|
return
|
|
}
|
|
|
|
didInitializeObservers = true
|
|
ProxyLogger.ui.info("HomeView: initial task setup")
|
|
startObservation(source: "initial")
|
|
observeNewTraffic()
|
|
}
|
|
}
|
|
|
|
private let rulesRepo = RulesRepository()
|
|
|
|
private func addToSSLProxyingList(domain: String) {
|
|
var entry = SSLProxyingEntry(domainPattern: domain, isInclude: true)
|
|
try? rulesRepo.insertSSLEntry(&entry)
|
|
// Auto-enable SSL proxying if not already
|
|
if !IPCManager.shared.isSSLProxyingEnabled {
|
|
IPCManager.shared.isSSLProxyingEnabled = true
|
|
}
|
|
IPCManager.shared.post(.configurationChanged)
|
|
}
|
|
|
|
private func addToBlockList(domain: String) {
|
|
var entry = BlockListEntry(urlPattern: "*\(domain)*")
|
|
try? rulesRepo.insertBlockEntry(&entry)
|
|
if !IPCManager.shared.isBlockListEnabled {
|
|
IPCManager.shared.isBlockListEnabled = true
|
|
}
|
|
IPCManager.shared.post(.configurationChanged)
|
|
}
|
|
|
|
private func startObservation(source: String) {
|
|
refreshSequence += 1
|
|
let sequence = refreshSequence
|
|
ProxyLogger.ui.info("HomeView: starting GRDB observation source=\(source) seq=\(sequence)")
|
|
observation?.cancel()
|
|
observation = trafficRepo.observeDomainGroups()
|
|
.start(in: DatabaseManager.shared.dbPool) { error in
|
|
ProxyLogger.ui.error("HomeView: observation error source=\(source) seq=\(sequence) error=\(error.localizedDescription)")
|
|
} onChange: { newDomains in
|
|
let preview = newDomains.prefix(3).map(\.domain).joined(separator: ", ")
|
|
ProxyLogger.ui.info("HomeView: domains updated source=\(source) seq=\(sequence) count=\(newDomains.count) preview=\(preview)")
|
|
withAnimation {
|
|
domains = newDomains
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeNewTraffic() {
|
|
ProxyLogger.ui.info("HomeView: registering Darwin notification observer")
|
|
IPCManager.shared.observe(.newTrafficCaptured) {
|
|
ProxyLogger.ui.info("HomeView: Darwin notification received")
|
|
DispatchQueue.main.async {
|
|
startObservation(source: "darwin")
|
|
}
|
|
}
|
|
}
|
|
}
|