krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v1.7.2: DomainDig/HistoryView.swift · raw
1import SwiftUI
2import MapKit
3
4struct HistoryView: View {
5 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
7
8 private let dateFmt: DateFormatter = {
9 let f = DateFormatter()
10 f.dateStyle = .medium
11 f.timeStyle = .short
12 return f
13 }()
14
15 var body: some View {
16 List {
17 if viewModel.history.isEmpty {
18 Text("No lookup history")
19 .font(.system(.callout, design: .monospaced))
20 .foregroundStyle(.secondary)
21 .listRowBackground(Color(.systemGray6).opacity(0.5))
22 } else {
23 ForEach(viewModel.history) { entry in
24 NavigationLink {
25 HistoryDetailView(entry: entry)
26 } label: {
27 VStack(alignment: .leading, spacing: 2) {
28 Text(entry.domain)
29 .font(.system(.callout, design: .monospaced))
30 .foregroundStyle(.primary)
31 Text(dateFmt.string(from: entry.timestamp))
32 .font(.system(.caption2, design: .monospaced))
33 .foregroundStyle(.secondary)
34 }
35 }
36 .listRowBackground(Color(.systemGray6).opacity(0.5))
37 }
38 .onDelete { offsets in
39 viewModel.removeHistoryEntries(at: offsets)
40 }
41 }
42 }
43 .scrollContentBackground(.hidden)
44 .background(Color.black)
45 .navigationTitle("History")
46 .toolbar {
47 if !viewModel.history.isEmpty {
48 EditButton()
49 }
50 }
51 .preferredColorScheme(.dark)
52 }
53}
54
55// MARK: - History Detail View (Read-Only Cached Results)
56
57struct HistoryDetailView: View {
58 let entry: HistoryEntry
59
60 private let dateFmt: DateFormatter = {
61 let f = DateFormatter()
62 f.dateStyle = .medium
63 f.timeStyle = .short
64 return f
65 }()
66
67 var body: some View {
68 ScrollView(.vertical) {
69 VStack(alignment: .leading, spacing: 0) {
70 cachedBanner
71 reachabilitySection
72 redirectChainSection
73 dnsSection
74 emailSecuritySection
75 sslSection
76 httpHeadersSection
77 ipGeolocationSection
78 portScanSection
79 }
80 .padding(.horizontal)
81 .padding(.bottom, 32)
82 }
83 .background(Color.black)
84 .navigationTitle(entry.domain)
85 .preferredColorScheme(.dark)
86 }
87
88 // MARK: - Cached Banner
89
90 private var cachedBanner: some View {
91 HStack(spacing: 6) {
92 Image(systemName: "archivebox")
93 .font(.caption)
94 Text("Cached result from \(dateFmt.string(from: entry.timestamp))")
95 .font(.system(.caption, design: .monospaced))
96 }
97 .foregroundStyle(.secondary)
98 .padding(8)
99 .frame(maxWidth: .infinity, alignment: .leading)
100 .background(Color(.systemGray6).opacity(0.3))
101 .cornerRadius(6)
102 .padding(.vertical, 12)
103 }
104
105 // MARK: - Reachability
106
107 private var reachabilitySection: some View {
108 VStack(alignment: .leading, spacing: 12) {
109 if !entry.reachabilityResults.isEmpty {
110 sectionHeader("Reachability")
111 VStack(alignment: .leading, spacing: 4) {
112 ForEach(entry.reachabilityResults) { result in
113 HStack(spacing: 8) {
114 Circle()
115 .fill(result.reachable ? Color.green : Color.red)
116 .frame(width: 8, height: 8)
117 Text("Port \(result.port)")
118 .font(.system(.caption, design: .monospaced))
119 if result.reachable, let ms = result.latencyMs {
120 Text("\(ms)ms")
121 .font(.system(.caption, design: .monospaced))
122 .foregroundStyle(.secondary)
123 } else if !result.reachable {
124 Text("—")
125 .font(.system(.caption, design: .monospaced))
126 .foregroundStyle(.secondary)
127 }
128 Spacer()
129 Text(result.reachable ? "Reachable" : "Unreachable")
130 .font(.system(.caption, design: .monospaced))
131 .foregroundStyle(result.reachable ? .green : .red)
132 }
133 }
134 }
135 .padding(10)
136 .background(Color(.systemGray6).opacity(0.5))
137 .cornerRadius(6)
138 }
139 }
140 .padding(.top, 8)
141 }
142
143 // MARK: - Redirect Chain
144
145 private var redirectChainSection: some View {
146 VStack(alignment: .leading, spacing: 12) {
147 if !entry.redirectChain.isEmpty {
148 sectionHeader("Redirect Chain")
149 if entry.redirectChain.count == 1,
150 let only = entry.redirectChain.first,
151 only.isFinal, !(300...399).contains(only.statusCode) {
152 Text("No redirects — direct connection")
153 .font(.system(.caption, design: .monospaced))
154 .foregroundStyle(.secondary)
155 .padding(10)
156 .frame(maxWidth: .infinity, alignment: .leading)
157 .background(Color(.systemGray6).opacity(0.5))
158 .cornerRadius(6)
159 } else {
160 horizontallyScrollableCard {
161 ForEach(entry.redirectChain) { hop in
162 HStack(alignment: .top, spacing: 6) {
163 Text("\(hop.stepNumber)")
164 .font(.system(.caption, design: .monospaced))
165 .foregroundStyle(.secondary)
166 .frame(width: 16, alignment: .trailing)
167 Text("\(hop.statusCode)")
168 .font(.system(.caption, design: .monospaced))
169 .foregroundStyle(.cyan)
170 .frame(width: 30, alignment: .leading)
171 Text(hop.url)
172 .font(.system(.caption, design: .monospaced))
173 .foregroundStyle(.primary)
174 .textSelection(.enabled)
175 if hop.isFinal {
176 Text("(final)")
177 .font(.system(.caption2, design: .monospaced))
178 .foregroundStyle(.secondary)
179 }
180 }
181 }
182 }
183 }
184 }
185 }
186 .padding(.top, 16)
187 }
188
189 // MARK: - DNS
190
191 private var dnsSection: some View {
192 VStack(alignment: .leading, spacing: 12) {
193 sectionHeader("DNS Records")
194 ForEach(entry.dnsSections) { section in
195 horizontallyScrollableCard {
196 Text(section.recordType.rawValue)
197 .font(.system(.subheadline, design: .monospaced))
198 .fontWeight(.semibold)
199 .foregroundStyle(.cyan)
200
201 if let error = section.error {
202 errorLabel(error)
203 } else if section.records.isEmpty {
204 Text("No records found")
205 .font(.system(.caption, design: .monospaced))
206 .foregroundStyle(.secondary)
207 } else {
208 recordRows(section.records)
209 }
210
211 if !section.wildcardRecords.isEmpty {
212 Text("*.\(entry.domain)")
213 .font(.system(.caption, design: .monospaced))
214 .fontWeight(.medium)
215 .foregroundStyle(.cyan.opacity(0.7))
216 .padding(.top, 4)
217 recordRows(section.wildcardRecords)
218 }
219 }
220
221 if section.recordType == .A {
222 horizontallyScrollableCard {
223 Text("PTR (Reverse DNS)")
224 .font(.system(.subheadline, design: .monospaced))
225 .fontWeight(.semibold)
226 .foregroundStyle(.cyan)
227
228 if let ptr = entry.ptrRecord {
229 Text(ptr)
230 .font(.system(.caption, design: .monospaced))
231 .foregroundStyle(.primary)
232 .textSelection(.enabled)
233 } else {
234 Text("No PTR record found")
235 .font(.system(.caption, design: .monospaced))
236 .foregroundStyle(.secondary)
237 }
238 }
239 }
240 }
241 }
242 .padding(.top, 16)
243 }
244
245 // MARK: - Email Security
246
247 @State private var expandedEmailField: String?
248
249 private var emailSecuritySection: some View {
250 VStack(alignment: .leading, spacing: 12) {
251 if let email = entry.emailSecurity {
252 sectionHeader("Email Security")
253 horizontallyScrollableCard(spacing: 6) {
254 historyEmailRow("SPF", record: email.spf)
255 historyEmailRow("DMARC", record: email.dmarc)
256 historyEmailRow("DKIM", record: email.dkim)
257 historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts)
258 historyEmailRow("BIMI", record: email.bimi)
259 }
260 }
261 }
262 .frame(maxWidth: .infinity, alignment: .leading)
263 .padding(.top, 16)
264 }
265
266 private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
267 VStack(alignment: .leading, spacing: 2) {
268 HStack(spacing: 8) {
269 Text(label)
270 .font(.system(.caption, design: .monospaced))
271 .fontWeight(.semibold)
272 .frame(width: 72, alignment: .leading)
273 Text(record.found ? "✓" : "✗")
274 .font(.system(.caption, design: .monospaced))
275 .foregroundStyle(record.found ? .green : .red)
276 if let value = record.value {
277 let isExpanded = expandedEmailField == label
278 let displayValue = isExpanded ? value : String(value.prefix(80))
279 Text(displayValue)
280 .font(.system(.caption2, design: .monospaced))
281 .foregroundStyle(.primary)
282 .textSelection(.enabled)
283 .lineLimit(isExpanded ? nil : 1)
284 .onTapGesture {
285 withAnimation {
286 expandedEmailField = isExpanded ? nil : label
287 }
288 }
289 if let selector = record.matchedSelector {
290 Text("(selector: \(selector))")
291 .font(.system(.caption2, design: .monospaced))
292 .foregroundStyle(.secondary)
293 }
294 } else {
295 Text("No record found")
296 .font(.system(.caption2, design: .monospaced))
297 .foregroundStyle(.secondary)
298 }
299 }
300 }
301 }
302
303 private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
304 VStack(alignment: .leading, spacing: 2) {
305 HStack(spacing: 8) {
306 Text(label)
307 .font(.system(.caption, design: .monospaced))
308 .fontWeight(.semibold)
309 .frame(width: 72, alignment: .leading)
310 Text(mtaSts?.txtFound == true ? "✓" : "✗")
311 .font(.system(.caption, design: .monospaced))
312 .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
313 if let policyMode = mtaSts?.policyMode {
314 Text(policyMode)
315 .font(.system(.caption2, design: .monospaced))
316 .foregroundStyle(.primary)
317 .textSelection(.enabled)
318 } else {
319 Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
320 .font(.system(.caption2, design: .monospaced))
321 .foregroundStyle(.secondary)
322 }
323 }
324 }
325 }
326
327 // MARK: - SSL
328
329 private var sslSection: some View {
330 VStack(alignment: .leading, spacing: 12) {
331 if let info = entry.sslInfo {
332 sectionHeader("SSL / TLS Certificate")
333 horizontallyScrollableCard(spacing: 8) {
334 labelRow("Common Name", info.commonName)
335 labelRow("Issuer", info.issuer)
336
337 VStack(alignment: .leading, spacing: 2) {
338 Text("SANs")
339 .font(.system(.caption2, design: .monospaced))
340 .foregroundStyle(.secondary)
341 ForEach(info.subjectAltNames, id: \.self) { san in
342 Text(san)
343 .font(.system(.caption, design: .monospaced))
344 .textSelection(.enabled)
345 }
346 }
347
348 labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
349 labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
350
351 HStack {
352 Text("Days Until Expiry")
353 .font(.system(.caption2, design: .monospaced))
354 .foregroundStyle(.secondary)
355 Spacer()
356 Text("\(info.daysUntilExpiry)")
357 .font(.system(.caption, design: .monospaced))
358 .fontWeight(.bold)
359 .foregroundStyle(expiryColor(info.daysUntilExpiry))
360 }
361
362 labelRow("Chain Depth", "\(info.chainDepth)")
363 }
364 }
365 }
366 .padding(.top, 16)
367 }
368
369 // MARK: - HTTP Headers
370
371 private var httpHeadersSection: some View {
372 VStack(alignment: .leading, spacing: 12) {
373 if !entry.httpHeaders.isEmpty {
374 sectionHeader("HTTP Headers")
375 horizontallyScrollableCard {
376 ForEach(entry.httpHeaders) { header in
377 HStack(alignment: .top, spacing: 4) {
378 Text(header.name + ":")
379 .font(.system(.caption, design: .monospaced))
380 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
381 Text(header.value)
382 .font(.system(.caption, design: .monospaced))
383 .foregroundStyle(.primary)
384 .textSelection(.enabled)
385 }
386 }
387 }
388 }
389 }
390 .padding(.top, 16)
391 }
392
393 // MARK: - IP Geolocation
394
395 private var ipGeolocationSection: some View {
396 VStack(alignment: .leading, spacing: 12) {
397 if let geo = entry.ipGeolocation {
398 sectionHeader("IP Location")
399 VStack(alignment: .leading, spacing: 6) {
400 horizontallyScrollableContent(spacing: 6) {
401 labelRow("IP", geo.ip)
402 if let org = geo.org {
403 labelRow("Org / ISP", org)
404 }
405 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
406 if !location.isEmpty {
407 labelRow("Location", location)
408 }
409 }
410
411 if let lat = geo.latitude, let lon = geo.longitude {
412 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
413 Map(initialPosition: .region(MKCoordinateRegion(
414 center: coordinate,
415 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
416 ))) {
417 Marker(geo.ip, coordinate: coordinate)
418 }
419 .mapStyle(.standard)
420 .frame(maxWidth: .infinity)
421 .frame(height: 180)
422 .cornerRadius(8)
423 }
424 }
425 .padding(10)
426 .background(Color(.systemGray6).opacity(0.5))
427 .cornerRadius(6)
428 }
429 }
430 .padding(.top, 16)
431 }
432
433 // MARK: - Port Scan
434
435 private var portScanSection: some View {
436 VStack(alignment: .leading, spacing: 12) {
437 if !entry.portScanResults.isEmpty {
438 sectionHeader("Open Ports")
439 VStack(alignment: .leading, spacing: 4) {
440 ForEach(entry.portScanResults) { result in
441 HStack(spacing: 8) {
442 Circle()
443 .fill(result.open ? Color.green : Color(.systemGray4))
444 .frame(width: 8, height: 8)
445 Text("\(result.port)")
446 .font(.system(.caption, design: .monospaced))
447 .frame(width: 44, alignment: .leading)
448 Text(result.service)
449 .font(.system(.caption, design: .monospaced))
450 .foregroundStyle(result.open ? .primary : .secondary)
451 Spacer()
452 if result.open {
453 Text("Open")
454 .font(.system(.caption2, design: .monospaced))
455 .foregroundStyle(.green)
456 }
457 }
458 }
459 }
460 .padding(10)
461 .background(Color(.systemGray6).opacity(0.5))
462 .cornerRadius(6)
463 }
464 }
465 .padding(.top, 16)
466 }
467
468 // MARK: - Helpers
469
470 private func sectionHeader(_ title: String) -> some View {
471 Text(title)
472 .font(.system(.headline, design: .default))
473 .foregroundStyle(.white)
474 }
475
476 private func labelRow(_ label: String, _ value: String) -> some View {
477 VStack(alignment: .leading, spacing: 2) {
478 Text(label)
479 .font(.system(.caption2, design: .monospaced))
480 .foregroundStyle(.secondary)
481 Text(value)
482 .font(.system(.caption, design: .monospaced))
483 .textSelection(.enabled)
484 }
485 }
486
487 private func recordRows(_ records: [DNSRecord]) -> some View {
488 ForEach(records) { record in
489 HStack(alignment: .top) {
490 Text(record.value)
491 .font(.system(.caption, design: .monospaced))
492 .foregroundStyle(.primary)
493 .textSelection(.enabled)
494 Spacer()
495 Text("TTL \(record.ttl)")
496 .font(.system(.caption2, design: .monospaced))
497 .foregroundStyle(.secondary)
498 }
499 }
500 }
501
502 private func horizontallyScrollableCard<Content: View>(
503 spacing: CGFloat = 4,
504 @ViewBuilder content: () -> Content
505 ) -> some View {
506 horizontallyScrollableContent(spacing: spacing) {
507 content()
508 }
509 .padding(10)
510 .background(Color(.systemGray6).opacity(0.5))
511 .cornerRadius(6)
512 }
513
514 private func horizontallyScrollableContent<Content: View>(
515 spacing: CGFloat = 4,
516 @ViewBuilder content: () -> Content
517 ) -> some View {
518 ScrollView(.horizontal) {
519 VStack(alignment: .leading, spacing: spacing) {
520 content()
521 }
522 .scrollTargetLayout()
523 }
524 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
525 .frame(maxWidth: .infinity, alignment: .leading)
526 }
527
528 private func errorLabel(_ message: String) -> some View {
529 Label(message, systemImage: "exclamationmark.triangle.fill")
530 .font(.system(.caption, design: .monospaced))
531 .foregroundStyle(.red)
532 .padding(8)
533 }
534
535 private func expiryColor(_ days: Int) -> Color {
536 if days < 30 { return .red }
537 if days < 60 { return .yellow }
538 return .green
539 }
540}