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