Initial project setup - Phases 1-3 complete
This commit is contained in:
27
UI/More/AdvancedSettingsView.swift
Normal file
27
UI/More/AdvancedSettingsView.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct AdvancedSettingsView: View {
|
||||
@State private var hideSystemTraffic = IPCManager.shared.hideSystemTraffic
|
||||
@State private var showImagePreview = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Hide iOS System Traffic", isOn: $hideSystemTraffic)
|
||||
.onChange(of: hideSystemTraffic) { _, newValue in
|
||||
IPCManager.shared.hideSystemTraffic = newValue
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
39
UI/More/AppSettingsView.swift
Normal file
39
UI/More/AppSettingsView.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppSettingsView: View {
|
||||
@State private var analyticsEnabled = false
|
||||
@State private var crashReportingEnabled = true
|
||||
@State private var showClearCacheConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Analytics", isOn: $analyticsEnabled)
|
||||
Toggle("Crash Reporting", isOn: $crashReportingEnabled)
|
||||
} footer: {
|
||||
Text("Help improve the app by sharing anonymous usage data.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Clear App Cache", role: .destructive) {
|
||||
showClearCacheConfirmation = true
|
||||
}
|
||||
} footer: {
|
||||
Text("Remove all cached data. This does not delete captured traffic.")
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0.0")
|
||||
LabeledContent("Build", value: "1")
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Settings")
|
||||
.confirmationDialog("Clear Cache", isPresented: $showClearCacheConfirmation) {
|
||||
Button("Clear Cache", role: .destructive) {
|
||||
// TODO: Clear URL cache, image cache, etc.
|
||||
}
|
||||
} message: {
|
||||
Text("This will clear all cached data.")
|
||||
}
|
||||
}
|
||||
}
|
||||
144
UI/More/BlockListView.swift
Normal file
144
UI/More/BlockListView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct BlockListView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBlockListEnabled
|
||||
@State private var entries: [BlockListEntry] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "Block List",
|
||||
description: "Block requests matching these rules. Blocked requests will be dropped or hidden based on the action.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isBlockListEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if entries.isEmpty {
|
||||
Text("No block rules")
|
||||
.foregroundStyle(.secondary)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = entries[index].id {
|
||||
try? rulesRepo.deleteBlockEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Block List")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NewBlockRuleView { entry in
|
||||
var entry = entry
|
||||
try? rulesRepo.insertBlockEntry(&entry)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeBlockListEntries()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Block list observation error: \(error)")
|
||||
} onChange: { newEntries in
|
||||
entries = newEntries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - New Block Rule
|
||||
|
||||
struct NewBlockRuleView: View {
|
||||
let onSave: (BlockListEntry) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var urlPattern = ""
|
||||
@State private var method = "ANY"
|
||||
@State private var includeSubpaths = true
|
||||
@State private var blockAction: BlockAction = .blockAndHide
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name (optional)", text: $name)
|
||||
TextField("URL Pattern", text: $urlPattern)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Method", selection: $method) {
|
||||
Text("ANY").tag("ANY")
|
||||
ForEach(ProxyConstants.httpMethods, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
Toggle("Include Subpaths", isOn: $includeSubpaths)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Block Action", selection: $blockAction) {
|
||||
ForEach(BlockAction.allCases, id: \.self) { action in
|
||||
Text(action.displayName).tag(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Block Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let entry = BlockListEntry(
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
includeSubpaths: includeSubpaths,
|
||||
blockAction: blockAction
|
||||
)
|
||||
onSave(entry)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
UI/More/BreakpointRulesView.swift
Normal file
99
UI/More/BreakpointRulesView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct BreakpointRulesView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBreakpointEnabled
|
||||
@State private var rules: [BreakpointRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "Breakpoint",
|
||||
description: "Pause and modify HTTP requests and responses in real-time before they reach the server or the app.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isBreakpointEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Breakpoint")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeBreakpointRules()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Breakpoint observation error: \(error)")
|
||||
} onChange: { newRules in
|
||||
rules = newRules
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
UI/More/CertificateView.swift
Normal file
50
UI/More/CertificateView.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct CertificateView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: appState.isCertificateTrusted ? "checkmark.shield.fill" : "exclamationmark.shield")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(appState.isCertificateTrusted ? .green : .orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(appState.isCertificateTrusted
|
||||
? "Certificate is installed & trusted!"
|
||||
: "Certificate not installed")
|
||||
.font(.headline)
|
||||
Text("Required for HTTPS decryption")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("CA Certificate", value: "Proxy CA (\(UIDevice.current.name))")
|
||||
LabeledContent("Generated", value: "-")
|
||||
LabeledContent("Expires", value: "-")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Install Certificate") {
|
||||
// TODO: Phase 3 - Export and open cert installation
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Regenerate Certificate", role: .destructive) {
|
||||
// TODO: Phase 3 - Generate new CA
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Certificate")
|
||||
}
|
||||
}
|
||||
92
UI/More/DNSSpoofingView.swift
Normal file
92
UI/More/DNSSpoofingView.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct DNSSpoofingView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isDNSSpoofingEnabled
|
||||
@State private var rules: [DNSSpoofRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "DNS Spoofing",
|
||||
description: "Redirect domain resolution to a different target. Useful for routing production domains to development servers.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isDNSSpoofingEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "network",
|
||||
title: "No DNS Spoofing Rules",
|
||||
subtitle: "Tap + to create a new rule."
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteDNSSpoofRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("DNS Spoofing")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeDNSSpoofRules()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("DNS Spoof observation error: \(error)")
|
||||
} onChange: { newRules in
|
||||
rules = newRules
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
UI/More/MapLocalView.swift
Normal file
88
UI/More/MapLocalView.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct MapLocalView: View {
|
||||
@State private var rules: [MapLocalRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Map Local")
|
||||
.font(.headline)
|
||||
Text("Intercept requests and replace the response with local content. Define custom mock responses for matched URLs.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "doc.on.doc",
|
||||
title: "No Map Local Rules",
|
||||
subtitle: "Tap + to create a new rule."
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteMapLocalRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Map Local")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showAddRule = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeMapLocalRules()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Map Local observation error: \(error)")
|
||||
} onChange: { newRules in
|
||||
rules = newRules
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
UI/More/MoreView.swift
Normal file
87
UI/More/MoreView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct MoreView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink {
|
||||
SetupGuideView()
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Setup Guide")
|
||||
Text(appState.isVPNConnected ? "Ready to Intercept" : "Setup Required")
|
||||
.font(.caption)
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
CertificateView()
|
||||
} label: {
|
||||
Label("Certificate", systemImage: "lock.shield")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Rules") {
|
||||
NavigationLink {
|
||||
SSLProxyingListView()
|
||||
} label: {
|
||||
Label("SSL Proxying List", systemImage: "lock.fill")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BlockListView()
|
||||
} label: {
|
||||
Label("Block List", systemImage: "xmark.shield")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BreakpointRulesView()
|
||||
} label: {
|
||||
Label("Breakpoint", systemImage: "pause.circle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MapLocalView()
|
||||
} label: {
|
||||
Label("Map Local", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
NoCachingView()
|
||||
} label: {
|
||||
Label("No Caching", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
DNSSpoofingView()
|
||||
} label: {
|
||||
Label("DNS Spoofing", systemImage: "network")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Settings") {
|
||||
NavigationLink {
|
||||
AdvancedSettingsView()
|
||||
} label: {
|
||||
Label("Advanced", systemImage: "gearshape.2")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AppSettingsView()
|
||||
} label: {
|
||||
Label("App Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("More")
|
||||
}
|
||||
}
|
||||
42
UI/More/NoCachingView.swift
Normal file
42
UI/More/NoCachingView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct NoCachingView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isNoCachingEnabled
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "No Caching",
|
||||
description: "Bypass all caching layers to always see the latest server response. Strips cache headers from requests and responses.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isNoCachingEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("How it works") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Request modifications:")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("Removes If-Modified-Since, If-None-Match\nAdds Pragma: no-cache, Cache-Control: no-cache")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Response modifications:")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("Removes Expires, Last-Modified, ETag\nAdds Expires: 0, Cache-Control: no-cache")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("No Caching")
|
||||
}
|
||||
}
|
||||
150
UI/More/SSLProxyingListView.swift
Normal file
150
UI/More/SSLProxyingListView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct SSLProxyingListView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isSSLProxyingEnabled
|
||||
@State private var entries: [SSLProxyingEntry] = []
|
||||
@State private var showAddInclude = false
|
||||
@State private var showAddExclude = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
var includeEntries: [SSLProxyingEntry] {
|
||||
entries.filter(\.isInclude)
|
||||
}
|
||||
|
||||
var excludeEntries: [SSLProxyingEntry] {
|
||||
entries.filter { !$0.isInclude }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ToggleHeaderView(
|
||||
title: "SSL Proxying",
|
||||
description: "Decrypt HTTPS traffic from included domains. Excluded domains are always passed through.",
|
||||
isEnabled: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
IPCManager.shared.isSSLProxyingEnabled = newValue
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Include") {
|
||||
if includeEntries.isEmpty {
|
||||
Text("No include entries")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(includeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = includeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Exclude") {
|
||||
if excludeEntries.isEmpty {
|
||||
Text("No exclude entries")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(excludeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = excludeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("SSL Proxying")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("Add Include Entry") { showAddInclude = true }
|
||||
Button("Add Exclude Entry") { showAddExclude = true }
|
||||
Divider()
|
||||
Button("Clear All Rules", role: .destructive) {
|
||||
try? rulesRepo.deleteAllSSLEntries()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddInclude) {
|
||||
DomainEntrySheet(title: "New Include Entry", isInclude: true) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: true)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddExclude) {
|
||||
DomainEntrySheet(title: "New Exclude Entry", isInclude: false) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: false)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeSSLEntries()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("SSL observation error: \(error)")
|
||||
} onChange: { newEntries in
|
||||
entries = newEntries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Domain Entry Sheet
|
||||
|
||||
struct DomainEntrySheet: View {
|
||||
let title: String
|
||||
let isInclude: Bool
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var domainPattern = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Domain Pattern", text: $domainPattern)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} footer: {
|
||||
Text("Supports wildcards: * (zero or more) and ? (single character). Example: *.example.com")
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
onSave(domainPattern)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(domainPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
UI/More/SetupGuideView.swift
Normal file
116
UI/More/SetupGuideView.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct SetupGuideView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var isReady: Bool {
|
||||
appState.isVPNConnected && appState.isCertificateTrusted
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
// Status Banner
|
||||
HStack {
|
||||
Image(systemName: isReady ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(isReady ? "Ready to Intercept" : "Setup Required")
|
||||
.font(.headline)
|
||||
Text(isReady ? "All systems are configured correctly" : "Complete the steps below to start")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.background(isReady ? Color.green : Color.orange, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text("Follow these two steps to start capturing network traffic on your device.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Step 1: VPN
|
||||
stepRow(
|
||||
title: "VPN Extension Enabled",
|
||||
subtitle: appState.isVPNConnected
|
||||
? "VPN is running and capturing traffic"
|
||||
: "Tap to enable VPN",
|
||||
isComplete: appState.isVPNConnected,
|
||||
action: {
|
||||
Task { await appState.toggleVPN() }
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// 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")
|
||||
.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)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Setup Guide")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func stepRow(title: String, subtitle: String, isComplete: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: isComplete ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(isComplete ? .green : .secondary)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(isComplete ? .green : .primary)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user