Commit 36ea668d7f

36ea668d7fc9070770973b0bc329f4319bf43cab

parent: 3846dc2f5d

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 06:20 UTC

feat(v2​.6​.0): refine ​UI, density, and usability

• add compact/comfortable density modes
• implement collapsible sections and sticky summary header
• improve visual status indicators and copy actions
• refine watchlist and history UX with swipe actions
• reorganize settings and improve empty states
• add subtle animations and accessibility improvements

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -354,7 +354,7 @@
354354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356356 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 18;
357 CURRENT_PROJECT_VERSION = 19;
358358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359359 ENABLE_PREVIEWS = YES;
360360 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
371371 "$(inherited)",
372372 "@executable_path/Frameworks",
373373 );
374 MARKETING_VERSION = 2.5.0;
374 MARKETING_VERSION = 2.6.0;
375375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376376 PRODUCT_NAME = "$(TARGET_NAME)";
377377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
390390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392392 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 18;
393 CURRENT_PROJECT_VERSION = 19;
394394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395395 ENABLE_PREVIEWS = YES;
396396 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
407407 "$(inherited)",
408408 "@executable_path/Frameworks",
409409 );
410 MARKETING_VERSION = 2.5.0;
410 MARKETING_VERSION = 2.6.0;
411411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412412 PRODUCT_NAME = "$(TARGET_NAME)";
413413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/BatchResultsView.swift +28 −26
@@ -1,11 +1,12 @@
11import SwiftUI
22
33struct BatchResultsView: View {
4 @Environment(\.appDensity) private var appDensity
45 @Bindable var viewModel: DomainViewModel
56 let title: String
67
78 var body: some View {
8 VStack(alignment: .leading, spacing: 12) {
9 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
910 HStack(alignment: .top) {
1011 SectionTitleView(title: title)
1112 Spacer()
@@ -15,18 +16,23 @@ struct BatchResultsView: View {
1516 .tint(.cyan)
1617 .frame(width: 120)
1718 Text(viewModel.batchProgressLabel)
18 .font(.system(.caption2, design: .monospaced))
19 .font(appDensity.font(.caption2))
1920 .foregroundStyle(.secondary)
2021 }
2122 } else if !viewModel.batchResults.isEmpty {
2223 Text("\(viewModel.batchResults.count) domains")
23 .font(.system(.caption2, design: .monospaced))
24 .font(appDensity.font(.caption2))
2425 .foregroundStyle(.secondary)
2526 }
2627 }
2728
2829 if viewModel.batchResults.isEmpty {
29 MessageCardView(text: "No batch results yet", isError: false)
30 EmptyStateCardView(
31 title: "No Batch Results Yet",
32 message: "Batch runs collect availability, IP, and change status for multiple domains in one pass.",
33 suggestion: "Switch to Bulk mode, paste a list of domains, then run a batch lookup.",
34 systemImage: "square.stack.3d.up"
35 )
3036 } else {
3137 CardView(allowsHorizontalScroll: false) {
3238 ForEach(viewModel.batchResults) { result in
@@ -48,50 +54,46 @@ struct BatchResultsView: View {
4854}
4955
5056struct BatchResultRowView: View {
57 @Environment(\.appDensity) private var appDensity
5158 let result: BatchLookupResult
5259
5360 var body: some View {
54 VStack(alignment: .leading, spacing: 6) {
61 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
5562 HStack(alignment: .firstTextBaseline, spacing: 8) {
5663 Text(result.domain)
57 .font(.system(.callout, design: .monospaced))
64 .font(appDensity.font(.callout))
5865 .foregroundStyle(.primary)
5966 .lineLimit(1)
6067 Spacer(minLength: 8)
6168 Text(result.resultSource.label.lowercased())
62 .font(.system(.caption2, design: .monospaced))
69 .font(appDensity.font(.caption2))
6370 .foregroundStyle(.secondary)
64 Text(result.quickStatus)
65 .font(.system(.caption2, design: .monospaced))
66 .foregroundStyle(quickStatusColor)
67 .padding(.horizontal, 8)
68 .padding(.vertical, 4)
69 .background(quickStatusColor.opacity(0.16))
70 .clipShape(Capsule())
71 AppStatusBadgeView(model: quickStatusBadge)
7172 }
7273
7374 HStack(spacing: 10) {
74 Text(availabilityText)
75 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability))
7576 Text(result.primaryIP ?? "No IP")
7677 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
7778 }
78 .font(.system(.caption2, design: .monospaced))
79 .font(appDensity.font(.caption2))
7980 .foregroundStyle(.secondary)
8081
8182 if let summaryMessage = result.summaryMessage {
8283 Text(summaryMessage)
83 .font(.system(.caption2, design: .monospaced))
84 .font(appDensity.font(.caption2))
8485 .foregroundStyle(.secondary)
8586 }
8687
8788 if let errorMessage = result.errorMessage {
8889 Text(errorMessage)
89 .font(.system(.caption2, design: .monospaced))
90 .font(appDensity.font(.caption2))
9091 .foregroundStyle(result.status == .failed ? .red : .secondary)
9192 }
9293 }
9394 .frame(maxWidth: .infinity, alignment: .leading)
9495 .padding(.vertical, 4)
96 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
9597 }
9698
9799 private var availabilityText: String {
@@ -105,25 +107,25 @@ struct BatchResultRowView: View {
105107 }
106108 }
107109
108 private var quickStatusColor: Color {
110 private var quickStatusBadge: AppStatusBadgeModel {
109111 switch result.status {
110112 case .pending:
111 return .secondary
113 return .init(title: "Pending", systemImage: "clock", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
112114 case .running:
113 return .cyan
115 return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
114116 case .completed:
115117 if result.changeSeverity == .high || result.certificateWarningLevel == .critical {
116 return .red
118 return .init(title: "High", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
117119 }
118120 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
119 return .yellow
121 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
120122 }
121123 if result.quickStatus == "Changed" {
122 return .blue
124 return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
123125 }
124 return .green
126 return .init(title: "Stable", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
125127 case .failed:
126 return .red
128 return .init(title: "Failed", systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
127129 }
128130 }
129131}
DomainDig/ContentView.swift +416 −182
@@ -8,7 +8,18 @@ enum LookupInputMode: String, CaseIterable, Identifiable {
88 var id: String { rawValue }
99}
1010
11enum ResultSection: String, Hashable {
12 case domain
13 case ownership
14 case dns
15 case web
16 case email
17 case network
18 case subdomains
19}
20
1121struct ContentView: View {
22 @Environment(\.appDensity) private var appDensity
1223 @State private var viewModel = DomainViewModel()
1324 @State private var navigationPath = NavigationPath()
1425 @FocusState private var domainFieldFocused: Bool
@@ -18,6 +29,7 @@ struct ContentView: View {
1829 @State private var editingTrackedDomain: TrackedDomain?
1930 @State private var showTrackLimitAlert = false
2031 @State private var inputMode: LookupInputMode = .single
32 @State private var collapsedSections: Set<ResultSection> = [.network]
2133
2234 var body: some View {
2335 NavigationStack(path: $navigationPath) {
@@ -26,21 +38,22 @@ struct ContentView: View {
2638 inputSection
2739 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
2840 batchSection
29 .padding(.top, 8)
41 .padding(.top, appDensity.metrics.cardSpacing)
3042 }
3143 if viewModel.hasRun {
3244 actionButtons
33 if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) {
45 if let statusMessage = resultStatusMessage {
3446 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
35 .padding(.top, 8)
47 .padding(.top, appDensity.metrics.cardSpacing)
3648 }
3749 SummaryView(fields: viewModel.summaryFields)
38 .padding(.top, 8)
50 .padding(.top, appDensity.metrics.cardSpacing)
3951 if let changeSummary = viewModel.currentChangeSummary {
4052 DomainChangeSummaryView(summary: changeSummary)
41 .padding(.top, 12)
53 .padding(.top, appDensity.metrics.cardSpacing)
4254 }
4355 DomainSectionView(
56 isCollapsed: sectionCollapsedBinding(.domain),
4457 rows: viewModel.domainRows,
4558 suggestions: viewModel.suggestionRows,
4659 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
@@ -63,38 +76,42 @@ struct ContentView: View {
6376 editingTrackedDomain = trackedDomain
6477 }
6578 )
66 .padding(.top, 16)
79 .padding(.top, appDensity.metrics.sectionSpacing)
6780 OwnershipSectionView(
81 isCollapsed: sectionCollapsedBinding(.ownership),
6882 rows: viewModel.ownershipRows,
6983 loading: viewModel.ownershipLoading,
7084 error: viewModel.ownershipError,
7185 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
7286 )
73 .padding(.top, 16)
87 .padding(.top, appDensity.metrics.sectionSpacing)
7488 SubdomainsSectionView(
89 isCollapsed: sectionCollapsedBinding(.subdomains),
7590 rows: viewModel.subdomainRows,
7691 loading: viewModel.subdomainsLoading,
7792 error: viewModel.subdomainsError,
7893 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
7994 )
80 .padding(.top, 16)
95 .padding(.top, appDensity.metrics.sectionSpacing)
8196 if !viewModel.currentDiffSections.isEmpty {
8297 DomainDiffView(
8398 title: "Latest Changes",
8499 sections: viewModel.currentDiffSections,
85100 showsUnchanged: false
86101 )
87 .padding(.top, 16)
102 .padding(.top, appDensity.metrics.sectionSpacing)
88103 }
89104 DNSSectionView(
105 isCollapsed: sectionCollapsedBinding(.dns),
90106 dnssecLabel: viewModel.dnssecLabel,
91107 sections: viewModel.dnsRows,
92108 ptrMessage: viewModel.ptrMessage,
93109 loading: viewModel.dnsLoading || viewModel.ptrLoading,
94110 sectionError: viewModel.dnsError
95111 )
96 .padding(.top, 16)
112 .padding(.top, appDensity.metrics.sectionSpacing)
97113 WebSectionView(
114 isCollapsed: sectionCollapsedBinding(.web),
98115 certificateRows: viewModel.webCertificateRows,
99116 sslInfo: viewModel.sslInfo,
100117 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
@@ -108,14 +125,16 @@ struct ContentView: View {
108125 redirectError: viewModel.redirectChainError,
109126 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
110127 )
111 .padding(.top, 16)
128 .padding(.top, appDensity.metrics.sectionSpacing)
112129 EmailSectionView(
130 isCollapsed: sectionCollapsedBinding(.email),
113131 rows: viewModel.emailRows,
114132 loading: viewModel.emailSecurityLoading,
115133 error: viewModel.emailSecurityError
116134 )
117 .padding(.top, 16)
135 .padding(.top, appDensity.metrics.sectionSpacing)
118136 NetworkSectionView(
137 isCollapsed: sectionCollapsedBinding(.network),
119138 reachabilityRows: viewModel.reachabilityRows,
120139 reachabilityLoading: viewModel.reachabilityLoading,
121140 reachabilityError: viewModel.reachabilityError,
@@ -134,7 +153,7 @@ struct ContentView: View {
134153 customPortInput: $customPortInput,
135154 onScanCustomPorts: runCustomPortScan
136155 )
137 .padding(.top, 16)
156 .padding(.top, appDensity.metrics.sectionSpacing)
138157 } else if !viewModel.recentSearches.isEmpty {
139158 recentSearchesSection
140159 }
@@ -142,7 +161,34 @@ struct ContentView: View {
142161 .padding(.horizontal)
143162 .padding(.bottom, 32)
144163 }
145 .background(Color.black)
164 .safeAreaInset(edge: .top) {
165 if viewModel.hasRun {
166 StickyLookupSummaryView(
167 domain: viewModel.searchedDomain,
168 availability: viewModel.availabilityResult?.status,
169 primaryIP: currentPrimaryIP,
170 sslInfo: viewModel.sslInfo,
171 sslError: viewModel.sslError,
172 emailSecurity: viewModel.emailSecurity,
173 emailError: viewModel.emailSecurityError,
174 changeSummary: viewModel.currentChangeSummary
175 )
176 .padding(.horizontal)
177 .padding(.top, 6)
178 .background {
179 Rectangle()
180 .fill(.ultraThinMaterial)
181 .opacity(0.96)
182 }
183 }
184 }
185 .background(
186 LinearGradient(
187 colors: [Color.black, Color(.systemGray6).opacity(0.12)],
188 startPoint: .top,
189 endPoint: .bottom
190 )
191 )
146192 .navigationTitle("DomainDig")
147193 .toolbarColorScheme(.dark, for: .navigationBar)
148194 .preferredColorScheme(.dark)
@@ -176,7 +222,7 @@ struct ContentView: View {
176222 }
177223
178224 NavigationLink {
179 SettingsView()
225 SettingsView(viewModel: viewModel)
180226 } label: {
181227 Label("Settings", systemImage: "gearshape")
182228 }
@@ -190,6 +236,9 @@ struct ContentView: View {
190236 .onAppear {
191237 domainFieldFocused = true
192238 }
239 .onChange(of: viewModel.searchedDomain) { _, _ in
240 collapsedSections = defaultCollapsedSections
241 }
193242 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
194243 navigationPath = NavigationPath()
195244 domainFieldFocused = false
@@ -228,7 +277,7 @@ struct ContentView: View {
228277 }
229278
230279 private var inputSection: some View {
231 VStack(spacing: 12) {
280 VStack(spacing: appDensity.metrics.cardSpacing + 2) {
232281 Picker("Mode", selection: $inputMode) {
233282 Text("Single").tag(LookupInputMode.single)
234283 Text("Bulk").tag(LookupInputMode.bulk)
@@ -237,13 +286,14 @@ struct ContentView: View {
237286
238287 if inputMode == .single {
239288 TextField("e.g. cleberg.net", text: $viewModel.domain)
240 .font(.system(.title3, design: .monospaced))
289 .font(appDensity.font(.title3, design: .monospaced))
241290 .textInputAutocapitalization(.never)
242291 .autocorrectionDisabled()
243292 .keyboardType(.URL)
244 .padding(12)
293 .padding(.horizontal, 12)
294 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
245295 .background(Color(.systemGray6))
246 .cornerRadius(8)
296 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
247297 .focused($domainFieldFocused)
248298 .onSubmit { viewModel.run() }
249299
@@ -252,16 +302,16 @@ struct ContentView: View {
252302 viewModel.run()
253303 } label: {
254304 Text("Run")
255 .font(.headline)
305 .font(appDensity.font(.headline, design: .default, weight: .semibold))
256306 .frame(maxWidth: .infinity)
257 .padding(.vertical, 12)
307 .frame(minHeight: appDensity.metrics.controlMinHeight)
258308 }
259309 .buttonStyle(.borderedProminent)
260310 .disabled(viewModel.trimmedDomain.isEmpty)
261311 } else {
262 VStack(alignment: .leading, spacing: 8) {
312 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
263313 Text("Paste domains separated by new lines or commas.")
264 .font(.system(.caption, design: .monospaced))
314 .font(appDensity.font(.caption))
265315 .foregroundStyle(.secondary)
266316
267317 TextField(
@@ -269,30 +319,31 @@ struct ContentView: View {
269319 text: $viewModel.bulkInput,
270320 axis: .vertical
271321 )
272 .font(.system(.body, design: .monospaced))
322 .font(appDensity.font(.body))
273323 .textInputAutocapitalization(.never)
274324 .autocorrectionDisabled()
275325 .keyboardType(.URL)
276326 .lineLimit(4...10)
277 .padding(12)
327 .padding(.horizontal, 12)
328 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
278329 .background(Color(.systemGray6))
279 .cornerRadius(8)
330 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
280331
281332 Button {
282333 domainFieldFocused = false
283334 viewModel.runBulkLookup()
284335 } label: {
285336 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
286 .font(.headline)
337 .font(appDensity.font(.headline, design: .default, weight: .semibold))
287338 .frame(maxWidth: .infinity)
288 .padding(.vertical, 12)
339 .frame(minHeight: appDensity.metrics.controlMinHeight)
289340 }
290341 .buttonStyle(.borderedProminent)
291342 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
292343 }
293344 }
294345 }
295 .padding(.vertical, 16)
346 .padding(.vertical, appDensity.metrics.sectionSpacing)
296347 }
297348
298349 private var actionButtons: some View {
@@ -303,7 +354,7 @@ struct ContentView: View {
303354 viewModel.toggleSavedDomain()
304355 } label: {
305356 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
306 .font(.system(.body))
357 .font(appDensity.font(.body, design: .default))
307358 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
308359 }
309360 Menu {
@@ -318,7 +369,7 @@ struct ContentView: View {
318369 }
319370 } label: {
320371 Image(systemName: "square.and.arrow.up")
321 .font(.system(.body))
372 .font(appDensity.font(.body, design: .default))
322373 .foregroundStyle(.secondary)
323374 }
324375 }
@@ -326,7 +377,7 @@ struct ContentView: View {
326377 }
327378
328379 private var batchSection: some View {
329 VStack(alignment: .leading, spacing: 12) {
380 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
330381 HStack {
331382 Spacer()
332383 if viewModel.batchLookupRunning {
@@ -334,7 +385,7 @@ struct ContentView: View {
334385 viewModel.cancelBatchLookup()
335386 }
336387 .buttonStyle(.bordered)
337 .font(.system(.caption, design: .monospaced))
388 .font(appDensity.font(.caption))
338389 }
339390 if !viewModel.currentBatchResultEntries.isEmpty {
340391 Menu {
@@ -349,7 +400,7 @@ struct ContentView: View {
349400 }
350401 } label: {
351402 Label("Export", systemImage: "square.and.arrow.up")
352 .font(.system(.caption, design: .monospaced))
403 .font(appDensity.font(.caption))
353404 }
354405 .buttonStyle(.bordered)
355406 }
@@ -363,16 +414,16 @@ struct ContentView: View {
363414 }
364415
365416 private var recentSearchesSection: some View {
366 VStack(alignment: .leading, spacing: 8) {
417 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
367418 HStack {
368419 Text("RECENT")
369 .font(.system(.caption2, design: .monospaced))
420 .font(appDensity.font(.caption2))
370421 .foregroundStyle(.secondary)
371422 Spacer()
372423 Button("Clear") {
373424 viewModel.clearRecentSearches()
374425 }
375 .font(.system(.caption2, design: .monospaced))
426 .font(appDensity.font(.caption2))
376427 .foregroundStyle(.secondary)
377428 }
378429
@@ -383,17 +434,17 @@ struct ContentView: View {
383434 viewModel.run()
384435 } label: {
385436 Text(domain)
386 .font(.system(.callout, design: .monospaced))
437 .font(appDensity.font(.callout))
387438 .foregroundStyle(.primary)
388439 .frame(maxWidth: .infinity, alignment: .leading)
389 .padding(.vertical, 6)
440 .padding(.vertical, 8)
390441 .padding(.horizontal, 10)
391442 .background(Color(.systemGray6).opacity(0.5))
392 .cornerRadius(6)
443 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
393444 }
394445 }
395446 }
396 .padding(.top, 8)
447 .padding(.top, appDensity.metrics.cardSpacing)
397448 }
398449
399450 private func runCustomPortScan() {
@@ -468,37 +519,125 @@ struct ContentView: View {
468519
469520 return (filename, data)
470521 }
522
523 private var defaultCollapsedSections: Set<ResultSection> {
524 var sections: Set<ResultSection> = []
525 if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil {
526 sections.insert(.network)
527 }
528 return sections
529 }
530
531 private var currentPrimaryIP: String? {
532 viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
533 }
534
535 private var resultStatusMessage: String? {
536 if let currentStatusMessage = viewModel.currentStatusMessage {
537 return currentStatusMessage
538 }
539
540 if viewModel.currentResultSource != .live {
541 return viewModel.currentResultSource.label
542 }
543
544 return nil
545 }
546
547 private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
548 Binding(
549 get: { collapsedSections.contains(section) },
550 set: { isCollapsed in
551 if isCollapsed {
552 collapsedSections.insert(section)
553 } else {
554 collapsedSections.remove(section)
555 }
556 }
557 )
558 }
471559}
472560
473561struct SummaryView: View {
562 @Environment(\.appDensity) private var appDensity
474563 let fields: [SummaryFieldViewData]
475564
476565 var body: some View {
477 VStack(alignment: .leading, spacing: 12) {
566 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
478567 SectionTitleView(title: "Summary")
479 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
568 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
480569 ForEach(fields) { field in
481 VStack(alignment: .leading, spacing: 4) {
570 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
482571 Text(field.label)
483 .font(.system(.caption2, design: .monospaced))
572 .font(appDensity.font(.caption2))
484573 .foregroundStyle(.secondary)
485574 Text(field.value)
486 .font(.system(.caption, design: .monospaced))
575 .font(appDensity.font(.caption))
487576 .foregroundStyle(ResultColors.color(for: field.tone))
488577 .lineLimit(2)
489578 .textSelection(.enabled)
490579 }
580 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
491581 .frame(maxWidth: .infinity, alignment: .leading)
492 .padding(10)
582 .padding(appDensity.metrics.cardPadding)
493583 .background(Color(.systemGray6).opacity(0.5))
494 .cornerRadius(6)
584 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
495585 }
496586 }
497587 }
498588 }
499589}
500590
591struct StickyLookupSummaryView: View {
592 @Environment(\.appDensity) private var appDensity
593
594 let domain: String
595 let availability: DomainAvailabilityStatus?
596 let primaryIP: String?
597 let sslInfo: SSLCertificateInfo?
598 let sslError: String?
599 let emailSecurity: EmailSecurityResult?
600 let emailError: String?
601 let changeSummary: DomainChangeSummary?
602
603 var body: some View {
604 CardView(allowsHorizontalScroll: false) {
605 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
606 HStack(alignment: .center, spacing: 10) {
607 Text(domain)
608 .font(appDensity.font(.headline, weight: .semibold))
609 .foregroundStyle(.primary)
610 .lineLimit(1)
611 Spacer(minLength: 6)
612 AppCopyButton(value: domain, label: "Copy domain")
613 }
614
615 ScrollView(.horizontal, showsIndicators: false) {
616 HStack(spacing: 8) {
617 AppStatusBadgeView(model: AppStatusFactory.availability(availability))
618 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
619 AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
620 AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
621 }
622 }
623
624 if let primaryIP {
625 HStack(spacing: 8) {
626 Label(primaryIP, systemImage: "network")
627 .font(appDensity.font(.caption))
628 .foregroundStyle(.secondary)
629 Spacer(minLength: 6)
630 AppCopyButton(value: primaryIP, label: "Copy IP")
631 }
632 }
633 }
634 }
635 .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
636 }
637}
638
501639struct LookupStatusBannerView: View {
640 @Environment(\.appDensity) private var appDensity
502641 let message: String
503642 let resultSource: LookupResultSource
504643
@@ -507,14 +646,14 @@ struct LookupStatusBannerView: View {
507646 Image(systemName: iconName)
508647 .font(.caption)
509648 Text(message)
510 .font(.system(.caption, design: .monospaced))
649 .font(appDensity.font(.caption))
511650 Spacer()
512651 }
513652 .foregroundStyle(color)
514 .padding(8)
653 .padding(appDensity.metrics.cardPadding - 2)
515654 .frame(maxWidth: .infinity, alignment: .leading)
516655 .background(color.opacity(0.12))
517 .clipShape(RoundedRectangle(cornerRadius: 8))
656 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
518657 }
519658
520659 private var color: Color {
@@ -545,29 +684,30 @@ struct LookupStatusBannerView: View {
545684}
546685
547686struct DomainChangeSummaryView: View {
687 @Environment(\.appDensity) private var appDensity
548688 let summary: DomainChangeSummary
549689
550690 var body: some View {
551691 CardView(allowsHorizontalScroll: false) {
552692 HStack {
553693 Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
554 .font(.system(.caption, design: .monospaced))
694 .font(appDensity.font(.caption))
555695 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green)
556696 Spacer()
557697 Text(summary.severity.title.uppercased())
558 .font(.system(.caption2, design: .monospaced))
698 .font(appDensity.font(.caption2))
559699 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary)
560700 .padding(.horizontal, 8)
561701 .padding(.vertical, 4)
562702 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
563703 .clipShape(Capsule())
564704 Text(summary.generatedAt, style: .time)
565 .font(.system(.caption2, design: .monospaced))
705 .font(appDensity.font(.caption2))
566706 .foregroundStyle(.secondary)
567707 }
568708
569709 Text(summary.message)
570 .font(.system(.caption, design: .monospaced))
710 .font(appDensity.font(.caption))
571711 .foregroundStyle(.primary)
572712 }
573713 }
@@ -770,6 +910,8 @@ struct TrackedDomainDetailHeaderView: View {
770910}
771911
772912struct DomainSectionView: View {
913 @Environment(\.appDensity) private var appDensity
914 @Binding var isCollapsed: Bool
773915 let rows: [InfoRowViewData]
774916 let suggestions: [DomainSuggestionViewData]
775917 let showSuggestions: Bool
@@ -782,38 +924,34 @@ struct DomainSectionView: View {
782924 let onEditNote: (() -> Void)?
783925
784926 var body: some View {
785 VStack(alignment: .leading, spacing: 12) {
786 HStack {
787 SectionTitleView(title: "Domain")
788 Spacer()
789 if let trackedDomain {
790 HStack(spacing: 8) {
791 Text("Tracked")
792 .font(.system(.caption, design: .monospaced))
793 .foregroundStyle(.green)
794 Button {
795 onTogglePinned()
796 } label: {
797 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
927 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
928 if let trackedDomain {
929 HStack(spacing: 8) {
930 AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)))
931 Button {
932 onTogglePinned()
933 } label: {
934 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
935 }
936 .buttonStyle(.bordered)
937 .font(appDensity.font(.caption))
938 if let onEditNote {
939 Button("Note") {
940 onEditNote()
798941 }
799942 .buttonStyle(.bordered)
800 .font(.system(.caption, design: .monospaced))
801 if let onEditNote {
802 Button("Note") {
803 onEditNote()
804 }
805 .buttonStyle(.bordered)
806 .font(.system(.caption, design: .monospaced))
807 }
808 }
809 } else {
810 Button("Track") {
811 onTrack()
943 .font(appDensity.font(.caption))
812944 }
813 .buttonStyle(.bordered)
814 .font(.system(.caption, design: .monospaced))
815945 }
946 } else {
947 Button("Track") {
948 AppHaptics.track()
949 onTrack()
950 }
951 .buttonStyle(.bordered)
952 .font(appDensity.font(.caption))
816953 }
954 } content: {
817955 CardView(allowsHorizontalScroll: false) {
818956 ForEach(rows) { row in
819957 LabeledValueRow(row: row)
@@ -832,7 +970,7 @@ struct DomainSectionView: View {
832970 }
833971 if showSuggestions {
834972 Text("Suggestions")
835 .font(.system(.caption, design: .monospaced))
973 .font(appDensity.font(.caption))
836974 .foregroundStyle(.secondary)
837975 .padding(.top, 4)
838976 if suggestionsLoading {
@@ -844,13 +982,11 @@ struct DomainSectionView: View {
844982 ForEach(suggestions) { suggestion in
845983 HStack {
846984 Text(suggestion.domain)
847 .font(.system(.caption, design: .monospaced))
985 .font(appDensity.font(.caption))
848986 .foregroundStyle(.primary)
849987 .textSelection(.enabled)
850988 Spacer()
851 Text(suggestion.status)
852 .font(.system(.caption2, design: .monospaced))
853 .foregroundStyle(ResultColors.color(for: suggestion.tone))
989 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered))
854990 }
855991 }
856992 }
@@ -861,14 +997,14 @@ struct DomainSectionView: View {
861997}
862998
863999struct OwnershipSectionView: View {
1000 @Binding var isCollapsed: Bool
8641001 let rows: [InfoRowViewData]
8651002 let loading: Bool
8661003 let error: String?
8671004 let showsHistoryPlaceholder: Bool
8681005
8691006 var body: some View {
870 VStack(alignment: .leading, spacing: 12) {
871 SectionTitleView(title: "Ownership")
1007 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
8721008 CardView(allowsHorizontalScroll: false) {
8731009 if loading {
8741010 ProgressView("Fetching RDAP ownership…")
@@ -892,21 +1028,15 @@ struct OwnershipSectionView: View {
8921028}
8931029
8941030struct SubdomainsSectionView: View {
1031 @Environment(\.appDensity) private var appDensity
1032 @Binding var isCollapsed: Bool
8951033 let rows: [SubdomainRowViewData]
8961034 let loading: Bool
8971035 let error: String?
8981036 let showsExtendedPlaceholder: Bool
8991037
9001038 var body: some View {
901 VStack(alignment: .leading, spacing: 12) {
902 HStack {
903 SectionTitleView(title: "Subdomains")
904 Spacer()
905 Text("\(rows.count)")
906 .font(.system(.caption2, design: .monospaced))
907 .foregroundStyle(.secondary)
908 }
909
1039 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
9101040 CardView(allowsHorizontalScroll: false) {
9111041 if loading {
9121042 ProgressView("Checking certificate transparency…")
@@ -921,7 +1051,7 @@ struct SubdomainsSectionView: View {
9211051 ForEach(rows) { row in
9221052 HStack(spacing: 8) {
9231053 Text(row.hostname)
924 .font(.system(.caption, design: .monospaced))
1054 .font(appDensity.font(.caption))
9251055 .foregroundStyle(.primary)
9261056 .textSelection(.enabled)
9271057 Spacer()
@@ -947,6 +1077,7 @@ struct SubdomainsSectionView: View {
9471077}
9481078
9491079struct DNSSectionView: View {
1080 @Binding var isCollapsed: Bool
9501081 let dnssecLabel: String?
9511082 let sections: [DNSRecordSectionViewData]
9521083 let ptrMessage: SectionMessageViewData?
@@ -954,18 +1085,7 @@ struct DNSSectionView: View {
9541085 let sectionError: String?
9551086
9561087 var body: some View {
957 VStack(alignment: .leading, spacing: 12) {
958 HStack(alignment: .top, spacing: 8) {
959 SectionTitleView(title: "DNS")
960 Spacer()
961 if let dnssecLabel {
962 Text(dnssecLabel)
963 .font(.system(.caption2, design: .monospaced))
964 .foregroundStyle(.secondary)
965 .multilineTextAlignment(.trailing)
966 }
967 }
968
1088 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
9691089 if loading {
9701090 LoadingCardView(text: "Querying DNS…")
9711091 } else if let sectionError, sections.isEmpty {
@@ -1013,6 +1133,8 @@ struct DNSSectionView: View {
10131133}
10141134
10151135struct WebSectionView: View {
1136 @Environment(\.appDensity) private var appDensity
1137 @Binding var isCollapsed: Bool
10161138 let certificateRows: [InfoRowViewData]
10171139 let sslInfo: SSLCertificateInfo?
10181140 let sslLoading: Bool
@@ -1027,14 +1149,15 @@ struct WebSectionView: View {
10271149 let finalURL: String?
10281150
10291151 var body: some View {
1030 VStack(alignment: .leading, spacing: 12) {
1031 SectionTitleView(title: "Web")
1032
1152 CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
10331153 CardView {
1034 Text("TLS")
1035 .font(.system(.subheadline, design: .monospaced))
1036 .fontWeight(.semibold)
1037 .foregroundStyle(.cyan)
1154 HStack {
1155 Text("TLS")
1156 .font(appDensity.font(.subheadline, weight: .semibold))
1157 .foregroundStyle(.cyan)
1158 Spacer()
1159 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
1160 }
10381161 if sslLoading {
10391162 ProgressView("Checking certificate…")
10401163 .appLoadingStyle()
@@ -1046,12 +1169,16 @@ struct WebSectionView: View {
10461169 }
10471170 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
10481171 Text("SANs")
1049 .font(.system(.caption2, design: .monospaced))
1172 .font(appDensity.font(.caption2))
10501173 .foregroundStyle(.secondary)
10511174 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
1052 Text(san)
1053 .font(.system(.caption, design: .monospaced))
1054 .textSelection(.enabled)
1175 HStack(spacing: 8) {
1176 Text(san)
1177 .font(appDensity.font(.caption))
1178 .textSelection(.enabled)
1179 Spacer()
1180 AppCopyButton(value: san, label: "Copy certificate SAN")
1181 }
10551182 }
10561183 }
10571184 }
@@ -1059,8 +1186,7 @@ struct WebSectionView: View {
10591186
10601187 CardView {
10611188 Text("Headers")
1062 .font(.system(.subheadline, design: .monospaced))
1063 .fontWeight(.semibold)
1189 .font(appDensity.font(.subheadline, weight: .semibold))
10641190 .foregroundStyle(.cyan)
10651191 if headersLoading {
10661192 ProgressView("Fetching headers…")
@@ -1077,10 +1203,10 @@ struct WebSectionView: View {
10771203 ForEach(headers) { header in
10781204 HStack(alignment: .top, spacing: 4) {
10791205 Text(header.name + ":")
1080 .font(.system(.caption, design: .monospaced))
1206 .font(appDensity.font(.caption))
10811207 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
10821208 Text(header.value)
1083 .font(.system(.caption, design: .monospaced))
1209 .font(appDensity.font(.caption))
10841210 .foregroundStyle(.primary)
10851211 .textSelection(.enabled)
10861212 }
@@ -1090,10 +1216,15 @@ struct WebSectionView: View {
10901216 }
10911217
10921218 CardView {
1093 Text("Redirects")
1094 .font(.system(.subheadline, design: .monospaced))
1095 .fontWeight(.semibold)
1096 .foregroundStyle(.cyan)
1219 HStack {
1220 Text("Redirects")
1221 .font(appDensity.font(.subheadline, weight: .semibold))
1222 .foregroundStyle(.cyan)
1223 Spacer()
1224 if let finalURL {
1225 AppCopyButton(value: finalURL, label: "Copy redirect URL")
1226 }
1227 }
10971228 if redirectLoading {
10981229 ProgressView("Tracing redirects…")
10991230 .appLoadingStyle()
@@ -1108,19 +1239,20 @@ struct WebSectionView: View {
11081239 ForEach(redirects) { redirect in
11091240 HStack(alignment: .top, spacing: 6) {
11101241 Text(redirect.stepLabel)
1111 .font(.system(.caption, design: .monospaced))
1242 .font(appDensity.font(.caption))
11121243 .foregroundStyle(.secondary)
11131244 .frame(width: 16, alignment: .trailing)
11141245 Text(redirect.statusCode)
1115 .font(.system(.caption, design: .monospaced))
1246 .font(appDensity.font(.caption))
11161247 .foregroundStyle(.cyan)
11171248 .frame(width: 36, alignment: .leading)
11181249 Text(redirect.url)
1119 .font(.system(.caption, design: .monospaced))
1250 .font(appDensity.font(.caption))
11201251 .textSelection(.enabled)
1252 AppCopyButton(value: redirect.url, label: "Copy redirect URL")
11211253 if redirect.isFinal {
11221254 Text("(final)")
1123 .font(.system(.caption2, design: .monospaced))
1255 .font(appDensity.font(.caption2))
11241256 .foregroundStyle(.secondary)
11251257 }
11261258 }
@@ -1132,14 +1264,20 @@ struct WebSectionView: View {
11321264}
11331265
11341266struct EmailSectionView: View {
1267 @Environment(\.appDensity) private var appDensity
1268 @Binding var isCollapsed: Bool
11351269 let rows: [EmailRowViewData]
11361270 let loading: Bool
11371271 let error: String?
11381272
11391273 var body: some View {
1140 VStack(alignment: .leading, spacing: 12) {
1141 SectionTitleView(title: "Email")
1274 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
11421275 CardView {
1276 HStack {
1277 Spacer()
1278 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
1279 .opacity(loading ? 0 : 1)
1280 }
11431281 if loading {
11441282 ProgressView("Checking email records…")
11451283 .appLoadingStyle()
@@ -1152,20 +1290,18 @@ struct EmailSectionView: View {
11521290 VStack(alignment: .leading, spacing: 4) {
11531291 HStack(spacing: 8) {
11541292 Text(row.label)
1155 .font(.system(.caption, design: .monospaced))
1293 .font(appDensity.font(.caption))
11561294 .foregroundStyle(.cyan)
11571295 .frame(width: 76, alignment: .leading)
1158 Text(row.status)
1159 .font(.system(.caption, design: .monospaced))
1160 .foregroundStyle(ResultColors.color(for: row.statusTone))
1296 AppStatusBadgeView(model: emailRowBadge(row))
11611297 }
11621298 Text(row.detail)
1163 .font(.system(.caption2, design: .monospaced))
1299 .font(appDensity.font(.caption2))
11641300 .foregroundStyle(.primary)
11651301 .textSelection(.enabled)
11661302 if let auxiliaryDetail = row.auxiliaryDetail {
11671303 Text(auxiliaryDetail)
1168 .font(.system(.caption2, design: .monospaced))
1304 .font(appDensity.font(.caption2))
11691305 .foregroundStyle(.secondary)
11701306 }
11711307 }
@@ -1174,9 +1310,24 @@ struct EmailSectionView: View {
11741310 }
11751311 }
11761312 }
1313
1314 private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
1315 switch row.statusTone {
1316 case .success:
1317 return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
1318 case .warning:
1319 return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
1320 case .failure:
1321 return .init(title: row.status, systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1322 case .primary, .secondary:
1323 return .init(title: row.status, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1324 }
1325 }
11771326}
11781327
11791328struct NetworkSectionView: View {
1329 @Environment(\.appDensity) private var appDensity
1330 @Binding var isCollapsed: Bool
11801331 let reachabilityRows: [ReachabilityRowViewData]
11811332 let reachabilityLoading: Bool
11821333 let reachabilityError: String?
@@ -1196,13 +1347,10 @@ struct NetworkSectionView: View {
11961347 let onScanCustomPorts: () -> Void
11971348
11981349 var body: some View {
1199 VStack(alignment: .leading, spacing: 12) {
1200 SectionTitleView(title: "Network")
1201
1350 CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
12021351 CardView {
12031352 Text("Reachability")
1204 .font(.system(.subheadline, design: .monospaced))
1205 .fontWeight(.semibold)
1353 .font(appDensity.font(.subheadline, weight: .semibold))
12061354 .foregroundStyle(.cyan)
12071355 if reachabilityLoading {
12081356 ProgressView("Checking ports…")
@@ -1213,14 +1361,12 @@ struct NetworkSectionView: View {
12131361 ForEach(reachabilityRows) { row in
12141362 HStack {
12151363 Text(row.portLabel)
1216 .font(.system(.caption, design: .monospaced))
1364 .font(appDensity.font(.caption))
12171365 Spacer()
12181366 Text(row.latencyLabel)
1219 .font(.system(.caption2, design: .monospaced))
1367 .font(appDensity.font(.caption2))
12201368 .foregroundStyle(.secondary)
1221 Text(row.statusLabel)
1222 .font(.system(.caption, design: .monospaced))
1223 .foregroundStyle(ResultColors.color(for: row.statusTone))
1369 AppStatusBadgeView(model: reachabilityBadge(row))
12241370 }
12251371 }
12261372 }
@@ -1228,8 +1374,7 @@ struct NetworkSectionView: View {
12281374
12291375 CardView(allowsHorizontalScroll: false) {
12301376 Text("Location")
1231 .font(.system(.subheadline, design: .monospaced))
1232 .fontWeight(.semibold)
1377 .font(appDensity.font(.subheadline, weight: .semibold))
12331378 .foregroundStyle(.cyan)
12341379 if geolocationLoading {
12351380 ProgressView("Looking up location…")
@@ -1260,13 +1405,12 @@ struct NetworkSectionView: View {
12601405
12611406 CardView(allowsHorizontalScroll: false) {
12621407 Text("Port Scan")
1263 .font(.system(.subheadline, design: .monospaced))
1264 .fontWeight(.semibold)
1408 .font(appDensity.font(.subheadline, weight: .semibold))
12651409 .foregroundStyle(.cyan)
12661410
12671411 if isCloudflareProxied {
12681412 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
1269 .font(.system(.caption2, design: .monospaced))
1413 .font(appDensity.font(.caption2))
12701414 .foregroundStyle(.orange)
12711415 .fixedSize(horizontal: false, vertical: true)
12721416 }
@@ -1286,15 +1430,16 @@ struct NetworkSectionView: View {
12861430 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
12871431 VStack(alignment: .leading, spacing: 10) {
12881432 TextField("8888, 9000, 27017", text: $customPortInput)
1289 .font(.system(.caption, design: .monospaced))
1433 .font(appDensity.font(.caption))
12901434 .textInputAutocapitalization(.never)
12911435 .autocorrectionDisabled()
12921436 .keyboardType(.numberPad)
12931437 .padding(10)
12941438 .background(Color(.systemGray6).opacity(0.5))
1295 .cornerRadius(6)
1439 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
12961440
12971441 Button("Scan") {
1442 AppHaptics.refresh()
12981443 onScanCustomPorts()
12991444 }
13001445 .buttonStyle(.borderedProminent)
@@ -1316,9 +1461,23 @@ struct NetworkSectionView: View {
13161461 }
13171462 }
13181463 }
1464
1465 private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
1466 switch row.statusTone {
1467 case .success:
1468 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
1469 case .warning:
1470 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
1471 case .failure:
1472 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
1473 case .primary, .secondary:
1474 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1475 }
1476 }
13191477}
13201478
13211479struct PortRowsView: View {
1480 @Environment(\.appDensity) private var appDensity
13221481 let rows: [PortScanRowViewData]
13231482
13241483 var body: some View {
@@ -1326,47 +1485,61 @@ struct PortRowsView: View {
13261485 MessageRowView(text: "No results", isError: false)
13271486 } else {
13281487 ForEach(rows) { row in
1329 VStack(alignment: .leading, spacing: 2) {
1488 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
13301489 HStack {
13311490 Text(row.portLabel)
1332 .font(.system(.caption, design: .monospaced))
1491 .font(appDensity.font(.caption))
13331492 .frame(width: 52, alignment: .leading)
13341493 Text(row.service)
1335 .font(.system(.caption, design: .monospaced))
1494 .font(appDensity.font(.caption))
13361495 .foregroundStyle(.primary)
13371496 Spacer()
13381497 if let durationLabel = row.durationLabel {
13391498 Text(durationLabel)
1340 .font(.system(.caption2, design: .monospaced))
1499 .font(appDensity.font(.caption2))
13411500 .foregroundStyle(.secondary)
13421501 }
1343 Text(row.statusLabel)
1344 .font(.system(.caption2, design: .monospaced))
1345 .foregroundStyle(ResultColors.color(for: row.statusTone))
1502 AppStatusBadgeView(model: portBadge(row))
13461503 }
13471504 if let banner = row.banner {
13481505 Text(banner)
1349 .font(.system(.caption2, design: .monospaced))
1506 .font(appDensity.font(.caption2))
13501507 .foregroundStyle(.secondary)
13511508 .padding(.leading, 8)
13521509 }
13531510 }
1511 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
13541512 }
13551513 }
13561514 }
1515
1516 private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
1517 switch row.statusTone {
1518 case .success:
1519 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
1520 case .warning:
1521 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
1522 case .failure:
1523 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
1524 case .primary, .secondary:
1525 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
1526 }
1527 }
13571528}
13581529
13591530struct SectionTitleView: View {
1531 @Environment(\.appDensity) private var appDensity
13601532 let title: String
13611533
13621534 var body: some View {
13631535 Text(title)
1364 .font(.system(.headline))
1536 .font(appDensity.font(.headline, design: .default, weight: .semibold))
13651537 .foregroundStyle(.white)
13661538 }
13671539}
13681540
13691541struct CardView<Content: View>: View {
1542 @Environment(\.appDensity) private var appDensity
13701543 let allowsHorizontalScroll: Bool
13711544 let content: Content
13721545
@@ -1389,13 +1562,13 @@ struct CardView<Content: View>: View {
13891562 }
13901563 }
13911564 .frame(maxWidth: .infinity, alignment: .leading)
1392 .padding(10)
1565 .padding(appDensity.metrics.cardPadding)
13931566 .background(Color(.systemGray6).opacity(0.5))
1394 .cornerRadius(6)
1567 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
13951568 }
13961569
13971570 private var cardContent: some View {
1398 VStack(alignment: .leading, spacing: 6) {
1571 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
13991572 content
14001573 }
14011574 }
@@ -1425,29 +1598,40 @@ struct MessageCardView: View {
14251598}
14261599
14271600struct MessageRowView: View {
1601 @Environment(\.appDensity) private var appDensity
14281602 let text: String
14291603 let isError: Bool
14301604
14311605 var body: some View {
14321606 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1433 .font(.system(.caption, design: .monospaced))
1607 .font(appDensity.font(.caption))
14341608 .foregroundStyle(isError ? .red : .secondary)
14351609 }
14361610}
14371611
14381612struct LabeledValueRow: View {
1613 @Environment(\.appDensity) private var appDensity
14391614 let row: InfoRowViewData
14401615
14411616 var body: some View {
1442 VStack(alignment: .leading, spacing: 2) {
1443 Text(row.label)
1444 .font(.system(.caption2, design: .monospaced))
1445 .foregroundStyle(.secondary)
1446 Text(row.value)
1447 .font(.system(.caption, design: .monospaced))
1448 .foregroundStyle(ResultColors.color(for: row.tone))
1449 .textSelection(.enabled)
1617 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1618 HStack(alignment: .top, spacing: 8) {
1619 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1620 Text(row.label)
1621 .font(appDensity.font(.caption2))
1622 .foregroundStyle(.secondary)
1623 Text(row.value)
1624 .font(appDensity.font(.caption))
1625 .foregroundStyle(ResultColors.color(for: row.tone))
1626 .textSelection(.enabled)
1627 }
1628 Spacer(minLength: 6)
1629 if !row.value.isEmpty, row.value != "Unavailable" {
1630 AppCopyButton(value: row.value, label: "Copy \(row.label)")
1631 }
1632 }
14501633 }
1634 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
14511635 }
14521636}
14531637
@@ -1490,11 +1674,17 @@ private extension String {
14901674}
14911675
14921676private struct SettingsView: View {
1677 @Environment(\.appDensity) private var appDensity
1678 @Bindable var viewModel: DomainViewModel
14931679 @AppStorage(DNSResolverOption.userDefaultsKey)
14941680 private var storedResolverURL = DNSResolverOption.defaultURLString
1681 @AppStorage(AppDensity.userDefaultsKey)
1682 private var storedDensity = AppDensity.compact.rawValue
14951683
14961684 @State private var resolverOption: DNSResolverOption = .cloudflare
14971685 @State private var customResolverURL = DNSResolverOption.defaultURLString
1686 @State private var showClearHistoryConfirmation = false
1687 @State private var showClearCacheConfirmation = false
14981688
14991689 private var customResolverError: String? {
15001690 guard resolverOption == .custom else {
@@ -1505,7 +1695,15 @@ private struct SettingsView: View {
15051695
15061696 var body: some View {
15071697 Form {
1508 Section {
1698 Section("Display") {
1699 Picker("Density", selection: $storedDensity) {
1700 ForEach(AppDensity.allCases) { density in
1701 Text(density.title).tag(density.rawValue)
1702 }
1703 }
1704 }
1705
1706 Section("Network") {
15091707 Picker("Resolver", selection: $resolverOption) {
15101708 ForEach(DNSResolverOption.allCases) { option in
15111709 Text(option.title).tag(option)
@@ -1520,13 +1718,45 @@ private struct SettingsView: View {
15201718
15211719 if let customResolverError {
15221720 Text(customResolverError)
1523 .font(.caption)
1721 .font(appDensity.font(.caption, design: .default))
15241722 .foregroundStyle(.red)
15251723 }
15261724 }
15271725 }
1726
1727 Section("Data") {
1728 Button("Clear History", role: .destructive) {
1729 showClearHistoryConfirmation = true
1730 }
1731
1732 Button("Clear Cache", role: .destructive) {
1733 showClearCacheConfirmation = true
1734 }
1735 }
1736
1737 Section("About") {
1738 LabeledContent("Version", value: appVersion)
1739 LabeledContent("Storage", value: "Local-only")
1740 LabeledContent("Focus", value: "Readable domain inspection")
1741 }
15281742 }
15291743 .navigationTitle("Settings")
1744 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
1745 Button("Clear", role: .destructive) {
1746 viewModel.clearHistory()
1747 }
1748 Button("Cancel", role: .cancel) {}
1749 } message: {
1750 Text("This removes saved lookup snapshots from this device.")
1751 }
1752 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
1753 Button("Clear", role: .destructive) {
1754 viewModel.clearLookupCache()
1755 }
1756 Button("Cancel", role: .cancel) {}
1757 } message: {
1758 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
1759 }
15301760 .onAppear {
15311761 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
15321762 resolverOption = DNSResolverOption.option(for: currentResolverURL)
@@ -1544,6 +1774,10 @@ private struct SettingsView: View {
15441774 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
15451775 }
15461776 }
1777
1778 private var appVersion: String {
1779 Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0"
1780 }
15471781}
15481782
15491783#Preview {
DomainDig/DomainDigApp.swift +3
@@ -9,6 +9,8 @@ import SwiftUI
99
1010@main
1111struct DomainDigApp: App {
12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
13
1214 init() {
1315 LocalNotificationService.shared.configureForegroundPresentation()
1416 }
@@ -16,6 +18,7 @@ struct DomainDigApp: App {
1618 var body: some Scene {
1719 WindowGroup {
1820 ContentView()
21 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
1922 }
2023 }
2124}
DomainDig/DomainDigUI.swift added +319
@@ -0,0 +1,319 @@
1import SwiftUI
2
3#if canImport(UIKit)
4import UIKit
5#elseif canImport(AppKit)
6import AppKit
7#endif
8
9enum AppDensity: String, CaseIterable, Identifiable {
10 case compact
11 case comfortable
12
13 static let userDefaultsKey = "appDensity"
14
15 var id: String { rawValue }
16
17 var title: String {
18 switch self {
19 case .compact:
20 return "Compact"
21 case .comfortable:
22 return "Comfortable"
23 }
24 }
25
26 var metrics: AppDensityMetrics {
27 switch self {
28 case .compact:
29 return AppDensityMetrics(
30 sectionSpacing: 14,
31 cardSpacing: 6,
32 cardPadding: 10,
33 rowSpacing: 4,
34 rowMinHeight: 30,
35 controlVerticalPadding: 10,
36 controlMinHeight: 42,
37 cardCornerRadius: 10
38 )
39 case .comfortable:
40 return AppDensityMetrics(
41 sectionSpacing: 18,
42 cardSpacing: 10,
43 cardPadding: 14,
44 rowSpacing: 7,
45 rowMinHeight: 38,
46 controlVerticalPadding: 14,
47 controlMinHeight: 48,
48 cardCornerRadius: 14
49 )
50 }
51 }
52
53 func font(_ textStyle: Font.TextStyle, design: Font.Design = .monospaced, weight: Font.Weight? = nil) -> Font {
54 var font = Font.system(textStyle, design: design)
55 if let weight {
56 font = font.weight(weight)
57 }
58 return font
59 }
60}
61
62struct AppDensityMetrics: Equatable {
63 let sectionSpacing: CGFloat
64 let cardSpacing: CGFloat
65 let cardPadding: CGFloat
66 let rowSpacing: CGFloat
67 let rowMinHeight: CGFloat
68 let controlVerticalPadding: CGFloat
69 let controlMinHeight: CGFloat
70 let cardCornerRadius: CGFloat
71}
72
73private struct AppDensityKey: EnvironmentKey {
74 static let defaultValue: AppDensity = .compact
75}
76
77extension EnvironmentValues {
78 var appDensity: AppDensity {
79 get { self[AppDensityKey.self] }
80 set { self[AppDensityKey.self] = newValue }
81 }
82}
83
84struct AppStatusBadgeModel: Equatable {
85 let title: String
86 let systemImage: String?
87 let foregroundColor: Color
88 let backgroundColor: Color
89}
90
91enum AppStatusFactory {
92 static func availability(_ status: DomainAvailabilityStatus?) -> AppStatusBadgeModel {
93 switch status {
94 case .available:
95 return .init(title: "Available", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
96 case .registered:
97 return .init(title: "Registered", systemImage: "circle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
98 case .unknown, .none:
99 return .init(title: "Unknown", systemImage: "questionmark.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
100 }
101 }
102
103 static func tls(sslInfo: SSLCertificateInfo?, error: String?) -> AppStatusBadgeModel {
104 if error != nil || sslInfo == nil {
105 return .init(title: "Invalid", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
106 }
107 if let sslInfo, sslInfo.daysUntilExpiry <= 14 {
108 return .init(title: "Expiring", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
109 }
110 return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
111 }
112
113 static func email(_ result: EmailSecurityResult?, error: String?) -> AppStatusBadgeModel {
114 guard error == nil, let result else {
115 return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
116 }
117
118 let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found].filter { $0 }.count
119 switch foundCount {
120 case 3:
121 return .init(title: "Secure", systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
122 case 1, 2:
123 return .init(title: "Partial", systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
124 default:
125 return .init(title: "Missing", systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
126 }
127 }
128
129 static func change(_ summary: DomainChangeSummary?) -> AppStatusBadgeModel {
130 guard let summary else {
131 return .init(title: "Unchanged", systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
132 }
133 if summary.hasChanges {
134 return .init(title: "Changed", systemImage: "arrow.triangle.2.circlepath", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
135 }
136 return .init(title: "Unchanged", systemImage: "checkmark.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55))
137 }
138}
139
140struct AppStatusBadgeView: View {
141 @Environment(\.appDensity) private var appDensity
142
143 let model: AppStatusBadgeModel
144
145 var body: some View {
146 HStack(spacing: 6) {
147 if let systemImage = model.systemImage {
148 Image(systemName: systemImage)
149 .font(.caption2)
150 }
151 Text(model.title)
152 }
153 .font(appDensity.font(.caption, weight: .semibold))
154 .foregroundStyle(model.foregroundColor)
155 .padding(.horizontal, 9)
156 .padding(.vertical, 5)
157 .background(model.backgroundColor)
158 .clipShape(Capsule())
159 }
160}
161
162struct AppCopyButton: View {
163 @Environment(\.appDensity) private var appDensity
164 @State private var didCopy = false
165
166 let value: String
167 let label: String
168
169 var body: some View {
170 Button {
171 AppClipboard.copy(value)
172 AppHaptics.copy()
173 withAnimation(.easeInOut(duration: 0.18)) {
174 didCopy = true
175 }
176 Task {
177 try? await Task.sleep(nanoseconds: 900_000_000)
178 await MainActor.run {
179 withAnimation(.easeInOut(duration: 0.18)) {
180 didCopy = false
181 }
182 }
183 }
184 } label: {
185 Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
186 .font(appDensity.font(.caption))
187 .foregroundStyle(didCopy ? Color.green : .secondary)
188 .frame(width: 30, height: 30)
189 .background(Color(.systemGray5).opacity(0.35))
190 .clipShape(RoundedRectangle(cornerRadius: 8))
191 }
192 .buttonStyle(.plain)
193 .accessibilityLabel(didCopy ? "\(label) copied" : label)
194 }
195}
196
197enum AppClipboard {
198 static func copy(_ value: String) {
199 #if canImport(UIKit)
200 UIPasteboard.general.string = value
201 #elseif canImport(AppKit)
202 NSPasteboard.general.clearContents()
203 NSPasteboard.general.setString(value, forType: .string)
204 #endif
205 }
206}
207
208enum AppHaptics {
209 static func copy() {
210 #if canImport(UIKit)
211 let generator = UINotificationFeedbackGenerator()
212 generator.notificationOccurred(.success)
213 #endif
214 }
215
216 static func refresh() {
217 #if canImport(UIKit)
218 let generator = UIImpactFeedbackGenerator(style: .light)
219 generator.impactOccurred()
220 #endif
221 }
222
223 static func track() {
224 #if canImport(UIKit)
225 let generator = UIImpactFeedbackGenerator(style: .soft)
226 generator.impactOccurred()
227 #endif
228 }
229}
230
231struct EmptyStateCardView: View {
232 @Environment(\.appDensity) private var appDensity
233
234 let title: String
235 let message: String
236 let suggestion: String
237 let systemImage: String
238
239 var body: some View {
240 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
241 Label(title, systemImage: systemImage)
242 .font(appDensity.font(.headline, weight: .semibold))
243 .foregroundStyle(.primary)
244
245 Text(message)
246 .font(appDensity.font(.body))
247 .foregroundStyle(.secondary)
248 .fixedSize(horizontal: false, vertical: true)
249
250 Text(suggestion)
251 .font(appDensity.font(.caption))
252 .foregroundStyle(.cyan)
253 }
254 .frame(maxWidth: .infinity, alignment: .leading)
255 .padding(appDensity.metrics.cardPadding)
256 .background(Color(.systemGray6).opacity(0.45))
257 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
258 }
259}
260
261struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
262 @Environment(\.appDensity) private var appDensity
263
264 let title: String
265 @Binding var isCollapsed: Bool
266 let subtitle: String?
267 @ViewBuilder let trailing: () -> HeaderTrailing
268 @ViewBuilder let content: () -> Content
269
270 init(
271 title: String,
272 isCollapsed: Binding<Bool>,
273 subtitle: String? = nil,
274 @ViewBuilder trailing: @escaping () -> HeaderTrailing = { EmptyView() },
275 @ViewBuilder content: @escaping () -> Content
276 ) {
277 self.title = title
278 self._isCollapsed = isCollapsed
279 self.subtitle = subtitle
280 self.trailing = trailing
281 self.content = content
282 }
283
284 var body: some View {
285 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
286 Button {
287 withAnimation(.easeInOut(duration: 0.2)) {
288 isCollapsed.toggle()
289 }
290 } label: {
291 HStack(alignment: .center, spacing: 10) {
292 VStack(alignment: .leading, spacing: 3) {
293 Text(title)
294 .font(appDensity.font(.headline, design: .default, weight: .semibold))
295 .foregroundStyle(.white)
296 if let subtitle {
297 Text(subtitle)
298 .font(appDensity.font(.caption))
299 .foregroundStyle(.secondary)
300 }
301 }
302 Spacer(minLength: 8)
303 trailing()
304 Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
305 .font(.caption.weight(.semibold))
306 .foregroundStyle(.secondary)
307 }
308 .contentShape(Rectangle())
309 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
310 }
311 .buttonStyle(.plain)
312
313 if !isCollapsed {
314 content()
315 .transition(.opacity.combined(with: .move(edge: .top)))
316 }
317 }
318 }
319}
DomainDig/DomainViewModel.swift +6
@@ -598,6 +598,12 @@ final class DomainViewModel {
598598 persistHistory()
599599 }
600600
601 func clearLookupCache() {
602 Task {
603 await LookupRuntime.shared.clearCache()
604 }
605 }
606
601607 func clearRecentSearches() {
602608 recentSearches.removeAll()
603609 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
DomainDig/HistoryView.swift +95 −43
@@ -1,53 +1,69 @@
11import SwiftUI
22
33struct HistoryView: View {
4 @Environment(\.appDensity) private var appDensity
45 @Bindable var viewModel: DomainViewModel
56 @Environment(\.dismiss) private var dismiss
67 @State private var showClearAllConfirmation = false
78
8 private let dateFormatter: DateFormatter = {
9 let formatter = DateFormatter()
10 formatter.dateStyle = .medium
11 formatter.timeStyle = .short
12 return formatter
13 }()
9 private var groupedHistory: [HistoryGroup] {
10 HistoryGroup.groups(for: viewModel.filteredHistory)
11 }
1412
1513 var body: some View {
1614 List {
1715 if viewModel.filteredHistory.isEmpty {
18 Text("No lookup history")
19 .font(.system(.callout, design: .monospaced))
20 .foregroundStyle(.secondary)
16 EmptyStateCardView(
17 title: "No History Yet",
18 message: "History stores local snapshots of previous inspections so you can revisit and compare them later.",
19 suggestion: "Run a lookup to create your first saved snapshot.",
20 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90"
21 )
2122 .listRowBackground(Color(.systemGray6).opacity(0.5))
2223 } else {
23 ForEach(viewModel.filteredHistory) { entry in
24 NavigationLink {
25 HistoryDetailView(viewModel: viewModel, entry: entry)
26 } label: {
27 VStack(alignment: .leading, spacing: 4) {
28 Text(entry.domain)
29 .font(.system(.callout, design: .monospaced))
30 .foregroundStyle(.primary)
31 if let summary = entry.changeSummary {
32 Text(summary.hasChanges ? "Changed" : "Unchanged")
33 .font(.system(.caption2, design: .monospaced))
34 .foregroundStyle(summary.hasChanges ? .yellow : .green)
24 ForEach(groupedHistory) { group in
25 Section(group.title) {
26 ForEach(group.entries) { entry in
27 NavigationLink {
28 HistoryDetailView(viewModel: viewModel, entry: entry)
29 } label: {
30 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
31 HStack(alignment: .center, spacing: 8) {
32 Text(entry.domain)
33 .font(appDensity.font(.callout))
34 .foregroundStyle(.primary)
35 Spacer()
36 AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary))
37 }
38
39 HStack(spacing: 8) {
40 AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status))
41 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError))
42 }
43
44 HStack(spacing: 8) {
45 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
46 Text(entry.timestamp.formatted(.relative(presentation: .named)))
47 Text(entry.resolverDisplayName)
48 if let totalLookupDurationMs = entry.totalLookupDurationMs {
49 Text("\(totalLookupDurationMs) ms")
50 }
51 }
52 .font(appDensity.font(.caption2))
53 .foregroundStyle(.secondary)
54 }
3555 }
36 HStack(spacing: 8) {
37 Text(dateFormatter.string(from: entry.timestamp))
38 Text("Snapshot")
39 Text(entry.resolverDisplayName)
40 if let totalLookupDurationMs = entry.totalLookupDurationMs {
41 Text("\(totalLookupDurationMs) ms")
56 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
57 Button(role: .destructive) {
58 viewModel.removeHistoryEntries(withIDs: [entry.id])
59 } label: {
60 Label("Delete", systemImage: "trash")
4261 }
4362 }
44 .font(.system(.caption2, design: .monospaced))
45 .foregroundStyle(.secondary)
4663 }
64 .listRowBackground(Color(.systemGray6).opacity(0.5))
4765 }
48 .listRowBackground(Color(.systemGray6).opacity(0.5))
4966 }
50 .onDelete(perform: deleteFilteredHistoryEntries)
5167 }
5268 }
5369 .scrollContentBackground(.hidden)
@@ -108,6 +124,7 @@ struct HistoryView: View {
108124}
109125
110126struct HistoryDetailView: View {
127 @Environment(\.appDensity) private var appDensity
111128 @Bindable var viewModel: DomainViewModel
112129 let entry: HistoryEntry
113130 @Environment(\.dismiss) private var dismiss
@@ -130,6 +147,7 @@ struct HistoryDetailView: View {
130147 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
131148 .padding(.top, 8)
132149 DomainSectionView(
150 isCollapsed: .constant(false),
133151 rows: DomainViewModel.domainRows(from: snapshot),
134152 suggestions: DomainViewModel.suggestionRows(from: snapshot),
135153 showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
@@ -146,42 +164,46 @@ struct HistoryDetailView: View {
146164 },
147165 onEditNote: nil
148166 )
149 .padding(.top, 16)
167 .padding(.top, appDensity.metrics.sectionSpacing)
150168 OwnershipSectionView(
169 isCollapsed: .constant(false),
151170 rows: DomainViewModel.ownershipRows(from: snapshot),
152171 loading: false,
153172 error: snapshot.ownershipError,
154173 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
155174 )
156 .padding(.top, 16)
175 .padding(.top, appDensity.metrics.sectionSpacing)
157176 SubdomainsSectionView(
177 isCollapsed: .constant(false),
158178 rows: DomainViewModel.subdomainRows(from: snapshot),
159179 loading: false,
160180 error: snapshot.subdomainsError,
161181 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
162182 )
163 .padding(.top, 16)
183 .padding(.top, appDensity.metrics.sectionSpacing)
164184 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
165185 if let changeSummary = entry.changeSummary {
166186 DomainChangeSummaryView(summary: changeSummary)
167 .padding(.top, 16)
187 .padding(.top, appDensity.metrics.sectionSpacing)
168188 }
169189 DomainDiffView(
170190 title: "Compared With Previous Snapshot",
171191 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
172192 showsUnchanged: false
173193 )
174 .padding(.top, 16)
194 .padding(.top, appDensity.metrics.sectionSpacing)
175195 }
176196 DNSSectionView(
197 isCollapsed: .constant(false),
177198 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
178199 sections: DomainViewModel.dnsRows(from: snapshot),
179200 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
180201 loading: false,
181202 sectionError: snapshot.dnsError
182203 )
183 .padding(.top, 16)
204 .padding(.top, appDensity.metrics.sectionSpacing)
184205 WebSectionView(
206 isCollapsed: .constant(false),
185207 certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
186208 sslInfo: snapshot.sslInfo,
187209 sslLoading: false,
@@ -195,14 +217,16 @@ struct HistoryDetailView: View {
195217 redirectError: snapshot.redirectChainError,
196218 finalURL: snapshot.redirectChain.last?.url
197219 )
198 .padding(.top, 16)
220 .padding(.top, appDensity.metrics.sectionSpacing)
199221 EmailSectionView(
222 isCollapsed: .constant(false),
200223 rows: DomainViewModel.emailRows(from: snapshot),
201224 loading: false,
202225 error: snapshot.emailSecurityError
203226 )
204 .padding(.top, 16)
227 .padding(.top, appDensity.metrics.sectionSpacing)
205228 NetworkSectionView(
229 isCollapsed: .constant(false),
206230 reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
207231 reachabilityLoading: false,
208232 reachabilityError: snapshot.reachabilityError,
@@ -221,7 +245,7 @@ struct HistoryDetailView: View {
221245 customPortInput: .constant(""),
222246 onScanCustomPorts: {}
223247 )
224 .padding(.top, 16)
248 .padding(.top, appDensity.metrics.sectionSpacing)
225249 }
226250 .padding(.horizontal)
227251 .padding(.bottom, 32)
@@ -244,17 +268,45 @@ struct HistoryDetailView: View {
244268 Image(systemName: "archivebox")
245269 .font(.caption)
246270 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
247 .font(.system(.caption, design: .monospaced))
271 .font(appDensity.font(.caption))
248272 Spacer()
249273 Text("Live re-run available")
250 .font(.system(.caption2, design: .monospaced))
274 .font(appDensity.font(.caption2))
251275 .foregroundStyle(.secondary)
252276 }
253277 .foregroundStyle(.secondary)
254278 .padding(8)
255279 .frame(maxWidth: .infinity, alignment: .leading)
256280 .background(Color(.systemGray6).opacity(0.3))
257 .cornerRadius(6)
281 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
258282 .padding(.vertical, 12)
259283 }
260284}
285
286private struct HistoryGroup: Identifiable {
287 let title: String
288 let entries: [HistoryEntry]
289
290 var id: String { title }
291
292 static func groups(for entries: [HistoryEntry]) -> [HistoryGroup] {
293 let calendar = Calendar.current
294 let today = Date()
295 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
296
297 let grouped = Dictionary(grouping: entries) { entry -> String in
298 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
299 return "Today"
300 }
301 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
302 return "Yesterday"
303 }
304 return "Older"
305 }
306
307 return ["Today", "Yesterday", "Older"].compactMap { title in
308 guard let entries = grouped[title], !entries.isEmpty else { return nil }
309 return HistoryGroup(title: title, entries: entries)
310 }
311 }
312}
DomainDig/LookupRuntime.swift +7
@@ -58,6 +58,13 @@ actor LookupRuntime {
5858 private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
5959 private var nextAllowedAt: [RateLimitBucket: Date] = [:]
6060
61 func clearCache() {
62 cache.removeAll()
63 inFlight.values.forEach { $0.cancel() }
64 inFlight.removeAll()
65 nextAllowedAt.removeAll()
66 }
67
6168 func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> {
6269 await execute(
6370 key: .domain(domain, .dns),
DomainDig/WatchlistView.swift +119 −133
@@ -1,9 +1,18 @@
11import SwiftUI
22
33struct WatchlistView: View {
4 @Environment(\.appDensity) private var appDensity
45 @Bindable var viewModel: DomainViewModel
56 @Environment(\.dismiss) private var dismiss
67
8 private var pinnedDomains: [TrackedDomain] {
9 viewModel.filteredTrackedDomains.filter(\.isPinned)
10 }
11
12 private var otherDomains: [TrackedDomain] {
13 viewModel.filteredTrackedDomains.filter { !$0.isPinned }
14 }
15
716 var body: some View {
817 List {
918 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
@@ -13,7 +22,7 @@ struct WatchlistView: View {
1322 .tint(.cyan)
1423 HStack {
1524 Text(viewModel.batchProgressLabel)
16 .font(.system(.caption, design: .monospaced))
25 .font(appDensity.font(.caption))
1726 .foregroundStyle(.secondary)
1827 Spacer()
1928 if viewModel.batchLookupRunning {
@@ -21,7 +30,7 @@ struct WatchlistView: View {
2130 viewModel.cancelBatchLookup()
2231 }
2332 .buttonStyle(.bordered)
24 .font(.system(.caption2, design: .monospaced))
33 .font(appDensity.font(.caption2))
2534 }
2635 }
2736
@@ -36,87 +45,34 @@ struct WatchlistView: View {
3645
3746 if viewModel.filteredTrackedDomains.isEmpty {
3847 Section {
39 VStack(alignment: .leading, spacing: 8) {
40 Text("No tracked domains yet")
41 .font(.system(.callout, design: .monospaced))
42 .foregroundStyle(.primary)
43 Text("Tracked domains appear here. Tracking is local and manual for now.")
44 .font(.system(.caption, design: .monospaced))
45 .foregroundStyle(.secondary)
46 }
47 .padding(.vertical, 8)
48 EmptyStateCardView(
49 title: "No Tracked Domains",
50 message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
51 suggestion: "Run an inspection and use the Track action on a domain you care about.",
52 systemImage: "eye"
53 )
4854 }
4955 .listRowBackground(Color(.systemGray6).opacity(0.5))
5056 } else {
5157 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
5258 Section {
5359 Text(limitMessage)
54 .font(.system(.caption, design: .monospaced))
60 .font(appDensity.font(.caption))
5561 .foregroundStyle(.secondary)
5662 }
5763 .listRowBackground(Color(.systemGray6).opacity(0.5))
5864 }
5965
60 Section {
61 ForEach(viewModel.filteredTrackedDomains) { trackedDomain in
62 NavigationLink {
63 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
64 } label: {
65 WatchlistRowView(
66 trackedDomain: trackedDomain,
67 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
68 )
69 }
70 .buttonStyle(.plain)
71 .swipeActions(edge: .leading, allowsFullSwipe: false) {
72 Button {
73 viewModel.togglePinned(for: trackedDomain)
74 } label: {
75 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
76 }
77 .tint(.yellow)
78 }
79 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
80 Button(role: .destructive) {
81 viewModel.deleteTrackedDomain(trackedDomain)
82 } label: {
83 Label("Delete", systemImage: "trash")
84 }
85 }
86 .contextMenu {
87 Button {
88 viewModel.refreshTrackedDomain(trackedDomain)
89 } label: {
90 Label("Refresh", systemImage: "arrow.clockwise")
91 }
92
93 Button {
94 dismiss()
95 viewModel.rerunInspection(for: trackedDomain)
96 } label: {
97 Label("Open Inspection", systemImage: "magnifyingglass")
98 }
99
100 Button {
101 viewModel.togglePinned(for: trackedDomain)
102 } label: {
103 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
104 }
66 if !pinnedDomains.isEmpty {
67 trackedSection(title: "Pinned", domains: pinnedDomains)
68 }
10569
106 Button(role: .destructive) {
107 viewModel.deleteTrackedDomain(trackedDomain)
108 } label: {
109 Label("Delete", systemImage: "trash")
110 }
111 }
112 .listRowBackground(Color(.systemGray6).opacity(0.5))
113 }
114 .onDelete(perform: deleteFilteredTrackedDomains)
115 } header: {
116 Text("Tracked Domains")
70 if !otherDomains.isEmpty {
71 trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains)
11772 }
11873 }
11974 }
75 .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
12076 .scrollContentBackground(.hidden)
12177 .background(Color.black)
12278 .navigationTitle("Watchlist")
@@ -138,6 +94,7 @@ struct WatchlistView: View {
13894 }
13995
14096 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
97 AppHaptics.refresh()
14198 viewModel.refreshAllTrackedDomains()
14299 }
143100 .disabled(viewModel.batchLookupRunning)
@@ -170,6 +127,86 @@ struct WatchlistView: View {
170127 .preferredColorScheme(.dark)
171128 }
172129
130 @ViewBuilder
131 private func trackedSection(title: String, domains: [TrackedDomain]) -> some View {
132 Section(title) {
133 ForEach(domains) { trackedDomain in
134 trackedDomainRow(trackedDomain)
135 }
136 }
137 }
138
139 private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View {
140 NavigationLink {
141 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
142 } label: {
143 WatchlistRowView(
144 trackedDomain: trackedDomain,
145 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
146 )
147 }
148 .buttonStyle(.plain)
149 .swipeActions(edge: .leading, allowsFullSwipe: false) {
150 Button {
151 AppHaptics.refresh()
152 viewModel.refreshTrackedDomain(trackedDomain)
153 } label: {
154 Label("Refresh", systemImage: "arrow.clockwise")
155 }
156 .tint(.cyan)
157
158 Button {
159 viewModel.togglePinned(for: trackedDomain)
160 } label: {
161 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
162 }
163 .tint(.yellow)
164 }
165 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
166 Button {
167 AppHaptics.refresh()
168 viewModel.refreshTrackedDomain(trackedDomain)
169 } label: {
170 Label("Refresh", systemImage: "arrow.clockwise")
171 }
172 .tint(.cyan)
173
174 Button(role: .destructive) {
175 viewModel.deleteTrackedDomain(trackedDomain)
176 } label: {
177 Label("Delete", systemImage: "trash")
178 }
179 }
180 .contextMenu {
181 Button {
182 AppHaptics.refresh()
183 viewModel.refreshTrackedDomain(trackedDomain)
184 } label: {
185 Label("Refresh", systemImage: "arrow.clockwise")
186 }
187
188 Button {
189 dismiss()
190 viewModel.rerunInspection(for: trackedDomain)
191 } label: {
192 Label("Open Inspection", systemImage: "magnifyingglass")
193 }
194
195 Button {
196 viewModel.togglePinned(for: trackedDomain)
197 } label: {
198 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
199 }
200
201 Button(role: .destructive) {
202 viewModel.deleteTrackedDomain(trackedDomain)
203 } label: {
204 Label("Delete", systemImage: "trash")
205 }
206 }
207 .listRowBackground(Color(.systemGray6).opacity(0.5))
208 }
209
173210 private var batchSummaryBinding: Binding<BatchSweepSummary?> {
174211 Binding(
175212 get: { viewModel.latestBatchSweepSummary },
@@ -203,11 +240,12 @@ struct WatchlistView: View {
203240}
204241
205242struct WatchlistRowView: View {
243 @Environment(\.appDensity) private var appDensity
206244 let trackedDomain: TrackedDomain
207245 let isRefreshing: Bool
208246
209247 var body: some View {
210 VStack(alignment: .leading, spacing: 6) {
248 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
211249 HStack(alignment: .firstTextBaseline, spacing: 8) {
212250 if trackedDomain.isPinned {
213251 Image(systemName: "pin.fill")
@@ -215,7 +253,7 @@ struct WatchlistRowView: View {
215253 .foregroundStyle(.yellow)
216254 }
217255 Text(trackedDomain.domain)
218 .font(.system(.callout, design: .monospaced))
256 .font(appDensity.font(.callout))
219257 .foregroundStyle(.primary)
220258 .lineLimit(2)
221259 .multilineTextAlignment(.leading)
@@ -224,19 +262,19 @@ struct WatchlistRowView: View {
224262 }
225263
226264 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
227 .font(.system(.caption2, design: .monospaced))
265 .font(appDensity.font(.caption2))
228266 .foregroundStyle(.secondary)
229267
230268 indicatorRow
231269
232270 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
233271 Text(note)
234 .font(.system(.caption, design: .monospaced))
272 .font(appDensity.font(.caption))
235273 .foregroundStyle(.secondary)
236274 .lineLimit(2)
237275 } else if let summary = trackedDomain.lastChangeSummary {
238276 Text(summary.message)
239 .font(.system(.caption, design: .monospaced))
277 .font(appDensity.font(.caption))
240278 .foregroundStyle(.secondary)
241279 .lineLimit(2)
242280 }
@@ -259,84 +297,32 @@ struct WatchlistRowView: View {
259297 @ViewBuilder
260298 private var statusBadge: some View {
261299 if isRefreshing {
262 HStack(spacing: 6) {
263 ProgressView()
264 .controlSize(.small)
265 Text("Refreshing")
266 .font(.system(.caption2, design: .monospaced))
267 .foregroundStyle(.secondary)
268 }
269 .padding(.horizontal, 8)
270 .padding(.vertical, 4)
271 .background(Color(.systemGray5).opacity(0.6))
272 .clipShape(Capsule())
300 AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6)))
273301 } else {
274 Text(availabilityLabel(trackedDomain.lastKnownAvailability))
275 .font(.system(.caption2, design: .monospaced))
276 .foregroundStyle(badgeColor)
277 .padding(.horizontal, 8)
278 .padding(.vertical, 4)
279 .background(badgeBackground)
280 .clipShape(Capsule())
281 }
282 }
283
284 private var badgeColor: Color {
285 switch trackedDomain.lastKnownAvailability {
286 case .available:
287 return .green
288 case .registered:
289 return .yellow
290 case .unknown, .none:
291 return .secondary
292 }
293 }
294
295 private var badgeBackground: Color {
296 switch trackedDomain.lastKnownAvailability {
297 case .available:
298 return .green.opacity(0.16)
299 case .registered:
300 return .yellow.opacity(0.16)
301 case .unknown, .none:
302 return Color(.systemGray5).opacity(0.6)
302 AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability))
303303 }
304304 }
305305
306306 @ViewBuilder
307307 private var indicatorRow: some View {
308308 HStack(spacing: 8) {
309 if let severity = trackedDomain.lastChangeSeverity, severity >= .medium {
310 Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill")
311 .font(.system(.caption2, design: .monospaced))
312 .foregroundStyle(severity == .high ? .red : .yellow)
313 } else {
314 Label("Stable", systemImage: "circle.fill")
315 .font(.system(.caption2, design: .monospaced))
316 .foregroundStyle(.secondary)
317 }
309 AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
318310
319311 if trackedDomain.certificateWarningLevel != .none {
320 Text(certificateLabel)
321 .font(.system(.caption2, design: .monospaced))
322 .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow)
323 .padding(.horizontal, 8)
324 .padding(.vertical, 4)
325 .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16))
326 .clipShape(Capsule())
312 AppStatusBadgeView(model: certificateBadge)
327313 }
328314 }
329315 }
330316
331 private var certificateLabel: String {
317 private var certificateBadge: AppStatusBadgeModel {
332318 let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
333319 switch trackedDomain.certificateWarningLevel {
334320 case .critical:
335 return "Cert \(days)"
321 return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
336322 case .warning:
337 return "Warn \(days)"
323 return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
338324 case .none:
339 return ""
325 return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
340326 }
341327 }
342328}