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:
Trey t
2026-04-11 12:52:18 -05:00
parent c77e506db5
commit 148bc3887c
77 changed files with 6710 additions and 847 deletions

File diff suppressed because one or more lines are too long

5
.gitignore vendored
View File

@@ -1,5 +1,10 @@
.DS_Store
*.xcuserdata
**/xcuserdata/
DerivedData/
.build/
build/
screens/
*.xcuserstate
ProxyApp.xcodeproj/project.xcworkspace/xcuserdata/
ProxyApp.xcodeproj/xcuserdata/

View File

@@ -1,6 +1,8 @@
import SwiftUI
import NetworkExtension
@preconcurrency import NetworkExtension
import LocalAuthentication
import ProxyCore
import GRDB
@Observable
@MainActor
@@ -8,14 +10,44 @@ final class AppState {
var vpnStatus: NEVPNStatus = .disconnected
var isCertificateInstalled: Bool = false
var isCertificateTrusted: Bool = false
var isLocked: Bool = false
var runtimeStatus = ProxyRuntimeStatus()
var isAppLockEnabled: Bool {
get { UserDefaults.standard.bool(forKey: "appLockEnabled") }
set { UserDefaults.standard.set(newValue, forKey: "appLockEnabled") }
}
private var vpnManager: NETunnelProviderManager?
private var statusObservation: NSObjectProtocol?
@ObservationIgnored private var runtimeObservation: AnyDatabaseCancellable?
private let runtimeStatusRepo = RuntimeStatusRepository()
init() {
if UserDefaults.standard.bool(forKey: "appLockEnabled") {
isLocked = true
}
isCertificateInstalled = CertificateManager.shared.hasCA
Task {
await loadVPNManager()
}
observeRuntimeStatus()
}
func authenticate() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
isLocked = false
return
}
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Proxy") { [weak self] success, _ in
Task { @MainActor in
if success {
self?.isLocked = false
}
}
}
}
func loadVPNManager() async {
@@ -70,6 +102,19 @@ final class AppState {
vpnStatus == .connected
}
var hasSharedCertificate: Bool {
guard let localFingerprint = CertificateManager.shared.caFingerprint else { return false }
return localFingerprint == runtimeStatus.caFingerprint
}
var isHTTPSInspectionVerified: Bool {
runtimeStatus.lastSuccessfulMITMAt != nil
}
var lastRuntimeError: String? {
runtimeStatus.lastMITMError ?? runtimeStatus.lastConnectError ?? runtimeStatus.lastProxyError
}
var vpnStatusText: String {
switch vpnStatus {
case .connected: "Connected"
@@ -100,4 +145,17 @@ final class AppState {
}
}
}
private func observeRuntimeStatus() {
runtimeObservation = runtimeStatusRepo.observeStatus()
.start(in: DatabaseManager.shared.dbPool) { error in
ProxyLogger.ui.error("AppState runtime observation error: \(error.localizedDescription)")
} onChange: { [weak self] status in
Task { @MainActor in
self?.runtimeStatus = status
self?.isCertificateInstalled = CertificateManager.shared.hasCA
self?.isCertificateTrusted = status.lastSuccessfulMITMAt != nil
}
}
}
}

View File

@@ -2,46 +2,131 @@ import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var appState
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
enum Tab: Hashable {
case home, pin, compose, more
enum Tab: String, Hashable, CaseIterable {
case home = "Home"
case pin = "Pin"
case compose = "Compose"
case more = "More"
var icon: String {
switch self {
case .home: return "house.fill"
case .pin: return "pin.fill"
case .compose: return "square.and.pencil"
case .more: return "ellipsis.circle.fill"
}
}
}
@State private var selectedTab: Tab = .home
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView()
Group {
if horizontalSizeClass == .regular {
iPadLayout
} else {
iPhoneLayout
}
.tabItem {
Label("Home", systemImage: "house.fill")
}
.overlay {
if appState.isLocked {
lockScreen
}
.tag(Tab.home)
NavigationStack {
PinView()
}
.tabItem {
Label("Pin", systemImage: "pin.fill")
}
.tag(Tab.pin)
NavigationStack {
ComposeListView()
}
.tabItem {
Label("Compose", systemImage: "square.and.pencil")
}
.tag(Tab.compose)
NavigationStack {
MoreView()
}
.tabItem {
Label("More", systemImage: "ellipsis.circle.fill")
}
.tag(Tab.more)
}
}
private var iPhoneLayout: some View {
TabView(selection: $selectedTab) {
NavigationStack { HomeView() }
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(Tab.home)
NavigationStack { PinView() }
.tabItem { Label("Pin", systemImage: "pin.fill") }
.tag(Tab.pin)
NavigationStack { ComposeListView() }
.tabItem { Label("Compose", systemImage: "square.and.pencil") }
.tag(Tab.compose)
NavigationStack { MoreView() }
.tabItem { Label("More", systemImage: "ellipsis.circle.fill") }
.tag(Tab.more)
}
}
private var iPadLayout: some View {
NavigationSplitView {
iPadSidebar
} detail: {
iPadDetail
}
}
private var iPadSidebar: some View {
VStack(spacing: 4) {
ForEach(Tab.allCases, id: \.self) { tab in
iPadSidebarButton(tab: tab)
}
Spacer()
}
.padding(.top, 8)
.navigationTitle("Proxy")
}
private func iPadSidebarButton(tab: Tab) -> some View {
Button {
selectedTab = tab
} label: {
Label(tab.rawValue, systemImage: tab.icon)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
selectedTab == tab ? Color.accentColor.opacity(0.12) : Color.clear,
in: RoundedRectangle(cornerRadius: 10)
)
.foregroundStyle(selectedTab == tab ? Color.accentColor : Color.primary)
}
.buttonStyle(.plain)
.padding(.horizontal, 12)
}
@ViewBuilder
private var iPadDetail: some View {
NavigationStack {
switch selectedTab {
case .home: HomeView()
case .pin: PinView()
case .compose: ComposeListView()
case .more: MoreView()
}
}
}
private var lockScreen: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: "lock.fill")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Unlock Proxy")
.font(.title2.weight(.semibold))
Button {
appState.authenticate()
} label: {
Text("Unlock")
.font(.headline)
.frame(maxWidth: 300)
.padding()
.background(.blue, in: RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.white)
}
}
}
.onAppear { appState.authenticate() }
}
}

View File

@@ -23,7 +23,7 @@
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict/>
@@ -31,5 +31,12 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

View File

@@ -1,18 +1,55 @@
import NetworkExtension
import ProxyCore
import os
private let log = Logger(subsystem: "com.treyt.proxyapp", category: "tunnel")
class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
private var proxyServer: ProxyServer?
private let runtimeStatusRepo = RuntimeStatusRepository()
override func startTunnel(options: [String: NSObject]? = nil) async throws {
// Start the local proxy server
log.info("========== TUNNEL STARTING ==========")
CertificateManager.shared.reloadSharedCA()
runtimeStatusRepo.update {
$0.tunnelState = ProxyRuntimeState.starting.rawValue
$0.proxyHost = nil
$0.proxyPort = nil
$0.caFingerprint = CertificateManager.shared.caFingerprint
$0.lastProxyError = nil
$0.lastConnectError = nil
$0.lastMITMError = nil
$0.lastExtensionStartAt = Date().timeIntervalSince1970
}
let server = ProxyServer()
try await server.start()
proxyServer = server
do {
try await server.start()
proxyServer = server
log.info("ProxyServer started on \(ProxyConstants.proxyHost):\(ProxyConstants.proxyPort)")
} catch {
log.error("ProxyServer FAILED to start: \(error.localizedDescription)")
runtimeStatusRepo.update {
$0.tunnelState = ProxyRuntimeState.failed.rawValue
$0.lastProxyError = error.localizedDescription
$0.caFingerprint = CertificateManager.shared.caFingerprint
}
throw error
}
// Configure tunnel to redirect HTTP/HTTPS to our local proxy
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "192.0.2.1")
// Assign a dummy IP to the tunnel interface.
// This is required for NEPacketTunnelProvider to function.
let ipv4 = NEIPv4Settings(addresses: ["198.51.100.1"], subnetMasks: ["255.255.255.0"])
// Do NOT add includedRoutes we don't want to route IP packets through the tunnel.
// Only the proxy settings below handle traffic redirection for HTTP/HTTPS.
ipv4.includedRoutes = []
ipv4.excludedRoutes = [NEIPv4Route.default()]
settings.ipv4Settings = ipv4
// HTTP/HTTPS proxy this is the actual interception mechanism.
// Apps using NSURLSession/WKWebView will route through our proxy.
let proxySettings = NEProxySettings()
proxySettings.httpServer = NEProxyServer(
address: ProxyConstants.proxyHost,
@@ -24,27 +61,66 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
)
proxySettings.httpEnabled = true
proxySettings.httpsEnabled = true
proxySettings.matchDomains = [""] // Match all domains
proxySettings.matchDomains = [""]
proxySettings.excludeSimpleHostnames = true
settings.proxySettings = proxySettings
// DNS settings to ensure proper resolution
let dnsSettings = NEDNSSettings(servers: ["8.8.8.8", "8.8.4.4"])
dnsSettings.matchDomains = [""] // Match all
settings.dnsSettings = dnsSettings
log.info("Applying tunnel settings")
do {
try await setTunnelNetworkSettings(settings)
} catch {
runtimeStatusRepo.update {
$0.tunnelState = ProxyRuntimeState.failed.rawValue
$0.lastProxyError = "Tunnel settings: \(error.localizedDescription)"
}
await proxyServer?.stop()
proxyServer = nil
throw error
}
log.info("Tunnel settings applied successfully")
runtimeStatusRepo.update {
$0.tunnelState = ProxyRuntimeState.running.rawValue
$0.proxyHost = ProxyConstants.proxyHost
$0.proxyPort = ProxyConstants.proxyPort
$0.caFingerprint = CertificateManager.shared.caFingerprint
}
try await setTunnelNetworkSettings(settings)
// Start reading packets from the tunnel to prevent the queue from filling up.
// We don't need to process them just drain them. All real traffic goes
// through the HTTP proxy, not the IP tunnel.
startPacketDrain()
IPCManager.shared.post(.extensionStarted)
log.info("========== TUNNEL STARTED ==========")
}
override func stopTunnel(with reason: NEProviderStopReason) async {
log.info("========== TUNNEL STOPPING (reason: \(String(describing: reason))) ==========")
await proxyServer?.stop()
proxyServer = nil
runtimeStatusRepo.update {
$0.tunnelState = ProxyRuntimeState.stopped.rawValue
$0.proxyHost = nil
$0.proxyPort = nil
$0.lastExtensionStopAt = Date().timeIntervalSince1970
}
IPCManager.shared.post(.extensionStopped)
log.info("========== TUNNEL STOPPED ==========")
}
override func handleAppMessage(_ messageData: Data) async -> Data? {
// Handle IPC messages from the main app
log.debug("Received app message: \(messageData.count) bytes")
return nil
}
/// Continuously drain packets from the tunnel interface.
/// Since we exclude all IP routes, very few packets should arrive here.
/// But we must read them to prevent the tunnel from backing up.
private func startPacketDrain() {
packetFlow.readPackets { [weak self] packets, protocols in
// Discard packets all real traffic goes through the HTTP proxy
// Re-arm the read
self?.startPacketDrain()
}
}
}

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 63;
objects = {
/* Begin PBXBuildFile section */
@@ -14,35 +14,52 @@
078E6456816B5FD9C8F8693C /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = BE056A6D2498A5D37D3D654F /* NIOCore */; };
0AD1CD2C01C1818765918E79 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 4984B6EFE9C646250BBC622F /* GRDB */; };
1032DF442393FF744C5D6CB7 /* ComposeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBC2F9C7D32A3D7BA4AFDA9 /* ComposeListView.swift */; };
14B66ED5980D1C365A5F1492 /* FullResponseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2979CAC28594BA43FDD20CE /* FullResponseView.swift */; };
161B0B0900010F54252B2D3D /* FilterChipsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F02A950CF29A22F5EC9BD3B /* FilterChipsView.swift */; };
1773C53EAEA72B3B586F9881 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = 2DE391CB5A75FCC4AC9A7B64 /* NIOCore */; };
1B346F9DF3D2319ED2CE49BD /* AppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC29FEF9E5C7AE175FE49A9 /* AppSettingsView.swift */; };
20E68EF40E3BA8131B479E55 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE456728047AE1A81104E30 /* AppLockView.swift */; };
2311FADAB0FC08035CD1199A /* ProxyRuntimeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710EE134699388E09F8FEA04 /* ProxyRuntimeStatus.swift */; };
25DFC386BDBFC3B799E7ADCF /* DomainDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519A53ABF80C96A2F7BE48C0 /* DomainDetailView.swift */; };
261724F00B739E099F864897 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F7677A32280A2AB999BBC1DA /* NIOSSL */; };
268C1BC427C7CC81DCF6C7C8 /* KeyValueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2D5D80F2ABE544DDA4D672F /* KeyValueRow.swift */; };
273250EA21F8FC1AAF587B57 /* JSONTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B509AF6612E720D2D2C907 /* JSONTreeView.swift */; };
28E89C9BCF2C36DA2A513708 /* SelectableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3627CA99CA08EDB8F960BF /* SelectableTextView.swift */; };
2D294CFFDB2FF485FDDF338E /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE284C06437A69DA262860D /* AdvancedSettingsView.swift */; };
34E1EA5C2AA423CB092D99B7 /* IPCManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4506DB95E7CB1AD63D7BDBFD /* IPCManager.swift */; };
36BA84C1E610292F4DC98D1A /* SSLProxyingListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D18045C7039E4D081D2E0FB /* SSLProxyingListView.swift */; };
3CFD7D055BAB97B819189496 /* SystemTrafficFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED7DD286592C95E02C2E5CD /* SystemTrafficFilter.swift */; };
3E0939BAB9A087647A8943A2 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB466F4510A96A63A4D28BB2 /* HomeView.swift */; };
41E9BEEBA72730B7D9B8DDA6 /* MITMHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B122B086EE413B9FFA987D /* MITMHandler.swift */; };
44D7306C16BF67B232AE93A7 /* HeaderEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E48DB7CB700107069A0B6E7 /* HeaderEditorSheet.swift */; };
4556109D775B65AC4D596E67 /* BreakpointRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E3E2A57818A9523AE4909C /* BreakpointRulesView.swift */; };
47FCBF7C704629E6760E1B0C /* RequestDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 637C568F583A70F1B0F951AC /* RequestDetailView.swift */; };
488299CE2605314EE0C949AA /* AppGroupPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0F682D15A388E25E266F9 /* AppGroupPaths.swift */; };
4C1A9246FCBC2F86E0D33E10 /* ConnectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C4B5CB3F4FD77839836ED4 /* ConnectHandler.swift */; };
56C49856550867A6DD6360A2 /* DNSSpoofRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8A7A619BD4FEF8FE1299DDD /* DNSSpoofRule.swift */; };
575E76B3A4C1FF71DCFA2A09 /* RulesEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C88E06D884049204F768A14 /* RulesEngine.swift */; };
580734EA8A5E30B56AAD592C /* NIOHTTP1 in Frameworks */ = {isa = PBXBuildFile; productRef = 2C85D26D13198732391DB72A /* NIOHTTP1 */; };
58AEADB31F9ACB5A0B918A97 /* RuntimeStatusRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CB8BE90C6FF4EE6F23629 /* RuntimeStatusRepository.swift */; };
59C38D09525034A6E4D4DDBE /* ComposeEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173FF6F938E59C6FD5733FED /* ComposeEditorView.swift */; };
5B66F70CBCE439C4E1A5D108 /* ProxyServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F864AD4A39C9F503DE3F13 /* ProxyServer.swift */; };
5CBD25190C3F1AED2CCD821A /* CapturedTraffic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5235D9F2226096BF7BCCB45B /* CapturedTraffic.swift */; };
5CCD73D04194E548C72A9482 /* MapLocalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B4BCD97141A64AFC5E679A /* MapLocalView.swift */; };
60A7F6D2ED7CAFE06BDA05AB /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 8FADF83FADCE3702AFCC45C9 /* NIOPosix */; };
67A1B4E0EF5F307638959DD8 /* MoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27159F89C452CEF6FFE21FC9 /* MoreView.swift */; };
6AD008CC5844A43CF575C1C1 /* QueryEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D129A31ADDFEA3A09C64EBC6 /* QueryEditorSheet.swift */; };
6F9A607918651BB36266193A /* WildcardMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E026D97293D56740403666A3 /* WildcardMatcher.swift */; };
7034A0C2F5A8CDC56360D73A /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097285BB4EF5D3115F7F09BF /* PacketTunnelProvider.swift */; };
71D9DEC1148AED65248D4C81 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 9B2A586748D557D26CE9C75D /* NIOSSL */; };
73197B515ABE80AB74BC232A /* MapLocalRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEBD24F668D1AA572F4A669 /* MapLocalRule.swift */; };
771183F6D74FBD03BC8DC686 /* NotificationThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB2EFF2D0A3A7DCB9007021 /* NotificationThrottle.swift */; };
777697FE6E08DFBC5CCCFDA9 /* PinnedDomainRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362D8C1620E15594C41D214E /* PinnedDomainRepository.swift */; };
79FE52B43CBAF0F42EAA5926 /* DomainGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2704184BD5C72B01D95A6BD8 /* DomainGroup.swift */; };
80B683DD5BDBF9C1F6A512E6 /* BlockListEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F228CD8526A2D7CD3F283A4 /* BlockListEntry.swift */; };
8258F5ED1BD2A12C52FED4EE /* ProxyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67F12E77EB9B01FFC29EE452 /* ProxyApp.swift */; };
8B71F895F74E16DA92D557DE /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 425205F9256BA5CD3D743B30 /* GRDB */; };
91A5B9A95AFF29C206B8B495 /* HexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872AA1A5DF7FA7D3D99C810E /* HexView.swift */; };
9368026FF53296BDD315D114 /* NIOExtras in Frameworks */ = {isa = PBXBuildFile; productRef = E3702F97C3DF37F2C4BEE30C /* NIOExtras */; };
99B97DA715D9B1B3E9FD12A9 /* NIOHTTP1 in Frameworks */ = {isa = PBXBuildFile; productRef = 74FAB262A4E71F9B9DF02DE5 /* NIOHTTP1 */; };
9A5AD5BB0DA413AF852084EF /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 302E511C58383FFA46C46C1A /* X509 */; };
9A91E027039087C8E07F224B /* CURLImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE5A751E8F7842716D8D9C5 /* CURLImportView.swift */; };
9D2AF127D52466CC1DF030C7 /* RulesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074C4C0E9529236CB373091C /* RulesRepository.swift */; };
@@ -53,7 +70,10 @@
AB7825AE02AFC8C30B87CB6D /* CertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E52853A3CF25765660F938 /* CertificateManager.swift */; };
AB808FC6FBA5DB69260AD642 /* PacketTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FE274B16256054C197609357 /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AD0314CCE960088687E23B9C /* DNSSpoofingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE75F0F15AA8A6A47422D5 /* DNSSpoofingView.swift */; };
AE4846E36ACE9F748967FEE7 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8F906D014F34C26B78E90B76 /* NIOCore */; };
AE4AEED1C775E143C8E364AB /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23D9B9787A45AF89ED0ACD1 /* StatusBadge.swift */; };
AE6439B7C988FC159CBFBC11 /* PinnedDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFF88D20F555DFB1BCD079C /* PinnedDomain.swift */; };
AF58844E5F07109BAF1A9FBA /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = D872B7228838C504D217B061 /* X509 */; };
B1ED1B3C0D80C2D0DCD48EBF /* MethodBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F71C43C1B985E45828AF13 /* MethodBadge.swift */; };
B703C5C4402C3821B94FED7F /* ComposeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777EEE35DFFD4F3411B3FCE3 /* ComposeRequest.swift */; };
B7E0A7EDA5ECD495D8AE1B1F /* TrafficRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F3E254F52DA9F9345B69FF /* TrafficRepository.swift */; };
@@ -63,15 +83,22 @@
BF30AC37B886A0CBC171CC0C /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 7ACEE8638C00CA74E27095D3 /* NIOPosix */; };
C07BCF735C7ED4DDC54CED7A /* GlueHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0874F5DFE7C72F953E6FC41 /* GlueHandler.swift */; };
C659BA93C402A36E3E108706 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B1AA944B076F281D2926BA /* ContentView.swift */; };
CA9312C98F79DD310CF97193 /* NIOExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBB9D83DA8C2BD34605DD8F /* NIOExtras */; };
D5F263D81C5381B39E3D92D9 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = B23BF84F58802C2FD4181AD2 /* GRDB */; };
D7D0DED251BC60F65CA595BC /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF0747C1BE12B4259866382 /* EmptyStateView.swift */; };
DEC2257E2B9263AA09F9EF2C /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 2C7F42CDC12F03C0FDD6539A /* NIOSSL */; };
E36D7B872F13A4CB0DCBBC44 /* HTTPBodyDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B395BEF3848DE00AAC48304C /* HTTPBodyDecoder.swift */; };
E51E4D4ABE03C86CAC2A3803 /* ConfigurationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76714FEFFCCDE8C3A78AEEFF /* ConfigurationRepository.swift */; };
E7497BB12483B0928783579F /* CertificateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C130891674901F95EFF6C10 /* CertificateView.swift */; };
E7AA10E880398BCC7E2642EC /* CURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38485B7FCE595D168C1C846 /* CURLParser.swift */; };
E9D761ACA5FF7955B5BDCBA4 /* NIOExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 11DB0A1E449F691613E238DE /* NIOExtras */; };
EABEAF43B23210EAC0023B33 /* PinnedDomainsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DB54F1CF988A0D2204F6AC /* PinnedDomainsView.swift */; };
EC3F750A051B55A202E26571 /* ProxyConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CAEAE036BBFFA8715EACFD /* ProxyConfiguration.swift */; };
F32E1472628FC2FD12CC833C /* BreakpointRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F4B5CB6D7868661D6846B1 /* BreakpointRule.swift */; };
F38240D6A5DF02C96345910F /* ComposeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BDB66B8FF54D24E75D9DA /* ComposeRepository.swift */; };
F5EA05A3520645ABD5FD6266 /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 2B0A2F194C23CE100A7B16B6 /* NIOPosix */; };
F7518FEFC1BD4D9C494E77E6 /* ProxyLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4DA7994BF0161D6DCD4BEC /* ProxyLogger.swift */; };
FA65F94CE9CC44A2E3D5BABC /* Crypto in Frameworks */ = {isa = PBXBuildFile; productRef = 13768686350369739100F57B /* Crypto */; };
FD565FCB905CB38529F4AF19 /* HTTPCaptureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F1AF90F2B1B2004D34587E /* HTTPCaptureHandler.swift */; };
FF47A342C5D326B1CDFBA554 /* PinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C1921178726BCFB9689CD40 /* PinView.swift */; };
FF490C33F07B362A6E3A04C9 /* Crypto in Frameworks */ = {isa = PBXBuildFile; productRef = ED925608F42DF4F167F4AD6A /* Crypto */; };
@@ -117,19 +144,28 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0235538D05FCD58BB88004AB /* ProxyApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = ProxyApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
0235538D05FCD58BB88004AB /* ProxyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ProxyApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
074C4C0E9529236CB373091C /* RulesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesRepository.swift; sourceTree = "<group>"; };
075A09C6B272A53485322E22 /* TrafficRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficRowView.swift; sourceTree = "<group>"; };
097285BB4EF5D3115F7F09BF /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
11F4B5CB6D7868661D6846B1 /* BreakpointRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreakpointRule.swift; sourceTree = "<group>"; };
13CAEAE036BBFFA8715EACFD /* ProxyConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfiguration.swift; sourceTree = "<group>"; };
15B1AA944B076F281D2926BA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
173FF6F938E59C6FD5733FED /* ComposeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEditorView.swift; sourceTree = "<group>"; };
1C130891674901F95EFF6C10 /* CertificateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateView.swift; sourceTree = "<group>"; };
1E48DB7CB700107069A0B6E7 /* HeaderEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderEditorSheet.swift; sourceTree = "<group>"; };
235CB8BE90C6FF4EE6F23629 /* RuntimeStatusRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeStatusRepository.swift; sourceTree = "<group>"; };
2704184BD5C72B01D95A6BD8 /* DomainGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainGroup.swift; sourceTree = "<group>"; };
27159F89C452CEF6FFE21FC9 /* MoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreView.swift; sourceTree = "<group>"; };
28FE75F0F15AA8A6A47422D5 /* DNSSpoofingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNSSpoofingView.swift; sourceTree = "<group>"; };
2B3627CA99CA08EDB8F960BF /* SelectableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTextView.swift; sourceTree = "<group>"; };
2DE0F682D15A388E25E266F9 /* AppGroupPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupPaths.swift; sourceTree = "<group>"; };
2F228CD8526A2D7CD3F283A4 /* BlockListEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListEntry.swift; sourceTree = "<group>"; };
362D8C1620E15594C41D214E /* PinnedDomainRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedDomainRepository.swift; sourceTree = "<group>"; };
36B509AF6612E720D2D2C907 /* JSONTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONTreeView.swift; sourceTree = "<group>"; };
4506DB95E7CB1AD63D7BDBFD /* IPCManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCManager.swift; sourceTree = "<group>"; };
4C88E06D884049204F768A14 /* RulesEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesEngine.swift; sourceTree = "<group>"; };
4DFF88D20F555DFB1BCD079C /* PinnedDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedDomain.swift; sourceTree = "<group>"; };
519A53ABF80C96A2F7BE48C0 /* DomainDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDetailView.swift; sourceTree = "<group>"; };
5235D9F2226096BF7BCCB45B /* CapturedTraffic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturedTraffic.swift; sourceTree = "<group>"; };
52F71C43C1B985E45828AF13 /* MethodBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodBadge.swift; sourceTree = "<group>"; };
@@ -141,10 +177,15 @@
6DE284C06437A69DA262860D /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = "<group>"; };
6F02A950CF29A22F5EC9BD3B /* FilterChipsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterChipsView.swift; sourceTree = "<group>"; };
6FC29FEF9E5C7AE175FE49A9 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = "<group>"; };
710EE134699388E09F8FEA04 /* ProxyRuntimeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyRuntimeStatus.swift; sourceTree = "<group>"; };
72C4B5CB3F4FD77839836ED4 /* ConnectHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectHandler.swift; sourceTree = "<group>"; };
76714FEFFCCDE8C3A78AEEFF /* ConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationRepository.swift; sourceTree = "<group>"; };
777EEE35DFFD4F3411B3FCE3 /* ComposeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRequest.swift; sourceTree = "<group>"; };
7AF0747C1BE12B4259866382 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
86F3E254F52DA9F9345B69FF /* TrafficRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficRepository.swift; sourceTree = "<group>"; };
872AA1A5DF7FA7D3D99C810E /* HexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HexView.swift; sourceTree = "<group>"; };
88DB54F1CF988A0D2204F6AC /* PinnedDomainsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedDomainsView.swift; sourceTree = "<group>"; };
8BB2EFF2D0A3A7DCB9007021 /* NotificationThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationThrottle.swift; sourceTree = "<group>"; };
8C1921178726BCFB9689CD40 /* PinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinView.swift; sourceTree = "<group>"; };
8E977B7F6D1876286BD79D75 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = "<group>"; };
A0874F5DFE7C72F953E6FC41 /* GlueHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlueHandler.swift; sourceTree = "<group>"; };
@@ -154,14 +195,19 @@
AB466F4510A96A63A4D28BB2 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B2FD8501FF5549114D704AED /* PacketTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PacketTunnel.entitlements; sourceTree = "<group>"; };
B38485B7FCE595D168C1C846 /* CURLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CURLParser.swift; sourceTree = "<group>"; };
B395BEF3848DE00AAC48304C /* HTTPBodyDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPBodyDecoder.swift; sourceTree = "<group>"; };
C79DF53F3FB49209C5D4C072 /* ToggleHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleHeaderView.swift; sourceTree = "<group>"; };
D129A31ADDFEA3A09C64EBC6 /* QueryEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEditorSheet.swift; sourceTree = "<group>"; };
D2BB3537EE02470EC6CF8856 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
D69E8CA968380EEC4CEEAC58 /* SSLProxyingEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSLProxyingEntry.swift; sourceTree = "<group>"; };
DAC65A5C14A2D9474DC55BAD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
DAEBD24F668D1AA572F4A669 /* MapLocalRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLocalRule.swift; sourceTree = "<group>"; };
DD4DA7994BF0161D6DCD4BEC /* ProxyLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyLogger.swift; sourceTree = "<group>"; };
DD774DF1EE6D16C71CDBDE39 /* ProxyApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ProxyApp.entitlements; sourceTree = "<group>"; };
DED7DD286592C95E02C2E5CD /* SystemTrafficFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTrafficFilter.swift; sourceTree = "<group>"; };
DEE5A751E8F7842716D8D9C5 /* CURLImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CURLImportView.swift; sourceTree = "<group>"; };
E026D97293D56740403666A3 /* WildcardMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WildcardMatcher.swift; sourceTree = "<group>"; };
E2979CAC28594BA43FDD20CE /* FullResponseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullResponseView.swift; sourceTree = "<group>"; };
E7B122B086EE413B9FFA987D /* MITMHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MITMHandler.swift; sourceTree = "<group>"; };
E8A7A619BD4FEF8FE1299DDD /* DNSSpoofRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNSSpoofRule.swift; sourceTree = "<group>"; };
E8F864AD4A39C9F503DE3F13 /* ProxyServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyServer.swift; sourceTree = "<group>"; };
@@ -173,7 +219,8 @@
F6B4BCD97141A64AFC5E679A /* MapLocalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLocalView.swift; sourceTree = "<group>"; };
FAF1EADE5958349770CE6D69 /* SetupGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupGuideView.swift; sourceTree = "<group>"; };
FD6BDB66B8FF54D24E75D9DA /* ComposeRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepository.swift; sourceTree = "<group>"; };
FE274B16256054C197609357 /* PacketTunnel.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
FDE456728047AE1A81104E30 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = "<group>"; };
FE274B16256054C197609357 /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
FFBBFDC8A74655F6BABEC8F2 /* ProxyCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ProxyCore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -199,6 +246,13 @@
files = (
044C2A568C13E889BC2AE30C /* ProxyCore.framework in Frameworks */,
8B71F895F74E16DA92D557DE /* GRDB in Frameworks */,
AE4846E36ACE9F748967FEE7 /* NIOCore in Frameworks */,
60A7F6D2ED7CAFE06BDA05AB /* NIOPosix in Frameworks */,
71D9DEC1148AED65248D4C81 /* NIOSSL in Frameworks */,
99B97DA715D9B1B3E9FD12A9 /* NIOHTTP1 in Frameworks */,
CA9312C98F79DD310CF97193 /* NIOExtras in Frameworks */,
AF58844E5F07109BAF1A9FBA /* X509 in Frameworks */,
FA65F94CE9CC44A2E3D5BABC /* Crypto in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -224,8 +278,11 @@
children = (
7AF0747C1BE12B4259866382 /* EmptyStateView.swift */,
6F02A950CF29A22F5EC9BD3B /* FilterChipsView.swift */,
872AA1A5DF7FA7D3D99C810E /* HexView.swift */,
36B509AF6612E720D2D2C907 /* JSONTreeView.swift */,
A2D5D80F2ABE544DDA4D672F /* KeyValueRow.swift */,
52F71C43C1B985E45828AF13 /* MethodBadge.swift */,
2B3627CA99CA08EDB8F960BF /* SelectableTextView.swift */,
F23D9B9787A45AF89ED0ACD1 /* StatusBadge.swift */,
C79DF53F3FB49209C5D4C072 /* ToggleHeaderView.swift */,
);
@@ -236,6 +293,7 @@
isa = PBXGroup;
children = (
6DE284C06437A69DA262860D /* AdvancedSettingsView.swift */,
FDE456728047AE1A81104E30 /* AppLockView.swift */,
6FC29FEF9E5C7AE175FE49A9 /* AppSettingsView.swift */,
A9C7DCE312827E925D474D1C /* BlockListView.swift */,
F2E3E2A57818A9523AE4909C /* BreakpointRulesView.swift */,
@@ -244,6 +302,7 @@
F6B4BCD97141A64AFC5E679A /* MapLocalView.swift */,
27159F89C452CEF6FFE21FC9 /* MoreView.swift */,
6897481A0E0AFC428D7B0760 /* NoCachingView.swift */,
88DB54F1CF988A0D2204F6AC /* PinnedDomainsView.swift */,
FAF1EADE5958349770CE6D69 /* SetupGuideView.swift */,
5D18045C7039E4D081D2E0FB /* SSLProxyingListView.swift */,
);
@@ -256,6 +315,9 @@
173FF6F938E59C6FD5733FED /* ComposeEditorView.swift */,
ECBC2F9C7D32A3D7BA4AFDA9 /* ComposeListView.swift */,
DEE5A751E8F7842716D8D9C5 /* CURLImportView.swift */,
E2979CAC28594BA43FDD20CE /* FullResponseView.swift */,
1E48DB7CB700107069A0B6E7 /* HeaderEditorSheet.swift */,
D129A31ADDFEA3A09C64EBC6 /* QueryEditorSheet.swift */,
);
path = Compose;
sourceTree = "<group>";
@@ -310,6 +372,9 @@
E8A7A619BD4FEF8FE1299DDD /* DNSSpoofRule.swift */,
2704184BD5C72B01D95A6BD8 /* DomainGroup.swift */,
DAEBD24F668D1AA572F4A669 /* MapLocalRule.swift */,
4DFF88D20F555DFB1BCD079C /* PinnedDomain.swift */,
13CAEAE036BBFFA8715EACFD /* ProxyConfiguration.swift */,
710EE134699388E09F8FEA04 /* ProxyRuntimeStatus.swift */,
D69E8CA968380EEC4CEEAC58 /* SSLProxyingEntry.swift */,
);
path = Models;
@@ -324,6 +389,7 @@
A1F1AF90F2B1B2004D34587E /* HTTPCaptureHandler.swift */,
E7B122B086EE413B9FFA987D /* MITMHandler.swift */,
E8F864AD4A39C9F503DE3F13 /* ProxyServer.swift */,
4C88E06D884049204F768A14 /* RulesEngine.swift */,
);
path = ProxyEngine;
sourceTree = "<group>";
@@ -362,7 +428,10 @@
isa = PBXGroup;
children = (
FD6BDB66B8FF54D24E75D9DA /* ComposeRepository.swift */,
76714FEFFCCDE8C3A78AEEFF /* ConfigurationRepository.swift */,
362D8C1620E15594C41D214E /* PinnedDomainRepository.swift */,
074C4C0E9529236CB373091C /* RulesRepository.swift */,
235CB8BE90C6FF4EE6F23629 /* RuntimeStatusRepository.swift */,
86F3E254F52DA9F9345B69FF /* TrafficRepository.swift */,
);
path = Repositories;
@@ -387,9 +456,14 @@
DB5183BCE0B690BE0937F924 /* Shared */ = {
isa = PBXGroup;
children = (
2DE0F682D15A388E25E266F9 /* AppGroupPaths.swift */,
F1E030EAC76D5AD8FFC4CE41 /* Constants.swift */,
B38485B7FCE595D168C1C846 /* CURLParser.swift */,
B395BEF3848DE00AAC48304C /* HTTPBodyDecoder.swift */,
4506DB95E7CB1AD63D7BDBFD /* IPCManager.swift */,
8BB2EFF2D0A3A7DCB9007021 /* NotificationThrottle.swift */,
DD4DA7994BF0161D6DCD4BEC /* ProxyLogger.swift */,
DED7DD286592C95E02C2E5CD /* SystemTrafficFilter.swift */,
E026D97293D56740403666A3 /* WildcardMatcher.swift */,
);
path = Shared;
@@ -448,6 +522,13 @@
name = ProxyApp;
packageProductDependencies = (
425205F9256BA5CD3D743B30 /* GRDB */,
8F906D014F34C26B78E90B76 /* NIOCore */,
8FADF83FADCE3702AFCC45C9 /* NIOPosix */,
9B2A586748D557D26CE9C75D /* NIOSSL */,
74FAB262A4E71F9B9DF02DE5 /* NIOHTTP1 */,
9FBB9D83DA8C2BD34605DD8F /* NIOExtras */,
D872B7228838C504D217B061 /* X509 */,
13768686350369739100F57B /* Crypto */,
);
productName = ProxyApp;
productReference = 0235538D05FCD58BB88004AB /* ProxyApp.app */;
@@ -514,7 +595,6 @@
LastUpgradeCheck = 2630;
TargetAttributes = {
2300A5AA6E7BFEFBD7C0D1ED = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
70F9CA505ABDF65D6F84D6DD = {
@@ -522,7 +602,6 @@
ProvisioningStyle = Automatic;
};
C707902F2AC99A166223934F = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
};
@@ -545,7 +624,6 @@
2FE2868EB18FE0C5308D2320 /* XCRemoteSwiftPackageReference "swift-nio-extras" */,
22C1FB1D2F618C70B47E42CD /* XCRemoteSwiftPackageReference "swift-nio-ssl" */,
);
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
@@ -569,6 +647,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
488299CE2605314EE0C949AA /* AppGroupPaths.swift in Sources */,
80B683DD5BDBF9C1F6A512E6 /* BlockListEntry.swift in Sources */,
F32E1472628FC2FD12CC833C /* BreakpointRule.swift in Sources */,
E7AA10E880398BCC7E2642EC /* CURLParser.swift in Sources */,
@@ -576,19 +655,30 @@
AB7825AE02AFC8C30B87CB6D /* CertificateManager.swift in Sources */,
F38240D6A5DF02C96345910F /* ComposeRepository.swift in Sources */,
B703C5C4402C3821B94FED7F /* ComposeRequest.swift in Sources */,
E51E4D4ABE03C86CAC2A3803 /* ConfigurationRepository.swift in Sources */,
4C1A9246FCBC2F86E0D33E10 /* ConnectHandler.swift in Sources */,
020C7E9BFD5FB376A5B5AB92 /* Constants.swift in Sources */,
56C49856550867A6DD6360A2 /* DNSSpoofRule.swift in Sources */,
BBFB2C1995747DBD3B1A322B /* DatabaseManager.swift in Sources */,
79FE52B43CBAF0F42EAA5926 /* DomainGroup.swift in Sources */,
C07BCF735C7ED4DDC54CED7A /* GlueHandler.swift in Sources */,
E36D7B872F13A4CB0DCBBC44 /* HTTPBodyDecoder.swift in Sources */,
FD565FCB905CB38529F4AF19 /* HTTPCaptureHandler.swift in Sources */,
34E1EA5C2AA423CB092D99B7 /* IPCManager.swift in Sources */,
41E9BEEBA72730B7D9B8DDA6 /* MITMHandler.swift in Sources */,
73197B515ABE80AB74BC232A /* MapLocalRule.swift in Sources */,
771183F6D74FBD03BC8DC686 /* NotificationThrottle.swift in Sources */,
AE6439B7C988FC159CBFBC11 /* PinnedDomain.swift in Sources */,
777697FE6E08DFBC5CCCFDA9 /* PinnedDomainRepository.swift in Sources */,
EC3F750A051B55A202E26571 /* ProxyConfiguration.swift in Sources */,
F7518FEFC1BD4D9C494E77E6 /* ProxyLogger.swift in Sources */,
2311FADAB0FC08035CD1199A /* ProxyRuntimeStatus.swift in Sources */,
5B66F70CBCE439C4E1A5D108 /* ProxyServer.swift in Sources */,
575E76B3A4C1FF71DCFA2A09 /* RulesEngine.swift in Sources */,
9D2AF127D52466CC1DF030C7 /* RulesRepository.swift in Sources */,
58AEADB31F9ACB5A0B918A97 /* RuntimeStatusRepository.swift in Sources */,
9E7B90C28927E808B6EE8275 /* SSLProxyingEntry.swift in Sources */,
3CFD7D055BAB97B819189496 /* SystemTrafficFilter.swift in Sources */,
B7E0A7EDA5ECD495D8AE1B1F /* TrafficRepository.swift in Sources */,
6F9A607918651BB36266193A /* WildcardMatcher.swift in Sources */,
);
@@ -599,6 +689,7 @@
buildActionMask = 2147483647;
files = (
2D294CFFDB2FF485FDDF338E /* AdvancedSettingsView.swift in Sources */,
20E68EF40E3BA8131B479E55 /* AppLockView.swift in Sources */,
1B346F9DF3D2319ED2CE49BD /* AppSettingsView.swift in Sources */,
A946C1681FB46D11AA6912B0 /* AppState.swift in Sources */,
AA23DE16F97A24279BBC6C1E /* BlockListView.swift in Sources */,
@@ -612,16 +703,23 @@
25DFC386BDBFC3B799E7ADCF /* DomainDetailView.swift in Sources */,
D7D0DED251BC60F65CA595BC /* EmptyStateView.swift in Sources */,
161B0B0900010F54252B2D3D /* FilterChipsView.swift in Sources */,
14B66ED5980D1C365A5F1492 /* FullResponseView.swift in Sources */,
44D7306C16BF67B232AE93A7 /* HeaderEditorSheet.swift in Sources */,
91A5B9A95AFF29C206B8B495 /* HexView.swift in Sources */,
3E0939BAB9A087647A8943A2 /* HomeView.swift in Sources */,
273250EA21F8FC1AAF587B57 /* JSONTreeView.swift in Sources */,
268C1BC427C7CC81DCF6C7C8 /* KeyValueRow.swift in Sources */,
5CCD73D04194E548C72A9482 /* MapLocalView.swift in Sources */,
B1ED1B3C0D80C2D0DCD48EBF /* MethodBadge.swift in Sources */,
67A1B4E0EF5F307638959DD8 /* MoreView.swift in Sources */,
BB1A565DEF2504F7B1031366 /* NoCachingView.swift in Sources */,
FF47A342C5D326B1CDFBA554 /* PinView.swift in Sources */,
EABEAF43B23210EAC0023B33 /* PinnedDomainsView.swift in Sources */,
8258F5ED1BD2A12C52FED4EE /* ProxyApp.swift in Sources */,
6AD008CC5844A43CF575C1C1 /* QueryEditorSheet.swift in Sources */,
47FCBF7C704629E6760E1B0C /* RequestDetailView.swift in Sources */,
36BA84C1E610292F4DC98D1A /* SSLProxyingListView.swift in Sources */,
28E89C9BCF2C36DA2A513708 /* SelectableTextView.swift in Sources */,
FF970F984CE302E0099E94B1 /* SetupGuideView.swift in Sources */,
AE4AEED1C775E143C8E364AB /* StatusBadge.swift in Sources */,
0472CBEB3A89C801E4057FBA /* ToggleHeaderView.swift in Sources */,
@@ -654,6 +752,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PacketTunnel/Entitlements/PacketTunnel.entitlements;
DEVELOPMENT_TEAM = V3PF3M6B6U;
INFOPLIST_FILE = PacketTunnel/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -739,6 +838,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/Entitlements/ProxyApp.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = V3PF3M6B6U;
INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -760,11 +860,14 @@
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ProxyCore/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MACH_O_TYPE = staticlib;
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.proxyapp.ProxyCore;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
@@ -783,11 +886,14 @@
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ProxyCore/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MACH_O_TYPE = staticlib;
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.proxyapp.ProxyCore;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
@@ -801,6 +907,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PacketTunnel/Entitlements/PacketTunnel.entitlements;
DEVELOPMENT_TEAM = V3PF3M6B6U;
INFOPLIST_FILE = PacketTunnel/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -879,6 +986,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/Entitlements/ProxyApp.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = V3PF3M6B6U;
INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -989,6 +1097,11 @@
package = 2FE2868EB18FE0C5308D2320 /* XCRemoteSwiftPackageReference "swift-nio-extras" */;
productName = NIOExtras;
};
13768686350369739100F57B /* Crypto */ = {
isa = XCSwiftPackageProductDependency;
package = BF38BBC56E412F25947ECED0 /* XCRemoteSwiftPackageReference "swift-crypto" */;
productName = Crypto;
};
2B0A2F194C23CE100A7B16B6 /* NIOPosix */ = {
isa = XCSwiftPackageProductDependency;
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
@@ -1024,6 +1137,11 @@
package = 66704B6AC3BDA168FF5DFD37 /* XCRemoteSwiftPackageReference "GRDB.swift" */;
productName = GRDB;
};
74FAB262A4E71F9B9DF02DE5 /* NIOHTTP1 */ = {
isa = XCSwiftPackageProductDependency;
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
productName = NIOHTTP1;
};
7ACEE8638C00CA74E27095D3 /* NIOPosix */ = {
isa = XCSwiftPackageProductDependency;
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
@@ -1034,6 +1152,26 @@
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
productName = NIOHTTP1;
};
8F906D014F34C26B78E90B76 /* NIOCore */ = {
isa = XCSwiftPackageProductDependency;
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
productName = NIOCore;
};
8FADF83FADCE3702AFCC45C9 /* NIOPosix */ = {
isa = XCSwiftPackageProductDependency;
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
productName = NIOPosix;
};
9B2A586748D557D26CE9C75D /* NIOSSL */ = {
isa = XCSwiftPackageProductDependency;
package = 22C1FB1D2F618C70B47E42CD /* XCRemoteSwiftPackageReference "swift-nio-ssl" */;
productName = NIOSSL;
};
9FBB9D83DA8C2BD34605DD8F /* NIOExtras */ = {
isa = XCSwiftPackageProductDependency;
package = 2FE2868EB18FE0C5308D2320 /* XCRemoteSwiftPackageReference "swift-nio-extras" */;
productName = NIOExtras;
};
B23BF84F58802C2FD4181AD2 /* GRDB */ = {
isa = XCSwiftPackageProductDependency;
package = 66704B6AC3BDA168FF5DFD37 /* XCRemoteSwiftPackageReference "GRDB.swift" */;
@@ -1044,6 +1182,11 @@
package = 8341BFADEB5EA31123791331 /* XCRemoteSwiftPackageReference "swift-nio" */;
productName = NIOCore;
};
D872B7228838C504D217B061 /* X509 */ = {
isa = XCSwiftPackageProductDependency;
package = 3E7691AA8929525CAC738FC5 /* XCRemoteSwiftPackageReference "swift-certificates" */;
productName = X509;
};
E3702F97C3DF37F2C4BEE30C /* NIOExtras */ = {
isa = XCSwiftPackageProductDependency;
package = 2FE2868EB18FE0C5308D2320 /* XCRemoteSwiftPackageReference "swift-nio-extras" */;

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C707902F2AC99A166223934F"
BuildableName = "PacketTunnel.appex"
BlueprintName = "PacketTunnel"
ReferencedContainer = "container:ProxyApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2300A5AA6E7BFEFBD7C0D1ED"
BuildableName = "ProxyApp.app"
BlueprintName = "ProxyApp"
ReferencedContainer = "container:ProxyApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "70F9CA505ABDF65D6F84D6DD"
BuildableName = "ProxyCore.framework"
BlueprintName = "ProxyCore"
ReferencedContainer = "container:ProxyApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2300A5AA6E7BFEFBD7C0D1ED"
BuildableName = "ProxyApp.app"
BlueprintName = "ProxyApp"
ReferencedContainer = "container:ProxyApp.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2300A5AA6E7BFEFBD7C0D1ED"
BuildableName = "ProxyApp.app"
BlueprintName = "ProxyApp"
ReferencedContainer = "container:ProxyApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2300A5AA6E7BFEFBD7C0D1ED"
BuildableName = "ProxyApp.app"
BlueprintName = "ProxyApp"
ReferencedContainer = "container:ProxyApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

22
ProxyCore/Info.plist Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@@ -1,14 +1,23 @@
import Foundation
import GRDB
import os
private let log = Logger(subsystem: "com.treyt.proxyapp", category: "db")
public final class DatabaseManager: Sendable {
public let dbPool: DatabasePool
public static let shared: DatabaseManager = {
let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.treyt.proxyapp")!
.appendingPathComponent("proxy.sqlite")
return try! DatabaseManager(path: url.path)
let url = AppGroupPaths.containerURL.appendingPathComponent("proxy.sqlite")
log.info("DB path: \(url.path)")
do {
let mgr = try DatabaseManager(path: url.path)
log.info("DatabaseManager initialized OK")
return mgr
} catch {
log.fault("DatabaseManager FATAL: \(error.localizedDescription)")
fatalError("[DatabaseManager] Failed to initialize database at \(url.path): \(error)")
}
}()
public init(path: String) throws {
@@ -129,6 +138,65 @@ public final class DatabaseManager: Sendable {
}
}
migrator.registerMigration("v2_create_proxy_configuration") { db in
try db.create(table: "proxy_configuration") { t in
t.column("id", .integer).primaryKey(onConflict: .replace)
t.column("sslProxyingEnabled", .boolean).notNull().defaults(to: false)
t.column("blockListEnabled", .boolean).notNull().defaults(to: false)
t.column("breakpointEnabled", .boolean).notNull().defaults(to: false)
t.column("noCachingEnabled", .boolean).notNull().defaults(to: false)
t.column("dnsSpoofingEnabled", .boolean).notNull().defaults(to: false)
t.column("hideSystemTraffic", .boolean).notNull().defaults(to: false)
t.column("updatedAt", .double).notNull()
}
try db.execute(
sql: """
INSERT INTO proxy_configuration (
id, sslProxyingEnabled, blockListEnabled, breakpointEnabled,
noCachingEnabled, dnsSpoofingEnabled, hideSystemTraffic, updatedAt
) VALUES (1, 0, 0, 0, 0, 0, 0, ?)
""",
arguments: [Date().timeIntervalSince1970]
)
}
migrator.registerMigration("v3_create_proxy_runtime_status") { db in
try db.create(table: "proxy_runtime_status") { t in
t.column("id", .integer).primaryKey(onConflict: .replace)
t.column("tunnelState", .text).notNull().defaults(to: ProxyRuntimeState.stopped.rawValue)
t.column("proxyHost", .text)
t.column("proxyPort", .integer)
t.column("caFingerprint", .text)
t.column("lastProxyError", .text)
t.column("lastMITMError", .text)
t.column("lastConnectError", .text)
t.column("lastSuccessfulMITMDomain", .text)
t.column("lastSuccessfulMITMAt", .double)
t.column("lastExtensionStartAt", .double)
t.column("lastExtensionStopAt", .double)
t.column("updatedAt", .double).notNull()
}
try db.execute(
sql: """
INSERT INTO proxy_runtime_status (
id, tunnelState, updatedAt
) VALUES (1, ?, ?)
""",
arguments: [ProxyRuntimeState.stopped.rawValue, Date().timeIntervalSince1970]
)
}
migrator.registerMigration("v4_create_pinned_domains") { db in
try db.create(table: "pinned_domains") { t in
t.autoIncrementedPrimaryKey("id")
t.column("domain", .text).notNull().unique()
t.column("reason", .text)
t.column("detectedAt", .double).notNull()
}
}
try migrator.migrate(dbPool)
}
}

View File

@@ -117,6 +117,26 @@ extension CapturedTraffic {
return dict
}
public func requestHeaderValue(named name: String) -> String? {
HTTPBodyDecoder.headerValue(named: name, in: decodedRequestHeaders)
}
public func responseHeaderValue(named name: String) -> String? {
HTTPBodyDecoder.headerValue(named: name, in: decodedResponseHeaders)
}
public var decodedResponseBodyData: Data? {
HTTPBodyDecoder.decodedBodyData(from: responseBody, headers: decodedResponseHeaders)
}
public var searchableResponseBodyText: String? {
HTTPBodyDecoder.searchableText(from: responseBody, headers: decodedResponseHeaders)
}
public var responseBodyDecodingHint: String {
HTTPBodyDecoder.decodingHint(for: responseBody, headers: decodedResponseHeaders)
}
public var decodedQueryParameters: [String: String] {
guard let data = queryParameters?.data(using: .utf8),
let dict = try? JSONDecoder().decode([String: String].self, from: data) else {

View File

@@ -0,0 +1,24 @@
import Foundation
import GRDB
/// A domain detected as using SSL pinning. MITM will automatically skip these
/// and fall back to passthrough mode.
public struct PinnedDomain: Codable, FetchableRecord, MutablePersistableRecord, Identifiable, Sendable {
public var id: Int64?
public var domain: String
public var reason: String?
public var detectedAt: Double
public static let databaseTableName = "pinned_domains"
public mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
public init(id: Int64? = nil, domain: String, reason: String? = nil, detectedAt: Double = Date().timeIntervalSince1970) {
self.id = id
self.domain = domain
self.reason = reason
self.detectedAt = detectedAt
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import GRDB
public struct ProxyConfiguration: Codable, FetchableRecord, MutablePersistableRecord, Sendable {
public static let databaseTableName = "proxy_configuration"
public var id: Int64
public var sslProxyingEnabled: Bool
public var blockListEnabled: Bool
public var breakpointEnabled: Bool
public var noCachingEnabled: Bool
public var dnsSpoofingEnabled: Bool
public var hideSystemTraffic: Bool
public var updatedAt: Double
public init(
id: Int64 = 1,
sslProxyingEnabled: Bool = false,
blockListEnabled: Bool = false,
breakpointEnabled: Bool = false,
noCachingEnabled: Bool = false,
dnsSpoofingEnabled: Bool = false,
hideSystemTraffic: Bool = false,
updatedAt: Double = Date().timeIntervalSince1970
) {
self.id = id
self.sslProxyingEnabled = sslProxyingEnabled
self.blockListEnabled = blockListEnabled
self.breakpointEnabled = breakpointEnabled
self.noCachingEnabled = noCachingEnabled
self.dnsSpoofingEnabled = dnsSpoofingEnabled
self.hideSystemTraffic = hideSystemTraffic
self.updatedAt = updatedAt
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
import GRDB
public enum ProxyRuntimeState: String, Codable, Sendable {
case stopped
case starting
case running
case failed
}
public struct ProxyRuntimeStatus: Codable, FetchableRecord, MutablePersistableRecord, Sendable {
public static let databaseTableName = "proxy_runtime_status"
public var id: Int64
public var tunnelState: String
public var proxyHost: String?
public var proxyPort: Int?
public var caFingerprint: String?
public var lastProxyError: String?
public var lastMITMError: String?
public var lastConnectError: String?
public var lastSuccessfulMITMDomain: String?
public var lastSuccessfulMITMAt: Double?
public var lastExtensionStartAt: Double?
public var lastExtensionStopAt: Double?
public var updatedAt: Double
public init(
id: Int64 = 1,
tunnelState: ProxyRuntimeState = .stopped,
proxyHost: String? = nil,
proxyPort: Int? = nil,
caFingerprint: String? = nil,
lastProxyError: String? = nil,
lastMITMError: String? = nil,
lastConnectError: String? = nil,
lastSuccessfulMITMDomain: String? = nil,
lastSuccessfulMITMAt: Double? = nil,
lastExtensionStartAt: Double? = nil,
lastExtensionStopAt: Double? = nil,
updatedAt: Double = Date().timeIntervalSince1970
) {
self.id = id
self.tunnelState = tunnelState.rawValue
self.proxyHost = proxyHost
self.proxyPort = proxyPort
self.caFingerprint = caFingerprint
self.lastProxyError = lastProxyError
self.lastMITMError = lastMITMError
self.lastConnectError = lastConnectError
self.lastSuccessfulMITMDomain = lastSuccessfulMITMDomain
self.lastSuccessfulMITMAt = lastSuccessfulMITMAt
self.lastExtensionStartAt = lastExtensionStartAt
self.lastExtensionStopAt = lastExtensionStopAt
self.updatedAt = updatedAt
}
public var state: ProxyRuntimeState {
ProxyRuntimeState(rawValue: tunnelState) ?? .stopped
}
}

View File

@@ -14,6 +14,10 @@ public final class ComposeRepository: Sendable {
}
}
public func fetch(id: Int64) throws -> ComposeRequest? {
try db.dbPool.read { db in try ComposeRequest.fetchOne(db, id: id) }
}
public func insert(_ request: inout ComposeRequest) throws {
try db.dbPool.write { db in try request.insert(db) }
}

View File

@@ -0,0 +1,41 @@
import Foundation
import GRDB
public final class ConfigurationRepository: Sendable {
private let db: DatabaseManager
public init(db: DatabaseManager = .shared) {
self.db = db
}
public func observeConfiguration() -> ValueObservation<ValueReducers.Fetch<ProxyConfiguration>> {
ValueObservation.tracking { db in
try ProxyConfiguration.fetchOne(db, key: 1) ?? ProxyConfiguration()
}
}
public func current() throws -> ProxyConfiguration {
try db.dbPool.read { db in
try ProxyConfiguration.fetchOne(db, key: 1) ?? ProxyConfiguration()
}
}
public func update(_ mutate: (inout ProxyConfiguration) -> Void) throws {
try db.dbPool.write { db in
var configuration = try fetchOrCreate(in: db)
mutate(&configuration)
configuration.updatedAt = Date().timeIntervalSince1970
try configuration.save(db)
}
}
private func fetchOrCreate(in db: Database) throws -> ProxyConfiguration {
if let configuration = try ProxyConfiguration.fetchOne(db, key: 1) {
return configuration
}
var configuration = ProxyConfiguration()
try configuration.insert(db)
return configuration
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
import GRDB
public final class PinnedDomainRepository: Sendable {
private let db: DatabaseManager
public init(db: DatabaseManager = .shared) {
self.db = db
}
/// Check if a domain (or any parent wildcard) is pinned.
public func isPinned(domain: String) -> Bool {
do {
return try db.dbPool.read { db in
try PinnedDomain.filter(Column("domain") == domain).fetchCount(db) > 0
}
} catch {
return false
}
}
/// Record a domain as pinned after a TLS handshake failure.
public func markPinned(domain: String, reason: String) {
do {
try db.dbPool.write { db in
// Use INSERT OR IGNORE since domain has UNIQUE constraint
try db.execute(
sql: "INSERT OR IGNORE INTO pinned_domains (domain, reason, detectedAt) VALUES (?, ?, ?)",
arguments: [domain, reason, Date().timeIntervalSince1970]
)
}
ProxyLogger.mitm.info("Marked domain as PINNED: \(domain) reason=\(reason)")
} catch {
ProxyLogger.mitm.error("Failed to mark pinned domain: \(error.localizedDescription)")
}
}
/// Remove a domain from the pinned list (user override).
public func unpin(domain: String) throws {
try db.dbPool.write { db in
_ = try PinnedDomain.filter(Column("domain") == domain).deleteAll(db)
}
}
/// Get all pinned domains.
public func fetchAll() throws -> [PinnedDomain] {
try db.dbPool.read { db in
try PinnedDomain.order(Column("detectedAt").desc).fetchAll(db)
}
}
public func observeAll() -> ValueObservation<ValueReducers.Fetch<[PinnedDomain]>> {
ValueObservation.tracking { db in
try PinnedDomain.order(Column("detectedAt").desc).fetchAll(db)
}
}
public func deleteAll() throws {
try db.dbPool.write { db in
_ = try PinnedDomain.deleteAll(db)
}
}
}

View File

@@ -26,6 +26,10 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in try entry.insert(db) }
}
public func updateSSLEntry(_ entry: SSLProxyingEntry) throws {
try db.dbPool.write { db in try entry.update(db) }
}
public func deleteSSLEntry(id: Int64) throws {
try db.dbPool.write { db in _ = try SSLProxyingEntry.deleteOne(db, id: id) }
}
@@ -34,6 +38,12 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in _ = try SSLProxyingEntry.deleteAll(db) }
}
public func fetchEnabledBlockEntries() throws -> [BlockListEntry] {
try db.dbPool.read { db in
try BlockListEntry.filter(Column("isEnabled") == true).fetchAll(db)
}
}
// MARK: - Block List
public func observeBlockListEntries() -> ValueObservation<ValueReducers.Fetch<[BlockListEntry]>> {
@@ -82,6 +92,12 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in _ = try BreakpointRule.deleteAll(db) }
}
public func fetchEnabledMapLocalRules() throws -> [MapLocalRule] {
try db.dbPool.read { db in
try MapLocalRule.filter(Column("isEnabled") == true).fetchAll(db)
}
}
// MARK: - Map Local Rules
public func observeMapLocalRules() -> ValueObservation<ValueReducers.Fetch<[MapLocalRule]>> {
@@ -106,6 +122,12 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in _ = try MapLocalRule.deleteAll(db) }
}
public func fetchEnabledDNSSpoofRules() throws -> [DNSSpoofRule] {
try db.dbPool.read { db in
try DNSSpoofRule.filter(Column("isEnabled") == true).fetchAll(db)
}
}
// MARK: - DNS Spoof Rules
public func observeDNSSpoofRules() -> ValueObservation<ValueReducers.Fetch<[DNSSpoofRule]>> {
@@ -118,6 +140,10 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in try rule.insert(db) }
}
public func updateDNSSpoofRule(_ rule: DNSSpoofRule) throws {
try db.dbPool.write { db in try rule.update(db) }
}
public func deleteDNSSpoofRule(id: Int64) throws {
try db.dbPool.write { db in _ = try DNSSpoofRule.deleteOne(db, id: id) }
}

View File

@@ -0,0 +1,45 @@
import Foundation
import GRDB
public final class RuntimeStatusRepository: Sendable {
private let db: DatabaseManager
public init(db: DatabaseManager = .shared) {
self.db = db
}
public func observeStatus() -> ValueObservation<ValueReducers.Fetch<ProxyRuntimeStatus>> {
ValueObservation.tracking { db in
try ProxyRuntimeStatus.fetchOne(db, key: 1) ?? ProxyRuntimeStatus()
}
}
public func current() throws -> ProxyRuntimeStatus {
try db.dbPool.read { db in
try ProxyRuntimeStatus.fetchOne(db, key: 1) ?? ProxyRuntimeStatus()
}
}
public func update(_ mutate: (inout ProxyRuntimeStatus) -> Void) {
do {
try db.dbPool.write { db in
var status = try fetchOrCreate(in: db)
mutate(&status)
status.updatedAt = Date().timeIntervalSince1970
try status.save(db)
}
} catch {
ProxyLogger.db.error("RuntimeStatusRepository update failed: \(error.localizedDescription)")
}
}
private func fetchOrCreate(in db: Database) throws -> ProxyRuntimeStatus {
if let status = try ProxyRuntimeStatus.fetchOne(db, key: 1) {
return status
}
var status = ProxyRuntimeStatus()
try status.insert(db)
return status
}
}

View File

@@ -7,7 +7,8 @@ import NIOSSL
import UIKit
#endif
/// Manages root CA generation, leaf certificate signing, and an LRU certificate cache.
/// Manages the shared MITM root CA. The app owns generation and writes the CA
/// into the App Group container; the extension only loads that shared identity.
public final class CertificateManager: @unchecked Sendable {
public static let shared = CertificateManager()
@@ -15,16 +16,16 @@ public final class CertificateManager: @unchecked Sendable {
private var rootCAKey: P256.Signing.PrivateKey?
private var rootCACert: Certificate?
private var rootCANIOSSL: NIOSSLCertificate?
private var caFingerprintCache: String?
private var certificateMTime: Date?
private var keyMTime: Date?
// LRU cache for generated leaf certificates
private var certCache: [String: (NIOSSLCertificate, NIOSSLPrivateKey)] = [:]
private var cacheOrder: [String] = []
private let keychainCAKeyTag = "com.treyt.proxyapp.ca.privatekey"
private let keychainCACertTag = "com.treyt.proxyapp.ca.cert"
private init() {
loadOrGenerateCA()
loadOrGenerateCAIfNeeded()
}
// MARK: - Public API
@@ -32,7 +33,25 @@ public final class CertificateManager: @unchecked Sendable {
public var hasCA: Bool {
lock.lock()
defer { lock.unlock() }
return rootCACert != nil
refreshFromDiskLocked()
return rootCACert != nil && rootCAKey != nil
}
public var caFingerprint: String? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
return caFingerprintCache
}
public var canGenerateCA: Bool {
Bundle.main.infoDictionary?["NSExtension"] == nil
}
public func reloadSharedCA() {
lock.lock()
refreshFromDiskLocked(force: true)
lock.unlock()
}
/// Get or generate a leaf certificate + key for the given domain.
@@ -40,41 +59,57 @@ public final class CertificateManager: @unchecked Sendable {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
if let cached = certCache[domain] {
cacheOrder.removeAll { $0 == domain }
cacheOrder.append(domain)
ProxyLogger.cert.debug("TLS context CACHE HIT for \(domain)")
return try makeServerContext(cert: cached.0, key: cached.1)
}
guard let caKey = rootCAKey, let caCert = rootCACert else {
ProxyLogger.cert.error("TLS context FAILED for \(domain): no CA loaded. hasKey=\(self.rootCAKey != nil) hasCert=\(self.rootCACert != nil)")
throw CertificateError.caNotFound
}
let (leafCert, leafKey) = try generateLeaf(domain: domain, caKey: caKey, caCert: caCert)
ProxyLogger.cert.info("TLS: generating leaf cert for \(domain), CA issuer=\(String(describing: caCert.subject))")
// Serialize to DER/PEM for NIOSSL
var serializer = DER.Serializer()
try leafCert.serialize(into: &serializer)
let leafDER = serializer.serializedBytes
let nioLeafCert = try NIOSSLCertificate(bytes: leafDER, format: .der)
let leafKeyPEM = leafKey.pemRepresentation
let nioLeafKey = try NIOSSLPrivateKey(bytes: [UInt8](leafKeyPEM.utf8), format: .pem)
do {
let (leafCert, leafKey) = try generateLeaf(domain: domain, caKey: caKey, caCert: caCert)
ProxyLogger.cert.info("TLS: leaf cert generated for \(domain), SAN=\(domain), notBefore=\(leafCert.notValidBefore), notAfter=\(leafCert.notValidAfter)")
certCache[domain] = (nioLeafCert, nioLeafKey)
cacheOrder.append(domain)
var serializer = DER.Serializer()
try leafCert.serialize(into: &serializer)
let leafDER = serializer.serializedBytes
ProxyLogger.cert.debug("TLS: leaf DER size=\(leafDER.count) bytes")
while cacheOrder.count > ProxyConstants.certificateCacheSize {
let evicted = cacheOrder.removeFirst()
certCache.removeValue(forKey: evicted)
let nioLeafCert = try NIOSSLCertificate(bytes: leafDER, format: .der)
let leafKeyPEM = leafKey.pemRepresentation
let nioLeafKey = try NIOSSLPrivateKey(bytes: [UInt8](leafKeyPEM.utf8), format: .pem)
certCache[domain] = (nioLeafCert, nioLeafKey)
cacheOrder.append(domain)
while cacheOrder.count > ProxyConstants.certificateCacheSize {
let evicted = cacheOrder.removeFirst()
certCache.removeValue(forKey: evicted)
}
let ctx = try makeServerContext(cert: nioLeafCert, key: nioLeafKey)
ProxyLogger.cert.info("TLS: server context READY for \(domain)")
return ctx
} catch {
ProxyLogger.cert.error("TLS: leaf cert/context FAILED for \(domain): \(error)")
throw error
}
return try makeServerContext(cert: nioLeafCert, key: nioLeafKey)
}
/// Export the root CA as DER data for user installation.
public func exportCACertificateDER() -> [UInt8]? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
guard let cert = rootCACert else { return nil }
var serializer = DER.Serializer()
try? cert.serialize(into: &serializer)
@@ -85,63 +120,173 @@ public final class CertificateManager: @unchecked Sendable {
public func exportCACertificatePEM() -> String? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
guard let cert = rootCACert else { return nil }
guard let pem = try? cert.serializeAsPEM() else { return nil }
return pem.pemString
}
public var caNotValidAfter: Date? {
public var caGeneratedDate: Date? {
lock.lock()
defer { lock.unlock() }
// notValidAfter is a Time, not directly a Date we stored the date when generating
return nil // Will be set properly after we store dates
refreshFromDiskLocked()
return rootCACert?.notValidBefore
}
// MARK: - CA Generation
public var caExpirationDate: Date? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
return rootCACert?.notValidAfter
}
private func loadOrGenerateCA() {
if loadCAFromKeychain() { return }
public func regenerateCA() {
guard canGenerateCA else {
ProxyLogger.cert.error("Refusing to regenerate CA from extension context")
return
}
lock.lock()
clearStateLocked()
deleteStoredCALocked()
do {
let key = P256.Signing.PrivateKey()
let name = try DistinguishedName {
CommonName("Proxy CA (\(deviceName()))")
OrganizationName("ProxyApp")
}
let now = Date()
let twoYearsLater = now.addingTimeInterval(365 * 24 * 3600 * 2)
let extensions = try Certificate.Extensions {
Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0))
Critical(KeyUsage(keyCertSign: true, cRLSign: true))
}
let cert = try Certificate(
version: .v3,
serialNumber: Certificate.SerialNumber(),
publicKey: .init(key.publicKey),
notValidBefore: now,
notValidAfter: twoYearsLater,
issuer: name,
subject: name,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: .init(key)
)
self.rootCAKey = key
self.rootCACert = cert
var serializer = DER.Serializer()
try cert.serialize(into: &serializer)
let der = serializer.serializedBytes
self.rootCANIOSSL = try NIOSSLCertificate(bytes: der, format: .der)
saveCAToKeychain(key: key, certDER: der)
print("[CertificateManager] Generated new root CA")
try generateAndStoreCALocked()
} catch {
print("[CertificateManager] Failed to generate CA: \(error)")
ProxyLogger.cert.error("CA regeneration failed: \(error.localizedDescription)")
}
lock.unlock()
}
// MARK: - CA bootstrap
private func loadOrGenerateCAIfNeeded() {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked(force: true)
guard rootCACert == nil || rootCAKey == nil else { return }
guard canGenerateCA else {
ProxyLogger.cert.info("Shared CA not found; extension will remain passthrough-only")
return
}
do {
try generateAndStoreCALocked()
} catch {
ProxyLogger.cert.error("Failed to generate shared CA: \(error.localizedDescription)")
}
}
private func refreshFromDiskLocked(force: Bool = false) {
let certURL = AppGroupPaths.caCertificateURL
let keyURL = AppGroupPaths.caPrivateKeyURL
let certExists = FileManager.default.fileExists(atPath: certURL.path)
let keyExists = FileManager.default.fileExists(atPath: keyURL.path)
guard certExists, keyExists else {
if rootCACert != nil || rootCAKey != nil {
ProxyLogger.cert.info("Shared CA files missing; clearing in-memory state")
}
clearStateLocked()
return
}
let currentCertMTime = modificationDate(for: certURL)
let currentKeyMTime = modificationDate(for: keyURL)
if !force, currentCertMTime == certificateMTime, currentKeyMTime == keyMTime {
return
}
do {
let certData = try Data(contentsOf: certURL)
let keyData = try Data(contentsOf: keyURL)
let key = try P256.Signing.PrivateKey(rawRepresentation: keyData)
let cert = try Certificate(derEncoded: [UInt8](certData))
let nioCert = try NIOSSLCertificate(bytes: [UInt8](certData), format: .der)
rootCAKey = key
rootCACert = cert
rootCANIOSSL = nioCert
certificateMTime = currentCertMTime
keyMTime = currentKeyMTime
caFingerprintCache = fingerprint(for: certData)
certCache.removeAll()
cacheOrder.removeAll()
ProxyLogger.cert.info("Loaded shared CA from App Group container")
} catch {
ProxyLogger.cert.error("Failed to load shared CA: \(error.localizedDescription)")
clearStateLocked()
}
}
private func generateAndStoreCALocked() throws {
let key = P256.Signing.PrivateKey()
let name = try DistinguishedName {
CommonName("Proxy CA (\(deviceName()))")
OrganizationName("ProxyApp")
}
let now = Date()
let twoYearsLater = now.addingTimeInterval(365 * 24 * 3600 * 2)
let extensions = try Certificate.Extensions {
Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0))
Critical(KeyUsage(keyCertSign: true, cRLSign: true))
}
let cert = try Certificate(
version: .v3,
serialNumber: Certificate.SerialNumber(),
publicKey: .init(key.publicKey),
notValidBefore: now,
notValidAfter: twoYearsLater,
issuer: name,
subject: name,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: .init(key)
)
var serializer = DER.Serializer()
try cert.serialize(into: &serializer)
let der = serializer.serializedBytes
try FileManager.default.createDirectory(
at: AppGroupPaths.certificatesDirectory,
withIntermediateDirectories: true,
attributes: nil
)
try Data(der).write(to: AppGroupPaths.caCertificateURL, options: .atomic)
try key.rawRepresentation.write(to: AppGroupPaths.caPrivateKeyURL, options: .atomic)
rootCAKey = key
rootCACert = cert
rootCANIOSSL = try NIOSSLCertificate(bytes: der, format: .der)
certificateMTime = modificationDate(for: AppGroupPaths.caCertificateURL)
keyMTime = modificationDate(for: AppGroupPaths.caPrivateKeyURL)
caFingerprintCache = fingerprint(for: Data(der))
certCache.removeAll()
cacheOrder.removeAll()
ProxyLogger.cert.info("Generated new shared root CA")
}
private func clearStateLocked() {
rootCAKey = nil
rootCACert = nil
rootCANIOSSL = nil
caFingerprintCache = nil
certificateMTime = nil
keyMTime = nil
certCache.removeAll()
cacheOrder.removeAll()
}
private func deleteStoredCALocked() {
for url in [AppGroupPaths.caCertificateURL, AppGroupPaths.caPrivateKeyURL] {
try? FileManager.default.removeItem(at: url)
}
}
@@ -199,81 +344,16 @@ public final class CertificateManager: @unchecked Sendable {
return try NIOSSLContext(configuration: config)
}
// MARK: - Keychain
private func loadCAFromKeychain() -> Bool {
let keyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainCAKeyTag,
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
kSecReturnData as String: true
]
var keyResult: AnyObject?
guard SecItemCopyMatching(keyQuery as CFDictionary, &keyResult) == errSecSuccess,
let keyData = keyResult as? Data else { return false }
let certQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainCACertTag,
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
kSecReturnData as String: true
]
var certResult: AnyObject?
guard SecItemCopyMatching(certQuery as CFDictionary, &certResult) == errSecSuccess,
let certData = certResult as? Data else { return false }
do {
let key = try P256.Signing.PrivateKey(rawRepresentation: keyData)
let cert = try Certificate(derEncoded: [UInt8](certData))
let nioCert = try NIOSSLCertificate(bytes: [UInt8](certData), format: .der)
self.rootCAKey = key
self.rootCACert = cert
self.rootCANIOSSL = nioCert
print("[CertificateManager] Loaded CA from Keychain")
return true
} catch {
print("[CertificateManager] Failed to load CA from Keychain: \(error)")
return false
}
}
private func saveCAToKeychain(key: P256.Signing.PrivateKey, certDER: [UInt8]) {
let keyData = key.rawRepresentation
// Delete existing entries
for tag in [keychainCAKeyTag, keychainCACertTag] {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tag,
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier
]
SecItemDelete(deleteQuery as CFDictionary)
}
// Save key
let addKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainCAKeyTag,
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
kSecValueData as String: keyData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(addKeyQuery as CFDictionary, nil)
// Save cert
let addCertQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainCACertTag,
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
kSecValueData as String: Data(certDER),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(addCertQuery as CFDictionary, nil)
}
// MARK: - Helpers
private func modificationDate(for url: URL) -> Date? {
(try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? nil
}
private func fingerprint(for data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private func deviceName() -> String {
#if canImport(UIKit)
return UIDevice.current.name
@@ -283,8 +363,6 @@ public final class CertificateManager: @unchecked Sendable {
}
public enum CertificateError: Error {
case notImplemented
case generationFailed
case caNotFound
}
}

View File

@@ -4,19 +4,21 @@ import NIOPosix
import NIOHTTP1
/// Handles incoming proxy requests:
/// - HTTP CONNECT establishes TCP tunnel (GlueHandler passthrough, or MITM in Phase 3)
/// - Plain HTTP connects upstream, forwards request, captures request+response
/// - HTTP CONNECT -> TCP tunnel (GlueHandler passthrough or MITM)
/// - Plain HTTP -> forward with capture
final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart
private let trafficRepo: TrafficRepository
private let runtimeStatusRepo = RuntimeStatusRepository()
private var pendingConnectHead: HTTPRequestHead?
private var pendingConnectBytes: [ByteBuffer] = []
// Buffer request parts until we've connected upstream
private var pendingHead: HTTPRequestHead?
private var pendingBody: [ByteBuffer] = []
private var pendingEnd: HTTPHeaders?
private var receivedEnd = false
init(trafficRepo: TrafficRepository) {
self.trafficRepo = trafficRepo
@@ -28,145 +30,400 @@ final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
switch part {
case .head(let head):
if head.method == .CONNECT {
handleConnect(context: context, head: head)
ProxyLogger.connect.info("CONNECT \(head.uri)")
pendingConnectHead = head
pendingConnectBytes.removeAll()
} else {
ProxyLogger.connect.info("HTTP \(head.method.rawValue) \(head.uri)")
pendingHead = head
pendingBody.removeAll()
pendingEnd = nil
}
case .body(let buffer):
pendingBody.append(buffer)
if pendingConnectHead != nil {
pendingConnectBytes.append(buffer)
} else {
pendingBody.append(buffer)
}
case .end(let trailers):
if let connectHead = pendingConnectHead {
let bufferedBytes = pendingConnectBytes
pendingConnectHead = nil
pendingConnectBytes.removeAll()
handleConnect(context: context, head: connectHead, initialBuffers: bufferedBytes)
return
}
if pendingHead != nil {
pendingEnd = trailers
receivedEnd = true
handleHTTPRequest(context: context)
}
}
}
// MARK: - CONNECT (HTTPS tunnel)
// MARK: - CONNECT
private func handleConnect(context: ChannelHandlerContext, head: HTTPRequestHead) {
private func handleConnect(
context: ChannelHandlerContext,
head: HTTPRequestHead,
initialBuffers: [ByteBuffer]
) {
let components = head.uri.split(separator: ":")
let host = String(components[0])
let originalHost = String(components[0])
let port = components.count > 1 ? Int(components[1]) ?? 443 : 443
let connectURL = "https://\(originalHost):\(port)"
// Check if this domain should be MITM'd (SSL Proxying enabled + domain in include list)
let shouldMITM = shouldInterceptSSL(domain: host)
// Send 200 Connection Established
let responseHead = HTTPResponseHead(version: .http1_1, status: .ok)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
if shouldMITM {
// MITM mode: strip HTTP handlers, install MITMHandler
setupMITM(context: context, host: host, port: port)
} else {
// Passthrough mode: record domain-level entry, tunnel raw bytes
recordConnectTraffic(host: host, port: port)
// We don't need to connect upstream ourselves GlueHandler does raw forwarding
// But GlueHandler pairs two channels, so we need the remote channel first
ClientBootstrap(group: context.eventLoop)
.channelOption(.socketOption(.so_reuseaddr), value: 1)
.connect(host: host, port: port)
.whenComplete { result in
switch result {
case .success(let remoteChannel):
self.setupGlue(context: context, remoteChannel: remoteChannel)
case .failure(let error):
print("[Proxy] CONNECT passthrough failed to \(host):\(port): \(error)")
context.close(promise: nil)
}
}
}
}
private func shouldInterceptSSL(domain: String) -> Bool {
guard IPCManager.shared.isSSLProxyingEnabled else { return false }
guard CertificateManager.shared.hasCA else { return false }
// Check SSL proxying list from database
let rulesRepo = RulesRepository()
do {
let entries = try rulesRepo.fetchAllSSLEntries()
// Check exclude list first
for entry in entries where !entry.isInclude {
if WildcardMatcher.matches(domain, pattern: entry.domainPattern) {
return false
if let blockAction = RulesEngine.checkBlockList(url: connectURL, method: "CONNECT"),
blockAction != .hideOnly {
ProxyLogger.connect.info("BLOCKED \(originalHost) action=\(blockAction.rawValue)")
if blockAction == .blockAndDisplay {
var traffic = CapturedTraffic(
domain: originalHost, url: connectURL, method: "CONNECT", scheme: "https",
statusCode: 403, statusText: "Blocked",
startedAt: Date().timeIntervalSince1970,
completedAt: Date().timeIntervalSince1970, durationMs: 0, isSslDecrypted: false
)
do {
try trafficRepo.insert(&traffic)
IPCManager.shared.post(.newTrafficCaptured)
} catch {
ProxyLogger.db.error("DB insert blocked traffic failed: \(error.localizedDescription)")
}
}
// Check include list
let responseHead = HTTPResponseHead(version: .http1_1, status: .forbidden)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
context.close(promise: nil)
return
}
let upstreamHost = RulesEngine.checkDNSSpoof(domain: originalHost) ?? originalHost
let shouldMITM = shouldInterceptSSL(domain: originalHost)
let shouldHide = shouldHideConnect(url: connectURL, host: originalHost)
ProxyLogger.connect.info("=== CONNECT original=\(originalHost) upstream=\(upstreamHost):\(port) mitm=\(shouldMITM) ===")
if shouldMITM {
upgradeToMITM(
context: context,
originalHost: originalHost,
upstreamHost: upstreamHost,
port: port,
initialBuffers: initialBuffers
)
return
}
ClientBootstrap(group: context.eventLoop)
.channelOption(.socketOption(.so_reuseaddr), value: 1)
.channelOption(.autoRead, value: false)
.connect(host: upstreamHost, port: port)
.whenComplete { result in
switch result {
case .success(let remoteChannel):
ProxyLogger.connect.info("Upstream connected to \(upstreamHost):\(port), upgrading to passthrough")
self.upgradeToPassthrough(
context: context,
remoteChannel: remoteChannel,
originalHost: originalHost,
upstreamHost: upstreamHost,
port: port,
initialBuffers: initialBuffers,
isHidden: shouldHide
)
case .failure(let error):
ProxyLogger.connect.error("Upstream connect FAILED \(upstreamHost):\(port): \(error.localizedDescription)")
self.runtimeStatusRepo.update {
$0.lastConnectError = "CONNECT \(originalHost): \(error.localizedDescription)"
}
let responseHead = HTTPResponseHead(version: .http1_1, status: .badGateway)
context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
context.close(promise: nil)
}
}
}
private func shouldInterceptSSL(domain: String) -> Bool {
let sslEnabled = IPCManager.shared.isSSLProxyingEnabled
let hasCA = CertificateManager.shared.hasCA
ProxyLogger.connect.info("shouldInterceptSSL(\(domain)): sslEnabled=\(sslEnabled) hasCA=\(hasCA)")
// Write diagnostic info so the app can display what the extension sees
runtimeStatusRepo.update {
$0.caFingerprint = CertificateManager.shared.caFingerprint
$0.lastConnectError = "SSL check: domain=\(domain) sslEnabled=\(sslEnabled) hasCA=\(hasCA)"
}
guard sslEnabled else {
ProxyLogger.connect.info("SSL proxying DISABLED globally — skipping MITM")
runtimeStatusRepo.update {
$0.lastMITMError = "SSL proxying disabled (sslEnabled=false in DB)"
}
return false
}
guard hasCA else {
ProxyLogger.connect.info("Shared CA unavailable in extension — skipping MITM")
runtimeStatusRepo.update {
$0.lastMITMError = "No CA in extension (hasCA=false)"
}
return false
}
// Check if domain was auto-detected as using SSL pinning
if PinnedDomainRepository().isPinned(domain: domain) {
ProxyLogger.connect.info("SSL PINNED (auto-detected): \(domain) — using passthrough")
runtimeStatusRepo.update {
$0.lastMITMError = "Pinned domain (auto-fallback): \(domain)"
}
return false
}
let rulesRepo = RulesRepository()
do {
let entries = try rulesRepo.fetchAllSSLEntries()
let includeCount = entries.filter(\.isInclude).count
let excludeCount = entries.filter { !$0.isInclude }.count
let patterns = entries.map { "\($0.isInclude ? "+" : "-")\($0.domainPattern)" }.joined(separator: ", ")
ProxyLogger.connect.info("SSL entries: \(entries.count) (include=\(includeCount) exclude=\(excludeCount)) patterns=[\(patterns)]")
runtimeStatusRepo.update {
$0.lastConnectError = "SSL rules: \(entries.count) entries [\(patterns)] checking domain=\(domain)"
}
for entry in entries where !entry.isInclude {
if WildcardMatcher.matches(domain, pattern: entry.domainPattern) {
ProxyLogger.connect.debug("SSL EXCLUDED by pattern: \(entry.domainPattern)")
return false
}
}
for entry in entries where entry.isInclude {
if WildcardMatcher.matches(domain, pattern: entry.domainPattern) {
ProxyLogger.connect.info("SSL INCLUDED by pattern: \(entry.domainPattern) -> MITM ON")
runtimeStatusRepo.update {
$0.lastMITMError = nil
$0.lastConnectError = "MITM enabled for \(domain) via pattern \(entry.domainPattern)"
}
return true
}
}
} catch {
print("[Proxy] Failed to check SSL proxying list: \(error)")
ProxyLogger.connect.error("SSL list fetch failed: \(error.localizedDescription)")
runtimeStatusRepo.update {
$0.lastMITMError = "SSL list DB error: \(error.localizedDescription)"
}
}
ProxyLogger.connect.debug("SSL: no matching rule for \(domain)")
return false
}
private func setupMITM(context: ChannelHandlerContext, host: String, port: Int) {
let mitmHandler = MITMHandler(host: host, port: port, trafficRepo: trafficRepo)
private func shouldHideConnect(url: String, host: String) -> Bool {
if let blockAction = RulesEngine.checkBlockList(url: url, method: "CONNECT"), blockAction == .hideOnly {
return true
}
return IPCManager.shared.hideSystemTraffic && SystemTrafficFilter.isSystemDomain(host)
}
// Remove HTTP handlers, keep raw bytes for MITMHandler
context.channel.pipeline.handler(type: ByteToMessageHandler<HTTPRequestDecoder>.self)
.whenSuccess { handler in
context.channel.pipeline.removeHandler(handler, promise: nil)
}
private func upgradeToMITM(
context: ChannelHandlerContext,
originalHost: String,
upstreamHost: String,
port: Int,
initialBuffers: [ByteBuffer]
) {
let channel = context.channel
context.pipeline.removeHandler(context: context).whenComplete { _ in
context.channel.pipeline.addHandler(mitmHandler).whenFailure { error in
print("[Proxy] Failed to install MITM handler: \(error)")
context.close(promise: nil)
channel.setOption(.autoRead, value: false).flatMap {
self.upgradeClientChannelToRaw(channel)
}.flatMap {
channel.pipeline.addHandler(
MITMHandler(
originalHost: originalHost,
upstreamHost: upstreamHost,
port: port,
trafficRepo: self.trafficRepo
)
)
}.flatMap {
self.sendConnectEstablished(on: channel)
}.whenComplete { result in
switch result {
case .success:
ProxyLogger.connect.info("MITM pipeline ready for \(originalHost):\(port)")
self.runtimeStatusRepo.update {
$0.lastConnectError = nil
$0.lastMITMError = nil
}
channel.setOption(.autoRead, value: true).whenComplete { _ in
channel.read()
for buffer in initialBuffers {
channel.pipeline.fireChannelRead(NIOAny(buffer))
}
}
case .failure(let error):
ProxyLogger.connect.error("MITM upgrade FAILED for \(originalHost):\(port): \(error.localizedDescription)")
self.runtimeStatusRepo.update {
$0.lastMITMError = "MITM setup \(originalHost): \(error.localizedDescription)"
}
channel.close(promise: nil)
}
}
}
private func setupGlue(context: ChannelHandlerContext, remoteChannel: Channel) {
private func upgradeToPassthrough(
context: ChannelHandlerContext,
remoteChannel: Channel,
originalHost: String,
upstreamHost: String,
port: Int,
initialBuffers: [ByteBuffer],
isHidden: Bool
) {
let channel = context.channel
let localGlue = GlueHandler()
let remoteGlue = GlueHandler()
localGlue.partner = remoteGlue
remoteGlue.partner = localGlue
// Remove all HTTP handlers from the client channel, leaving raw bytes
context.channel.pipeline.handler(type: ByteToMessageHandler<HTTPRequestDecoder>.self)
.whenSuccess { handler in
context.channel.pipeline.removeHandler(handler, promise: nil)
}
context.pipeline.removeHandler(context: context).whenComplete { _ in
context.channel.pipeline.addHandler(localGlue).whenSuccess {
remoteChannel.pipeline.addHandler(remoteGlue).whenFailure { _ in
context.close(promise: nil)
remoteChannel.close(promise: nil)
channel.setOption(.autoRead, value: false).flatMap {
self.upgradeClientChannelToRaw(channel)
}.flatMap {
remoteChannel.pipeline.addHandler(remoteGlue)
}.flatMap {
channel.pipeline.addHandler(localGlue)
}.flatMap {
self.sendConnectEstablished(on: channel)
}.whenComplete { result in
switch result {
case .success:
ProxyLogger.connect.info("Passthrough tunnel ready for \(originalHost):\(port) via \(upstreamHost)")
self.runtimeStatusRepo.update {
$0.lastConnectError = nil
}
self.recordConnectTraffic(host: originalHost, port: port, isHidden: isHidden)
for buffer in initialBuffers {
remoteChannel.write(NIOAny(buffer), promise: nil)
}
remoteChannel.flush()
channel.setOption(.autoRead, value: true).whenComplete { _ in
channel.read()
}
remoteChannel.setOption(.autoRead, value: true).whenComplete { _ in
remoteChannel.read()
}
case .failure(let error):
ProxyLogger.connect.error("Passthrough upgrade FAILED for \(originalHost):\(port): \(error.localizedDescription)")
self.runtimeStatusRepo.update {
$0.lastConnectError = "Passthrough \(originalHost): \(error.localizedDescription)"
}
channel.close(promise: nil)
remoteChannel.close(promise: nil)
}
}
}
// MARK: - Plain HTTP forwarding
private func upgradeClientChannelToRaw(_ channel: Channel) -> EventLoopFuture<Void> {
removeHandler(ByteToMessageHandler<HTTPRequestDecoder>.self, from: channel).flatMap { _ in
self.removeHandler(HTTPResponseEncoder.self, from: channel)
}.flatMap { _ in
channel.pipeline.removeHandler(self)
}
}
private func sendConnectEstablished(on channel: Channel) -> EventLoopFuture<Void> {
var buffer = channel.allocator.buffer(capacity: 64)
buffer.writeString("HTTP/1.1 200 Connection Established\r\n\r\n")
return channel.writeAndFlush(NIOAny(buffer))
}
private func removeHandler<H: RemovableChannelHandler>(_ type: H.Type, from channel: Channel) -> EventLoopFuture<Void> {
channel.pipeline.handler(type: type).flatMap { handler in
channel.pipeline.removeHandler(handler)
}.recover { _ in () }
}
// MARK: - Plain HTTP
private func handleHTTPRequest(context: ChannelHandlerContext) {
guard let head = pendingHead else { return }
// Parse host and port from the absolute URI or Host header
guard let (host, port, path) = parseHTTPTarget(head: head) else {
ProxyLogger.connect.error("HTTP: failed to parse target from \(head.uri)")
let responseHead = HTTPResponseHead(version: .http1_1, status: .badRequest)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
pendingHead = nil
pendingBody.removeAll()
pendingEnd = nil
return
}
let fullURL = "http://\(host)\(path)"
let method = head.method.rawValue
let upstreamHost = RulesEngine.checkDNSSpoof(domain: host) ?? host
ProxyLogger.connect.info("HTTP FORWARD \(method) \(fullURL)")
if let blockAction = RulesEngine.checkBlockList(url: fullURL, method: method),
blockAction != .hideOnly {
ProxyLogger.connect.info("HTTP BLOCKED \(fullURL) action=\(blockAction.rawValue)")
if blockAction == .blockAndDisplay {
var traffic = CapturedTraffic(
domain: host, url: fullURL, method: method, scheme: "http",
statusCode: 403, statusText: "Blocked",
startedAt: Date().timeIntervalSince1970,
completedAt: Date().timeIntervalSince1970, durationMs: 0, isSslDecrypted: false
)
do {
try trafficRepo.insert(&traffic)
IPCManager.shared.post(.newTrafficCaptured)
} catch {
ProxyLogger.db.error("DB insert failed: \(error.localizedDescription)")
}
}
let responseHead = HTTPResponseHead(version: .http1_1, status: .forbidden)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
pendingHead = nil
pendingBody.removeAll()
pendingEnd = nil
return
}
if let mapRule = RulesEngine.checkMapLocal(url: fullURL, method: method) {
ProxyLogger.connect.info("MAP LOCAL match for \(fullURL) -> status \(mapRule.responseStatus)")
let status = HTTPResponseStatus(statusCode: mapRule.responseStatus)
var headers = decodeHeaders(mapRule.responseHeaders)
if let ct = mapRule.responseContentType, !ct.isEmpty {
headers.replaceOrAdd(name: "Content-Type", value: ct)
}
let bodyData = mapRule.responseBody
if let bodyData, !bodyData.isEmpty {
headers.replaceOrAdd(name: "Content-Length", value: "\(bodyData.count)")
}
let responseHead = HTTPResponseHead(version: .http1_1, status: status, headers: headers)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
if let bodyData, !bodyData.isEmpty {
var buffer = context.channel.allocator.buffer(capacity: bodyData.count)
buffer.writeBytes(bodyData)
context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
}
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
pendingHead = nil
pendingBody.removeAll()
pendingEnd = nil
return
}
// Rewrite the request URI to relative path (upstream expects /path, not http://host/path)
var upstreamHead = head
upstreamHead.uri = path
// Ensure Host header is set
if !upstreamHead.headers.contains(name: "Host") {
upstreamHead.headers.add(name: "Host", value: host)
}
@@ -176,7 +433,6 @@ final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
ClientBootstrap(group: context.eventLoop)
.channelOption(.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
// Remote channel: decode HTTP responses, encode HTTP requests
channel.pipeline.addHandler(HTTPRequestEncoder()).flatMap {
channel.pipeline.addHandler(ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .forwardBytes)))
}.flatMap {
@@ -187,80 +443,90 @@ final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
)
}
}
.connect(host: host, port: port)
.connect(host: upstreamHost, port: port)
.whenComplete { result in
switch result {
case .success(let remoteChannel):
// Forward the buffered request to upstream
ProxyLogger.connect.info("HTTP upstream connected to \(upstreamHost):\(port), forwarding request")
remoteChannel.write(NIOAny(HTTPClientRequestPart.head(upstreamHead)), promise: nil)
for bodyBuffer in self.pendingBody {
remoteChannel.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(bodyBuffer))), promise: nil)
}
remoteChannel.writeAndFlush(NIOAny(HTTPClientRequestPart.end(self.pendingEnd)), promise: nil)
// Clear buffered data
self.pendingHead = nil
self.pendingBody.removeAll()
self.pendingEnd = nil
case .failure(let error):
print("[Proxy] HTTP forward failed to \(host):\(port): \(error)")
ProxyLogger.connect.error("HTTP upstream connect FAILED \(host):\(port): \(error.localizedDescription)")
self.runtimeStatusRepo.update {
$0.lastConnectError = "HTTP \(fullURL): \(error.localizedDescription)"
}
let responseHead = HTTPResponseHead(version: .http1_1, status: .badGateway)
context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
self.pendingHead = nil
self.pendingBody.removeAll()
self.pendingEnd = nil
}
}
}
private func decodeHeaders(_ json: String?) -> HTTPHeaders {
guard let json,
let data = json.data(using: .utf8),
let dict = try? JSONDecoder().decode([String: String].self, from: data) else {
return HTTPHeaders()
}
var headers = HTTPHeaders()
for (name, value) in dict {
headers.add(name: name, value: value)
}
return headers
}
// MARK: - URL Parsing
private func parseHTTPTarget(head: HTTPRequestHead) -> (host: String, port: Int, path: String)? {
// Absolute URI: "http://example.com:8080/path?query"
if head.uri.hasPrefix("http://") || head.uri.hasPrefix("https://") {
guard let url = URLComponents(string: head.uri) else { return nil }
let host = url.host ?? ""
let port = url.port ?? (head.uri.hasPrefix("https") ? 443 : 80)
var path = url.path.isEmpty ? "/" : url.path
if let query = url.query {
path += "?\(query)"
}
if let query = url.query { path += "?\(query)" }
return (host, port, path)
}
// Relative URI with Host header
if let hostHeader = head.headers.first(name: "Host") {
let parts = hostHeader.split(separator: ":")
let host = String(parts[0])
let port = parts.count > 1 ? Int(parts[1]) ?? 80 : 80
return (host, port, head.uri)
}
return nil
}
// MARK: - CONNECT traffic recording
private func recordConnectTraffic(host: String, port: Int) {
private func recordConnectTraffic(host: String, port: Int, isHidden: Bool) {
var traffic = CapturedTraffic(
domain: host,
url: "https://\(host):\(port)",
method: "CONNECT",
scheme: "https",
statusCode: 200,
statusText: "Connection Established",
startedAt: Date().timeIntervalSince1970,
completedAt: Date().timeIntervalSince1970,
durationMs: 0,
isSslDecrypted: false
domain: host, url: "https://\(host):\(port)", method: "CONNECT", scheme: "https",
statusCode: 200, statusText: "Connection Established",
startedAt: Date().timeIntervalSince1970, completedAt: Date().timeIntervalSince1970,
durationMs: 0, isSslDecrypted: false, isHidden: isHidden
)
try? trafficRepo.insert(&traffic)
IPCManager.shared.post(.newTrafficCaptured)
do {
try trafficRepo.insert(&traffic)
ProxyLogger.db.debug("Recorded CONNECT \(host) (hidden=\(isHidden))")
} catch {
ProxyLogger.db.error("Failed to record CONNECT \(host): \(error.localizedDescription)")
}
NotificationThrottle.shared.throttle()
}
}
// MARK: - HTTPRelayHandler
/// Relays HTTP responses from the upstream server back to the proxy client.
final class HTTPRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = HTTPClientResponsePart
@@ -274,11 +540,10 @@ final class HTTPRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)
switch part {
case .head(let head):
let serverHead = HTTPResponseHead(version: head.version, status: head.status, headers: head.headers)
clientContext.write(wrapResponse(.head(serverHead)), promise: nil)
ProxyLogger.connect.debug("HTTPRelay response: \(head.status.code)")
clientContext.write(wrapResponse(.head(HTTPResponseHead(version: head.version, status: head.status, headers: head.headers))), promise: nil)
case .body(let buffer):
clientContext.write(wrapResponse(.body(.byteBuffer(buffer))), promise: nil)
case .end(let trailers):
@@ -287,11 +552,12 @@ final class HTTPRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
}
func channelInactive(context: ChannelHandlerContext) {
ProxyLogger.connect.debug("HTTPRelay: remote channel inactive")
clientContext.close(promise: nil)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
print("[Proxy] Relay error: \(error)")
ProxyLogger.connect.error("HTTPRelay error: \(error.localizedDescription)")
context.close(promise: nil)
clientContext.close(promise: nil)
}

View File

@@ -2,7 +2,6 @@ import Foundation
import NIOCore
/// Bidirectional TCP forwarder. Pairs two channels so bytes flow in both directions.
/// Used for CONNECT tunneling (passthrough mode, no MITM).
final class GlueHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
@@ -13,15 +12,19 @@ final class GlueHandler: ChannelInboundHandler, RemovableChannelHandler {
func handlerAdded(context: ChannelHandlerContext) {
self.context = context
ProxyLogger.glue.debug("GlueHandler added to \(context.channel.localAddress?.description ?? "?")")
}
func handlerRemoved(context: ChannelHandlerContext) {
ProxyLogger.glue.debug("GlueHandler removed from \(context.channel.localAddress?.description ?? "?")")
self.context = nil
self.partner = nil
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
partner?.write(unwrapInboundIn(data))
let buf = unwrapInboundIn(data)
ProxyLogger.glue.debug("GlueHandler read \(buf.readableBytes) bytes, forwarding to partner")
partner?.write(buf)
}
func channelReadComplete(context: ChannelHandlerContext) {
@@ -29,10 +32,12 @@ final class GlueHandler: ChannelInboundHandler, RemovableChannelHandler {
}
func channelInactive(context: ChannelHandlerContext) {
ProxyLogger.glue.debug("GlueHandler channelInactive — closing partner")
partner?.close()
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
ProxyLogger.glue.error("GlueHandler error: \(error.localizedDescription)")
context.close(promise: nil)
}
@@ -42,8 +47,6 @@ final class GlueHandler: ChannelInboundHandler, RemovableChannelHandler {
}
}
// MARK: - Partner operations
private func write(_ buffer: ByteBuffer) {
context?.write(wrapOutboundOut(buffer), promise: nil)
}

View File

@@ -3,7 +3,6 @@ import NIOCore
import NIOHTTP1
/// Captures HTTP request/response pairs and writes them to the traffic database.
/// Inserted into the pipeline after TLS termination (MITM) or for plain HTTP.
final class HTTPCaptureHandler: ChannelDuplexHandler {
typealias InboundIn = HTTPClientResponsePart
typealias InboundOut = HTTPClientResponsePart
@@ -21,10 +20,14 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
private var responseBody = Data()
private var requestStartTime: Double = 0
private let hardcodedDebugDomain = "okcupid"
private let hardcodedDebugNeedle = "jill"
init(trafficRepo: TrafficRepository, domain: String, scheme: String = "https") {
self.trafficRepo = trafficRepo
self.domain = domain
self.scheme = scheme
ProxyLogger.capture.debug("HTTPCaptureHandler created for \(domain) (\(scheme))")
}
// MARK: - Outbound (Request)
@@ -33,16 +36,29 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
let part = unwrapOutboundIn(data)
switch part {
case .head(let head):
case .head(var head):
currentRequestId = UUID().uuidString
requestHead = head
requestBody = Data()
requestStartTime = Date().timeIntervalSince1970
if RulesEngine.shouldStripCache() {
head.headers.remove(name: "If-Modified-Since")
head.headers.remove(name: "If-None-Match")
head.headers.replaceOrAdd(name: "Cache-Control", value: "no-cache")
head.headers.replaceOrAdd(name: "Pragma", value: "no-cache")
}
requestHead = head
ProxyLogger.capture.info("CAPTURE REQ \(head.method.rawValue) \(self.scheme)://\(self.domain)\(head.uri)")
context.write(self.wrapOutboundOut(.head(head)), promise: promise)
return
case .body(.byteBuffer(let buffer)):
if requestBody.count < ProxyConstants.maxBodySizeBytes {
requestBody.append(contentsOf: buffer.readableBytesView)
}
ProxyLogger.capture.debug("CAPTURE REQ body chunk: \(buffer.readableBytes) bytes (total: \(self.requestBody.count))")
case .end:
ProxyLogger.capture.debug("CAPTURE REQ end — saving to DB")
saveRequest()
default:
break
@@ -57,14 +73,27 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
let part = unwrapInboundIn(data)
switch part {
case .head(let head):
case .head(var head):
if RulesEngine.shouldStripCache() {
head.headers.remove(name: "Expires")
head.headers.remove(name: "Last-Modified")
head.headers.remove(name: "ETag")
head.headers.replaceOrAdd(name: "Expires", value: "0")
head.headers.replaceOrAdd(name: "Cache-Control", value: "no-cache")
}
responseHead = head
responseBody = Data()
ProxyLogger.capture.info("CAPTURE RESP \(head.status.code) for \(self.domain)")
context.fireChannelRead(NIOAny(HTTPClientResponsePart.head(head)))
return
case .body(let buffer):
if responseBody.count < ProxyConstants.maxBodySizeBytes {
responseBody.append(contentsOf: buffer.readableBytesView)
}
ProxyLogger.capture.debug("CAPTURE RESP body chunk: \(buffer.readableBytes) bytes (total: \(self.responseBody.count))")
case .end:
ProxyLogger.capture.debug("CAPTURE RESP end — saving to DB")
saveResponse()
}
@@ -74,56 +103,79 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
// MARK: - Persistence
private func saveRequest() {
guard let head = requestHead, let reqId = currentRequestId else { return }
guard let head = requestHead, let reqId = currentRequestId else {
ProxyLogger.capture.error("saveRequest: no head or requestId!")
return
}
let url = "\(scheme)://\(domain)\(head.uri)"
let headersJSON = encodeHeaders(head.headers)
let queryParams = extractQueryParams(from: head.uri)
let shouldHide =
(IPCManager.shared.hideSystemTraffic && SystemTrafficFilter.isSystemDomain(domain)) ||
RulesEngine.checkBlockList(url: url, method: head.method.rawValue) == .hideOnly
let headerCount = head.headers.count
let bodySize = requestBody.count
var traffic = CapturedTraffic(
requestId: reqId,
domain: domain,
url: url,
method: head.method.rawValue,
scheme: scheme,
requestId: reqId, domain: domain, url: url,
method: head.method.rawValue, scheme: scheme,
requestHeaders: headersJSON,
requestBody: requestBody.isEmpty ? nil : requestBody,
requestBodySize: requestBody.count,
requestContentType: head.headers.first(name: "Content-Type"),
queryParameters: queryParams,
startedAt: requestStartTime,
isSslDecrypted: scheme == "https"
isSslDecrypted: scheme == "https",
isHidden: shouldHide
)
try? trafficRepo.insert(&traffic)
do {
try trafficRepo.insert(&traffic)
ProxyLogger.capture.info("DB INSERT OK: \(head.method.rawValue) \(self.domain) headers=\(headerCount) body=\(bodySize)B id=\(reqId)")
} catch {
ProxyLogger.capture.error("DB INSERT FAILED: \(error.localizedDescription)")
}
}
private func saveResponse() {
guard let reqId = currentRequestId, let head = responseHead else { return }
guard let reqId = currentRequestId, let head = responseHead else {
ProxyLogger.capture.error("saveResponse: no requestId or responseHead!")
return
}
let now = Date().timeIntervalSince1970
let durationMs = Int((now - requestStartTime) * 1000)
let headerCount = head.headers.count
let bodySize = responseBody.count
try? trafficRepo.updateResponse(
requestId: reqId,
statusCode: Int(head.status.code),
statusText: head.status.reasonPhrase,
responseHeaders: encodeHeaders(head.headers),
responseBody: responseBody.isEmpty ? nil : responseBody,
responseBodySize: responseBody.count,
responseContentType: head.headers.first(name: "Content-Type"),
completedAt: now,
durationMs: durationMs
)
do {
try trafficRepo.updateResponse(
requestId: reqId,
statusCode: Int(head.status.code),
statusText: head.status.reasonPhrase,
responseHeaders: encodeHeaders(head.headers),
responseBody: responseBody.isEmpty ? nil : responseBody,
responseBodySize: responseBody.count,
responseContentType: head.headers.first(name: "Content-Type"),
completedAt: now,
durationMs: durationMs
)
ProxyLogger.capture.info("DB UPDATE OK: \(head.status.code) \(self.domain) headers=\(headerCount) body=\(bodySize)B duration=\(durationMs)ms id=\(reqId)")
} catch {
ProxyLogger.capture.error("DB UPDATE FAILED for \(reqId): \(error.localizedDescription)")
}
IPCManager.shared.post(.newTrafficCaptured)
logHardcodedBodyDebug(responseHead: head, requestId: reqId)
// Debounce don't flood with notifications for every single response
NotificationThrottle.shared.throttle()
}
private func encodeHeaders(_ headers: HTTPHeaders) -> String? {
var dict: [String: String] = [:]
for (name, value) in headers {
dict[name] = value
}
for (name, value) in headers { dict[name] = value }
guard let data = try? JSONEncoder().encode(dict) else { return nil }
return String(data: data, encoding: .utf8)
}
@@ -132,10 +184,59 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
guard let url = URLComponents(string: uri),
let items = url.queryItems, !items.isEmpty else { return nil }
var dict: [String: String] = [:]
for item in items {
dict[item.name] = item.value ?? ""
}
for item in items { dict[item.name] = item.value ?? "" }
guard let data = try? JSONEncoder().encode(dict) else { return nil }
return String(data: data, encoding: .utf8)
}
private func logHardcodedBodyDebug(responseHead: HTTPResponseHead, requestId: String) {
let responseHeaders = headerDictionary(from: responseHead.headers)
let decodedBody = HTTPBodyDecoder.decodedBodyData(from: responseBody, headers: responseHeaders)
let searchableBody = HTTPBodyDecoder.searchableText(from: responseBody, headers: responseHeaders) ?? ""
let preview = decodedBodyPreview(headers: responseHeaders)
guard domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) ||
requestHead?.uri.localizedCaseInsensitiveContains(hardcodedDebugDomain) == true ||
preview.localizedCaseInsensitiveContains(hardcodedDebugNeedle) else {
return
}
let contentType = responseHead.headers.first(name: "Content-Type") ?? "nil"
let contentEncoding = responseHead.headers.first(name: "Content-Encoding") ?? "nil"
let containsNeedle = searchableBody.localizedCaseInsensitiveContains(hardcodedDebugNeedle)
let decodingHint = HTTPBodyDecoder.decodingHint(for: responseBody, headers: responseHeaders)
ProxyLogger.capture.info(
"""
HARDCODED DEBUG capture domain=\(self.domain) id=\(requestId) status=\(responseHead.status.code) \
contentType=\(contentType) contentEncoding=\(contentEncoding) bodyBytes=\(self.responseBody.count) \
decodedBytes=\(decodedBody?.count ?? 0) decoding=\(decodingHint) containsNeedle=\(containsNeedle)
"""
)
if containsNeedle {
ProxyLogger.capture.info("HARDCODED DEBUG MATCH needle=\(self.hardcodedDebugNeedle) preview=\(preview)")
} else {
ProxyLogger.capture.info("HARDCODED DEBUG NO_MATCH needle=\(self.hardcodedDebugNeedle) preview=\(preview)")
}
}
private func decodedSearchableBody(headers: [String: String]) -> String {
HTTPBodyDecoder.searchableText(from: responseBody, headers: headers) ?? ""
}
private func decodedBodyPreview(headers: [String: String]) -> String {
let raw = decodedSearchableBody(headers: headers)
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
return String(raw.prefix(240))
}
private func headerDictionary(from headers: HTTPHeaders) -> [String: String] {
var dictionary: [String: String] = [:]
for (name, value) in headers {
dictionary[name] = value
}
return dictionary
}
}

View File

@@ -4,110 +4,108 @@ import NIOPosix
import NIOSSL
import NIOHTTP1
/// After a CONNECT tunnel is established, this handler:
/// 1. Reads the first bytes from the client to extract the SNI hostname from the TLS ClientHello
/// 2. Generates a per-domain leaf certificate via CertificateManager
/// 3. Terminates client-side TLS with the generated cert
/// 4. Initiates server-side TLS to the real server
/// 5. Installs HTTP codecs + HTTPCaptureHandler on both sides to capture decrypted traffic
final class MITMHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = ByteBuffer
private let host: String
private let originalHost: String
private let upstreamHost: String
private let port: Int
private let trafficRepo: TrafficRepository
private let certManager: CertificateManager
private let runtimeStatusRepo = RuntimeStatusRepository()
init(host: String, port: Int, trafficRepo: TrafficRepository, certManager: CertificateManager = .shared) {
self.host = host
init(
originalHost: String,
upstreamHost: String,
port: Int,
trafficRepo: TrafficRepository,
certManager: CertificateManager = .shared
) {
self.originalHost = originalHost
self.upstreamHost = upstreamHost
self.port = port
self.trafficRepo = trafficRepo
self.certManager = certManager
ProxyLogger.mitm.info("MITMHandler created original=\(originalHost) upstream=\(upstreamHost):\(port)")
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
var buffer = unwrapInboundIn(data)
let bufferSize = buffer.readableBytes
// Extract SNI from ClientHello if possible, otherwise use the CONNECT host
let sniDomain = extractSNI(from: buffer) ?? host
let sniDomain = extractSNI(from: buffer) ?? originalHost
ProxyLogger.mitm.info("MITM ClientHello: \(bufferSize) bytes, SNI=\(sniDomain) (fallback host=\(self.originalHost))")
// Remove this handler we'll rebuild the pipeline
context.pipeline.removeHandler(self, promise: nil)
// Get TLS context for this domain
let sslContext: NIOSSLContext
do {
sslContext = try certManager.tlsServerContext(for: sniDomain)
ProxyLogger.mitm.info("MITM TLS context created for \(sniDomain)")
} catch {
print("[MITM] Failed to get TLS context for \(sniDomain): \(error)")
ProxyLogger.mitm.error("MITM TLS context FAILED for \(sniDomain): \(error.localizedDescription)")
runtimeStatusRepo.update {
$0.lastMITMError = "TLS context \(sniDomain): \(error.localizedDescription)"
}
context.close(promise: nil)
return
}
// Add server-side TLS handler (we are the "server" to the client)
let sslServerHandler = NIOSSLServerHandler(context: sslContext)
let trafficRepo = self.trafficRepo
let host = self.host
let originalHost = self.originalHost
let upstreamHost = self.upstreamHost
let port = self.port
let runtimeStatusRepo = self.runtimeStatusRepo
let tlsErrorHandler = TLSErrorLogger(label: "CLIENT-SIDE", domain: sniDomain, runtimeStatusRepo: runtimeStatusRepo)
context.channel.pipeline.addHandler(sslServerHandler, position: .first).flatMap {
// Add HTTP codec after TLS
// Add TLS error logger right after the SSL handler to catch handshake failures
context.channel.pipeline.addHandler(tlsErrorHandler)
}.flatMap {
context.channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder()))
}.flatMap {
context.channel.pipeline.addHandler(HTTPResponseEncoder())
}.flatMap {
// Add the forwarding handler that connects to the real server
context.channel.pipeline.addHandler(
MITMForwardHandler(
remoteHost: host,
remoteHost: upstreamHost,
remotePort: port,
domain: sniDomain,
originalDomain: originalHost,
trafficRepo: trafficRepo
)
)
}.whenComplete { result in
switch result {
case .success:
// Re-fire the original ClientHello bytes so TLS handshake proceeds
ProxyLogger.mitm.info("MITM pipeline installed for \(sniDomain), re-firing ClientHello (\(bufferSize) bytes)")
context.channel.pipeline.fireChannelRead(NIOAny(buffer))
case .failure(let error):
print("[MITM] Pipeline setup failed: \(error)")
ProxyLogger.mitm.error("MITM pipeline setup FAILED for \(sniDomain): \(error)")
runtimeStatusRepo.update {
$0.lastMITMError = "Pipeline setup \(sniDomain): \(error.localizedDescription)"
}
context.close(promise: nil)
}
}
}
// MARK: - SNI Extraction
/// Parse the SNI hostname from a TLS ClientHello message.
private func extractSNI(from buffer: ByteBuffer) -> String? {
var buf = buffer
guard buf.readableBytes >= 43 else { return nil }
// TLS record header
guard buf.readInteger(as: UInt8.self) == 0x16 else { return nil } // Handshake
let _ = buf.readInteger(as: UInt16.self) // Version
let _ = buf.readInteger(as: UInt16.self) // Length
// Handshake header
guard buf.readInteger(as: UInt8.self) == 0x01 else { return nil } // ClientHello
let _ = buf.readBytes(length: 3) // Length (3 bytes)
// Client version
guard buf.readInteger(as: UInt8.self) == 0x16 else { return nil }
let _ = buf.readInteger(as: UInt16.self)
let _ = buf.readInteger(as: UInt16.self)
guard buf.readInteger(as: UInt8.self) == 0x01 else { return nil }
let _ = buf.readBytes(length: 3)
let _ = buf.readInteger(as: UInt16.self)
// Random (32 bytes)
guard buf.readBytes(length: 32) != nil else { return nil }
// Session ID
guard let sessionIdLen = buf.readInteger(as: UInt8.self) else { return nil }
guard buf.readBytes(length: Int(sessionIdLen)) != nil else { return nil }
// Cipher suites
guard let cipherSuitesLen = buf.readInteger(as: UInt16.self) else { return nil }
guard buf.readBytes(length: Int(cipherSuitesLen)) != nil else { return nil }
// Compression methods
guard let compMethodsLen = buf.readInteger(as: UInt8.self) else { return nil }
guard buf.readBytes(length: Int(compMethodsLen)) != nil else { return nil }
// Extensions
guard let extensionsLen = buf.readInteger(as: UInt16.self) else { return nil }
var extensionsRemaining = Int(extensionsLen)
@@ -116,50 +114,48 @@ final class MITMHandler: ChannelInboundHandler, RemovableChannelHandler {
let extLen = buf.readInteger(as: UInt16.self) else { return nil }
extensionsRemaining -= 4 + Int(extLen)
if extType == 0x0000 { // SNI extension
guard let _ = buf.readInteger(as: UInt16.self), // SNI list length
if extType == 0x0000 {
guard let _ = buf.readInteger(as: UInt16.self),
let nameType = buf.readInteger(as: UInt8.self),
nameType == 0x00, // hostname
nameType == 0x00,
let nameLen = buf.readInteger(as: UInt16.self),
let nameBytes = buf.readBytes(length: Int(nameLen)) else {
return nil
}
return String(bytes: nameBytes, encoding: .utf8)
let nameBytes = buf.readBytes(length: Int(nameLen)) else { return nil }
let name = String(bytes: nameBytes, encoding: .utf8)
ProxyLogger.mitm.debug("SNI extracted: \(name ?? "nil")")
return name
} else {
guard buf.readBytes(length: Int(extLen)) != nil else { return nil }
}
}
ProxyLogger.mitm.debug("SNI: not found in ClientHello")
return nil
}
}
// MARK: - MITMForwardHandler
/// Handles decrypted HTTP from the client, forwards to the real server over TLS,
/// and relays responses back. Captures everything via HTTPCaptureHandler.
final class MITMForwardHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart
private let remoteHost: String
private let remotePort: Int
private let domain: String
private let originalDomain: String
private let trafficRepo: TrafficRepository
private let runtimeStatusRepo = RuntimeStatusRepository()
private var remoteChannel: Channel?
// Buffer request parts until upstream is connected
private var pendingParts: [HTTPServerRequestPart] = []
private var isConnected = false
init(remoteHost: String, remotePort: Int, domain: String, trafficRepo: TrafficRepository) {
init(remoteHost: String, remotePort: Int, originalDomain: String, trafficRepo: TrafficRepository) {
self.remoteHost = remoteHost
self.remotePort = remotePort
self.domain = domain
self.originalDomain = originalDomain
self.trafficRepo = trafficRepo
}
func handlerAdded(context: ChannelHandlerContext) {
ProxyLogger.mitm.info("MITMForward: connecting to upstream \(self.remoteHost):\(self.remotePort)")
connectToRemote(context: context)
}
@@ -167,12 +163,17 @@ final class MITMForwardHandler: ChannelInboundHandler, RemovableChannelHandler {
let part = unwrapInboundIn(data)
if isConnected, let remote = remoteChannel {
// Forward to upstream as client request
switch part {
case .head(let head):
ProxyLogger.mitm.info("MITMForward: decrypted request \(head.method.rawValue) \(head.uri)")
var clientHead = HTTPRequestHead(version: head.version, method: head.method, uri: head.uri, headers: head.headers)
if !clientHead.headers.contains(name: "Host") {
clientHead.headers.add(name: "Host", value: domain)
clientHead.headers.add(name: "Host", value: originalDomain)
}
runtimeStatusRepo.update {
$0.lastSuccessfulMITMDomain = self.originalDomain
$0.lastSuccessfulMITMAt = Date().timeIntervalSince1970
$0.lastMITMError = nil
}
remote.write(NIOAny(HTTPClientRequestPart.head(clientHead)), promise: nil)
case .body(let buffer):
@@ -181,22 +182,27 @@ final class MITMForwardHandler: ChannelInboundHandler, RemovableChannelHandler {
remote.writeAndFlush(NIOAny(HTTPClientRequestPart.end(trailers)), promise: nil)
}
} else {
ProxyLogger.mitm.debug("MITMForward: buffering request part (not connected yet)")
pendingParts.append(part)
}
}
func channelInactive(context: ChannelHandlerContext) {
ProxyLogger.mitm.debug("MITMForward: client channel inactive")
remoteChannel?.close(promise: nil)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
print("[MITMForward] Error: \(error)")
ProxyLogger.mitm.error("MITMForward error: \(error.localizedDescription)")
runtimeStatusRepo.update {
$0.lastMITMError = "Forwarding \(self.originalDomain): \(error.localizedDescription)"
}
context.close(promise: nil)
remoteChannel?.close(promise: nil)
}
private func connectToRemote(context: ChannelHandlerContext) {
let captureHandler = HTTPCaptureHandler(trafficRepo: trafficRepo, domain: domain, scheme: "https")
let captureHandler = HTTPCaptureHandler(trafficRepo: trafficRepo, domain: originalDomain, scheme: "https")
let clientContext = context
do {
@@ -206,44 +212,63 @@ final class MITMForwardHandler: ChannelInboundHandler, RemovableChannelHandler {
ClientBootstrap(group: context.eventLoop)
.channelOption(.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
let sniHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: self.domain)
let sniHandler: NIOSSLClientHandler
do {
sniHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: self.originalDomain)
} catch {
ProxyLogger.mitm.error("NIOSSLClientHandler init FAILED: \(error.localizedDescription)")
self.runtimeStatusRepo.update {
$0.lastMITMError = "Client TLS handler \(self.originalDomain): \(error.localizedDescription)"
}
channel.close(promise: nil)
return channel.eventLoop.makeFailedFuture(error)
}
let upstreamTLSLogger = TLSErrorLogger(label: "UPSTREAM", domain: self.originalDomain, runtimeStatusRepo: self.runtimeStatusRepo)
return channel.pipeline.addHandler(sniHandler).flatMap {
channel.pipeline.addHandler(upstreamTLSLogger)
}.flatMap {
channel.pipeline.addHandler(HTTPRequestEncoder())
}.flatMap {
channel.pipeline.addHandler(ByteToMessageHandler(HTTPResponseDecoder()))
}.flatMap {
channel.pipeline.addHandler(captureHandler)
}.flatMap {
channel.pipeline.addHandler(
MITMRelayHandler(clientContext: clientContext)
)
channel.pipeline.addHandler(MITMRelayHandler(clientContext: clientContext))
}
}
.connect(host: remoteHost, port: remotePort)
.whenComplete { result in
switch result {
case .success(let channel):
ProxyLogger.mitm.info("MITMForward: upstream connected to \(self.remoteHost):\(self.remotePort)")
self.remoteChannel = channel
self.isConnected = true
self.flushPending(remote: channel)
case .failure(let error):
print("[MITMForward] Connect to \(self.remoteHost):\(self.remotePort) failed: \(error)")
ProxyLogger.mitm.error("MITMForward: upstream connect FAILED \(self.remoteHost):\(self.remotePort): \(error.localizedDescription)")
self.runtimeStatusRepo.update {
$0.lastMITMError = "Upstream \(self.originalDomain): \(error.localizedDescription)"
}
clientContext.close(promise: nil)
}
}
} catch {
print("[MITMForward] TLS setup failed: \(error)")
ProxyLogger.mitm.error("MITMForward: TLS context creation FAILED: \(error.localizedDescription)")
runtimeStatusRepo.update {
$0.lastMITMError = "TLS configuration \(self.originalDomain): \(error.localizedDescription)"
}
context.close(promise: nil)
}
}
private func flushPending(remote: Channel) {
ProxyLogger.mitm.debug("MITMForward: flushing \(self.pendingParts.count) buffered parts")
for part in pendingParts {
switch part {
case .head(let head):
var clientHead = HTTPRequestHead(version: head.version, method: head.method, uri: head.uri, headers: head.headers)
if !clientHead.headers.contains(name: "Host") {
clientHead.headers.add(name: "Host", value: domain)
clientHead.headers.add(name: "Host", value: originalDomain)
}
remote.write(NIOAny(HTTPClientRequestPart.head(clientHead)), promise: nil)
case .body(let buffer):
@@ -258,7 +283,6 @@ final class MITMForwardHandler: ChannelInboundHandler, RemovableChannelHandler {
// MARK: - MITMRelayHandler
/// Relays responses from the real server back to the proxy client.
final class MITMRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = HTTPClientResponsePart
@@ -270,11 +294,10 @@ final class MITMRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)
switch part {
case .head(let head):
let serverResponse = HTTPResponseHead(version: head.version, status: head.status, headers: head.headers)
clientContext.write(NIOAny(HTTPServerResponsePart.head(serverResponse)), promise: nil)
ProxyLogger.mitm.debug("MITMRelay response: \(head.status.code)")
clientContext.write(NIOAny(HTTPServerResponsePart.head(HTTPResponseHead(version: head.version, status: head.status, headers: head.headers))), promise: nil)
case .body(let buffer):
clientContext.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil)
case .end(let trailers):
@@ -283,12 +306,105 @@ final class MITMRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
}
func channelInactive(context: ChannelHandlerContext) {
ProxyLogger.mitm.debug("MITMRelay: remote inactive")
clientContext.close(promise: nil)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
print("[MITMRelay] Error: \(error)")
ProxyLogger.mitm.error("MITMRelay error: \(error.localizedDescription)")
RuntimeStatusRepository().update {
$0.lastMITMError = "Relay response: \(error.localizedDescription)"
}
context.close(promise: nil)
clientContext.close(promise: nil)
}
}
// MARK: - TLSErrorLogger
/// Catches and logs TLS handshake errors with detailed context.
/// Placed right after NIOSSLServerHandler/NIOSSLClientHandler in the pipeline.
final class TLSErrorLogger: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = NIOAny
private let label: String
private let domain: String
private let runtimeStatusRepo: RuntimeStatusRepository
init(label: String, domain: String, runtimeStatusRepo: RuntimeStatusRepository) {
self.label = label
self.domain = domain
self.runtimeStatusRepo = runtimeStatusRepo
}
func channelActive(context: ChannelHandlerContext) {
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): channel active (handshake starting)")
context.fireChannelActive()
}
func channelInactive(context: ChannelHandlerContext) {
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): channel inactive")
context.fireChannelInactive()
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
// TLS handshake completed if we're getting data through
context.fireChannelRead(data)
}
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
if let tlsEvent = event as? NIOSSLVerificationCallback {
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): verification callback triggered")
}
// Check for handshake completion by string matching the event type
let eventDesc = String(describing: event)
if eventDesc.contains("handshakeCompleted") {
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): HANDSHAKE COMPLETED event=\(eventDesc)")
} else {
ProxyLogger.mitm.debug("TLS[\(self.label)] \(self.domain): user event=\(eventDesc)")
}
context.fireUserInboundEventTriggered(event)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
let errorDesc = String(describing: error)
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): ERROR \(errorDesc)")
// Categorize and detect SSL pinning
let lowerError = errorDesc.lowercased()
var isPinningLikely = false
var category = "UNKNOWN"
if lowerError.contains("certificate") || lowerError.contains("trust") {
category = "CERTIFICATE_TRUST"
isPinningLikely = label == "CLIENT-SIDE"
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): CERTIFICATE TRUST ISSUE — client likely doesn't trust our CA")
} else if lowerError.contains("handshake") {
category = "HANDSHAKE_FAILURE"
isPinningLikely = label == "CLIENT-SIDE"
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): HANDSHAKE FAILURE — protocol mismatch or cert rejected")
} else if lowerError.contains("eof") || lowerError.contains("reset") || lowerError.contains("closed") || lowerError.contains("connection") {
category = "CONNECTION_RESET"
isPinningLikely = label == "CLIENT-SIDE"
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): CONNECTION RESET during handshake (SSL pinning suspected)")
} else if lowerError.contains("unrecognized") || lowerError.contains("alert") || lowerError.contains("fatal") {
category = "TLS_ALERT"
isPinningLikely = true
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): TLS ALERT — peer sent alert (unknown_ca / bad_certificate)")
}
// If this is a client-side error (the app rejected our cert), it's likely SSL pinning.
// Auto-record this domain as pinned so future connections use passthrough.
if isPinningLikely && label == "CLIENT-SIDE" {
let reason = "TLS \(category): \(String(errorDesc.prefix(200)))"
PinnedDomainRepository().markPinned(domain: domain, reason: reason)
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): AUTO-PINNED — future connections will use passthrough")
}
runtimeStatusRepo.update {
$0.lastMITMError = "TLS[\(self.label)] \(self.domain) [\(category)]: \(String(errorDesc.prefix(200)))"
}
context.fireErrorCaught(error)
}
}

View File

@@ -17,19 +17,21 @@ public final class ProxyServer: Sendable {
) {
self.host = host
self.port = port
// Use only 1 thread to conserve memory in the extension (50MB budget)
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.trafficRepo = trafficRepo
ProxyLogger.proxy.info("ProxyServer init: \(host):\(port)")
}
public func start() async throws {
let trafficRepo = self.trafficRepo
ProxyLogger.proxy.info("ProxyServer binding to \(self.host):\(self.port)...")
let bootstrap = ServerBootstrap(group: group)
.serverChannelOption(.backlog, value: 256)
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)
.childChannelInitializer { channel in
channel.pipeline.addHandler(
ProxyLogger.proxy.debug("New client connection from \(channel.remoteAddress?.description ?? "unknown")")
return channel.pipeline.addHandler(
ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes))
).flatMap {
channel.pipeline.addHandler(HTTPResponseEncoder())
@@ -41,15 +43,17 @@ public final class ProxyServer: Sendable {
.childChannelOption(.maxMessagesPerRead, value: 16)
channel = try await bootstrap.bind(host: host, port: port).get()
print("[ProxyServer] Listening on \(host):\(port)")
ProxyLogger.proxy.info("ProxyServer LISTENING on \(self.host):\(self.port)")
}
public func stop() async {
ProxyLogger.proxy.info("ProxyServer stopping...")
do {
try await channel?.close()
try await group.shutdownGracefully()
ProxyLogger.proxy.info("ProxyServer stopped cleanly")
} catch {
print("[ProxyServer] Shutdown error: \(error)")
ProxyLogger.proxy.error("ProxyServer shutdown error: \(error.localizedDescription)")
}
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
/// Centralized rules engine that checks proxy rules (block list, map local, DNS spoofing, no-cache)
/// against live traffic. All methods are static and synchronous for use in NIO pipeline handlers.
public enum RulesEngine {
private static let rulesRepo = RulesRepository()
// MARK: - Block List
/// Returns the `BlockAction` if the given URL + method matches an enabled block rule, or nil.
public static func checkBlockList(url: String, method: String) -> BlockAction? {
guard IPCManager.shared.isBlockListEnabled else { return nil }
do {
let entries = try rulesRepo.fetchEnabledBlockEntries()
for entry in entries {
guard entry.method == "ANY" || entry.method == method else { continue }
if blockEntry(entry, matches: url) {
return entry.action
}
}
} catch {
print("[RulesEngine] Failed to check block list: \(error)")
}
return nil
}
// MARK: - Map Local
/// Returns the first matching `MapLocalRule` for the URL + method, or nil.
public static func checkMapLocal(url: String, method: String) -> MapLocalRule? {
do {
let rules = try rulesRepo.fetchEnabledMapLocalRules()
for rule in rules {
guard rule.method == "ANY" || rule.method == method else { continue }
if WildcardMatcher.matches(url, pattern: rule.urlPattern) {
return rule
}
}
} catch {
print("[RulesEngine] Failed to check map local rules: \(error)")
}
return nil
}
// MARK: - DNS Spoofing
/// Returns the target domain if the given domain matches an enabled DNS spoof rule, or nil.
public static func checkDNSSpoof(domain: String) -> String? {
guard IPCManager.shared.isDNSSpoofingEnabled else { return nil }
do {
let rules = try rulesRepo.fetchEnabledDNSSpoofRules()
for rule in rules {
if WildcardMatcher.matches(domain, pattern: rule.sourceDomain) {
return rule.targetDomain
}
}
} catch {
print("[RulesEngine] Failed to check DNS spoof rules: \(error)")
}
return nil
}
// MARK: - No-Cache
/// Returns true if the no-caching toggle is enabled.
public static func shouldStripCache() -> Bool {
IPCManager.shared.isNoCachingEnabled
}
private static func blockEntry(_ entry: BlockListEntry, matches url: String) -> Bool {
if WildcardMatcher.matches(url, pattern: entry.urlPattern) {
return true
}
guard entry.includeSubpaths else { return false }
guard !entry.urlPattern.contains("*"), !entry.urlPattern.contains("?") else { return false }
let normalizedPattern = entry.urlPattern.hasSuffix("/") ? entry.urlPattern : "\(entry.urlPattern)/"
return url == entry.urlPattern || url.hasPrefix(normalizedPattern)
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
public enum AppGroupPaths {
public static var containerURL: URL {
if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: ProxyConstants.appGroupIdentifier) {
return groupURL
}
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
public static var certificatesDirectory: URL {
containerURL.appendingPathComponent("Certificates", isDirectory: true)
}
public static var caCertificateURL: URL {
certificatesDirectory.appendingPathComponent("proxy_ca.der")
}
public static var caPrivateKeyURL: URL {
certificatesDirectory.appendingPathComponent("proxy_ca_privatekey.raw")
}
}

View File

@@ -0,0 +1,203 @@
import Foundation
import zlib
public enum HTTPBodyDecoder {
private static let inflateChunkSize = 64 * 1024
private static let maxDecodedBodyBytes = 8 * 1024 * 1024
public static func headerValue(named name: String, in headers: [String: String]) -> String? {
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
}
public static func decodingHint(for body: Data?, headers: [String: String]) -> String {
guard let body, !body.isEmpty else { return "empty" }
let encodings = contentEncodings(in: headers)
if encodings.isEmpty {
return hasGzipMagic(body) ? "gzip-magic-no-header" : "identity"
}
var current = body
var applied: [String] = []
for encoding in encodings.reversed() {
switch encoding {
case "identity":
continue
case "gzip", "x-gzip":
guard let decoded = inflatePayload(current, windowBits: 47) else {
return "failed(\(encoding))"
}
current = decoded
applied.append(encoding)
case "deflate":
guard let decoded = inflateDeflatePayload(current) else {
return "failed(\(encoding))"
}
current = decoded
applied.append(encoding)
default:
return "unsupported(\(encoding))"
}
}
if applied.isEmpty {
return "identity"
}
return "decoded(\(applied.joined(separator: ",")))"
}
public static func decodedBodyData(from body: Data?, headers: [String: String]) -> Data? {
guard let body, !body.isEmpty else { return nil }
let encodings = contentEncodings(in: headers)
if encodings.isEmpty {
return hasGzipMagic(body) ? inflatePayload(body, windowBits: 47) : body
}
var current = body
for encoding in encodings.reversed() {
switch encoding {
case "identity":
continue
case "gzip", "x-gzip":
guard let decoded = inflatePayload(current, windowBits: 47) else { return nil }
current = decoded
case "deflate":
guard let decoded = inflateDeflatePayload(current) else { return nil }
current = decoded
default:
return nil
}
}
return current
}
public static func searchableText(from body: Data?, headers: [String: String]) -> String? {
guard let body, !body.isEmpty else { return nil }
let candidate = decodedBodyData(from: body, headers: headers) ?? body
if let jsonText = searchableJSONText(from: candidate) {
return jsonText
}
guard let string = String(data: candidate, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
return string
}
private static func contentEncodings(in headers: [String: String]) -> [String] {
guard let value = headerValue(named: "Content-Encoding", in: headers)?
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else {
return []
}
return value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private static func searchableJSONText(from data: Data) -> String? {
guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else {
return nil
}
let fragments = flattenedSearchFragments(from: jsonObject)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !fragments.isEmpty else { return nil }
return fragments.joined(separator: "\n")
}
private static func flattenedSearchFragments(from value: Any) -> [String] {
switch value {
case let dict as [String: Any]:
return dict.sorted(by: { $0.key < $1.key }).flatMap { key, nestedValue in
[key] + flattenedSearchFragments(from: nestedValue)
}
case let array as [Any]:
return array.flatMap(flattenedSearchFragments(from:))
case let string as String:
return [string]
case let number as NSNumber:
return ["\(number)"]
case _ as NSNull:
return []
default:
return ["\(value)"]
}
}
private static func inflateDeflatePayload(_ data: Data) -> Data? {
inflatePayload(data, windowBits: 47) ??
inflatePayload(data, windowBits: 15) ??
inflatePayload(data, windowBits: -15)
}
private static func inflatePayload(_ data: Data, windowBits: Int32) -> Data? {
guard !data.isEmpty else { return Data() }
return data.withUnsafeBytes { rawBuffer in
guard let sourceBytes = rawBuffer.bindMemory(to: Bytef.self).baseAddress else {
return Data()
}
var stream = z_stream()
stream.next_in = UnsafeMutablePointer(mutating: sourceBytes)
stream.avail_in = uInt(data.count)
let status = inflateInit2_(&stream, windowBits, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
guard status == Z_OK else { return nil }
defer { inflateEnd(&stream) }
var output = Data()
var buffer = [UInt8](repeating: 0, count: inflateChunkSize)
while true {
let result = buffer.withUnsafeMutableBufferPointer { outputBuffer -> Int32 in
stream.next_out = outputBuffer.baseAddress
stream.avail_out = uInt(outputBuffer.count)
return zlib.inflate(&stream, Z_SYNC_FLUSH)
}
let produced = inflateChunkSize - Int(stream.avail_out)
if produced > 0 {
buffer.withUnsafeBufferPointer { outputBuffer in
guard let baseAddress = outputBuffer.baseAddress else { return }
output.append(baseAddress, count: produced)
}
if output.count >= maxDecodedBodyBytes {
return Data(output.prefix(maxDecodedBodyBytes))
}
}
switch result {
case Z_STREAM_END:
return output
case Z_OK:
continue
default:
return nil
}
}
}
}
private static func hasGzipMagic(_ data: Data) -> Bool {
guard data.count >= 2 else { return false }
return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b
}
}

View File

@@ -1,12 +1,10 @@
import Foundation
/// Lightweight IPC between the main app and the packet tunnel extension
/// using Darwin notifications (fire-and-forget signals) and shared UserDefaults.
public final class IPCManager: Sendable {
/// using Darwin notifications and a shared database-backed configuration model.
public final class IPCManager: @unchecked Sendable {
public static let shared = IPCManager()
private let suiteName = "group.com.treyt.proxyapp"
public enum Notification: String, Sendable {
case newTrafficCaptured = "com.treyt.proxyapp.newTraffic"
case configurationChanged = "com.treyt.proxyapp.configChanged"
@@ -14,11 +12,16 @@ public final class IPCManager: Sendable {
case extensionStopped = "com.treyt.proxyapp.extensionStopped"
}
private init() {}
private let configurationRepo = ConfigurationRepository()
private init() {
ProxyLogger.ipc.info("IPCManager using shared database-backed configuration")
}
// MARK: - Darwin Notifications
public func post(_ notification: Notification) {
ProxyLogger.ipc.debug("POST Darwin: \(notification.rawValue)")
let name = CFNotificationName(notification.rawValue as CFString)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
@@ -27,12 +30,16 @@ public final class IPCManager: Sendable {
}
public func observe(_ notification: Notification, callback: @escaping @Sendable () -> Void) {
let didInstall = DarwinCallbackStore.shared.register(name: notification.rawValue, callback: callback)
if !didInstall {
ProxyLogger.ipc.info("OBSERVE Darwin reuse: \(notification.rawValue)")
return
}
ProxyLogger.ipc.info("OBSERVE Darwin install: \(notification.rawValue)")
let name = notification.rawValue as CFString
let center = CFNotificationCenterGetDarwinNotifyCenter()
// Store callback in a static dictionary keyed by notification name
DarwinCallbackStore.shared.register(name: notification.rawValue, callback: callback)
CFNotificationCenterAddObserver(
center, nil,
{ _, _, name, _, _ in
@@ -44,40 +51,58 @@ public final class IPCManager: Sendable {
)
}
// MARK: - Shared UserDefaults
public var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: suiteName)
}
// MARK: - Shared Config (file-based, reliable cross-process)
public var isSSLProxyingEnabled: Bool {
get { sharedDefaults?.bool(forKey: "sslProxyingEnabled") ?? false }
set { sharedDefaults?.set(newValue, forKey: "sslProxyingEnabled") }
get { get(\.sslProxyingEnabled) }
set { set(\.sslProxyingEnabled, newValue) }
}
public var isBlockListEnabled: Bool {
get { sharedDefaults?.bool(forKey: "blockListEnabled") ?? false }
set { sharedDefaults?.set(newValue, forKey: "blockListEnabled") }
get { get(\.blockListEnabled) }
set { set(\.blockListEnabled, newValue) }
}
public var isBreakpointEnabled: Bool {
get { sharedDefaults?.bool(forKey: "breakpointEnabled") ?? false }
set { sharedDefaults?.set(newValue, forKey: "breakpointEnabled") }
get { get(\.breakpointEnabled) }
set { set(\.breakpointEnabled, newValue) }
}
public var isNoCachingEnabled: Bool {
get { sharedDefaults?.bool(forKey: "noCachingEnabled") ?? false }
set { sharedDefaults?.set(newValue, forKey: "noCachingEnabled") }
get { get(\.noCachingEnabled) }
set { set(\.noCachingEnabled, newValue) }
}
public var isDNSSpoofingEnabled: Bool {
get { sharedDefaults?.bool(forKey: "dnsSpoofingEnabled") ?? false }
set { sharedDefaults?.set(newValue, forKey: "dnsSpoofingEnabled") }
get { get(\.dnsSpoofingEnabled) }
set { set(\.dnsSpoofingEnabled, newValue) }
}
public var hideSystemTraffic: Bool {
get { sharedDefaults?.bool(forKey: "hideSystemTraffic") ?? false }
set { sharedDefaults?.set(newValue, forKey: "hideSystemTraffic") }
get { get(\.hideSystemTraffic) }
set { set(\.hideSystemTraffic, newValue) }
}
// MARK: - Configuration
private func get(_ keyPath: KeyPath<ProxyConfiguration, Bool>) -> Bool {
do {
return try configurationRepo.current()[keyPath: keyPath]
} catch {
ProxyLogger.ipc.error("Config READ failed: \(error.localizedDescription)")
return false
}
}
private func set(_ keyPath: WritableKeyPath<ProxyConfiguration, Bool>, _ value: Bool) {
do {
try configurationRepo.update {
$0[keyPath: keyPath] = value
}
ProxyLogger.ipc.info("Config SET value=\(value)")
} catch {
ProxyLogger.ipc.error("Config WRITE failed: \(error.localizedDescription)")
}
}
}
@@ -86,18 +111,25 @@ public final class IPCManager: Sendable {
private final class DarwinCallbackStore: @unchecked Sendable {
static let shared = DarwinCallbackStore()
private var callbacks: [String: @Sendable () -> Void] = [:]
private var installedObserverNames: Set<String> = []
private var fireCounts: [String: Int] = [:]
private let lock = NSLock()
func register(name: String, callback: @escaping @Sendable () -> Void) {
func register(name: String, callback: @escaping @Sendable () -> Void) -> Bool {
lock.lock()
defer { lock.unlock() }
callbacks[name] = callback
lock.unlock()
let isNew = installedObserverNames.insert(name).inserted
return isNew
}
func fire(name: String) {
lock.lock()
let cb = callbacks[name]
fireCounts[name, default: 0] += 1
let count = fireCounts[name] ?? 0
lock.unlock()
ProxyLogger.ipc.debug("FIRE Darwin callback: \(name) count=\(count)")
cb?()
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
/// Throttles Darwin notification posting to at most once per 0.5 seconds.
/// Prevents flooding the main app with hundreds of "new traffic" notifications.
public final class NotificationThrottle: @unchecked Sendable {
public static let shared = NotificationThrottle()
private let lock = NSLock()
private var pending = false
private let interval: TimeInterval = 0.5
private init() {}
public func throttle() {
lock.lock()
if pending {
lock.unlock()
ProxyLogger.ipc.debug("NotificationThrottle: suppressed newTraffic while pending")
return
}
pending = true
lock.unlock()
DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
self?.lock.lock()
self?.pending = false
self?.lock.unlock()
ProxyLogger.ipc.debug("NotificationThrottle: emitting throttled newTraffic")
IPCManager.shared.post(.newTrafficCaptured)
}
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
import os
/// Centralized logging for the proxy app. Uses os.Logger so logs appear in
/// Console.app, Xcode debug console, and `xclog` capture even from the extension process.
public enum ProxyLogger {
public static let tunnel = Logger(subsystem: "com.treyt.proxyapp", category: "tunnel")
public static let proxy = Logger(subsystem: "com.treyt.proxyapp", category: "proxy")
public static let connect = Logger(subsystem: "com.treyt.proxyapp", category: "connect")
public static let glue = Logger(subsystem: "com.treyt.proxyapp", category: "glue")
public static let mitm = Logger(subsystem: "com.treyt.proxyapp", category: "mitm")
public static let capture = Logger(subsystem: "com.treyt.proxyapp", category: "capture")
public static let cert = Logger(subsystem: "com.treyt.proxyapp", category: "cert")
public static let rules = Logger(subsystem: "com.treyt.proxyapp", category: "rules")
public static let db = Logger(subsystem: "com.treyt.proxyapp", category: "db")
public static let ipc = Logger(subsystem: "com.treyt.proxyapp", category: "ipc")
public static let ui = Logger(subsystem: "com.treyt.proxyapp", category: "ui")
}

View File

@@ -0,0 +1,27 @@
import Foundation
public enum SystemTrafficFilter {
private static let systemDomains = [
"*.apple.com", "*.icloud.com", "*.icloud-content.com",
"*.mzstatic.com", "push.apple.com", "*.push.apple.com",
"*.itunes.apple.com", "gsp-ssl.ls.apple.com",
"mesu.apple.com", "xp.apple.com", "*.cdn-apple.com",
"time.apple.com", "time-ios.apple.com",
"*.applemusic.com", "*.apple-cloudkit.com",
"configuration.apple.com", "gdmf.apple.com",
"gspe1-ssl.ls.apple.com", "*.gc.apple.com",
"*.fe.apple-dns.net", "*.aaplimg.com",
"stocks.apple.com", "weather-data.apple.com",
"news-events.apple.com", "bag.itunes.apple.com"
]
public static func isSystemDomain(_ domain: String) -> Bool {
let lowered = domain.lowercased()
for pattern in systemDomains {
if WildcardMatcher.matches(lowered, pattern: pattern) {
return true
}
}
return false
}
}

View File

@@ -0,0 +1,660 @@
import AppKit
import CoreGraphics
import Foundation
enum IconStyle: String {
case flat = "Flat iOS-native"
case glossy = "Glossy iOS 26"
}
enum Concept: CaseIterable {
case routeLens
case splitLock
case packetPrism
case tunnelPulse
case stackedRequests
var title: String {
switch self {
case .routeLens: return "Route Lens"
case .splitLock: return "Split Lock"
case .packetPrism: return "Packet Prism"
case .tunnelPulse: return "Tunnel Pulse"
case .stackedRequests: return "Stacked Requests"
}
}
var summary: String {
switch self {
case .routeLens:
return "Inspect a live network path through a focused lens."
case .splitLock:
return "Show secure traffic opening into readable traces."
case .packetPrism:
return "Decode one incoming packet into multiple visible layers."
case .tunnelPulse:
return "Represent proxy transit as a bright pulse through a tunnel."
case .stackedRequests:
return "Frame the app as a polished request browser."
}
}
var notes: [String] {
switch self {
case .routeLens:
return [
"Best default option for broad appeal.",
"Reads clearly at small sizes.",
"Feels more like inspection than blocking."
]
case .splitLock:
return [
"Most explicit HTTPS inspection metaphor.",
"Feels technical without looking hostile.",
"Strong choice if SSL proxying is central to the brand."
]
case .packetPrism:
return [
"Highest-end and most design-forward direction.",
"Communicates transform and decode cleanly.",
"Best fit for a premium developer tool."
]
case .tunnelPulse:
return [
"Least literal and most atmospheric option.",
"Feels modern, fast, and infrastructural.",
"Works well if you want less obvious symbolism."
]
case .stackedRequests:
return [
"Most product-led and browser-like.",
"Hints at request rows and response status.",
"Useful if the app should feel approachable."
]
}
}
}
struct Page {
static let size = CGSize(width: 900, height: 1200)
}
let outputDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent("Design/icon_concepts", isDirectory: true)
let pdfURL = outputDirectory.appendingPathComponent("proxy_icon_concepts.pdf")
let pngURL = outputDirectory.appendingPathComponent("proxy_icon_concepts_preview.png")
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
renderPDF(to: pdfURL)
renderPreviewPNG(to: pngURL)
print("Generated:")
print(pdfURL.path)
print(pngURL.path)
func renderPDF(to url: URL) {
var mediaBox = CGRect(origin: .zero, size: Page.size)
guard let context = CGContext(url as CFURL, mediaBox: &mediaBox, nil) else {
fatalError("Unable to create PDF context")
}
beginPDFPage(context, mediaBox) {
drawCoverPage(in: mediaBox)
}
for concept in Concept.allCases {
beginPDFPage(context, mediaBox) {
drawDetailPage(for: concept, in: mediaBox)
}
}
context.closePDF()
}
func renderPreviewPNG(to url: URL) {
let previewSize = CGSize(width: 1500, height: 2000)
let image = NSImage(size: previewSize)
image.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
let context = NSGraphicsContext.current?.cgContext
let scale = previewSize.width / Page.size.width
context?.saveGState()
context?.scaleBy(x: scale, y: scale)
drawCoverPage(in: CGRect(origin: .zero, size: Page.size))
context?.restoreGState()
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:]) else {
fatalError("Unable to create PNG preview")
}
try? png.write(to: url)
}
func beginPDFPage(_ context: CGContext, _ mediaBox: CGRect, draw: () -> Void) {
context.beginPDFPage(nil)
let graphicsContext = NSGraphicsContext(cgContext: context, flipped: false)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
draw()
NSGraphicsContext.restoreGraphicsState()
context.endPDFPage()
}
func drawCoverPage(in rect: CGRect) {
fill(rect, color: NSColor(hex: 0xF4F6FB))
drawText(
"Proxy App Icon Concepts",
in: CGRect(x: 70, y: rect.height - 120, width: rect.width - 140, height: 44),
font: .systemFont(ofSize: 32, weight: .bold),
color: NSColor(hex: 0x101828)
)
drawText(
"Five directions, each tuned for an iOS-native flat pass and a layered glossy pass.",
in: CGRect(x: 70, y: rect.height - 165, width: rect.width - 140, height: 28),
font: .systemFont(ofSize: 16, weight: .medium),
color: NSColor(hex: 0x475467)
)
let cellWidth = (rect.width - 70 * 2 - 28) / 2
let cellHeight: CGFloat = 300
let rows: [(Concept, CGRect)] = [
(.routeLens, CGRect(x: 70, y: 760, width: cellWidth, height: cellHeight)),
(.splitLock, CGRect(x: 70 + cellWidth + 28, y: 760, width: cellWidth, height: cellHeight)),
(.packetPrism, CGRect(x: 70, y: 430, width: cellWidth, height: cellHeight)),
(.tunnelPulse, CGRect(x: 70 + cellWidth + 28, y: 430, width: cellWidth, height: cellHeight)),
(.stackedRequests, CGRect(x: 70, y: 100, width: cellWidth, height: cellHeight))
]
for (concept, frame) in rows {
drawCoverCard(for: concept, in: frame)
}
drawSelectionCard(
in: CGRect(x: 70 + cellWidth + 28, y: 100, width: cellWidth, height: cellHeight),
accent: NSColor(hex: 0x4F86FF)
)
}
func drawDetailPage(for concept: Concept, in rect: CGRect) {
fill(rect, color: NSColor(hex: 0xF7F8FC))
drawText(
concept.title,
in: CGRect(x: 72, y: rect.height - 120, width: rect.width - 144, height: 40),
font: .systemFont(ofSize: 34, weight: .bold),
color: NSColor(hex: 0x101828)
)
drawText(
concept.summary,
in: CGRect(x: 72, y: rect.height - 162, width: rect.width - 144, height: 26),
font: .systemFont(ofSize: 17, weight: .medium),
color: NSColor(hex: 0x475467)
)
let cardWidth = (rect.width - 72 * 2 - 30) / 2
let cardHeight: CGFloat = 520
let y: CGFloat = 430
drawVariantCard(
title: IconStyle.flat.rawValue,
subtitle: "Crisp, simplified, and system-friendly.",
concept: concept,
style: .flat,
frame: CGRect(x: 72, y: y, width: cardWidth, height: cardHeight)
)
drawVariantCard(
title: IconStyle.glossy.rawValue,
subtitle: "Layered, luminous, and ready for Liquid Glass.",
concept: concept,
style: .glossy,
frame: CGRect(x: 72 + cardWidth + 30, y: y, width: cardWidth, height: cardHeight)
)
drawNotesCard(
notes: concept.notes,
frame: CGRect(x: 72, y: 88, width: rect.width - 144, height: 250)
)
}
func drawCoverCard(for concept: Concept, in frame: CGRect) {
drawSurface(frame, fill: NSColor.white, stroke: NSColor(hex: 0xD8DEEA))
let iconRect = CGRect(x: frame.minX + 24, y: frame.minY + 72, width: 156, height: 156)
drawIcon(concept: concept, style: .glossy, in: iconRect)
drawText(
concept.title,
in: CGRect(x: frame.minX + 204, y: frame.maxY - 70, width: frame.width - 224, height: 30),
font: .systemFont(ofSize: 22, weight: .semibold),
color: NSColor(hex: 0x111827)
)
drawText(
concept.summary,
in: CGRect(x: frame.minX + 204, y: frame.maxY - 132, width: frame.width - 228, height: 64),
font: .systemFont(ofSize: 14, weight: .medium),
color: NSColor(hex: 0x475467)
)
drawPill("Glossy preview", in: CGRect(x: frame.minX + 204, y: frame.minY + 28, width: 110, height: 28), fill: NSColor(hex: 0xEAF1FF), textColor: NSColor(hex: 0x2457D6))
}
func drawSelectionCard(in frame: CGRect, accent: NSColor) {
drawSurface(frame, fill: NSColor(hex: 0x101828), stroke: accent.withAlphaComponent(0.3))
drawText(
"Pick One",
in: CGRect(x: frame.minX + 26, y: frame.maxY - 76, width: frame.width - 52, height: 28),
font: .systemFont(ofSize: 24, weight: .bold),
color: .white
)
drawText(
"Choose the direction with the strongest metaphor. I can then iterate on color, shape weight, gloss, and app-store readability.",
in: CGRect(x: frame.minX + 26, y: frame.maxY - 154, width: frame.width - 52, height: 84),
font: .systemFont(ofSize: 15, weight: .medium),
color: NSColor.white.withAlphaComponent(0.84)
)
let checklist = [
"Best all-around: Route Lens",
"Best technical: Split Lock",
"Best premium: Packet Prism"
]
for (index, item) in checklist.enumerated() {
let rowY = frame.minY + 112 - CGFloat(index) * 34
let dotRect = CGRect(x: frame.minX + 28, y: rowY + 6, width: 10, height: 10)
fillEllipse(dotRect, color: accent)
drawText(
item,
in: CGRect(x: frame.minX + 48, y: rowY, width: frame.width - 72, height: 20),
font: .systemFont(ofSize: 15, weight: .semibold),
color: .white
)
}
drawPill("PDF pages include flat + glossy", in: CGRect(x: frame.minX + 26, y: frame.minY + 24, width: 200, height: 30), fill: accent.withAlphaComponent(0.18), textColor: .white)
}
func drawVariantCard(title: String, subtitle: String, concept: Concept, style: IconStyle, frame: CGRect) {
drawSurface(frame, fill: NSColor.white, stroke: NSColor(hex: 0xD8DEEA))
drawText(
title,
in: CGRect(x: frame.minX + 28, y: frame.maxY - 54, width: frame.width - 56, height: 26),
font: .systemFont(ofSize: 22, weight: .bold),
color: NSColor(hex: 0x111827)
)
drawText(
subtitle,
in: CGRect(x: frame.minX + 28, y: frame.maxY - 82, width: frame.width - 56, height: 22),
font: .systemFont(ofSize: 14, weight: .medium),
color: NSColor(hex: 0x667085)
)
let iconRect = CGRect(
x: frame.midX - 132,
y: frame.midY - 40,
width: 264,
height: 264
)
drawIcon(concept: concept, style: style, in: iconRect)
let note = style == .flat
? "Keep the idea direct, centered, and clean."
: "Preserve the same concept but express it with layered depth."
drawText(
note,
in: CGRect(x: frame.minX + 28, y: frame.minY + 40, width: frame.width - 56, height: 40),
font: .systemFont(ofSize: 15, weight: .medium),
color: NSColor(hex: 0x475467)
)
}
func drawNotesCard(notes: [String], frame: CGRect) {
drawSurface(frame, fill: NSColor.white, stroke: NSColor(hex: 0xD8DEEA))
drawText(
"What To Evaluate",
in: CGRect(x: frame.minX + 26, y: frame.maxY - 46, width: frame.width - 52, height: 24),
font: .systemFont(ofSize: 22, weight: .bold),
color: NSColor(hex: 0x111827)
)
for (index, note) in notes.enumerated() {
let rowY = frame.maxY - 92 - CGFloat(index) * 46
fillEllipse(CGRect(x: frame.minX + 30, y: rowY + 8, width: 10, height: 10), color: NSColor(hex: 0x4F86FF))
drawText(
note,
in: CGRect(x: frame.minX + 52, y: rowY, width: frame.width - 82, height: 30),
font: .systemFont(ofSize: 16, weight: .medium),
color: NSColor(hex: 0x475467)
)
}
}
func drawIcon(concept: Concept, style: IconStyle, in rect: CGRect) {
drawIconBackground(style: style, in: rect)
switch concept {
case .routeLens:
drawRouteLens(style: style, in: rect.insetBy(dx: 18, dy: 18))
case .splitLock:
drawSplitLock(style: style, in: rect.insetBy(dx: 18, dy: 18))
case .packetPrism:
drawPacketPrism(style: style, in: rect.insetBy(dx: 18, dy: 18))
case .tunnelPulse:
drawTunnelPulse(style: style, in: rect.insetBy(dx: 18, dy: 18))
case .stackedRequests:
drawStackedRequests(style: style, in: rect.insetBy(dx: 18, dy: 18))
}
}
func drawIconBackground(style: IconStyle, in rect: CGRect) {
let path = NSBezierPath(roundedRect: rect, xRadius: rect.width * 0.23, yRadius: rect.height * 0.23)
switch style {
case .flat:
let gradient = NSGradient(colors: [
NSColor(hex: 0xFAFBFF),
NSColor(hex: 0xEEF3FF)
])
gradient?.draw(in: path, angle: 90)
NSColor.white.withAlphaComponent(0.55).setStroke()
path.lineWidth = 1.5
path.stroke()
case .glossy:
let gradient = NSGradient(colors: [
NSColor(hex: 0xF4F8FF),
NSColor(hex: 0xDCE8FF),
NSColor(hex: 0xC6D9FF)
])
gradient?.draw(in: path, angle: 90)
let topHighlight = NSBezierPath(roundedRect: rect.insetBy(dx: 18, dy: 18).offsetBy(dx: 0, dy: 24), xRadius: 40, yRadius: 40)
NSColor.white.withAlphaComponent(0.28).setFill()
topHighlight.fill()
NSColor.white.withAlphaComponent(0.7).setStroke()
path.lineWidth = 1.7
path.stroke()
}
}
func drawRouteLens(style: IconStyle, in rect: CGRect) {
let line = NSBezierPath()
line.move(to: CGPoint(x: rect.minX + 12, y: rect.midY - 24))
line.curve(
to: CGPoint(x: rect.maxX - 8, y: rect.midY + 12),
controlPoint1: CGPoint(x: rect.minX + 56, y: rect.maxY - 8),
controlPoint2: CGPoint(x: rect.maxX - 92, y: rect.minY + 30)
)
line.lineWidth = style == .flat ? 12 : 14
line.lineCapStyle = .round
NSColor(hex: style == .flat ? 0xA8C4FF : 0x85B5FF).setStroke()
line.stroke()
let lensRect = CGRect(x: rect.midX - 44, y: rect.midY - 12, width: 88, height: 88)
let lens = NSBezierPath(ovalIn: lensRect)
if style == .flat {
NSColor.white.setFill()
lens.fill()
NSColor(hex: 0x2764EA).setStroke()
} else {
let gradient = NSGradient(colors: [
NSColor.white.withAlphaComponent(0.92),
NSColor(hex: 0xEAF2FF).withAlphaComponent(0.92)
])
gradient?.draw(in: lens, angle: 90)
NSColor(hex: 0x3F7CFF).setStroke()
}
lens.lineWidth = style == .flat ? 12 : 10
lens.stroke()
let handle = NSBezierPath()
handle.move(to: CGPoint(x: lensRect.maxX - 4, y: lensRect.minY + 8))
handle.line(to: CGPoint(x: rect.maxX - 24, y: rect.minY + 22))
handle.lineWidth = style == .flat ? 14 : 16
handle.lineCapStyle = .round
NSColor(hex: 0x235BDA).setStroke()
handle.stroke()
fillEllipse(CGRect(x: rect.midX - 10, y: rect.midY + 14, width: 20, height: 20), color: NSColor(hex: 0x3AD4FF))
}
func drawSplitLock(style: IconStyle, in rect: CGRect) {
let bodyRect = CGRect(x: rect.midX - 66, y: rect.midY - 34, width: 132, height: 104)
let body = NSBezierPath(roundedRect: bodyRect, xRadius: 28, yRadius: 28)
if style == .flat {
NSColor(hex: 0xF7FBFF).setFill()
body.fill()
NSColor(hex: 0x0F172A).setStroke()
body.lineWidth = 7
body.stroke()
} else {
let gradient = NSGradient(colors: [
NSColor.white.withAlphaComponent(0.92),
NSColor(hex: 0xDCE8FF).withAlphaComponent(0.9)
])
gradient?.draw(in: body, angle: 90)
NSColor.white.withAlphaComponent(0.88).setStroke()
body.lineWidth = 6
body.stroke()
}
let shackle = NSBezierPath()
shackle.move(to: CGPoint(x: rect.midX - 42, y: bodyRect.maxY - 2))
shackle.curve(
to: CGPoint(x: rect.midX + 14, y: bodyRect.maxY + 52),
controlPoint1: CGPoint(x: rect.midX - 44, y: bodyRect.maxY + 54),
controlPoint2: CGPoint(x: rect.midX + 14, y: bodyRect.maxY + 54)
)
shackle.lineWidth = 16
shackle.lineCapStyle = .round
NSColor(hex: style == .flat ? 0x0F172A : 0xF8FAFC).setStroke()
shackle.stroke()
let splitMask = NSBezierPath(rect: CGRect(x: rect.midX - 2, y: bodyRect.minY - 10, width: bodyRect.width / 2 + 18, height: bodyRect.height + 96))
NSGraphicsContext.saveGraphicsState()
splitMask.addClip()
let leftFill = NSBezierPath(roundedRect: bodyRect, xRadius: 28, yRadius: 28)
NSColor(hex: style == .flat ? 0x2B6CF3 : 0xA6CAFF).setFill()
leftFill.fill()
NSGraphicsContext.restoreGraphicsState()
let traces = [
(CGPoint(x: rect.midX + 8, y: rect.midY + 20), CGPoint(x: rect.maxX - 18, y: rect.midY + 44)),
(CGPoint(x: rect.midX + 14, y: rect.midY), CGPoint(x: rect.maxX - 10, y: rect.midY + 4)),
(CGPoint(x: rect.midX + 10, y: rect.midY - 22), CGPoint(x: rect.maxX - 24, y: rect.midY - 38))
]
for (start, end) in traces {
let trace = NSBezierPath()
trace.move(to: start)
trace.curve(
to: end,
controlPoint1: CGPoint(x: start.x + 28, y: start.y),
controlPoint2: CGPoint(x: end.x - 22, y: end.y)
)
trace.lineWidth = 7
trace.lineCapStyle = .round
NSColor(hex: 0x56A7FF).setStroke()
trace.stroke()
fillEllipse(CGRect(x: end.x - 6, y: end.y - 6, width: 12, height: 12), color: NSColor(hex: 0x56A7FF))
}
}
func drawPacketPrism(style: IconStyle, in rect: CGRect) {
let prism = NSBezierPath()
prism.move(to: CGPoint(x: rect.midX - 18, y: rect.midY + 52))
prism.line(to: CGPoint(x: rect.midX + 52, y: rect.midY))
prism.line(to: CGPoint(x: rect.midX - 18, y: rect.midY - 52))
prism.close()
if style == .flat {
NSColor(hex: 0xEFF5FF).setFill()
prism.fill()
NSColor(hex: 0x2563EB).setStroke()
prism.lineWidth = 7
prism.stroke()
} else {
let gradient = NSGradient(colors: [
NSColor.white.withAlphaComponent(0.92),
NSColor(hex: 0xD7E6FF).withAlphaComponent(0.88)
])
gradient?.draw(in: prism, angle: 90)
NSColor.white.withAlphaComponent(0.9).setStroke()
prism.lineWidth = 6
prism.stroke()
}
let incoming = NSBezierPath()
incoming.move(to: CGPoint(x: rect.minX + 18, y: rect.midY))
incoming.line(to: CGPoint(x: rect.midX - 18, y: rect.midY))
incoming.lineWidth = 10
incoming.lineCapStyle = .round
NSColor(hex: 0x2D6BFF).setStroke()
incoming.stroke()
fillEllipse(CGRect(x: rect.minX + 8, y: rect.midY - 10, width: 20, height: 20), color: NSColor(hex: 0x2D6BFF))
let strands: [(CGFloat, Int)] = [(34, 0x2D6BFF), (0, 0x2ED1FF), (-34, 0x46C57A)]
for (offset, colorHex) in strands {
let strand = NSBezierPath()
strand.move(to: CGPoint(x: rect.midX + 42, y: rect.midY + offset * 0.25))
strand.curve(
to: CGPoint(x: rect.maxX - 18, y: rect.midY + offset),
controlPoint1: CGPoint(x: rect.midX + 72, y: rect.midY + offset * 0.28),
controlPoint2: CGPoint(x: rect.maxX - 44, y: rect.midY + offset)
)
strand.lineWidth = 9
strand.lineCapStyle = .round
NSColor(hex: colorHex).setStroke()
strand.stroke()
}
}
func drawTunnelPulse(style: IconStyle, in rect: CGRect) {
let outerArc = NSBezierPath()
outerArc.appendArc(
withCenter: CGPoint(x: rect.midX, y: rect.midY - 4),
radius: rect.width * 0.34,
startAngle: 208,
endAngle: -28,
clockwise: false
)
outerArc.lineWidth = style == .flat ? 26 : 30
outerArc.lineCapStyle = .round
NSColor(hex: style == .flat ? 0x0F305F : 0x1C3F7C).setStroke()
outerArc.stroke()
let innerArc = NSBezierPath()
innerArc.appendArc(
withCenter: CGPoint(x: rect.midX, y: rect.midY - 4),
radius: rect.width * 0.22,
startAngle: 212,
endAngle: -32,
clockwise: false
)
innerArc.lineWidth = style == .flat ? 16 : 18
innerArc.lineCapStyle = .round
NSColor(hex: style == .flat ? 0x55A9FF : 0x7DCAFF).setStroke()
innerArc.stroke()
let pulseRect = CGRect(x: rect.midX - 24, y: rect.midY - 24, width: 48, height: 48)
if style == .glossy {
let halo = NSBezierPath(ovalIn: pulseRect.insetBy(dx: -18, dy: -18))
NSColor(hex: 0x6DEBFF).withAlphaComponent(0.18).setFill()
halo.fill()
}
fillEllipse(pulseRect, color: NSColor(hex: style == .flat ? 0x19D7FF : 0x56F2FF))
fillEllipse(pulseRect.insetBy(dx: 12, dy: 12), color: NSColor.white.withAlphaComponent(0.92))
}
func drawStackedRequests(style: IconStyle, in rect: CGRect) {
let back = CGRect(x: rect.midX - 76, y: rect.midY + 18, width: 132, height: 90)
let middle = CGRect(x: rect.midX - 94, y: rect.midY - 10, width: 148, height: 100)
let front = CGRect(x: rect.midX - 112, y: rect.midY - 46, width: 164, height: 116)
drawMiniCard(back, fill: style == .flat ? NSColor(hex: 0xE6EEFf) : NSColor.white.withAlphaComponent(0.48))
drawMiniCard(middle, fill: style == .flat ? NSColor(hex: 0xF5F8FF) : NSColor.white.withAlphaComponent(0.70))
drawMiniCard(front, fill: style == .flat ? NSColor.white : NSColor.white.withAlphaComponent(0.88))
drawPill("GET", in: CGRect(x: front.minX + 18, y: front.maxY - 34, width: 40, height: 22), fill: NSColor(hex: 0xDBF3E4), textColor: NSColor(hex: 0x1E8C4A))
drawPill("200", in: CGRect(x: front.minX + 64, y: front.maxY - 34, width: 44, height: 22), fill: NSColor(hex: 0xE6EEFF), textColor: NSColor(hex: 0x275FD7))
drawText("api/profile", in: CGRect(x: front.minX + 18, y: front.maxY - 64, width: 110, height: 18), font: .systemFont(ofSize: 11, weight: .semibold), color: NSColor(hex: 0x111827))
fill(CGRect(x: front.minX + 18, y: front.maxY - 78, width: 120, height: 4), color: NSColor(hex: 0x4F86FF))
fill(CGRect(x: front.minX + 18, y: front.maxY - 92, width: 96, height: 4), color: NSColor(hex: 0xD0D9E8))
fill(CGRect(x: front.minX + 18, y: front.maxY - 104, width: 78, height: 4), color: NSColor(hex: 0xD0D9E8))
}
func drawMiniCard(_ rect: CGRect, fill color: NSColor) {
let path = NSBezierPath(roundedRect: rect, xRadius: 22, yRadius: 22)
color.setFill()
path.fill()
NSColor(hex: 0xD6DDEA).withAlphaComponent(0.75).setStroke()
path.lineWidth = 1
path.stroke()
}
func drawSurface(_ rect: CGRect, fill: NSColor, stroke: NSColor) {
let shadow = NSShadow()
shadow.shadowColor = NSColor.black.withAlphaComponent(0.08)
shadow.shadowOffset = CGSize(width: 0, height: -6)
shadow.shadowBlurRadius = 18
NSGraphicsContext.saveGraphicsState()
shadow.set()
let path = NSBezierPath(roundedRect: rect, xRadius: 26, yRadius: 26)
fill.setFill()
path.fill()
NSGraphicsContext.restoreGraphicsState()
let strokePath = NSBezierPath(roundedRect: rect, xRadius: 26, yRadius: 26)
stroke.setStroke()
strokePath.lineWidth = 1.2
strokePath.stroke()
}
func drawPill(_ title: String, in rect: CGRect, fill: NSColor, textColor: NSColor) {
let pill = NSBezierPath(roundedRect: rect, xRadius: rect.height / 2, yRadius: rect.height / 2)
fill.setFill()
pill.fill()
drawText(title, in: rect.offsetBy(dx: 0, dy: 2), font: .systemFont(ofSize: 11, weight: .bold), color: textColor, alignment: .center)
}
func drawText(_ text: String, in rect: CGRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
paragraph.lineBreakMode = .byWordWrapping
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraph
]
NSString(string: text).draw(with: rect, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: attributes)
}
func fill(_ rect: CGRect, color: NSColor) {
color.setFill()
NSBezierPath(rect: rect).fill()
}
func fillEllipse(_ rect: CGRect, color: NSColor) {
color.setFill()
NSBezierPath(ovalIn: rect).fill()
}
extension NSColor {
convenience init(hex: Int, alpha: CGFloat = 1.0) {
let red = CGFloat((hex >> 16) & 0xFF) / 255.0
let green = CGFloat((hex >> 8) & 0xFF) / 255.0
let blue = CGFloat(hex & 0xFF) / 255.0
self.init(calibratedRed: red, green: green, blue: blue, alpha: alpha)
}
}

View File

@@ -0,0 +1,538 @@
import AppKit
import CoreGraphics
import Foundation
enum SplitLockVariant: CaseIterable {
case balancedGate
case glassCore
case bracketTrace
case shieldLock
case signalKeyhole
var title: String {
switch self {
case .balancedGate: return "Balanced Gate"
case .glassCore: return "Glass Core"
case .bracketTrace: return "Bracket Trace"
case .shieldLock: return "Shield Lock"
case .signalKeyhole: return "Signal Keyhole"
}
}
var summary: String {
switch self {
case .balancedGate:
return "Closest to the original idea. Balanced lock and readable traces."
case .glassCore:
return "More premium and layered. Feels the most iOS 26-native."
case .bracketTrace:
return "Leans developer-tool. The right half reads closer to code."
case .shieldLock:
return "Feels safer and more consumer-trust oriented than technical."
case .signalKeyhole:
return "Adds a pulse/keyhole center so inspection feels active, not static."
}
}
var accent: NSColor {
switch self {
case .balancedGate: return NSColor(hex: 0x4F86FF)
case .glassCore: return NSColor(hex: 0x67B8FF)
case .bracketTrace: return NSColor(hex: 0x5A9DFF)
case .shieldLock: return NSColor(hex: 0x41A6FF)
case .signalKeyhole: return NSColor(hex: 0x4DE3FF)
}
}
}
enum LockStyle {
case flat
case glossy
}
struct Page {
static let size = CGSize(width: 960, height: 1400)
}
let outputDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent("Design/icon_concepts", isDirectory: true)
let pdfURL = outputDirectory.appendingPathComponent("split_lock_variants.pdf")
let pngURL = outputDirectory.appendingPathComponent("split_lock_variants_preview.png")
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
renderPDF(to: pdfURL)
renderPreview(to: pngURL)
print("Generated:")
print(pdfURL.path)
print(pngURL.path)
func renderPDF(to url: URL) {
var mediaBox = CGRect(origin: .zero, size: Page.size)
guard let context = CGContext(url as CFURL, mediaBox: &mediaBox, nil) else {
fatalError("Unable to create PDF context")
}
beginPDFPage(context, mediaBox) {
drawOverviewPage(in: mediaBox)
}
beginPDFPage(context, mediaBox) {
drawLargeIconsPage(in: mediaBox)
}
context.closePDF()
}
func renderPreview(to url: URL) {
let previewSize = CGSize(width: 1500, height: 2188)
let image = NSImage(size: previewSize)
image.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
let scale = previewSize.width / Page.size.width
let context = NSGraphicsContext.current?.cgContext
context?.saveGState()
context?.scaleBy(x: scale, y: scale)
drawOverviewPage(in: CGRect(origin: .zero, size: Page.size))
context?.restoreGState()
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:]) else {
fatalError("Unable to create PNG preview")
}
try? png.write(to: url)
}
func beginPDFPage(_ context: CGContext, _ mediaBox: CGRect, draw: () -> Void) {
context.beginPDFPage(nil)
let graphicsContext = NSGraphicsContext(cgContext: context, flipped: false)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
draw()
NSGraphicsContext.restoreGraphicsState()
context.endPDFPage()
}
func drawOverviewPage(in rect: CGRect) {
fill(rect, color: NSColor(hex: 0xF5F7FC))
drawText(
"Split Lock Explorations",
in: CGRect(x: 68, y: rect.height - 116, width: rect.width - 136, height: 40),
font: .systemFont(ofSize: 34, weight: .bold),
color: NSColor(hex: 0x0F172A)
)
drawText(
"Five directions built from the Split Lock concept. Each card shows the glossy primary icon with a small flat simplification inset.",
in: CGRect(x: 68, y: rect.height - 160, width: rect.width - 136, height: 34),
font: .systemFont(ofSize: 16, weight: .medium),
color: NSColor(hex: 0x475467)
)
let cardWidth = (rect.width - 68 * 2 - 26) / 2
let cardHeight: CGFloat = 300
let layouts: [(SplitLockVariant, CGRect)] = [
(.balancedGate, CGRect(x: 68, y: 900, width: cardWidth, height: cardHeight)),
(.glassCore, CGRect(x: 68 + cardWidth + 26, y: 900, width: cardWidth, height: cardHeight)),
(.bracketTrace, CGRect(x: 68, y: 574, width: cardWidth, height: cardHeight)),
(.shieldLock, CGRect(x: 68 + cardWidth + 26, y: 574, width: cardWidth, height: cardHeight)),
(.signalKeyhole, CGRect(x: 68, y: 248, width: cardWidth, height: cardHeight))
]
for (variant, frame) in layouts {
drawVariantCard(variant: variant, in: frame)
}
drawDecisionCard(
in: CGRect(x: 68 + cardWidth + 26, y: 248, width: cardWidth, height: cardHeight)
)
}
func drawLargeIconsPage(in rect: CGRect) {
fill(rect, color: NSColor(hex: 0xF8FAFD))
drawText(
"Large Icon Comparison",
in: CGRect(x: 68, y: rect.height - 114, width: rect.width - 136, height: 38),
font: .systemFont(ofSize: 32, weight: .bold),
color: NSColor(hex: 0x0F172A)
)
drawText(
"This page removes most of the annotation so you can judge silhouette, weight, and app-store readability.",
in: CGRect(x: 68, y: rect.height - 156, width: rect.width - 136, height: 28),
font: .systemFont(ofSize: 15, weight: .medium),
color: NSColor(hex: 0x475467)
)
let rowHeight: CGFloat = 220
for (index, variant) in SplitLockVariant.allCases.enumerated() {
let y = rect.height - 250 - CGFloat(index) * (rowHeight + 16)
let frame = CGRect(x: 68, y: y, width: rect.width - 136, height: rowHeight)
drawSurface(frame, fill: NSColor.white, stroke: NSColor(hex: 0xD9E0EC))
drawIconBackground(in: CGRect(x: frame.minX + 30, y: frame.minY + 26, width: 168, height: 168))
drawSplitLock(variant: variant, style: .glossy, in: CGRect(x: frame.minX + 42, y: frame.minY + 38, width: 144, height: 144))
drawText(
variant.title,
in: CGRect(x: frame.minX + 228, y: frame.maxY - 62, width: frame.width - 252, height: 30),
font: .systemFont(ofSize: 26, weight: .bold),
color: NSColor(hex: 0x111827)
)
drawText(
variant.summary,
in: CGRect(x: frame.minX + 228, y: frame.maxY - 118, width: frame.width - 252, height: 60),
font: .systemFont(ofSize: 16, weight: .medium),
color: NSColor(hex: 0x475467)
)
drawPill(
"glossy primary",
in: CGRect(x: frame.minX + 228, y: frame.minY + 28, width: 112, height: 28),
fill: variant.accent.withAlphaComponent(0.14),
textColor: NSColor(hex: 0x275FD7)
)
}
}
func drawVariantCard(variant: SplitLockVariant, in frame: CGRect) {
drawSurface(frame, fill: NSColor.white, stroke: NSColor(hex: 0xD9E0EC))
let iconFrame = CGRect(x: frame.minX + 24, y: frame.minY + 62, width: 158, height: 158)
drawIconBackground(in: iconFrame)
drawSplitLock(variant: variant, style: .glossy, in: iconFrame.insetBy(dx: 14, dy: 14))
let flatFrame = CGRect(x: frame.minX + 30, y: frame.minY + 196, width: 58, height: 58)
drawMiniBadge(flatFrame, variant: variant)
drawText(
variant.title,
in: CGRect(x: frame.minX + 204, y: frame.maxY - 70, width: frame.width - 226, height: 28),
font: .systemFont(ofSize: 22, weight: .bold),
color: NSColor(hex: 0x111827)
)
drawText(
variant.summary,
in: CGRect(x: frame.minX + 204, y: frame.maxY - 138, width: frame.width - 226, height: 78),
font: .systemFont(ofSize: 14, weight: .medium),
color: NSColor(hex: 0x475467)
)
drawPill(
"flat inset",
in: CGRect(x: frame.minX + 100, y: frame.minY + 208, width: 72, height: 22),
fill: NSColor(hex: 0xEDF2FF),
textColor: NSColor(hex: 0x3A63D1)
)
}
func drawDecisionCard(in frame: CGRect) {
drawSurface(frame, fill: NSColor(hex: 0x17223A), stroke: NSColor(hex: 0x17223A))
drawText(
"What To Pick For",
in: CGRect(x: frame.minX + 26, y: frame.maxY - 68, width: frame.width - 52, height: 28),
font: .systemFont(ofSize: 24, weight: .bold),
color: .white
)
let bullets = [
"Most balanced: Balanced Gate",
"Most premium: Glass Core",
"Most dev-tool: Bracket Trace",
"Most trustworthy: Shield Lock",
"Most active / dynamic: Signal Keyhole"
]
for (index, bullet) in bullets.enumerated() {
let rowY = frame.maxY - 118 - CGFloat(index) * 34
fillEllipse(CGRect(x: frame.minX + 28, y: rowY + 6, width: 10, height: 10), color: NSColor(hex: 0x6DA1FF))
drawText(
bullet,
in: CGRect(x: frame.minX + 48, y: rowY, width: frame.width - 72, height: 22),
font: .systemFont(ofSize: 15, weight: .semibold),
color: NSColor.white.withAlphaComponent(0.94)
)
}
drawText(
"Pick one and Ill iterate on thickness, color, background, or make it more minimal.",
in: CGRect(x: frame.minX + 26, y: frame.minY + 28, width: frame.width - 52, height: 42),
font: .systemFont(ofSize: 15, weight: .medium),
color: NSColor.white.withAlphaComponent(0.82)
)
}
func drawIconBackground(in rect: CGRect) {
let path = NSBezierPath(roundedRect: rect, xRadius: rect.width * 0.23, yRadius: rect.height * 0.23)
let gradient = NSGradient(colors: [
NSColor(hex: 0xF6F9FF),
NSColor(hex: 0xDFE9FF),
NSColor(hex: 0xD1DEFF)
])
gradient?.draw(in: path, angle: 90)
let highlight = NSBezierPath(roundedRect: rect.insetBy(dx: 16, dy: 16).offsetBy(dx: 0, dy: 20), xRadius: 34, yRadius: 34)
NSColor.white.withAlphaComponent(0.24).setFill()
highlight.fill()
NSColor.white.withAlphaComponent(0.74).setStroke()
path.lineWidth = 1.6
path.stroke()
}
func drawMiniBadge(_ rect: CGRect, variant: SplitLockVariant) {
let path = NSBezierPath(roundedRect: rect, xRadius: rect.width * 0.28, yRadius: rect.height * 0.28)
let gradient = NSGradient(colors: [NSColor(hex: 0xFCFDFF), NSColor(hex: 0xEEF3FF)])
gradient?.draw(in: path, angle: 90)
NSColor.white.withAlphaComponent(0.8).setStroke()
path.lineWidth = 1.2
path.stroke()
drawSplitLock(variant: variant, style: .flat, in: rect.insetBy(dx: 8, dy: 8))
}
func drawSplitLock(variant: SplitLockVariant, style: LockStyle, in rect: CGRect) {
switch variant {
case .balancedGate:
drawBalancedGate(style: style, in: rect)
case .glassCore:
drawGlassCore(style: style, in: rect)
case .bracketTrace:
drawBracketTrace(style: style, in: rect)
case .shieldLock:
drawShieldLock(style: style, in: rect)
case .signalKeyhole:
drawSignalKeyhole(style: style, in: rect)
}
}
func drawBalancedGate(style: LockStyle, in rect: CGRect) {
drawBaseLock(style: style, in: rect, bodyInset: 0, shackleLift: 0)
drawTraceLines(
in: rect,
color: NSColor(hex: style == .flat ? 0x5EA4FF : 0x7EB8FF),
spread: 26,
weight: style == .flat ? 6 : 7
)
}
func drawGlassCore(style: LockStyle, in rect: CGRect) {
drawBaseLock(style: style, in: rect, bodyInset: 4, shackleLift: 6)
let glowRect = CGRect(x: rect.midX - 18, y: rect.midY - 14, width: 36, height: 36)
if style == .glossy {
fillEllipse(glowRect.insetBy(dx: -10, dy: -10), color: NSColor(hex: 0x84DAFF).withAlphaComponent(0.16))
}
fillEllipse(glowRect, color: NSColor(hex: style == .flat ? 0x6CCFFF : 0x86E5FF))
drawTraceLines(
in: rect,
color: NSColor(hex: style == .flat ? 0x74B0FF : 0xA5CCFF),
spread: 20,
weight: style == .flat ? 5 : 6
)
}
func drawBracketTrace(style: LockStyle, in rect: CGRect) {
drawBaseLock(style: style, in: rect, bodyInset: 0, shackleLift: 0)
let bracket = NSBezierPath()
bracket.move(to: CGPoint(x: rect.midX + 10, y: rect.midY + 34))
bracket.line(to: CGPoint(x: rect.maxX - 30, y: rect.midY + 34))
bracket.line(to: CGPoint(x: rect.maxX - 30, y: rect.midY + 10))
bracket.move(to: CGPoint(x: rect.midX + 10, y: rect.midY - 34))
bracket.line(to: CGPoint(x: rect.maxX - 30, y: rect.midY - 34))
bracket.line(to: CGPoint(x: rect.maxX - 30, y: rect.midY - 10))
bracket.lineWidth = style == .flat ? 6 : 7
bracket.lineCapStyle = .round
bracket.lineJoinStyle = .round
NSColor(hex: style == .flat ? 0x67A8FF : 0x8FC1FF).setStroke()
bracket.stroke()
drawTraceLines(
in: rect,
color: NSColor(hex: style == .flat ? 0x4F86FF : 0x69A8FF),
spread: 24,
weight: style == .flat ? 5 : 6
)
}
func drawShieldLock(style: LockStyle, in rect: CGRect) {
let shield = NSBezierPath()
shield.move(to: CGPoint(x: rect.midX, y: rect.maxY - 6))
shield.curve(
to: CGPoint(x: rect.minX + 32, y: rect.midY + 18),
controlPoint1: CGPoint(x: rect.midX - 40, y: rect.maxY - 8),
controlPoint2: CGPoint(x: rect.minX + 26, y: rect.maxY - 26)
)
shield.line(to: CGPoint(x: rect.midX, y: rect.minY + 6))
shield.line(to: CGPoint(x: rect.maxX - 32, y: rect.midY + 18))
shield.curve(
to: CGPoint(x: rect.midX, y: rect.maxY - 6),
controlPoint1: CGPoint(x: rect.maxX - 26, y: rect.maxY - 26),
controlPoint2: CGPoint(x: rect.midX + 40, y: rect.maxY - 8)
)
shield.close()
if style == .glossy {
NSColor.white.withAlphaComponent(0.18).setFill()
shield.fill()
} else {
NSColor(hex: 0xEDF4FF).setFill()
shield.fill()
}
drawBaseLock(style: style, in: rect.insetBy(dx: 10, dy: 10), bodyInset: 2, shackleLift: 2)
drawTraceLines(
in: rect.insetBy(dx: 10, dy: 10),
color: NSColor(hex: style == .flat ? 0x5F9FFF : 0x8CC1FF),
spread: 22,
weight: style == .flat ? 5 : 6
)
}
func drawSignalKeyhole(style: LockStyle, in rect: CGRect) {
drawBaseLock(style: style, in: rect, bodyInset: 1, shackleLift: 0)
let keyholeCircle = CGRect(x: rect.midX - 14, y: rect.midY + 2, width: 28, height: 28)
let keyholeStem = NSBezierPath(roundedRect: CGRect(x: rect.midX - 7, y: rect.midY - 24, width: 14, height: 30), xRadius: 7, yRadius: 7)
if style == .glossy {
fillEllipse(keyholeCircle.insetBy(dx: -8, dy: -8), color: NSColor(hex: 0x7FE4FF).withAlphaComponent(0.16))
}
fillEllipse(keyholeCircle, color: NSColor(hex: style == .flat ? 0x143A6F : 0xF8FBFF))
let stemColor = style == .flat ? NSColor(hex: 0x143A6F) : NSColor(hex: 0xF8FBFF)
stemColor.setFill()
keyholeStem.fill()
drawTraceLines(
in: rect,
color: NSColor(hex: style == .flat ? 0x49CFFF : 0x5CE7FF),
spread: 28,
weight: style == .flat ? 5 : 6
)
}
func drawBaseLock(style: LockStyle, in rect: CGRect, bodyInset: CGFloat, shackleLift: CGFloat) {
let bodyRect = CGRect(
x: rect.midX - 42 + bodyInset,
y: rect.midY - 34,
width: 96 - bodyInset * 2,
height: 76
)
let body = NSBezierPath(roundedRect: bodyRect, xRadius: 22, yRadius: 22)
switch style {
case .flat:
NSColor(hex: 0xF7FAFF).setFill()
body.fill()
NSColor(hex: 0x1E3A72).setStroke()
body.lineWidth = 5.5
body.stroke()
case .glossy:
let gradient = NSGradient(colors: [
NSColor.white.withAlphaComponent(0.92),
NSColor(hex: 0xE0EBFF).withAlphaComponent(0.88)
])
gradient?.draw(in: body, angle: 90)
NSColor.white.withAlphaComponent(0.82).setStroke()
body.lineWidth = 5
body.stroke()
}
let shackle = NSBezierPath()
shackle.move(to: CGPoint(x: rect.midX - 32, y: bodyRect.maxY - 1))
shackle.curve(
to: CGPoint(x: rect.midX + 12, y: bodyRect.maxY + 40 + shackleLift),
controlPoint1: CGPoint(x: rect.midX - 34, y: bodyRect.maxY + 42 + shackleLift),
controlPoint2: CGPoint(x: rect.midX + 12, y: bodyRect.maxY + 42 + shackleLift)
)
shackle.lineWidth = style == .flat ? 11 : 12
shackle.lineCapStyle = .round
NSColor(hex: style == .flat ? 0x1E3A72 : 0xF8FBFF).setStroke()
shackle.stroke()
let leftFillRect = CGRect(x: bodyRect.minX, y: bodyRect.minY, width: bodyRect.width * 0.52, height: bodyRect.height)
let leftFill = NSBezierPath(roundedRect: leftFillRect, xRadius: 18, yRadius: 18)
NSColor(hex: style == .flat ? 0x76A8FF : 0xBCD4FF).setFill()
leftFill.fill()
}
func drawTraceLines(in rect: CGRect, color: NSColor, spread: CGFloat, weight: CGFloat) {
let traces: [(CGFloat, CGFloat)] = [(spread, 0.94), (0, 1.0), (-spread, 0.92)]
for (offset, scale) in traces {
let start = CGPoint(x: rect.midX + 6, y: rect.midY + offset * 0.32)
let end = CGPoint(x: rect.maxX - 20, y: rect.midY + offset)
let path = NSBezierPath()
path.move(to: start)
path.curve(
to: end,
controlPoint1: CGPoint(x: start.x + 22, y: start.y),
controlPoint2: CGPoint(x: end.x - 18, y: end.y)
)
path.lineWidth = weight * scale
path.lineCapStyle = .round
color.setStroke()
path.stroke()
fillEllipse(CGRect(x: end.x - 5, y: end.y - 5, width: 10, height: 10), color: color)
}
}
func drawSurface(_ rect: CGRect, fill: NSColor, stroke: NSColor) {
let shadow = NSShadow()
shadow.shadowColor = NSColor.black.withAlphaComponent(0.08)
shadow.shadowOffset = CGSize(width: 0, height: -7)
shadow.shadowBlurRadius = 18
NSGraphicsContext.saveGraphicsState()
shadow.set()
let path = NSBezierPath(roundedRect: rect, xRadius: 26, yRadius: 26)
fill.setFill()
path.fill()
NSGraphicsContext.restoreGraphicsState()
let strokePath = NSBezierPath(roundedRect: rect, xRadius: 26, yRadius: 26)
stroke.setStroke()
strokePath.lineWidth = 1.1
strokePath.stroke()
}
func drawPill(_ title: String, in rect: CGRect, fill: NSColor, textColor: NSColor) {
let path = NSBezierPath(roundedRect: rect, xRadius: rect.height / 2, yRadius: rect.height / 2)
fill.setFill()
path.fill()
drawText(title, in: rect.offsetBy(dx: 0, dy: 2), font: .systemFont(ofSize: 11, weight: .bold), color: textColor, alignment: .center)
}
func drawText(_ text: String, in rect: CGRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
paragraph.lineBreakMode = .byWordWrapping
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraph
]
NSString(string: text).draw(with: rect, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: attributes)
}
func fill(_ rect: CGRect, color: NSColor) {
color.setFill()
NSBezierPath(rect: rect).fill()
}
func fillEllipse(_ rect: CGRect, color: NSColor) {
color.setFill()
NSBezierPath(ovalIn: rect).fill()
}
extension NSColor {
convenience init(hex: Int, alpha: CGFloat = 1.0) {
let red = CGFloat((hex >> 16) & 0xFF) / 255.0
let green = CGFloat((hex >> 8) & 0xFF) / 255.0
let blue = CGFloat(hex & 0xFF) / 255.0
self.init(calibratedRed: red, green: green, blue: blue, alpha: alpha)
}
}

View File

@@ -6,13 +6,16 @@ struct ComposeEditorView: View {
@State private var method = "GET"
@State private var url = ""
@State private var headersText = ""
@State private var queryText = ""
@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()
@@ -77,47 +80,103 @@ struct ComposeEditorView: View {
}
// Response
if let responseBody {
if responseStatus != nil || responseBody != nil {
Divider()
ScrollView {
Text(responseBody)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
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)
}
}
.frame(maxHeight: 200)
.background(Color(.systemGray6))
}
}
.navigationTitle("New Request")
.navigationTitle("Edit Request")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await sendRequest() }
} label: {
Text("Send")
.font(.headline)
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 headersText.isEmpty {
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") {
// TODO: Open header editor sheet
showHeaderEditor = true
}
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
@@ -126,16 +185,32 @@ struct ComposeEditorView: View {
private var queryEditor: some View {
VStack(alignment: .leading, spacing: 8) {
TextEditor(text: $queryText)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 100)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4))
if queryParams.isEmpty {
EmptyStateView(
icon: "questionmark.circle",
title: "No Query Parameters",
subtitle: "Tap 'Edit Parameters' to add query parameters."
)
Text("Format: key=value, one per line")
.font(.caption2)
.foregroundStyle(.secondary)
} 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)
}
}
@@ -151,14 +226,52 @@ struct ComposeEditorView: View {
}
}
// 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 {
guard let requestURL = URL(string: url) else { return }
// 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)
}
@@ -175,6 +288,46 @@ struct ComposeEditorView: View {
}
} 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 }
}
}

View File

@@ -36,6 +36,15 @@ struct ComposeListView: View {
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let ts = request.lastSentAt {
Text(formatTimestamp(ts))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer()
if let status = request.responseStatus {
StatusBadge(statusCode: status)
}
}
}
@@ -124,6 +133,13 @@ struct ComposeListView: View {
try? composeRepo.insert(&request)
}
private func formatTimestamp(_ ts: Double) -> String {
let date = Date(timeIntervalSince1970: ts)
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return "Sent \(formatter.localizedString(for: date, relativeTo: Date()))"
}
private func encodeHeaders(_ headers: [(key: String, value: String)]) -> String? {
var dict: [String: String] = [:]
for h in headers { dict[h.key] = h.value }

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct FullResponseView: View {
let statusCode: Int?
let responseBody: String
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let status = statusCode {
HStack {
StatusBadge(statusCode: status)
Spacer()
}
}
Text(responseBody)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
.navigationTitle("Full Response")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
UIPasteboard.general.string = responseBody
} label: {
Image(systemName: "doc.on.doc")
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
import ProxyCore
struct HeaderEditorSheet: View {
@State var headers: [(key: String, value: String)]
let onSave: ([(key: String, value: String)]) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(headers.indices, id: \.self) { index in
HStack(spacing: 8) {
TextField("Header", text: Binding(
get: { headers[index].key },
set: { headers[index].key = $0 }
))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
TextField("Value", text: Binding(
get: { headers[index].value },
set: { headers[index].value = $0 }
))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
}
}
.onDelete { indexSet in
headers.remove(atOffsets: indexSet)
}
Menu {
ForEach(ProxyConstants.commonHeaders, id: \.self) { header in
Button(header) {
headers.append((key: header, value: ""))
}
}
Divider()
Button("Custom Header") {
headers.append((key: "", value: ""))
}
} label: {
Label("Add Header", systemImage: "plus.circle.fill")
}
}
.navigationTitle("Edit Headers")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
let filtered = headers.filter { !$0.key.isEmpty }
onSave(filtered)
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct QueryEditorSheet: View {
@State var parameters: [(key: String, value: String)]
let onSave: ([(key: String, value: String)]) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(parameters.indices, id: \.self) { index in
HStack(spacing: 8) {
TextField("Key", text: Binding(
get: { parameters[index].key },
set: { parameters[index].key = $0 }
))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
TextField("Value", text: Binding(
get: { parameters[index].value },
set: { parameters[index].value = $0 }
))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
}
}
.onDelete { indexSet in
parameters.remove(atOffsets: indexSet)
}
Button {
parameters.append((key: "", value: ""))
} label: {
Label("Add Parameter", systemImage: "plus.circle.fill")
}
}
.navigationTitle("Edit Query Parameters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
let filtered = parameters.filter { !$0.key.isEmpty }
onSave(filtered)
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}

View File

@@ -10,73 +10,375 @@ struct DomainDetailView: View {
@State private var filterChips: [FilterChip] = [
FilterChip(label: "JSON"),
FilterChip(label: "Form"),
FilterChip(label: "HTTP"),
FilterChip(label: "HTTPS"),
FilterChip(label: "Errors"),
FilterChip(label: "HTTPS")
]
@State private var observation: AnyDatabaseCancellable?
@State private var didStartObservation = false
private let trafficRepo = TrafficRepository()
private let hardcodedDebugDomain = "okcupid"
private let hardcodedDebugNeedle = "jill"
var filteredRequests: [CapturedTraffic] {
private var filteredRequests: [CapturedTraffic] {
filteredResults(for: requests)
}
private var httpsCount: Int {
requests.filter { $0.scheme == "https" }.count
}
private var errorCount: Int {
requests.filter { ($0.statusCode ?? 0) >= 400 }.count
}
private var jsonCount: Int {
requests.filter {
$0.responseContentType?.contains("json") == true ||
$0.requestContentType?.contains("json") == true
}.count
}
private var lastSeenText: String {
guard let date = requests.first?.startDate else { return "Waiting" }
return date.formatted(.relative(presentation: .named))
}
private var activeFilterLabels: String {
let labels = filterChips.filter(\.isSelected).map(\.label)
return labels.isEmpty ? "none" : labels.joined(separator: ",")
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
summaryCard
filtersCard
if filteredRequests.isEmpty {
emptyStateCard
} else {
LazyVStack(spacing: 12) {
ForEach(filteredRequests) { request in
if let id = request.id {
NavigationLink(value: id) {
TrafficRowView(traffic: request)
}
.buttonStyle(.plain)
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.padding(.bottom, 28)
}
.background(screenBackground)
.scrollIndicators(.hidden)
.searchable(text: $searchText, prompt: "Search path, method, status, or response body")
.navigationTitle(domain)
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: Int64.self) { id in
RequestDetailView(trafficId: id)
}
.onAppear {
ProxyLogger.ui.info("DomainDetailView[\(domain)]: onAppear requests=\(requests.count)")
}
.onDisappear {
ProxyLogger.ui.info("DomainDetailView[\(domain)]: onDisappear requests=\(requests.count)")
observation?.cancel()
observation = nil
didStartObservation = false
}
.onChange(of: searchText) { _, newValue in
ProxyLogger.ui.info("DomainDetailView[\(domain)]: search changed text=\(newValue)")
if newValue.localizedCaseInsensitiveContains(hardcodedDebugNeedle) {
logHardcodedSearchDebug(requests: requests, source: "searchChanged")
}
}
.onChange(of: filterChips) { _, _ in
ProxyLogger.ui.info("DomainDetailView[\(domain)]: filters changed active=\(activeFilterLabels)")
if searchText.localizedCaseInsensitiveContains(hardcodedDebugNeedle) {
logHardcodedSearchDebug(requests: requests, source: "filtersChanged")
}
}
.task {
guard !didStartObservation else {
ProxyLogger.ui.info("DomainDetailView[\(domain)]: task rerun ignored; observation already active")
return
}
didStartObservation = true
ProxyLogger.ui.info("DomainDetailView[\(domain)]: starting observation")
observation = trafficRepo.observeTraffic(forDomain: domain)
.start(in: DatabaseManager.shared.dbPool) { error in
ProxyLogger.ui.error("DomainDetailView[\(domain)]: observation error \(error.localizedDescription)")
} onChange: { newRequests in
let filteredCount = filteredResults(for: newRequests).count
let preview = newRequests.prefix(3).compactMap { request -> String? in
guard let id = request.id else { return request.method }
return "#\(id):\(request.method)"
}.joined(separator: ", ")
ProxyLogger.ui.info(
"DomainDetailView[\(domain)]: requests updated count=\(newRequests.count) filtered=\(filteredCount) preview=\(preview)"
)
logHardcodedSearchDebug(requests: newRequests, source: "observation")
withAnimation(.snappy) {
requests = newRequests
}
}
}
}
private var summaryCard: some View {
DomainSurfaceCard {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(domain)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text("Scan recent calls, errors, payload types, and request timing for this host.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
LazyVGrid(columns: summaryColumns, spacing: 10) {
summaryMetric("Requests", value: "\(requests.count)", systemImage: "point.3.connected.trianglepath.dotted")
summaryMetric("HTTPS", value: "\(httpsCount)", systemImage: "lock.fill")
summaryMetric("Errors", value: "\(errorCount)", systemImage: "exclamationmark.triangle.fill")
summaryMetric("JSON", value: "\(jsonCount)", systemImage: "curlybraces")
}
HStack(spacing: 8) {
domainTag("Last seen \(lastSeenText)", systemImage: "clock")
if !searchText.isEmpty {
domainTag("Searching", systemImage: "magnifyingglass", tint: .accentColor)
}
}
}
}
}
private var filtersCard: some View {
DomainSurfaceCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Refine Requests")
.font(.headline)
Text("\(filteredRequests.count) of \(requests.count) shown")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if filterChips.contains(where: \.isSelected) {
Button("Clear") {
withAnimation(.snappy) {
for index in filterChips.indices {
filterChips[index].isSelected = false
}
}
}
.font(.caption.weight(.semibold))
}
}
FilterChipsView(chips: $filterChips)
}
}
}
private var emptyStateCard: some View {
DomainSurfaceCard {
EmptyStateView(
icon: "line.3.horizontal.decrease.circle",
title: requests.isEmpty ? "No Requests Yet" : "No Matching Requests",
subtitle: requests.isEmpty
? "Traffic for this domain will appear here as soon as the app captures it."
: "Try a different search or clear one of the active filters."
)
}
}
private var summaryColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible())]
}
@ViewBuilder
private func summaryMetric(_ title: String, value: String, systemImage: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color(.systemBackground).opacity(0.75), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private func domainTag(_ text: String, systemImage: String, tint: Color = .secondary) -> some View {
Label(text, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tint.opacity(0.10), in: Capsule())
}
private var screenBackground: some View {
LinearGradient(
colors: [
Color(.systemGroupedBackground),
Color(.secondarySystemGroupedBackground)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
}
private func filteredResults(for requests: [CapturedTraffic]) -> [CapturedTraffic] {
var result = requests
if !searchText.isEmpty {
result = result.filter { $0.url.localizedCaseInsensitiveContains(searchText) }
result = result.filter {
$0.url.localizedCaseInsensitiveContains(searchText) ||
$0.method.localizedCaseInsensitiveContains(searchText) ||
($0.statusText?.localizedCaseInsensitiveContains(searchText) == true) ||
($0.searchableResponseBodyText?.localizedCaseInsensitiveContains(searchText) == true)
}
}
let activeFilters = filterChips.filter(\.isSelected).map(\.label)
let activeFilters = Set(filterChips.filter(\.isSelected).map(\.label))
if !activeFilters.isEmpty {
result = result.filter { request in
for filter in activeFilters {
activeFilters.contains { filter in
switch filter {
case "JSON":
if request.responseContentType?.contains("json") == true { return true }
return request.responseContentType?.contains("json") == true ||
request.requestContentType?.contains("json") == true
case "Form":
if request.requestContentType?.contains("form") == true { return true }
case "HTTP":
if request.scheme == "http" { return true }
return request.requestContentType?.contains("form") == true
case "Errors":
return (request.statusCode ?? 0) >= 400
case "HTTPS":
if request.scheme == "https" { return true }
default: break
return request.scheme == "https"
default:
return false
}
}
return false
}
}
return result
}
var body: some View {
VStack(spacing: 0) {
FilterChipsView(chips: $filterChips)
.padding(.vertical, 8)
private func logHardcodedSearchDebug(requests: [CapturedTraffic], source: String) {
guard domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) ||
requests.contains(where: {
$0.domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) ||
$0.url.localizedCaseInsensitiveContains(hardcodedDebugDomain)
}) ||
searchText.localizedCaseInsensitiveContains(hardcodedDebugNeedle) else {
return
}
List {
ForEach(filteredRequests) { request in
NavigationLink(value: request.id) {
TrafficRowView(traffic: request)
}
}
}
let matchingRequests = requests.filter { request in
request.searchableResponseBodyText?.localizedCaseInsensitiveContains(hardcodedDebugNeedle) == true
}
.searchable(text: $searchText)
.navigationTitle(domain)
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: Int64?.self) { id in
if let id {
RequestDetailView(trafficId: id)
}
if matchingRequests.isEmpty {
ProxyLogger.ui.info(
"HARDCODED DEBUG search source=\(source) domain=\(domain) needle=\(hardcodedDebugNeedle) found=0 total=\(requests.count) searchText=\(searchText)"
)
logHardcodedRequestDiagnostics(requests: requests, source: source)
return
}
.task {
observation = trafficRepo.observeTraffic(forDomain: domain)
.start(in: DatabaseManager.shared.dbPool) { error in
print("Observation error: \(error)")
} onChange: { newRequests in
withAnimation {
requests = newRequests
}
}
let filtered = filteredResults(for: requests)
for request in matchingRequests {
let isVisibleInFilteredResults = filtered.contains { $0.id == request.id }
let bodyPreview = String((request.searchableResponseBodyText ?? "").prefix(180))
.replacingOccurrences(of: "\n", with: " ")
ProxyLogger.ui.info(
"""
HARDCODED DEBUG search source=\(source) domain=\(domain) needle=\(hardcodedDebugNeedle) \
requestId=\(request.id ?? -1) visible=\(isVisibleInFilteredResults) url=\(request.url) \
status=\(request.statusCode ?? -1) preview=\(bodyPreview)
"""
)
}
}
private func logHardcodedRequestDiagnostics(requests: [CapturedTraffic], source: String) {
for request in requests.prefix(20) {
let contentEncoding = request.responseHeaderValue(named: "Content-Encoding") ?? "nil"
let contentType = request.responseContentType ?? "nil"
let bodySize = request.responseBody?.count ?? 0
let decodedBodySize = request.decodedResponseBodyData?.count ?? 0
let decodingHint = request.responseBodyDecodingHint
let gzipMagic = hasGzipMagic(request.responseBody)
let preview = String((request.searchableResponseBodyText ?? "<no searchable text>").prefix(140))
.replacingOccurrences(of: "\n", with: " ")
ProxyLogger.ui.info(
"""
HARDCODED DEBUG body source=\(source) domain=\(domain) requestId=\(request.id ?? -1) \
status=\(request.statusCode ?? -1) contentType=\(contentType) contentEncoding=\(contentEncoding) \
bodyBytes=\(bodySize) decodedBytes=\(decodedBodySize) decoding=\(decodingHint) \
gzipMagic=\(gzipMagic) preview=\(preview)
"""
)
}
}
private func hasGzipMagic(_ data: Data?) -> Bool {
guard let data, data.count >= 2 else { return false }
return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b
}
}
private struct DomainSurfaceCard<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.accentColor.opacity(0.10),
.clear,
Color.blue.opacity(0.05)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
}
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)
)
}
}

View File

@@ -8,6 +8,8 @@ struct HomeView: View {
@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()
@@ -52,6 +54,23 @@ struct HomeView: View {
.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")
}
}
}
}
}
@@ -86,19 +105,70 @@ struct HomeView: View {
} 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 {
startObservation()
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 func startObservation() {
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
print("[HomeView] Observation error: \(error)")
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")
}
}
}
}

View File

@@ -2,44 +2,54 @@ import SwiftUI
import ProxyCore
struct RequestDetailView: View {
@Environment(AppState.self) private var appState
let trafficId: Int64
@State private var traffic: CapturedTraffic?
@State private var selectedSegment: Segment = .request
@State private var requestBodyMode: BodyDisplayMode?
@State private var responseBodyMode: BodyDisplayMode?
@State private var showShareSheet = false
private let trafficRepo = TrafficRepository()
private let rulesRepo = RulesRepository()
enum Segment: String, CaseIterable {
case request = "Request"
case response = "Response"
}
enum BodyDisplayMode: String, CaseIterable {
case body = "Body"
case tree = "Tree"
case hex = "Hex"
}
var body: some View {
Group {
if let traffic {
VStack(spacing: 0) {
Picker("Segment", selection: $selectedSegment) {
ForEach(Segment.allCases, id: \.self) { segment in
Text(segment.rawValue).tag(segment)
}
}
.pickerStyle(.segmented)
.padding()
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
heroCard(for: traffic)
segmentSwitcher
ScrollView {
VStack(alignment: .leading, spacing: 16) {
switch selectedSegment {
case .request:
requestContent(traffic)
case .response:
responseContent(traffic)
}
switch selectedSegment {
case .request:
requestContent(traffic)
case .response:
responseContent(traffic)
}
.padding()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.padding(.bottom, 28)
}
.scrollIndicators(.hidden)
.background(screenBackground)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(screenBackground)
}
}
.navigationTitle(traffic?.domain ?? "Request")
@@ -48,6 +58,15 @@ struct RequestDetailView: View {
if let traffic {
ToolbarItem(placement: .topBarTrailing) {
Button {
ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: share tapped")
showShareSheet = true
} label: {
Image(systemName: "square.and.arrow.up")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: pin toggled to \(!traffic.isPinned)")
try? trafficRepo.togglePin(id: trafficId, isPinned: !traffic.isPinned)
self.traffic?.isPinned.toggle()
} label: {
@@ -56,8 +75,83 @@ struct RequestDetailView: View {
}
}
}
.sheet(isPresented: $showShareSheet) {
if let traffic {
ActivitySheet(activityItems: [buildCURL(from: traffic)])
}
}
.onAppear {
ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: onAppear")
}
.onDisappear {
ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: onDisappear")
}
.onChange(of: selectedSegment) { _, newValue in
ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: segment changed to \(newValue.rawValue)")
}
.task {
traffic = try? trafficRepo.traffic(byId: trafficId)
ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: loading traffic record")
do {
let loadedTraffic = try trafficRepo.traffic(byId: trafficId)
traffic = loadedTraffic
if let loadedTraffic {
ProxyLogger.ui.info(
"RequestDetailView[\(trafficId)]: loaded method=\(loadedTraffic.method) domain=\(loadedTraffic.domain) status=\(loadedTraffic.statusCode ?? -1)"
)
} else {
ProxyLogger.ui.error("RequestDetailView[\(trafficId)]: no traffic record found")
}
} catch {
ProxyLogger.ui.error("RequestDetailView[\(trafficId)]: load failed \(error.localizedDescription)")
}
}
}
private func heroCard(for traffic: CapturedTraffic) -> some View {
DetailScreenCard {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(primaryTitle(for: traffic))
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text(traffic.url)
.font(.subheadline)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
MethodBadge(method: traffic.method)
StatusBadge(statusCode: traffic.statusCode)
detailTag(traffic.scheme.uppercased(), systemImage: "network")
inspectionTag(for: traffic)
if traffic.isPinned {
detailTag("Pinned", systemImage: "pin.fill", tint: .secondary)
}
}
.padding(.vertical, 2)
}
LazyVGrid(columns: overviewColumns, spacing: 10) {
metricTile("Started", value: traffic.startDate.formatted(.dateTime.hour().minute().second()), systemImage: "clock")
metricTile("Duration", value: traffic.formattedDuration, systemImage: "timer")
metricTile("Request", value: formatBytes(traffic.requestBodySize), systemImage: "arrow.up.right")
metricTile("Response", value: responseSizeText(for: traffic), systemImage: "arrow.down.right")
}
}
}
}
private var segmentSwitcher: some View {
DetailScreenCard(padding: 8) {
Picker("Segment", selection: $selectedSegment) {
ForEach(Segment.allCases, id: \.self) { segment in
Text(segment.rawValue).tag(segment)
}
}
.pickerStyle(.segmented)
}
}
@@ -65,55 +159,70 @@ struct RequestDetailView: View {
@ViewBuilder
private func requestContent(_ traffic: CapturedTraffic) -> some View {
// General
DisclosureGroup("General") {
VStack(alignment: .leading, spacing: 12) {
KeyValueRow(key: "URL", value: traffic.url)
KeyValueRow(key: "Method", value: traffic.method)
KeyValueRow(key: "Scheme", value: traffic.scheme)
KeyValueRow(key: "Time", value: traffic.startDate.formatted(.dateTime))
if let duration = traffic.durationMs {
KeyValueRow(key: "Duration", value: "\(duration) ms")
}
if let status = traffic.statusCode {
KeyValueRow(key: "Status", value: "\(status) \(traffic.statusText ?? "")")
}
}
.padding(.vertical, 8)
if let tlsHint = tlsHint(for: traffic) {
warningCard(
title: "HTTPS Passthrough",
message: tlsHint
)
}
// Headers
let requestHeaders = traffic.decodedRequestHeaders
if !requestHeaders.isEmpty {
DisclosureGroup("Headers (\(requestHeaders.count))") {
VStack(alignment: .leading, spacing: 12) {
ForEach(requestHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
KeyValueRow(key: key, value: value)
DetailScreenCard {
VStack(alignment: .leading, spacing: 14) {
cardHeader(
title: "Request Overview",
subtitle: "Core metadata and payload info"
)
detailField("Host", value: traffic.domain)
detailField("Path", value: primaryTitle(for: traffic))
LazyVGrid(columns: overviewColumns, spacing: 12) {
detailField("Method", value: traffic.method.uppercased())
detailField("Scheme", value: traffic.scheme.uppercased())
if let contentType = traffic.requestContentType {
detailField("Content-Type", value: contentType)
}
detailField("Body Size", value: formatBytes(traffic.requestBodySize))
if !traffic.decodedQueryParameters.isEmpty {
detailField("Query Items", value: "\(traffic.decodedQueryParameters.count)")
}
if traffic.isWebsocket {
detailField("Upgrade", value: "WebSocket")
}
}
.padding(.vertical, 8)
}
}
// Query Parameters
let queryParams = traffic.decodedQueryParameters
if !queryParams.isEmpty {
DisclosureGroup("Query (\(queryParams.count))") {
VStack(alignment: .leading, spacing: 12) {
ForEach(queryParams.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
KeyValueRow(key: key, value: value)
}
}
.padding(.vertical, 8)
}
detailKeyValueCard(
title: "Query Parameters",
subtitle: "\(queryParams.count) items",
actionTitle: nil,
action: nil,
pairs: queryParams.sorted(by: { $0.key < $1.key })
)
}
let requestHeaders = traffic.decodedRequestHeaders
if !requestHeaders.isEmpty {
detailKeyValueCard(
title: "Headers",
subtitle: "\(requestHeaders.count) items",
actionTitle: "Copy",
action: { copyHeaders(requestHeaders) },
pairs: requestHeaders.sorted(by: { $0.key < $1.key })
)
}
// Body
if let body = traffic.requestBody, !body.isEmpty {
DisclosureGroup("Body (\(formatBytes(body.count)))") {
bodyView(data: body, contentType: traffic.requestContentType)
.padding(.vertical, 8)
}
bodyCard(
title: "Request Body",
subtitle: formatBytes(body.count),
data: body,
contentType: traffic.requestContentType,
mode: $requestBodyMode
)
}
}
@@ -121,63 +230,246 @@ struct RequestDetailView: View {
@ViewBuilder
private func responseContent(_ traffic: CapturedTraffic) -> some View {
if let status = traffic.statusCode {
// Status
HStack {
StatusBadge(statusCode: status)
Text(traffic.statusText ?? "")
.font(.subheadline)
if traffic.statusCode == nil {
DetailScreenCard {
EmptyStateView(
icon: "clock",
title: "Waiting for Response",
subtitle: "The response has not been received yet."
)
}
.padding(.vertical, 4)
}
} else {
let responseHeaders = traffic.decodedResponseHeaders
let displayedResponseBody = traffic.decodedResponseBodyData ?? traffic.responseBody
// Headers
let responseHeaders = traffic.decodedResponseHeaders
if !responseHeaders.isEmpty {
DisclosureGroup("Headers (\(responseHeaders.count))") {
VStack(alignment: .leading, spacing: 12) {
ForEach(responseHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
KeyValueRow(key: key, value: value)
DetailScreenCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Response Overview")
.font(.headline)
Text(traffic.statusText ?? "Response received")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
StatusBadge(statusCode: traffic.statusCode)
}
LazyVGrid(columns: overviewColumns, spacing: 12) {
if let statusCode = traffic.statusCode {
detailField("Status", value: "\(statusCode)")
}
detailField("Body Size", value: formatBytes(traffic.responseBodySize))
if let responseType = traffic.responseContentType {
detailField("Content-Type", value: responseType)
}
if let completedAt = traffic.completedAt {
detailField(
"Finished",
value: Date(timeIntervalSince1970: completedAt)
.formatted(.dateTime.hour().minute().second())
)
}
if let server = traffic.responseHeaderValue(named: "Server") {
detailField("Server", value: server)
}
detailField("Headers", value: "\(responseHeaders.count)")
}
}
.padding(.vertical, 8)
}
if !responseHeaders.isEmpty {
detailKeyValueCard(
title: "Headers",
subtitle: "\(responseHeaders.count) items",
actionTitle: "Copy",
action: { copyHeaders(responseHeaders) },
pairs: responseHeaders.sorted(by: { $0.key < $1.key })
)
}
if let body = displayedResponseBody, !body.isEmpty {
bodyCard(
title: "Response Body",
subtitle: responseBodySubtitle(for: traffic, displayedBody: body),
data: body,
contentType: traffic.responseContentType,
mode: $responseBodyMode
)
}
}
}
// Body
if let body = traffic.responseBody, !body.isEmpty {
DisclosureGroup("Body (\(formatBytes(body.count)))") {
bodyView(data: body, contentType: traffic.responseContentType)
.padding(.vertical, 8)
// MARK: - Shared Content Builders
@ViewBuilder
private func warningCard(title: String, message: String) -> some View {
DetailScreenCard {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "lock.fill")
.font(.headline)
.foregroundStyle(.orange)
.frame(width: 28, height: 28)
.background(Color.orange.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
if traffic.statusCode == nil {
EmptyStateView(
icon: "clock",
title: "Waiting for Response",
subtitle: "The response has not been received yet."
)
@ViewBuilder
private func detailKeyValueCard(
title: String,
subtitle: String?,
actionTitle: String?,
action: (() -> Void)?,
pairs: [(key: String, value: String)]
) -> some View {
DetailScreenCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if let actionTitle, let action {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.controlSize(.small)
}
}
VStack(spacing: 0) {
ForEach(Array(pairs.enumerated()), id: \.offset) { index, item in
VStack(alignment: .leading, spacing: 4) {
Text(item.key)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(item.value)
.font(.subheadline)
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 12)
if index < pairs.count - 1 {
Divider()
}
}
}
}
}
}
@ViewBuilder
private func bodyCard(
title: String,
subtitle: String,
data: Data,
contentType: String?,
mode: Binding<BodyDisplayMode?>
) -> some View {
DetailScreenCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Copy") {
copyBody(data, contentType: contentType)
}
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.controlSize(.small)
}
bodyView(data: data, contentType: contentType, mode: mode)
}
}
}
// MARK: - Body View
@ViewBuilder
private func bodyView(data: Data, contentType: String?) -> some View {
private func defaultMode(for data: Data, contentType: String?) -> BodyDisplayMode {
if let contentType, contentType.contains("json"),
let json = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted),
let string = String(data: pretty, encoding: .utf8) {
ScrollView(.horizontal) {
Text(string)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
(try? JSONSerialization.jsonObject(with: data)) != nil {
return .tree
} else if String(data: data, encoding: .utf8) != nil {
return .body
} else {
return .hex
}
}
@ViewBuilder
private func bodyView(data: Data, contentType: String?, mode: Binding<BodyDisplayMode?>) -> some View {
let resolvedMode = mode.wrappedValue ?? defaultMode(for: data, contentType: contentType)
VStack(alignment: .leading, spacing: 12) {
Picker("Display", selection: Binding(
get: { resolvedMode },
set: { mode.wrappedValue = $0 }
)) {
ForEach(BodyDisplayMode.allCases, id: \.self) { displayMode in
Text(displayMode.rawValue).tag(displayMode)
}
}
} else if let string = String(data: data, encoding: .utf8) {
Text(string)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.pickerStyle(.segmented)
Group {
switch resolvedMode {
case .body:
bodyTextView(data: data, contentType: contentType)
case .tree:
JSONTreeView(data: data)
case .hex:
ScrollView(.horizontal) {
HexView(data: data)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)
)
}
}
@ViewBuilder
private func bodyTextView(data: Data, contentType: String?) -> some View {
if let bodyString = formattedBodyString(data: data, contentType: contentType) {
SelectableTextView(text: bodyString)
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
} else {
Text("\(data.count) bytes (binary)")
.font(.caption)
@@ -185,9 +477,274 @@ struct RequestDetailView: View {
}
}
// MARK: - Helpers
private var overviewColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible())]
}
private func primaryTitle(for traffic: CapturedTraffic) -> String {
guard let components = URLComponents(string: traffic.url) else { return traffic.url }
var value = components.path
if let query = components.percentEncodedQuery, !query.isEmpty {
value += "?\(query)"
}
if value.isEmpty {
if let host = components.host {
let portSuffix = components.port.map { ":\($0)" } ?? ""
return "\(host)\(portSuffix)"
}
return traffic.url
}
return value
}
private func responseSizeText(for traffic: CapturedTraffic) -> String {
if traffic.responseBodySize > 0 {
return formatBytes(traffic.responseBodySize)
}
if traffic.statusCode == nil {
return "Pending"
}
return "0 B"
}
private func responseBodySubtitle(for traffic: CapturedTraffic, displayedBody: Data) -> String {
let rawSize = formatBytes(traffic.responseBodySize)
let hint = traffic.responseBodyDecodingHint
guard hint != "identity", hint != "empty" else {
return rawSize
}
return "\(formatBytes(displayedBody.count)) shown from \(rawSize) raw (\(hint))"
}
private func formattedBodyString(data: Data, contentType: String?) -> String? {
if let contentType, contentType.localizedCaseInsensitiveContains("json"),
let json = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]),
let string = String(data: pretty, encoding: .utf8) {
return string
}
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
return string
}
return nil
}
private func cardHeader(title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private func metricTile(_ title: String, value: String, systemImage: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color(.systemBackground).opacity(0.75), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private func detailField(_ title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline)
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
private func detailTag(_ text: String, systemImage: String? = nil, tint: Color = .secondary) -> some View {
Group {
if let systemImage {
Label(text, systemImage: systemImage)
} else {
Text(text)
}
}
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tint.opacity(0.10), in: Capsule())
}
@ViewBuilder
private func inspectionTag(for traffic: CapturedTraffic) -> some View {
if traffic.scheme == "http" {
detailTag("Plain HTTP", systemImage: "bolt.horizontal", tint: .blue)
} else if traffic.isSslDecrypted {
detailTag("Decrypted", systemImage: "lock.open.fill", tint: .green)
} else {
detailTag("Encrypted", systemImage: "lock.fill", tint: .orange)
}
}
private var screenBackground: some View {
LinearGradient(
colors: [
Color(.systemGroupedBackground),
Color(.secondarySystemGroupedBackground)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
}
private func formatBytes(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
}
private func tlsHint(for traffic: CapturedTraffic) -> String? {
guard !traffic.isSslDecrypted, traffic.method == "CONNECT" else { return nil }
if !IPCManager.shared.isSSLProxyingEnabled {
return "SSL Proxying is turned off. Enable it in More > SSL Proxying and retry the request."
}
let state = sslProxyState(for: traffic.domain)
if state.isExcluded {
return "This domain matches an SSL Proxying exclude rule, so the connection was intentionally passed through without decryption."
}
if !state.isIncluded {
return "This domain is not in the SSL Proxying include list. Add it in More > SSL Proxying to capture HTTPS headers and bodies."
}
if !appState.hasSharedCertificate {
return "This domain is included, but the extension has not loaded the same shared CA as the app yet. Reopen the tunnel or regenerate and reinstall the certificate."
}
if let lastMITMError = appState.runtimeStatus.lastMITMError {
return "This domain is included, but HTTPS interception failed. Latest MITM error: \(lastMITMError)"
}
if !appState.isHTTPSInspectionVerified {
return "This domain is included and the shared CA is available. If it still stays encrypted, install and trust the Proxy CA in Settings, then retry."
}
return "This request stayed encrypted even though SSL Proxying is enabled. The most likely causes are certificate pinning or an upstream TLS handshake failure."
}
private func sslProxyState(for domain: String) -> (isIncluded: Bool, isExcluded: Bool) {
do {
let entries = try rulesRepo.fetchAllSSLEntries()
let isExcluded = entries
.filter { !$0.isInclude }
.contains { WildcardMatcher.matches(domain, pattern: $0.domainPattern) }
let isIncluded = entries
.filter(\.isInclude)
.contains { WildcardMatcher.matches(domain, pattern: $0.domainPattern) }
return (isIncluded, isExcluded)
} catch {
return (false, false)
}
}
// MARK: - cURL Builder
private func buildCURL(from traffic: CapturedTraffic) -> String {
var parts = ["curl -X \(traffic.method) '\(traffic.url)'"]
for (key, value) in traffic.decodedRequestHeaders.sorted(by: { $0.key < $1.key }) {
parts.append("-H '\(key): \(value)'")
}
if let body = traffic.requestBody, let bodyString = String(data: body, encoding: .utf8), !bodyString.isEmpty {
let escaped = bodyString.replacingOccurrences(of: "'", with: "'\\''")
parts.append("-d '\(escaped)'")
}
return parts.joined(separator: " \\\n ")
}
// MARK: - Copy Helpers
private func copyHeaders(_ headers: [String: String]) {
let text = headers.sorted(by: { $0.key < $1.key })
.map { "\($0.key): \($0.value)" }
.joined(separator: "\n")
UIPasteboard.general.string = text
}
private func copyBody(_ data: Data, contentType: String?) {
if let contentType, contentType.contains("json"),
let json = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted),
let string = String(data: pretty, encoding: .utf8) {
UIPasteboard.general.string = string
} else if let string = String(data: data, encoding: .utf8) {
UIPasteboard.general.string = string
}
}
}
private struct DetailScreenCard<Content: View>: View {
let padding: CGFloat
let content: Content
init(padding: CGFloat = 18, @ViewBuilder content: () -> Content) {
self.padding = padding
self.content = content()
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.accentColor.opacity(0.08),
.clear,
Color.blue.opacity(0.04)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
content
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
}
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)
)
}
}
// MARK: - Share Sheet
private struct ActivitySheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -5,41 +5,79 @@ struct TrafficRowView: View {
let traffic: CapturedTraffic
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
MethodBadge(method: traffic.method)
StatusBadge(statusCode: traffic.statusCode)
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 10) {
HStack(spacing: 6) {
MethodBadge(method: traffic.method)
StatusBadge(statusCode: traffic.statusCode)
}
Spacer()
Spacer(minLength: 12)
Text(traffic.startDate, format: .dateTime.hour().minute().second().secondFraction(.fractional(3)))
.font(.caption2)
.foregroundStyle(.secondary)
VStack(alignment: .trailing, spacing: 4) {
Text(traffic.startDate, format: .dateTime.hour().minute().second().secondFraction(.fractional(3)))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Text(traffic.formattedDuration)
.font(.caption2)
.foregroundStyle(.secondary)
Text(traffic.formattedDuration)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
Text(traffic.url)
.font(.caption)
.lineLimit(3)
.foregroundStyle(.primary)
VStack(alignment: .leading, spacing: 4) {
Text(primaryLine)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(2)
HStack(spacing: 12) {
if traffic.requestBodySize > 0 {
Label(formatBytes(traffic.requestBodySize), systemImage: "arrow.up.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(secondaryLine)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
HStack(spacing: 8) {
TransferPill(
systemImage: "arrow.up.circle.fill",
text: formatBytes(traffic.requestBodySize),
tint: .green
)
TransferPill(
systemImage: "arrow.down.circle.fill",
text: responseSizeText,
tint: .blue
)
if let responseContentType = traffic.responseContentType {
MetaPill(text: shortContentType(responseContentType))
} else if let requestContentType = traffic.requestContentType {
MetaPill(text: shortContentType(requestContentType))
}
if traffic.responseBodySize > 0 {
Label(formatBytes(traffic.responseBodySize), systemImage: "arrow.down.circle.fill")
.font(.caption2)
.foregroundStyle(.blue)
if traffic.scheme == "https" && !traffic.isSslDecrypted {
MetaPill(text: "Encrypted", tint: .orange)
}
Spacer(minLength: 0)
if traffic.isPinned {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)
)
}
private func formatBytes(_ bytes: Int) -> String {
@@ -47,4 +85,64 @@ struct TrafficRowView: View {
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
}
private var primaryLine: String {
let components = URLComponents(string: traffic.url)
let path = components?.path ?? traffic.url
let query = components?.percentEncodedQuery.map { "?\($0)" } ?? ""
let route = path.isEmpty ? traffic.domain : path + query
return route.isEmpty ? traffic.url : route
}
private var secondaryLine: String {
if let statusText = traffic.statusText, let statusCode = traffic.statusCode {
return "\(traffic.domain)\(statusCode) \(statusText)"
}
return traffic.domain
}
private var responseSizeText: String {
if traffic.responseBodySize > 0 {
return formatBytes(traffic.responseBodySize)
}
if traffic.statusCode == nil {
return "Pending"
}
return "0 B"
}
private func shortContentType(_ contentType: String) -> String {
let base = contentType.split(separator: ";").first.map(String.init) ?? contentType
return base.replacingOccurrences(of: "application/", with: "")
.replacingOccurrences(of: "text/", with: "")
}
}
private struct TransferPill: View {
let systemImage: String
let text: String
let tint: Color
var body: some View {
Label(text, systemImage: systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tint.opacity(0.12), in: Capsule())
}
}
private struct MetaPill: View {
let text: String
var tint: Color = .secondary
var body: some View {
Text(text)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tint.opacity(0.10), in: Capsule())
}
}

View File

@@ -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
View 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")
}
}

View File

@@ -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.")

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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()
}
})
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View 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 }
}
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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) {

View File

@@ -5,6 +5,7 @@ import GRDB
struct PinView: View {
@State private var pinnedRequests: [CapturedTraffic] = []
@State private var observation: AnyDatabaseCancellable?
@State private var showClearAllConfirmation = false
private let trafficRepo = TrafficRepository()
@@ -19,19 +20,54 @@ struct PinView: View {
} else {
List {
ForEach(pinnedRequests) { request in
NavigationLink(value: request.id) {
TrafficRowView(traffic: request)
if let id = request.id {
NavigationLink(value: id) {
TrafficRowView(traffic: request)
}
}
}
.onDelete { offsets in
for index in offsets {
let request = pinnedRequests[index]
if let id = request.id {
try? trafficRepo.togglePin(id: id, isPinned: false)
}
}
}
}
.navigationDestination(for: Int64?.self) { id in
if let id {
RequestDetailView(trafficId: id)
}
.navigationDestination(for: Int64.self) { id in
RequestDetailView(trafficId: id)
}
}
}
.navigationTitle("Pin")
.toolbar {
if !pinnedRequests.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button(role: .destructive) {
showClearAllConfirmation = true
} label: {
Text("Clear All")
}
}
}
}
.confirmationDialog(
"Clear All Pins",
isPresented: $showClearAllConfirmation,
titleVisibility: .visible
) {
Button("Clear All Pins", role: .destructive) {
for request in pinnedRequests {
if let id = request.id {
try? trafficRepo.togglePin(id: id, isPinned: false)
}
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This will unpin all \(pinnedRequests.count) pinned requests.")
}
.task {
observation = trafficRepo.observePinnedTraffic()
.start(in: DatabaseManager.shared.dbPool) { error in

View File

@@ -1,6 +1,6 @@
import SwiftUI
struct FilterChip: Identifiable {
struct FilterChip: Identifiable, Equatable {
let id = UUID()
let label: String
var isSelected: Bool = false
@@ -14,21 +14,37 @@ struct FilterChipsView: View {
HStack(spacing: 8) {
ForEach($chips) { $chip in
Button {
chip.isSelected.toggle()
withAnimation(.spring(response: 0.24, dampingFraction: 0.9)) {
chip.isSelected.toggle()
}
} label: {
Text(chip.label)
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
chip.isSelected ? Color.accentColor : Color(.systemGray5),
in: Capsule()
)
.foregroundStyle(chip.isSelected ? .white : .primary)
HStack(spacing: 6) {
if chip.isSelected {
Image(systemName: "checkmark")
.font(.caption2.weight(.bold))
}
Text(chip.label)
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(
chip.isSelected ? Color.accentColor.opacity(0.16) : Color(.systemBackground),
in: Capsule()
)
.overlay(
Capsule()
.stroke(
chip.isSelected ? Color.accentColor.opacity(0.35) : Color.primary.opacity(0.06),
lineWidth: 1
)
)
.foregroundStyle(chip.isSelected ? Color.accentColor : .primary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.vertical, 2)
}
}
}

View File

@@ -0,0 +1,98 @@
import SwiftUI
struct HexView: View {
let data: Data
@State private var showAll = false
private var displayData: Data {
if showAll || data.count <= 1024 {
return data
}
return data.prefix(1024)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
Text("Offset 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ASCII")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
.padding(.bottom, 4)
// Hex rows
let bytes = [UInt8](displayData)
let rowCount = (bytes.count + 15) / 16
ForEach(0..<rowCount, id: \.self) { row in
hexRow(bytes: bytes, row: row)
}
// Show more
if !showAll && data.count > 1024 {
Button {
showAll = true
} label: {
Text("Show All (\(formatBytes(data.count)) total)")
.font(.system(.caption, design: .monospaced))
}
.padding(.top, 8)
}
}
.textSelection(.enabled)
}
private func hexRow(bytes: [UInt8], row: Int) -> some View {
let offset = row * 16
let end = min(offset + 16, bytes.count)
let rowBytes = Array(bytes[offset..<end])
return HStack(spacing: 0) {
// Offset
Text(String(format: "%08X ", offset))
.foregroundStyle(.secondary)
// Hex bytes
Text(hexString(rowBytes))
// ASCII
Text(" ")
Text(asciiString(rowBytes))
.foregroundStyle(.orange)
}
.font(.system(.caption2, design: .monospaced))
}
private func hexString(_ bytes: [UInt8]) -> String {
var result = ""
for i in 0..<16 {
if i < bytes.count {
result += String(format: "%02X ", bytes[i])
} else {
result += " "
}
if i == 7 {
result += " "
}
}
return result
}
private func asciiString(_ bytes: [UInt8]) -> String {
var result = ""
for byte in bytes {
if byte >= 0x20, byte <= 0x7E {
result += String(UnicodeScalar(byte))
} else {
result += "."
}
}
return result
}
private func formatBytes(_ count: Int) -> String {
if count < 1024 { return "\(count) B" }
if count < 1_048_576 { return String(format: "%.1f KB", Double(count) / 1024) }
return String(format: "%.1f MB", Double(count) / 1_048_576)
}
}

View File

@@ -0,0 +1,136 @@
import SwiftUI
struct JSONTreeView: View {
let data: Data
@State private var root: JSONNode?
var body: some View {
Group {
if let root {
JSONNodeExpandableView(
node: root,
collapseByDefault: false,
childCollapseThreshold: 50
)
} else {
Text("Invalid JSON")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.task {
root = parseJSON(data)
}
}
private func parseJSON(_ data: Data) -> JSONNode? {
guard let obj = try? JSONSerialization.jsonObject(with: data) else { return nil }
return buildNode(key: nil, value: obj)
}
private func buildNode(key: String?, value: Any) -> JSONNode {
if let dict = value as? [String: Any] {
let children = dict.sorted(by: { $0.key < $1.key }).map { buildNode(key: $0.key, value: $0.value) }
return JSONNode(key: key, kind: .object, displayValue: "{\(dict.count)}", children: children)
} else if let arr = value as? [Any] {
let children = arr.enumerated().map { buildNode(key: "[\($0.offset)]", value: $0.element) }
return JSONNode(key: key, kind: .array, displayValue: "[\(arr.count)]", children: children)
} else if let str = value as? String {
return JSONNode(key: key, kind: .string, displayValue: "\"\(str)\"", children: [])
} else if let num = value as? NSNumber {
if CFBooleanGetTypeID() == CFGetTypeID(num) {
let boolVal = num.boolValue
return JSONNode(key: key, kind: .bool, displayValue: boolVal ? "true" : "false", children: [])
}
return JSONNode(key: key, kind: .number, displayValue: "\(num)", children: [])
} else if value is NSNull {
return JSONNode(key: key, kind: .null, displayValue: "null", children: [])
} else {
return JSONNode(key: key, kind: .string, displayValue: "\(value)", children: [])
}
}
}
// MARK: - Model
private struct JSONNode: Identifiable {
let id = UUID()
let key: String?
let kind: JSONValueKind
let displayValue: String
let children: [JSONNode]
var childCount: Int {
children.count + children.reduce(0) { $0 + $1.childCount }
}
}
private enum JSONValueKind {
case object, array, string, number, bool, null
}
// MARK: - Expandable wrapper (allows per-node expand state)
private struct JSONNodeExpandableView: View {
let node: JSONNode
let collapseByDefault: Bool
let childCollapseThreshold: Int
@State private var isExpanded: Bool
init(node: JSONNode, collapseByDefault: Bool, childCollapseThreshold: Int) {
self.node = node
self.collapseByDefault = collapseByDefault
self.childCollapseThreshold = childCollapseThreshold
_isExpanded = State(initialValue: !collapseByDefault)
}
var body: some View {
if node.children.isEmpty {
leafRow
} else {
DisclosureGroup(isExpanded: $isExpanded) {
ForEach(node.children) { child in
JSONNodeExpandableView(
node: child,
collapseByDefault: child.childCount > childCollapseThreshold,
childCollapseThreshold: childCollapseThreshold
)
}
} label: {
labelRow(valueColor: .secondary)
}
}
}
private var leafRow: some View {
labelRow(valueColor: valueColor)
}
private func labelRow(valueColor: Color) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
if let key = node.key {
Text(key + ":")
.font(.system(.caption, design: .monospaced))
.bold()
.foregroundStyle(.primary)
}
Text(node.displayValue)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(valueColor)
.textSelection(.enabled)
}
}
private var valueColor: Color {
switch node.kind {
case .string: return .green
case .number: return .blue
case .bool: return .orange
case .null: return .gray
case .object, .array: return .secondary
}
}
}

View File

@@ -0,0 +1,34 @@
import SwiftUI
import UIKit
struct SelectableTextView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = true
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.adjustsFontForContentSizeCategory = true
textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
textView.textColor = .label
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text {
uiView.text = text
}
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
let width = proposal.width ?? UIScreen.main.bounds.width
let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude)
let size = uiView.sizeThatFits(fittingSize)
return CGSize(width: width, height: ceil(size.height))
}
}

View File

@@ -0,0 +1,13 @@
# Signal Geometry
## A Visual Philosophy
**Signal Geometry** holds that invisible infrastructure — the unseen pathways through which information travels — possesses an inherent visual beauty when rendered with the precision of cartographic science and the soul of concrete poetry. The philosophy treats data flow not as abstraction but as *terrain*: something that can be surveyed, contoured, and mapped with the same reverence a geologist brings to lithographic cross-sections. Every mark is meticulously crafted, the product of deep expertise and painstaking attention to spatial relationships that most would overlook entirely.
Form emerges from the tension between containment and passage. Circles suggest endpoints, nodes, origins. Lines suggest transit, inspection, the liminal space between request and response. The philosophy demands that these elemental shapes — circles bisected by lines, arrows that curve through bounded fields, apertures that frame what flows through them — carry all meaning. There is no decoration; every stroke is structural. The composition must appear as though someone at the absolute top of their field labored over every radius, every weight, every negative-space pocket until the geometry itself *breathes*. Master-level execution is the only acceptable standard.
Color operates as signal classification. A constrained palette — no more than three hues per composition — encodes function through chromatic discipline. Cool tones (deep navy, slate, teal) convey the substrate: the network itself, the infrastructure layer. A single warm or luminous accent (electric blue, amber, signal green) marks the point of intervention — the moment where passive flow becomes observed flow. This accent must feel *earned*, appearing only where the concept demands it, calibrated with the care of a master colorist mixing pigments for a single decisive brushstroke. The interplay between muted ground and vivid signal creates visual hierarchy without a single word of explanation.
Space is sacred and structural. Generous negative space is not emptiness but *bandwidth* — the capacity that makes transmission possible. Elements float in considered relationship to one another, their distances as meaningful as their forms. The philosophy insists on mathematical harmony: golden ratios, consistent stroke weights, curves that resolve with the inevitability of well-engineered protocols. Density is reserved for moments of visual emphasis — a cluster of concentric forms, a grid of repeated elements — surrounded by vast quiet that amplifies their presence. Every composition should reward sustained viewing, revealing secondary relationships and subtle alignments that prove this was the work of countless hours of refinement.
Typography, when it appears at all, exists as specimen rather than communication — a single word rendered as geometric artifact, its letterforms chosen for structural compatibility with the surrounding shapes. It whispers. It labels. It never explains. The mark of Signal Geometry is that the viewer understands through form alone what words could only diminish. The final artifact must feel like it was extracted from some future discipline's reference manual — systematic, authoritative, and quietly beautiful in its precision.

BIN
icons/icon_1_lens.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

34
icons/icon_1_lens.svg Normal file
View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<clipPath id="roundrect">
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
</clipPath>
</defs>
<g clip-path="url(#roundrect)">
<rect width="1024" height="1024" fill="#0A1628"/>
<!-- Outer concentric rings - the observation apparatus -->
<circle cx="512" cy="512" r="380" fill="none" stroke="#1B2D4A" stroke-width="1.5"/>
<circle cx="512" cy="512" r="320" fill="none" stroke="#1B2D4A" stroke-width="1.5"/>
<circle cx="512" cy="512" r="260" fill="none" stroke="#1E3454" stroke-width="2"/>
<circle cx="512" cy="512" r="200" fill="none" stroke="#243D5C" stroke-width="2.5"/>
<circle cx="512" cy="512" r="140" fill="none" stroke="#2A4766" stroke-width="3"/>
<!-- The core lens -->
<circle cx="512" cy="512" r="80" fill="none" stroke="#3B6B8A" stroke-width="4"/>
<circle cx="512" cy="512" r="36" fill="#00E5CC" opacity="0.15"/>
<circle cx="512" cy="512" r="8" fill="#00E5CC"/>
<!-- The signal line — data in transit, passing through the lens -->
<line x1="80" y1="512" x2="432" y2="512" stroke="#00E5CC" stroke-width="3" opacity="0.6"/>
<line x1="592" y1="512" x2="944" y2="512" stroke="#00E5CC" stroke-width="3" opacity="0.6"/>
<!-- Entry/exit nodes -->
<circle cx="80" cy="512" r="6" fill="#00E5CC" opacity="0.4"/>
<circle cx="944" cy="512" r="6" fill="#00E5CC" opacity="0.4"/>
<!-- Subtle grid marks -->
<line x1="512" y1="100" x2="512" y2="380" stroke="#1B2D4A" stroke-width="1" opacity="0.5"/>
<line x1="512" y1="644" x2="512" y2="924" stroke="#1B2D4A" stroke-width="1" opacity="0.5"/>
</g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
icons/icon_2_prism.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

40
icons/icon_2_prism.svg Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<clipPath id="roundrect">
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
</clipPath>
</defs>
<g clip-path="url(#roundrect)">
<rect width="1024" height="1024" fill="#0D1117"/>
<!-- The prism — a rotated square -->
<polygon points="512,220 780,512 512,804 244,512" fill="none" stroke="#2D3748" stroke-width="3"/>
<polygon points="512,280 720,512 512,744 304,512" fill="#151D2B" stroke="#374863" stroke-width="1.5"/>
<!-- Input signal — single beam entering -->
<line x1="80" y1="512" x2="304" y2="512" stroke="#F0B429" stroke-width="4" opacity="0.8"/>
<!-- Output signals — separated/inspected streams -->
<line x1="720" y1="512" x2="944" y2="390" stroke="#F0B429" stroke-width="2" opacity="0.7"/>
<line x1="720" y1="512" x2="944" y2="512" stroke="#F0B429" stroke-width="2" opacity="0.5"/>
<line x1="720" y1="512" x2="944" y2="634" stroke="#F0B429" stroke-width="2" opacity="0.3"/>
<!-- Exit nodes -->
<circle cx="944" cy="390" r="5" fill="#F0B429" opacity="0.7"/>
<circle cx="944" cy="512" r="5" fill="#F0B429" opacity="0.5"/>
<circle cx="944" cy="634" r="5" fill="#F0B429" opacity="0.3"/>
<!-- Entry node -->
<circle cx="80" cy="512" r="6" fill="#F0B429" opacity="0.8"/>
<!-- Inner detail — crosshair in prism center -->
<circle cx="512" cy="512" r="24" fill="none" stroke="#F0B429" stroke-width="1.5" opacity="0.4"/>
<circle cx="512" cy="512" r="4" fill="#F0B429"/>
<!-- Subtle measurement ticks -->
<line x1="512" y1="220" x2="512" y2="190" stroke="#2D3748" stroke-width="1"/>
<line x1="512" y1="804" x2="512" y2="834" stroke="#2D3748" stroke-width="1"/>
<line x1="244" y1="512" x2="214" y2="512" stroke="#2D3748" stroke-width="1"/>
<line x1="780" y1="512" x2="810" y2="512" stroke="#2D3748" stroke-width="1"/>
</g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/icon_3_node.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

40
icons/icon_3_node.svg Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<clipPath id="roundrect">
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
</clipPath>
</defs>
<g clip-path="url(#roundrect)">
<rect width="1024" height="1024" fill="#F5F5F0"/>
<!-- Radiating connection lines from center -->
<line x1="512" y1="512" x2="200" y2="240" stroke="#1A1A2E" stroke-width="1.5" opacity="0.3"/>
<line x1="512" y1="512" x2="824" y2="240" stroke="#1A1A2E" stroke-width="1.5" opacity="0.3"/>
<line x1="512" y1="512" x2="200" y2="784" stroke="#1A1A2E" stroke-width="1.5" opacity="0.3"/>
<line x1="512" y1="512" x2="824" y2="784" stroke="#1A1A2E" stroke-width="1.5" opacity="0.3"/>
<line x1="512" y1="512" x2="140" y2="512" stroke="#1A1A2E" stroke-width="1.5" opacity="0.3"/>
<line x1="512" y1="512" x2="884" y2="512" stroke="#1A1A2E" stroke-width="1.5" opacity="0.3"/>
<!-- Endpoint nodes -->
<circle cx="200" cy="240" r="20" fill="none" stroke="#1A1A2E" stroke-width="2" opacity="0.25"/>
<circle cx="824" cy="240" r="20" fill="none" stroke="#1A1A2E" stroke-width="2" opacity="0.25"/>
<circle cx="200" cy="784" r="20" fill="none" stroke="#1A1A2E" stroke-width="2" opacity="0.25"/>
<circle cx="824" cy="784" r="20" fill="none" stroke="#1A1A2E" stroke-width="2" opacity="0.25"/>
<circle cx="140" cy="512" r="20" fill="none" stroke="#1A1A2E" stroke-width="2" opacity="0.25"/>
<circle cx="884" cy="512" r="20" fill="none" stroke="#1A1A2E" stroke-width="2" opacity="0.25"/>
<!-- Small dots at endpoints -->
<circle cx="200" cy="240" r="4" fill="#1A1A2E" opacity="0.3"/>
<circle cx="824" cy="240" r="4" fill="#1A1A2E" opacity="0.3"/>
<circle cx="200" cy="784" r="4" fill="#1A1A2E" opacity="0.3"/>
<circle cx="824" cy="784" r="4" fill="#1A1A2E" opacity="0.3"/>
<circle cx="140" cy="512" r="4" fill="#1A1A2E" opacity="0.3"/>
<circle cx="884" cy="512" r="4" fill="#1A1A2E" opacity="0.3"/>
<!-- The central proxy node — prominent -->
<circle cx="512" cy="512" r="64" fill="none" stroke="#1A1A2E" stroke-width="3"/>
<circle cx="512" cy="512" r="40" fill="none" stroke="#1A1A2E" stroke-width="1.5"/>
<circle cx="512" cy="512" r="16" fill="#2563EB"/>
<circle cx="512" cy="512" r="8" fill="#F5F5F0"/>
</g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
icons/icon_4_gate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

42
icons/icon_4_gate.svg Normal file
View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<clipPath id="roundrect">
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
</clipPath>
</defs>
<g clip-path="url(#roundrect)">
<rect width="1024" height="1024" fill="#0F2027"/>
<!-- Left gate pillar -->
<rect x="300" y="180" width="60" height="664" rx="6" fill="#E8E8E0" opacity="0.9"/>
<!-- Right gate pillar -->
<rect x="664" y="180" width="60" height="664" rx="6" fill="#E8E8E0" opacity="0.9"/>
<!-- Horizontal bars connecting the gate -->
<rect x="300" y="180" width="424" height="6" fill="#E8E8E0" opacity="0.5"/>
<rect x="300" y="838" width="424" height="6" fill="#E8E8E0" opacity="0.5"/>
<!-- The signal passing through the gate -->
<line x1="100" y1="512" x2="300" y2="512" stroke="#00D4AA" stroke-width="3" opacity="0.5"/>
<line x1="724" y1="512" x2="924" y2="512" stroke="#00D4AA" stroke-width="3" opacity="0.5"/>
<!-- Inspection dot at the gate center -->
<circle cx="512" cy="512" r="28" fill="none" stroke="#00D4AA" stroke-width="2.5"/>
<circle cx="512" cy="512" r="6" fill="#00D4AA"/>
<!-- Through-line (the inspected data) -->
<line x1="360" y1="512" x2="484" y2="512" stroke="#00D4AA" stroke-width="2" stroke-dasharray="8,6" opacity="0.6"/>
<line x1="540" y1="512" x2="664" y2="512" stroke="#00D4AA" stroke-width="2" stroke-dasharray="8,6" opacity="0.6"/>
<!-- Subtle scan lines -->
<line x1="360" y1="380" x2="664" y2="380" stroke="#1A3A4A" stroke-width="0.5" opacity="0.4"/>
<line x1="360" y1="440" x2="664" y2="440" stroke="#1A3A4A" stroke-width="0.5" opacity="0.4"/>
<line x1="360" y1="580" x2="664" y2="580" stroke="#1A3A4A" stroke-width="0.5" opacity="0.4"/>
<line x1="360" y1="640" x2="664" y2="640" stroke="#1A3A4A" stroke-width="0.5" opacity="0.4"/>
<!-- Entry/exit arrows -->
<circle cx="100" cy="512" r="5" fill="#00D4AA" opacity="0.4"/>
<circle cx="924" cy="512" r="5" fill="#00D4AA" opacity="0.4"/>
</g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
icons/icon_5_shield.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

40
icons/icon_5_shield.svg Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<clipPath id="roundrect">
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
</clipPath>
</defs>
<g clip-path="url(#roundrect)">
<defs>
<linearGradient id="bg5" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0F0F1A"/>
<stop offset="100%" stop-color="#1A1A2E"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#bg5)"/>
<!-- Shield outline — a pointed-bottom rounded form -->
<path d="M512 160 C680 160 800 220 800 340 L800 540 C800 700 680 830 512 880 C344 830 224 700 224 540 L224 340 C224 220 344 160 512 160 Z"
fill="none" stroke="#2A2A4E" stroke-width="3"/>
<path d="M512 210 C650 210 750 260 750 360 L750 530 C750 670 650 780 512 825 C374 780 274 670 274 530 L274 360 C274 260 374 210 512 210 Z"
fill="#12122A" stroke="#333360" stroke-width="1.5"/>
<!-- The aperture/eye at center -->
<ellipse cx="512" cy="480" rx="120" ry="70" fill="none" stroke="#4466FF" stroke-width="2.5" opacity="0.6"/>
<ellipse cx="512" cy="480" rx="70" ry="42" fill="none" stroke="#4466FF" stroke-width="1.5" opacity="0.4"/>
<circle cx="512" cy="480" r="18" fill="#4466FF" opacity="0.8"/>
<circle cx="512" cy="480" r="6" fill="#FFFFFF"/>
<!-- Subtle radial lines from the eye -->
<line x1="512" y1="410" x2="512" y2="340" stroke="#4466FF" stroke-width="1" opacity="0.2"/>
<line x1="512" y1="550" x2="512" y2="620" stroke="#4466FF" stroke-width="1" opacity="0.2"/>
<line x1="392" y1="480" x2="340" y2="480" stroke="#4466FF" stroke-width="1" opacity="0.2"/>
<line x1="632" y1="480" x2="684" y2="480" stroke="#4466FF" stroke-width="1" opacity="0.2"/>
<!-- Small label-like text element -->
<text x="512" y="720" text-anchor="middle" fill="#4466FF" opacity="0.25"
font-family="Helvetica Neue, Helvetica, Arial" font-size="18" font-weight="300"
letter-spacing="8">PROXY</text>
</g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -6,6 +6,23 @@ options:
xcodeVersion: "26.3"
generateEmptyDirectories: true
schemes:
ProxyApp:
build:
targets:
ProxyApp: all
ProxyCore: all
PacketTunnel: all
run:
config: Debug
executable: ProxyApp
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
settings:
base:
SWIFT_VERSION: "6.0"
@@ -44,8 +61,21 @@ targets:
dependencies:
- target: PacketTunnel
- target: ProxyCore
embed: false
- package: GRDB
- package: SwiftNIO
product: NIOCore
- package: SwiftNIO
product: NIOPosix
- package: SwiftNIOSSL
product: NIOSSL
- package: SwiftNIO
product: NIOHTTP1
- package: SwiftNIOExtras
product: NIOExtras
- package: SwiftCertificates
product: X509
- package: SwiftCrypto
product: Crypto
info:
path: App/Info.plist
properties:
@@ -53,13 +83,19 @@ targets:
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
UIApplicationSupportsMultipleScenes: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.treyt.proxyapp
CODE_SIGN_ENTITLEMENTS: App/Entitlements/ProxyApp.entitlements
SWIFT_STRICT_CONCURRENCY: complete
TARGETED_DEVICE_FAMILY: "1,2"
entitlements:
path: App/Entitlements/ProxyApp.entitlements
properties:
@@ -75,7 +111,6 @@ targets:
- path: PacketTunnel
dependencies:
- target: ProxyCore
embed: false
- package: GRDB
- package: SwiftNIO
product: NIOCore
@@ -110,6 +145,10 @@ targets:
ProxyCore:
type: framework
platform: iOS
info:
path: ProxyCore/Info.plist
properties:
CFBundlePackageType: FMWK
sources:
- path: ProxyCore/Sources
dependencies:
@@ -132,3 +171,5 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.treyt.proxyapp.ProxyCore
SWIFT_STRICT_CONCURRENCY: complete
GENERATE_INFOPLIST_FILE: YES
MACH_O_TYPE: staticlib