krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v1.5.0: 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}