krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.4.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                }
258            }
259        }
260        .frame(maxWidth: .infinity, alignment: .leading)
261        .padding(.top, 16)
262    }
263
264    private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
265        VStack(alignment: .leading, spacing: 2) {
266            HStack(spacing: 8) {
267                Text(label)
268                    .font(.system(.caption, design: .monospaced))
269                    .fontWeight(.semibold)
270                    .frame(width: 52, alignment: .leading)
271                Text(record.found ? "" : "")
272                    .font(.system(.caption, design: .monospaced))
273                    .foregroundStyle(record.found ? .green : .red)
274                if let value = record.value {
275                    let isExpanded = expandedEmailField == label
276                    let displayValue = isExpanded ? value : String(value.prefix(80))
277                    Text(displayValue)
278                        .font(.system(.caption2, design: .monospaced))
279                        .foregroundStyle(.primary)
280                        .textSelection(.enabled)
281                        .lineLimit(isExpanded ? nil : 1)
282                        .onTapGesture {
283                            withAnimation {
284                                expandedEmailField = isExpanded ? nil : label
285                            }
286                        }
287                } else {
288                    Text("No record found")
289                        .font(.system(.caption2, design: .monospaced))
290                        .foregroundStyle(.secondary)
291                }
292            }
293        }
294    }
295
296    // MARK: - SSL
297
298    private var sslSection: some View {
299        VStack(alignment: .leading, spacing: 12) {
300            if let info = entry.sslInfo {
301                sectionHeader("SSL / TLS Certificate")
302                horizontallyScrollableCard(spacing: 8) {
303                    labelRow("Common Name", info.commonName)
304                    labelRow("Issuer", info.issuer)
305
306                    VStack(alignment: .leading, spacing: 2) {
307                        Text("SANs")
308                            .font(.system(.caption2, design: .monospaced))
309                            .foregroundStyle(.secondary)
310                        ForEach(info.subjectAltNames, id: \.self) { san in
311                            Text(san)
312                                .font(.system(.caption, design: .monospaced))
313                                .textSelection(.enabled)
314                        }
315                    }
316
317                    labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
318                    labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
319
320                    HStack {
321                        Text("Days Until Expiry")
322                            .font(.system(.caption2, design: .monospaced))
323                            .foregroundStyle(.secondary)
324                        Spacer()
325                        Text("\(info.daysUntilExpiry)")
326                            .font(.system(.caption, design: .monospaced))
327                            .fontWeight(.bold)
328                            .foregroundStyle(expiryColor(info.daysUntilExpiry))
329                    }
330
331                    labelRow("Chain Depth", "\(info.chainDepth)")
332                }
333            }
334        }
335        .padding(.top, 16)
336    }
337
338    // MARK: - HTTP Headers
339
340    private var httpHeadersSection: some View {
341        VStack(alignment: .leading, spacing: 12) {
342            if !entry.httpHeaders.isEmpty {
343                sectionHeader("HTTP Headers")
344                horizontallyScrollableCard {
345                    ForEach(entry.httpHeaders) { header in
346                        HStack(alignment: .top, spacing: 4) {
347                            Text(header.name + ":")
348                                .font(.system(.caption, design: .monospaced))
349                                .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
350                            Text(header.value)
351                                .font(.system(.caption, design: .monospaced))
352                                .foregroundStyle(.primary)
353                                .textSelection(.enabled)
354                        }
355                    }
356                }
357            }
358        }
359        .padding(.top, 16)
360    }
361
362    // MARK: - IP Geolocation
363
364    private var ipGeolocationSection: some View {
365        VStack(alignment: .leading, spacing: 12) {
366            if let geo = entry.ipGeolocation {
367                sectionHeader("IP Location")
368                VStack(alignment: .leading, spacing: 6) {
369                    horizontallyScrollableContent(spacing: 6) {
370                        labelRow("IP", geo.ip)
371                        if let org = geo.org {
372                            labelRow("Org / ISP", org)
373                        }
374                        let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
375                        if !location.isEmpty {
376                            labelRow("Location", location)
377                        }
378                    }
379
380                    if let lat = geo.latitude, let lon = geo.longitude {
381                        let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
382                        Map(initialPosition: .region(MKCoordinateRegion(
383                            center: coordinate,
384                            span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
385                        ))) {
386                            Marker(geo.ip, coordinate: coordinate)
387                        }
388                        .mapStyle(.standard)
389                        .frame(maxWidth: .infinity)
390                        .frame(height: 180)
391                        .cornerRadius(8)
392                    }
393                }
394                .padding(10)
395                .background(Color(.systemGray6).opacity(0.5))
396                .cornerRadius(6)
397            }
398        }
399        .padding(.top, 16)
400    }
401
402    // MARK: - Port Scan
403
404    private var portScanSection: some View {
405        VStack(alignment: .leading, spacing: 12) {
406            if !entry.portScanResults.isEmpty {
407                sectionHeader("Open Ports")
408                VStack(alignment: .leading, spacing: 4) {
409                    ForEach(entry.portScanResults) { result in
410                        HStack(spacing: 8) {
411                            Circle()
412                                .fill(result.open ? Color.green : Color(.systemGray4))
413                                .frame(width: 8, height: 8)
414                            Text("\(result.port)")
415                                .font(.system(.caption, design: .monospaced))
416                                .frame(width: 44, alignment: .leading)
417                            Text(result.service)
418                                .font(.system(.caption, design: .monospaced))
419                                .foregroundStyle(result.open ? .primary : .secondary)
420                            Spacer()
421                            if result.open {
422                                Text("Open")
423                                    .font(.system(.caption2, design: .monospaced))
424                                    .foregroundStyle(.green)
425                            }
426                        }
427                    }
428                }
429                .padding(10)
430                .background(Color(.systemGray6).opacity(0.5))
431                .cornerRadius(6)
432            }
433        }
434        .padding(.top, 16)
435    }
436
437    // MARK: - Helpers
438
439    private func sectionHeader(_ title: String) -> some View {
440        Text(title)
441            .font(.system(.headline, design: .default))
442            .foregroundStyle(.white)
443    }
444
445    private func labelRow(_ label: String, _ value: String) -> some View {
446        VStack(alignment: .leading, spacing: 2) {
447            Text(label)
448                .font(.system(.caption2, design: .monospaced))
449                .foregroundStyle(.secondary)
450            Text(value)
451                .font(.system(.caption, design: .monospaced))
452                .textSelection(.enabled)
453        }
454    }
455
456    private func recordRows(_ records: [DNSRecord]) -> some View {
457        ForEach(records) { record in
458            HStack(alignment: .top) {
459                Text(record.value)
460                    .font(.system(.caption, design: .monospaced))
461                    .foregroundStyle(.primary)
462                    .textSelection(.enabled)
463                Spacer()
464                Text("TTL \(record.ttl)")
465                    .font(.system(.caption2, design: .monospaced))
466                    .foregroundStyle(.secondary)
467            }
468        }
469    }
470
471    private func horizontallyScrollableCard<Content: View>(
472        spacing: CGFloat = 4,
473        @ViewBuilder content: () -> Content
474    ) -> some View {
475        horizontallyScrollableContent(spacing: spacing) {
476            content()
477        }
478        .padding(10)
479        .background(Color(.systemGray6).opacity(0.5))
480        .cornerRadius(6)
481    }
482
483    private func horizontallyScrollableContent<Content: View>(
484        spacing: CGFloat = 4,
485        @ViewBuilder content: () -> Content
486    ) -> some View {
487        ScrollView(.horizontal) {
488            VStack(alignment: .leading, spacing: spacing) {
489                content()
490            }
491            .scrollTargetLayout()
492        }
493        .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
494        .frame(maxWidth: .infinity, alignment: .leading)
495    }
496
497    private func errorLabel(_ message: String) -> some View {
498        Label(message, systemImage: "exclamationmark.triangle.fill")
499            .font(.system(.caption, design: .monospaced))
500            .foregroundStyle(.red)
501            .padding(8)
502    }
503
504    private func expiryColor(_ days: Int) -> Color {
505        if days < 30 { return .red }
506        if days < 60 { return .yellow }
507        return .green
508    }
509}