- 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
182 lines
6.8 KiB
Swift
182 lines
6.8 KiB
Swift
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 editingEntry: BlockListEntry?
|
|
@State private var entryToDelete: BlockListEntry?
|
|
@State private var showDeleteConfirmation = 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
|
|
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
|
|
if let index = indexSet.first {
|
|
entryToDelete = entries[index]
|
|
showDeleteConfirmation = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
.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
|
|
print("Block list observation error: \(error)")
|
|
} onChange: { newEntries in
|
|
entries = newEntries
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - New Block Rule
|
|
|
|
struct NewBlockRuleView: View {
|
|
let existingEntry: BlockListEntry?
|
|
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
|
|
|
|
init(existingEntry: BlockListEntry? = nil, onSave: @escaping (BlockListEntry) -> Void) {
|
|
self.existingEntry = existingEntry
|
|
self.onSave = onSave
|
|
}
|
|
|
|
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(existingEntry == nil ? "New Block Rule" : "Edit Block Rule")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") {
|
|
let entry = BlockListEntry(
|
|
id: existingEntry?.id,
|
|
name: name.isEmpty ? nil : name,
|
|
urlPattern: urlPattern,
|
|
method: method,
|
|
includeSubpaths: includeSubpaths,
|
|
blockAction: blockAction,
|
|
isEnabled: existingEntry?.isEnabled ?? true,
|
|
createdAt: existingEntry?.createdAt ?? Date().timeIntervalSince1970
|
|
)
|
|
onSave(entry)
|
|
dismiss()
|
|
}
|
|
.disabled(urlPattern.isEmpty)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if let entry = existingEntry {
|
|
name = entry.name ?? ""
|
|
urlPattern = entry.urlPattern
|
|
method = entry.method
|
|
includeSubpaths = entry.includeSubpaths
|
|
blockAction = entry.action
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|