| @@ -8,7 +8,18 @@ enum LookupInputMode: String, CaseIterable, Identifiable { |
| 8 | var id: String { rawValue } |
8 | var id: String { rawValue } |
| 9 | } |
9 | } |
| 10 | |
10 | |
| |
11 | enum 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 | |
| 11 | struct ContentView: View { |
21 | struct ContentView: View { |
| |
22 | @Environment(\.appDensity) private var appDensity |
| 12 | @State private var viewModel = DomainViewModel() |
23 | @State private var viewModel = DomainViewModel() |
| 13 | @State private var navigationPath = NavigationPath() |
24 | @State private var navigationPath = NavigationPath() |
| 14 | @FocusState private var domainFieldFocused: Bool |
25 | @FocusState private var domainFieldFocused: Bool |
| @@ -18,6 +29,7 @@ struct ContentView: View { |
| 18 | @State private var editingTrackedDomain: TrackedDomain? |
29 | @State private var editingTrackedDomain: TrackedDomain? |
| 19 | @State private var showTrackLimitAlert = false |
30 | @State private var showTrackLimitAlert = false |
| 20 | @State private var inputMode: LookupInputMode = .single |
31 | @State private var inputMode: LookupInputMode = .single |
| |
32 | @State private var collapsedSections: Set<ResultSection> = [.network] |
| 21 | |
33 | |
| 22 | var body: some View { |
34 | var body: some View { |
| 23 | NavigationStack(path: $navigationPath) { |
35 | NavigationStack(path: $navigationPath) { |
| @@ -26,21 +38,22 @@ struct ContentView: View { |
| 26 | inputSection |
38 | inputSection |
| 27 | if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning { |
39 | if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning { |
| 28 | batchSection |
40 | batchSection |
| 29 | .padding(.top, 8) |
41 | .padding(.top, appDensity.metrics.cardSpacing) |
| 30 | } |
42 | } |
| 31 | if viewModel.hasRun { |
43 | if viewModel.hasRun { |
| 32 | actionButtons |
44 | actionButtons |
| 33 | if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) { |
45 | if let statusMessage = resultStatusMessage { |
| 34 | LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) |
46 | LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) |
| 35 | .padding(.top, 8) |
47 | .padding(.top, appDensity.metrics.cardSpacing) |
| 36 | } |
48 | } |
| 37 | SummaryView(fields: viewModel.summaryFields) |
49 | SummaryView(fields: viewModel.summaryFields) |
| 38 | .padding(.top, 8) |
50 | .padding(.top, appDensity.metrics.cardSpacing) |
| 39 | if let changeSummary = viewModel.currentChangeSummary { |
51 | if let changeSummary = viewModel.currentChangeSummary { |
| 40 | DomainChangeSummaryView(summary: changeSummary) |
52 | DomainChangeSummaryView(summary: changeSummary) |
| 41 | .padding(.top, 12) |
53 | .padding(.top, appDensity.metrics.cardSpacing) |
| 42 | } |
54 | } |
| 43 | DomainSectionView( |
55 | DomainSectionView( |
| |
56 | isCollapsed: sectionCollapsedBinding(.domain), |
| 44 | rows: viewModel.domainRows, |
57 | rows: viewModel.domainRows, |
| 45 | suggestions: viewModel.suggestionRows, |
58 | suggestions: viewModel.suggestionRows, |
| 46 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, |
59 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, |
| @@ -63,38 +76,42 @@ struct ContentView: View { |
| 63 | editingTrackedDomain = trackedDomain |
76 | editingTrackedDomain = trackedDomain |
| 64 | } |
77 | } |
| 65 | ) |
78 | ) |
| 66 | .padding(.top, 16) |
79 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 67 | OwnershipSectionView( |
80 | OwnershipSectionView( |
| |
81 | isCollapsed: sectionCollapsedBinding(.ownership), |
| 68 | rows: viewModel.ownershipRows, |
82 | rows: viewModel.ownershipRows, |
| 69 | loading: viewModel.ownershipLoading, |
83 | loading: viewModel.ownershipLoading, |
| 70 | error: viewModel.ownershipError, |
84 | error: viewModel.ownershipError, |
| 71 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) |
85 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) |
| 72 | ) |
86 | ) |
| 73 | .padding(.top, 16) |
87 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 74 | SubdomainsSectionView( |
88 | SubdomainsSectionView( |
| |
89 | isCollapsed: sectionCollapsedBinding(.subdomains), |
| 75 | rows: viewModel.subdomainRows, |
90 | rows: viewModel.subdomainRows, |
| 76 | loading: viewModel.subdomainsLoading, |
91 | loading: viewModel.subdomainsLoading, |
| 77 | error: viewModel.subdomainsError, |
92 | error: viewModel.subdomainsError, |
| 78 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) |
93 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) |
| 79 | ) |
94 | ) |
| 80 | .padding(.top, 16) |
95 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 81 | if !viewModel.currentDiffSections.isEmpty { |
96 | if !viewModel.currentDiffSections.isEmpty { |
| 82 | DomainDiffView( |
97 | DomainDiffView( |
| 83 | title: "Latest Changes", |
98 | title: "Latest Changes", |
| 84 | sections: viewModel.currentDiffSections, |
99 | sections: viewModel.currentDiffSections, |
| 85 | showsUnchanged: false |
100 | showsUnchanged: false |
| 86 | ) |
101 | ) |
| 87 | .padding(.top, 16) |
102 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 88 | } |
103 | } |
| 89 | DNSSectionView( |
104 | DNSSectionView( |
| |
105 | isCollapsed: sectionCollapsedBinding(.dns), |
| 90 | dnssecLabel: viewModel.dnssecLabel, |
106 | dnssecLabel: viewModel.dnssecLabel, |
| 91 | sections: viewModel.dnsRows, |
107 | sections: viewModel.dnsRows, |
| 92 | ptrMessage: viewModel.ptrMessage, |
108 | ptrMessage: viewModel.ptrMessage, |
| 93 | loading: viewModel.dnsLoading || viewModel.ptrLoading, |
109 | loading: viewModel.dnsLoading || viewModel.ptrLoading, |
| 94 | sectionError: viewModel.dnsError |
110 | sectionError: viewModel.dnsError |
| 95 | ) |
111 | ) |
| 96 | .padding(.top, 16) |
112 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 97 | WebSectionView( |
113 | WebSectionView( |
| |
114 | isCollapsed: sectionCollapsedBinding(.web), |
| 98 | certificateRows: viewModel.webCertificateRows, |
115 | certificateRows: viewModel.webCertificateRows, |
| 99 | sslInfo: viewModel.sslInfo, |
116 | sslInfo: viewModel.sslInfo, |
| 100 | sslLoading: viewModel.sslLoading || viewModel.hstsLoading, |
117 | sslLoading: viewModel.sslLoading || viewModel.hstsLoading, |
| @@ -108,14 +125,16 @@ struct ContentView: View { |
| 108 | redirectError: viewModel.redirectChainError, |
125 | redirectError: viewModel.redirectChainError, |
| 109 | finalURL: viewModel.currentSnapshot.redirectChain.last?.url |
126 | finalURL: viewModel.currentSnapshot.redirectChain.last?.url |
| 110 | ) |
127 | ) |
| 111 | .padding(.top, 16) |
128 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 112 | EmailSectionView( |
129 | EmailSectionView( |
| |
130 | isCollapsed: sectionCollapsedBinding(.email), |
| 113 | rows: viewModel.emailRows, |
131 | rows: viewModel.emailRows, |
| 114 | loading: viewModel.emailSecurityLoading, |
132 | loading: viewModel.emailSecurityLoading, |
| 115 | error: viewModel.emailSecurityError |
133 | error: viewModel.emailSecurityError |
| 116 | ) |
134 | ) |
| 117 | .padding(.top, 16) |
135 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 118 | NetworkSectionView( |
136 | NetworkSectionView( |
| |
137 | isCollapsed: sectionCollapsedBinding(.network), |
| 119 | reachabilityRows: viewModel.reachabilityRows, |
138 | reachabilityRows: viewModel.reachabilityRows, |
| 120 | reachabilityLoading: viewModel.reachabilityLoading, |
139 | reachabilityLoading: viewModel.reachabilityLoading, |
| 121 | reachabilityError: viewModel.reachabilityError, |
140 | reachabilityError: viewModel.reachabilityError, |
| @@ -134,7 +153,7 @@ struct ContentView: View { |
| 134 | customPortInput: $customPortInput, |
153 | customPortInput: $customPortInput, |
| 135 | onScanCustomPorts: runCustomPortScan |
154 | onScanCustomPorts: runCustomPortScan |
| 136 | ) |
155 | ) |
| 137 | .padding(.top, 16) |
156 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 138 | } else if !viewModel.recentSearches.isEmpty { |
157 | } else if !viewModel.recentSearches.isEmpty { |
| 139 | recentSearchesSection |
158 | recentSearchesSection |
| 140 | } |
159 | } |
| @@ -142,7 +161,34 @@ struct ContentView: View { |
| 142 | .padding(.horizontal) |
161 | .padding(.horizontal) |
| 143 | .padding(.bottom, 32) |
162 | .padding(.bottom, 32) |
| 144 | } |
163 | } |
| 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 | ) |
| 146 | .navigationTitle("DomainDig") |
192 | .navigationTitle("DomainDig") |
| 147 | .toolbarColorScheme(.dark, for: .navigationBar) |
193 | .toolbarColorScheme(.dark, for: .navigationBar) |
| 148 | .preferredColorScheme(.dark) |
194 | .preferredColorScheme(.dark) |
| @@ -176,7 +222,7 @@ struct ContentView: View { |
| 176 | } |
222 | } |
| 177 | |
223 | |
| 178 | NavigationLink { |
224 | NavigationLink { |
| 179 | SettingsView() |
225 | SettingsView(viewModel: viewModel) |
| 180 | } label: { |
226 | } label: { |
| 181 | Label("Settings", systemImage: "gearshape") |
227 | Label("Settings", systemImage: "gearshape") |
| 182 | } |
228 | } |
| @@ -190,6 +236,9 @@ struct ContentView: View { |
| 190 | .onAppear { |
236 | .onAppear { |
| 191 | domainFieldFocused = true |
237 | domainFieldFocused = true |
| 192 | } |
238 | } |
| |
239 | .onChange(of: viewModel.searchedDomain) { _, _ in |
| |
240 | collapsedSections = defaultCollapsedSections |
| |
241 | } |
| 193 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
242 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 194 | navigationPath = NavigationPath() |
243 | navigationPath = NavigationPath() |
| 195 | domainFieldFocused = false |
244 | domainFieldFocused = false |
| @@ -228,7 +277,7 @@ struct ContentView: View { |
| 228 | } |
277 | } |
| 229 | |
278 | |
| 230 | private var inputSection: some View { |
279 | private var inputSection: some View { |
| 231 | VStack(spacing: 12) { |
280 | VStack(spacing: appDensity.metrics.cardSpacing + 2) { |
| 232 | Picker("Mode", selection: $inputMode) { |
281 | Picker("Mode", selection: $inputMode) { |
| 233 | Text("Single").tag(LookupInputMode.single) |
282 | Text("Single").tag(LookupInputMode.single) |
| 234 | Text("Bulk").tag(LookupInputMode.bulk) |
283 | Text("Bulk").tag(LookupInputMode.bulk) |
| @@ -237,13 +286,14 @@ struct ContentView: View { |
| 237 | |
286 | |
| 238 | if inputMode == .single { |
287 | if inputMode == .single { |
| 239 | TextField("e.g. cleberg.net", text: $viewModel.domain) |
288 | TextField("e.g. cleberg.net", text: $viewModel.domain) |
| 240 | .font(.system(.title3, design: .monospaced)) |
289 | .font(appDensity.font(.title3, design: .monospaced)) |
| 241 | .textInputAutocapitalization(.never) |
290 | .textInputAutocapitalization(.never) |
| 242 | .autocorrectionDisabled() |
291 | .autocorrectionDisabled() |
| 243 | .keyboardType(.URL) |
292 | .keyboardType(.URL) |
| 244 | .padding(12) |
293 | .padding(.horizontal, 12) |
| |
294 | .padding(.vertical, appDensity.metrics.controlVerticalPadding) |
| 245 | .background(Color(.systemGray6)) |
295 | .background(Color(.systemGray6)) |
| 246 | .cornerRadius(8) |
296 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 247 | .focused($domainFieldFocused) |
297 | .focused($domainFieldFocused) |
| 248 | .onSubmit { viewModel.run() } |
298 | .onSubmit { viewModel.run() } |
| 249 | |
299 | |
| @@ -252,16 +302,16 @@ struct ContentView: View { |
| 252 | viewModel.run() |
302 | viewModel.run() |
| 253 | } label: { |
303 | } label: { |
| 254 | Text("Run") |
304 | Text("Run") |
| 255 | .font(.headline) |
305 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 256 | .frame(maxWidth: .infinity) |
306 | .frame(maxWidth: .infinity) |
| 257 | .padding(.vertical, 12) |
307 | .frame(minHeight: appDensity.metrics.controlMinHeight) |
| 258 | } |
308 | } |
| 259 | .buttonStyle(.borderedProminent) |
309 | .buttonStyle(.borderedProminent) |
| 260 | .disabled(viewModel.trimmedDomain.isEmpty) |
310 | .disabled(viewModel.trimmedDomain.isEmpty) |
| 261 | } else { |
311 | } else { |
| 262 | VStack(alignment: .leading, spacing: 8) { |
312 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 263 | Text("Paste domains separated by new lines or commas.") |
313 | Text("Paste domains separated by new lines or commas.") |
| 264 | .font(.system(.caption, design: .monospaced)) |
314 | .font(appDensity.font(.caption)) |
| 265 | .foregroundStyle(.secondary) |
315 | .foregroundStyle(.secondary) |
| 266 | |
316 | |
| 267 | TextField( |
317 | TextField( |
| @@ -269,30 +319,31 @@ struct ContentView: View { |
| 269 | text: $viewModel.bulkInput, |
319 | text: $viewModel.bulkInput, |
| 270 | axis: .vertical |
320 | axis: .vertical |
| 271 | ) |
321 | ) |
| 272 | .font(.system(.body, design: .monospaced)) |
322 | .font(appDensity.font(.body)) |
| 273 | .textInputAutocapitalization(.never) |
323 | .textInputAutocapitalization(.never) |
| 274 | .autocorrectionDisabled() |
324 | .autocorrectionDisabled() |
| 275 | .keyboardType(.URL) |
325 | .keyboardType(.URL) |
| 276 | .lineLimit(4...10) |
326 | .lineLimit(4...10) |
| 277 | .padding(12) |
327 | .padding(.horizontal, 12) |
| |
328 | .padding(.vertical, appDensity.metrics.controlVerticalPadding) |
| 278 | .background(Color(.systemGray6)) |
329 | .background(Color(.systemGray6)) |
| 279 | .cornerRadius(8) |
330 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 280 | |
331 | |
| 281 | Button { |
332 | Button { |
| 282 | domainFieldFocused = false |
333 | domainFieldFocused = false |
| 283 | viewModel.runBulkLookup() |
334 | viewModel.runBulkLookup() |
| 284 | } label: { |
335 | } label: { |
| 285 | Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") |
336 | Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") |
| 286 | .font(.headline) |
337 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 287 | .frame(maxWidth: .infinity) |
338 | .frame(maxWidth: .infinity) |
| 288 | .padding(.vertical, 12) |
339 | .frame(minHeight: appDensity.metrics.controlMinHeight) |
| 289 | } |
340 | } |
| 290 | .buttonStyle(.borderedProminent) |
341 | .buttonStyle(.borderedProminent) |
| 291 | .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) |
342 | .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) |
| 292 | } |
343 | } |
| 293 | } |
344 | } |
| 294 | } |
345 | } |
| 295 | .padding(.vertical, 16) |
346 | .padding(.vertical, appDensity.metrics.sectionSpacing) |
| 296 | } |
347 | } |
| 297 | |
348 | |
| 298 | private var actionButtons: some View { |
349 | private var actionButtons: some View { |
| @@ -303,7 +354,7 @@ struct ContentView: View { |
| 303 | viewModel.toggleSavedDomain() |
354 | viewModel.toggleSavedDomain() |
| 304 | } label: { |
355 | } label: { |
| 305 | Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark") |
356 | Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark") |
| 306 | .font(.system(.body)) |
357 | .font(appDensity.font(.body, design: .default)) |
| 307 | .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) |
358 | .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) |
| 308 | } |
359 | } |
| 309 | Menu { |
360 | Menu { |
| @@ -318,7 +369,7 @@ struct ContentView: View { |
| 318 | } |
369 | } |
| 319 | } label: { |
370 | } label: { |
| 320 | Image(systemName: "square.and.arrow.up") |
371 | Image(systemName: "square.and.arrow.up") |
| 321 | .font(.system(.body)) |
372 | .font(appDensity.font(.body, design: .default)) |
| 322 | .foregroundStyle(.secondary) |
373 | .foregroundStyle(.secondary) |
| 323 | } |
374 | } |
| 324 | } |
375 | } |
| @@ -326,7 +377,7 @@ struct ContentView: View { |
| 326 | } |
377 | } |
| 327 | |
378 | |
| 328 | private var batchSection: some View { |
379 | private var batchSection: some View { |
| 329 | VStack(alignment: .leading, spacing: 12) { |
380 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 330 | HStack { |
381 | HStack { |
| 331 | Spacer() |
382 | Spacer() |
| 332 | if viewModel.batchLookupRunning { |
383 | if viewModel.batchLookupRunning { |
| @@ -334,7 +385,7 @@ struct ContentView: View { |
| 334 | viewModel.cancelBatchLookup() |
385 | viewModel.cancelBatchLookup() |
| 335 | } |
386 | } |
| 336 | .buttonStyle(.bordered) |
387 | .buttonStyle(.bordered) |
| 337 | .font(.system(.caption, design: .monospaced)) |
388 | .font(appDensity.font(.caption)) |
| 338 | } |
389 | } |
| 339 | if !viewModel.currentBatchResultEntries.isEmpty { |
390 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 340 | Menu { |
391 | Menu { |
| @@ -349,7 +400,7 @@ struct ContentView: View { |
| 349 | } |
400 | } |
| 350 | } label: { |
401 | } label: { |
| 351 | Label("Export", systemImage: "square.and.arrow.up") |
402 | Label("Export", systemImage: "square.and.arrow.up") |
| 352 | .font(.system(.caption, design: .monospaced)) |
403 | .font(appDensity.font(.caption)) |
| 353 | } |
404 | } |
| 354 | .buttonStyle(.bordered) |
405 | .buttonStyle(.bordered) |
| 355 | } |
406 | } |
| @@ -363,16 +414,16 @@ struct ContentView: View { |
| 363 | } |
414 | } |
| 364 | |
415 | |
| 365 | private var recentSearchesSection: some View { |
416 | private var recentSearchesSection: some View { |
| 366 | VStack(alignment: .leading, spacing: 8) { |
417 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 367 | HStack { |
418 | HStack { |
| 368 | Text("RECENT") |
419 | Text("RECENT") |
| 369 | .font(.system(.caption2, design: .monospaced)) |
420 | .font(appDensity.font(.caption2)) |
| 370 | .foregroundStyle(.secondary) |
421 | .foregroundStyle(.secondary) |
| 371 | Spacer() |
422 | Spacer() |
| 372 | Button("Clear") { |
423 | Button("Clear") { |
| 373 | viewModel.clearRecentSearches() |
424 | viewModel.clearRecentSearches() |
| 374 | } |
425 | } |
| 375 | .font(.system(.caption2, design: .monospaced)) |
426 | .font(appDensity.font(.caption2)) |
| 376 | .foregroundStyle(.secondary) |
427 | .foregroundStyle(.secondary) |
| 377 | } |
428 | } |
| 378 | |
429 | |
| @@ -383,17 +434,17 @@ struct ContentView: View { |
| 383 | viewModel.run() |
434 | viewModel.run() |
| 384 | } label: { |
435 | } label: { |
| 385 | Text(domain) |
436 | Text(domain) |
| 386 | .font(.system(.callout, design: .monospaced)) |
437 | .font(appDensity.font(.callout)) |
| 387 | .foregroundStyle(.primary) |
438 | .foregroundStyle(.primary) |
| 388 | .frame(maxWidth: .infinity, alignment: .leading) |
439 | .frame(maxWidth: .infinity, alignment: .leading) |
| 389 | .padding(.vertical, 6) |
440 | .padding(.vertical, 8) |
| 390 | .padding(.horizontal, 10) |
441 | .padding(.horizontal, 10) |
| 391 | .background(Color(.systemGray6).opacity(0.5)) |
442 | .background(Color(.systemGray6).opacity(0.5)) |
| 392 | .cornerRadius(6) |
443 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 393 | } |
444 | } |
| 394 | } |
445 | } |
| 395 | } |
446 | } |
| 396 | .padding(.top, 8) |
447 | .padding(.top, appDensity.metrics.cardSpacing) |
| 397 | } |
448 | } |
| 398 | |
449 | |
| 399 | private func runCustomPortScan() { |
450 | private func runCustomPortScan() { |
| @@ -468,37 +519,125 @@ struct ContentView: View { |
| 468 | |
519 | |
| 469 | return (filename, data) |
520 | return (filename, data) |
| 470 | } |
521 | } |
| |
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 | } |
| 471 | } |
559 | } |
| 472 | |
560 | |
| 473 | struct SummaryView: View { |
561 | struct SummaryView: View { |
| |
562 | @Environment(\.appDensity) private var appDensity |
| 474 | let fields: [SummaryFieldViewData] |
563 | let fields: [SummaryFieldViewData] |
| 475 | |
564 | |
| 476 | var body: some View { |
565 | var body: some View { |
| 477 | VStack(alignment: .leading, spacing: 12) { |
566 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 478 | SectionTitleView(title: "Summary") |
567 | SectionTitleView(title: "Summary") |
| 479 | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { |
568 | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) { |
| 480 | ForEach(fields) { field in |
569 | ForEach(fields) { field in |
| 481 | VStack(alignment: .leading, spacing: 4) { |
570 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) { |
| 482 | Text(field.label) |
571 | Text(field.label) |
| 483 | .font(.system(.caption2, design: .monospaced)) |
572 | .font(appDensity.font(.caption2)) |
| 484 | .foregroundStyle(.secondary) |
573 | .foregroundStyle(.secondary) |
| 485 | Text(field.value) |
574 | Text(field.value) |
| 486 | .font(.system(.caption, design: .monospaced)) |
575 | .font(appDensity.font(.caption)) |
| 487 | .foregroundStyle(ResultColors.color(for: field.tone)) |
576 | .foregroundStyle(ResultColors.color(for: field.tone)) |
| 488 | .lineLimit(2) |
577 | .lineLimit(2) |
| 489 | .textSelection(.enabled) |
578 | .textSelection(.enabled) |
| 490 | } |
579 | } |
| |
580 | .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading) |
| 491 | .frame(maxWidth: .infinity, alignment: .leading) |
581 | .frame(maxWidth: .infinity, alignment: .leading) |
| 492 | .padding(10) |
582 | .padding(appDensity.metrics.cardPadding) |
| 493 | .background(Color(.systemGray6).opacity(0.5)) |
583 | .background(Color(.systemGray6).opacity(0.5)) |
| 494 | .cornerRadius(6) |
584 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 495 | } |
585 | } |
| 496 | } |
586 | } |
| 497 | } |
587 | } |
| 498 | } |
588 | } |
| 499 | } |
589 | } |
| 500 | |
590 | |
| |
591 | struct 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 | |
| 501 | struct LookupStatusBannerView: View { |
639 | struct LookupStatusBannerView: View { |
| |
640 | @Environment(\.appDensity) private var appDensity |
| 502 | let message: String |
641 | let message: String |
| 503 | let resultSource: LookupResultSource |
642 | let resultSource: LookupResultSource |
| 504 | |
643 | |
| @@ -507,14 +646,14 @@ struct LookupStatusBannerView: View { |
| 507 | Image(systemName: iconName) |
646 | Image(systemName: iconName) |
| 508 | .font(.caption) |
647 | .font(.caption) |
| 509 | Text(message) |
648 | Text(message) |
| 510 | .font(.system(.caption, design: .monospaced)) |
649 | .font(appDensity.font(.caption)) |
| 511 | Spacer() |
650 | Spacer() |
| 512 | } |
651 | } |
| 513 | .foregroundStyle(color) |
652 | .foregroundStyle(color) |
| 514 | .padding(8) |
653 | .padding(appDensity.metrics.cardPadding - 2) |
| 515 | .frame(maxWidth: .infinity, alignment: .leading) |
654 | .frame(maxWidth: .infinity, alignment: .leading) |
| 516 | .background(color.opacity(0.12)) |
655 | .background(color.opacity(0.12)) |
| 517 | .clipShape(RoundedRectangle(cornerRadius: 8)) |
656 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 518 | } |
657 | } |
| 519 | |
658 | |
| 520 | private var color: Color { |
659 | private var color: Color { |
| @@ -545,29 +684,30 @@ struct LookupStatusBannerView: View { |
| 545 | } |
684 | } |
| 546 | |
685 | |
| 547 | struct DomainChangeSummaryView: View { |
686 | struct DomainChangeSummaryView: View { |
| |
687 | @Environment(\.appDensity) private var appDensity |
| 548 | let summary: DomainChangeSummary |
688 | let summary: DomainChangeSummary |
| 549 | |
689 | |
| 550 | var body: some View { |
690 | var body: some View { |
| 551 | CardView(allowsHorizontalScroll: false) { |
691 | CardView(allowsHorizontalScroll: false) { |
| 552 | HStack { |
692 | HStack { |
| 553 | Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") |
693 | 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)) |
| 555 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) |
695 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) |
| 556 | Spacer() |
696 | Spacer() |
| 557 | Text(summary.severity.title.uppercased()) |
697 | Text(summary.severity.title.uppercased()) |
| 558 | .font(.system(.caption2, design: .monospaced)) |
698 | .font(appDensity.font(.caption2)) |
| 559 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary) |
699 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary) |
| 560 | .padding(.horizontal, 8) |
700 | .padding(.horizontal, 8) |
| 561 | .padding(.vertical, 4) |
701 | .padding(.vertical, 4) |
| 562 | .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) |
702 | .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) |
| 563 | .clipShape(Capsule()) |
703 | .clipShape(Capsule()) |
| 564 | Text(summary.generatedAt, style: .time) |
704 | Text(summary.generatedAt, style: .time) |
| 565 | .font(.system(.caption2, design: .monospaced)) |
705 | .font(appDensity.font(.caption2)) |
| 566 | .foregroundStyle(.secondary) |
706 | .foregroundStyle(.secondary) |
| 567 | } |
707 | } |
| 568 | |
708 | |
| 569 | Text(summary.message) |
709 | Text(summary.message) |
| 570 | .font(.system(.caption, design: .monospaced)) |
710 | .font(appDensity.font(.caption)) |
| 571 | .foregroundStyle(.primary) |
711 | .foregroundStyle(.primary) |
| 572 | } |
712 | } |
| 573 | } |
713 | } |
| @@ -770,6 +910,8 @@ struct TrackedDomainDetailHeaderView: View { |
| 770 | } |
910 | } |
| 771 | |
911 | |
| 772 | struct DomainSectionView: View { |
912 | struct DomainSectionView: View { |
| |
913 | @Environment(\.appDensity) private var appDensity |
| |
914 | @Binding var isCollapsed: Bool |
| 773 | let rows: [InfoRowViewData] |
915 | let rows: [InfoRowViewData] |
| 774 | let suggestions: [DomainSuggestionViewData] |
916 | let suggestions: [DomainSuggestionViewData] |
| 775 | let showSuggestions: Bool |
917 | let showSuggestions: Bool |
| @@ -782,38 +924,34 @@ struct DomainSectionView: View { |
| 782 | let onEditNote: (() -> Void)? |
924 | let onEditNote: (() -> Void)? |
| 783 | |
925 | |
| 784 | var body: some View { |
926 | var body: some View { |
| 785 | VStack(alignment: .leading, spacing: 12) { |
927 | CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { |
| 786 | HStack { |
928 | if let trackedDomain { |
| 787 | SectionTitleView(title: "Domain") |
929 | HStack(spacing: 8) { |
| 788 | Spacer() |
930 | AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))) |
| 789 | if let trackedDomain { |
931 | Button { |
| 790 | HStack(spacing: 8) { |
932 | onTogglePinned() |
| 791 | Text("Tracked") |
933 | } label: { |
| 792 | .font(.system(.caption, design: .monospaced)) |
934 | Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") |
| 793 | .foregroundStyle(.green) |
935 | } |
| 794 | Button { |
936 | .buttonStyle(.bordered) |
| 795 | onTogglePinned() |
937 | .font(appDensity.font(.caption)) |
| 796 | } label: { |
938 | if let onEditNote { |
| 797 | Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") |
939 | Button("Note") { |
| |
940 | onEditNote() |
| 798 | } |
941 | } |
| 799 | .buttonStyle(.bordered) |
942 | .buttonStyle(.bordered) |
| 800 | .font(.system(.caption, design: .monospaced)) |
943 | .font(appDensity.font(.caption)) |
| 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() |
| |
| 812 | } |
944 | } |
| 813 | .buttonStyle(.bordered) |
| |
| 814 | .font(.system(.caption, design: .monospaced)) |
| |
| 815 | } |
945 | } |
| |
946 | } else { |
| |
947 | Button("Track") { |
| |
948 | AppHaptics.track() |
| |
949 | onTrack() |
| |
950 | } |
| |
951 | .buttonStyle(.bordered) |
| |
952 | .font(appDensity.font(.caption)) |
| 816 | } |
953 | } |
| |
954 | } content: { |
| 817 | CardView(allowsHorizontalScroll: false) { |
955 | CardView(allowsHorizontalScroll: false) { |
| 818 | ForEach(rows) { row in |
956 | ForEach(rows) { row in |
| 819 | LabeledValueRow(row: row) |
957 | LabeledValueRow(row: row) |
| @@ -832,7 +970,7 @@ struct DomainSectionView: View { |
| 832 | } |
970 | } |
| 833 | if showSuggestions { |
971 | if showSuggestions { |
| 834 | Text("Suggestions") |
972 | Text("Suggestions") |
| 835 | .font(.system(.caption, design: .monospaced)) |
973 | .font(appDensity.font(.caption)) |
| 836 | .foregroundStyle(.secondary) |
974 | .foregroundStyle(.secondary) |
| 837 | .padding(.top, 4) |
975 | .padding(.top, 4) |
| 838 | if suggestionsLoading { |
976 | if suggestionsLoading { |
| @@ -844,13 +982,11 @@ struct DomainSectionView: View { |
| 844 | ForEach(suggestions) { suggestion in |
982 | ForEach(suggestions) { suggestion in |
| 845 | HStack { |
983 | HStack { |
| 846 | Text(suggestion.domain) |
984 | Text(suggestion.domain) |
| 847 | .font(.system(.caption, design: .monospaced)) |
985 | .font(appDensity.font(.caption)) |
| 848 | .foregroundStyle(.primary) |
986 | .foregroundStyle(.primary) |
| 849 | .textSelection(.enabled) |
987 | .textSelection(.enabled) |
| 850 | Spacer() |
988 | Spacer() |
| 851 | Text(suggestion.status) |
989 | AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered)) |
| 852 | .font(.system(.caption2, design: .monospaced)) |
| |
| 853 | .foregroundStyle(ResultColors.color(for: suggestion.tone)) |
| |
| 854 | } |
990 | } |
| 855 | } |
991 | } |
| 856 | } |
992 | } |
| @@ -861,14 +997,14 @@ struct DomainSectionView: View { |
| 861 | } |
997 | } |
| 862 | |
998 | |
| 863 | struct OwnershipSectionView: View { |
999 | struct OwnershipSectionView: View { |
| |
1000 | @Binding var isCollapsed: Bool |
| 864 | let rows: [InfoRowViewData] |
1001 | let rows: [InfoRowViewData] |
| 865 | let loading: Bool |
1002 | let loading: Bool |
| 866 | let error: String? |
1003 | let error: String? |
| 867 | let showsHistoryPlaceholder: Bool |
1004 | let showsHistoryPlaceholder: Bool |
| 868 | |
1005 | |
| 869 | var body: some View { |
1006 | var body: some View { |
| 870 | VStack(alignment: .leading, spacing: 12) { |
1007 | CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { |
| 871 | SectionTitleView(title: "Ownership") |
| |
| 872 | CardView(allowsHorizontalScroll: false) { |
1008 | CardView(allowsHorizontalScroll: false) { |
| 873 | if loading { |
1009 | if loading { |
| 874 | ProgressView("Fetching RDAP ownership…") |
1010 | ProgressView("Fetching RDAP ownership…") |
| @@ -892,21 +1028,15 @@ struct OwnershipSectionView: View { |
| 892 | } |
1028 | } |
| 893 | |
1029 | |
| 894 | struct SubdomainsSectionView: View { |
1030 | struct SubdomainsSectionView: View { |
| |
1031 | @Environment(\.appDensity) private var appDensity |
| |
1032 | @Binding var isCollapsed: Bool |
| 895 | let rows: [SubdomainRowViewData] |
1033 | let rows: [SubdomainRowViewData] |
| 896 | let loading: Bool |
1034 | let loading: Bool |
| 897 | let error: String? |
1035 | let error: String? |
| 898 | let showsExtendedPlaceholder: Bool |
1036 | let showsExtendedPlaceholder: Bool |
| 899 | |
1037 | |
| 900 | var body: some View { |
1038 | var body: some View { |
| 901 | VStack(alignment: .leading, spacing: 12) { |
1039 | CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { |
| 902 | HStack { |
| |
| 903 | SectionTitleView(title: "Subdomains") |
| |
| 904 | Spacer() |
| |
| 905 | Text("\(rows.count)") |
| |
| 906 | .font(.system(.caption2, design: .monospaced)) |
| |
| 907 | .foregroundStyle(.secondary) |
| |
| 908 | } |
| |
| 909 | |
| |
| 910 | CardView(allowsHorizontalScroll: false) { |
1040 | CardView(allowsHorizontalScroll: false) { |
| 911 | if loading { |
1041 | if loading { |
| 912 | ProgressView("Checking certificate transparency…") |
1042 | ProgressView("Checking certificate transparency…") |
| @@ -921,7 +1051,7 @@ struct SubdomainsSectionView: View { |
| 921 | ForEach(rows) { row in |
1051 | ForEach(rows) { row in |
| 922 | HStack(spacing: 8) { |
1052 | HStack(spacing: 8) { |
| 923 | Text(row.hostname) |
1053 | Text(row.hostname) |
| 924 | .font(.system(.caption, design: .monospaced)) |
1054 | .font(appDensity.font(.caption)) |
| 925 | .foregroundStyle(.primary) |
1055 | .foregroundStyle(.primary) |
| 926 | .textSelection(.enabled) |
1056 | .textSelection(.enabled) |
| 927 | Spacer() |
1057 | Spacer() |
| @@ -947,6 +1077,7 @@ struct SubdomainsSectionView: View { |
| 947 | } |
1077 | } |
| 948 | |
1078 | |
| 949 | struct DNSSectionView: View { |
1079 | struct DNSSectionView: View { |
| |
1080 | @Binding var isCollapsed: Bool |
| 950 | let dnssecLabel: String? |
1081 | let dnssecLabel: String? |
| 951 | let sections: [DNSRecordSectionViewData] |
1082 | let sections: [DNSRecordSectionViewData] |
| 952 | let ptrMessage: SectionMessageViewData? |
1083 | let ptrMessage: SectionMessageViewData? |
| @@ -954,18 +1085,7 @@ struct DNSSectionView: View { |
| 954 | let sectionError: String? |
1085 | let sectionError: String? |
| 955 | |
1086 | |
| 956 | var body: some View { |
1087 | var body: some View { |
| 957 | VStack(alignment: .leading, spacing: 12) { |
1088 | CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { |
| 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 | |
| |
| 969 | if loading { |
1089 | if loading { |
| 970 | LoadingCardView(text: "Querying DNS…") |
1090 | LoadingCardView(text: "Querying DNS…") |
| 971 | } else if let sectionError, sections.isEmpty { |
1091 | } else if let sectionError, sections.isEmpty { |
| @@ -1013,6 +1133,8 @@ struct DNSSectionView: View { |
| 1013 | } |
1133 | } |
| 1014 | |
1134 | |
| 1015 | struct WebSectionView: View { |
1135 | struct WebSectionView: View { |
| |
1136 | @Environment(\.appDensity) private var appDensity |
| |
1137 | @Binding var isCollapsed: Bool |
| 1016 | let certificateRows: [InfoRowViewData] |
1138 | let certificateRows: [InfoRowViewData] |
| 1017 | let sslInfo: SSLCertificateInfo? |
1139 | let sslInfo: SSLCertificateInfo? |
| 1018 | let sslLoading: Bool |
1140 | let sslLoading: Bool |
| @@ -1027,14 +1149,15 @@ struct WebSectionView: View { |
| 1027 | let finalURL: String? |
1149 | let finalURL: String? |
| 1028 | |
1150 | |
| 1029 | var body: some View { |
1151 | var body: some View { |
| 1030 | VStack(alignment: .leading, spacing: 12) { |
1152 | CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) { |
| 1031 | SectionTitleView(title: "Web") |
| |
| 1032 | |
| |
| 1033 | CardView { |
1153 | CardView { |
| 1034 | Text("TLS") |
1154 | HStack { |
| 1035 | .font(.system(.subheadline, design: .monospaced)) |
1155 | Text("TLS") |
| 1036 | .fontWeight(.semibold) |
1156 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1037 | .foregroundStyle(.cyan) |
1157 | .foregroundStyle(.cyan) |
| |
1158 | Spacer() |
| |
1159 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) |
| |
1160 | } |
| 1038 | if sslLoading { |
1161 | if sslLoading { |
| 1039 | ProgressView("Checking certificate…") |
1162 | ProgressView("Checking certificate…") |
| 1040 | .appLoadingStyle() |
1163 | .appLoadingStyle() |
| @@ -1046,12 +1169,16 @@ struct WebSectionView: View { |
| 1046 | } |
1169 | } |
| 1047 | if let sslInfo, !sslInfo.subjectAltNames.isEmpty { |
1170 | if let sslInfo, !sslInfo.subjectAltNames.isEmpty { |
| 1048 | Text("SANs") |
1171 | Text("SANs") |
| 1049 | .font(.system(.caption2, design: .monospaced)) |
1172 | .font(appDensity.font(.caption2)) |
| 1050 | .foregroundStyle(.secondary) |
1173 | .foregroundStyle(.secondary) |
| 1051 | ForEach(sslInfo.subjectAltNames, id: \.self) { san in |
1174 | ForEach(sslInfo.subjectAltNames, id: \.self) { san in |
| 1052 | Text(san) |
1175 | HStack(spacing: 8) { |
| 1053 | .font(.system(.caption, design: .monospaced)) |
1176 | Text(san) |
| 1054 | .textSelection(.enabled) |
1177 | .font(appDensity.font(.caption)) |
| |
1178 | .textSelection(.enabled) |
| |
1179 | Spacer() |
| |
1180 | AppCopyButton(value: san, label: "Copy certificate SAN") |
| |
1181 | } |
| 1055 | } |
1182 | } |
| 1056 | } |
1183 | } |
| 1057 | } |
1184 | } |
| @@ -1059,8 +1186,7 @@ struct WebSectionView: View { |
| 1059 | |
1186 | |
| 1060 | CardView { |
1187 | CardView { |
| 1061 | Text("Headers") |
1188 | Text("Headers") |
| 1062 | .font(.system(.subheadline, design: .monospaced)) |
1189 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1063 | .fontWeight(.semibold) |
| |
| 1064 | .foregroundStyle(.cyan) |
1190 | .foregroundStyle(.cyan) |
| 1065 | if headersLoading { |
1191 | if headersLoading { |
| 1066 | ProgressView("Fetching headers…") |
1192 | ProgressView("Fetching headers…") |
| @@ -1077,10 +1203,10 @@ struct WebSectionView: View { |
| 1077 | ForEach(headers) { header in |
1203 | ForEach(headers) { header in |
| 1078 | HStack(alignment: .top, spacing: 4) { |
1204 | HStack(alignment: .top, spacing: 4) { |
| 1079 | Text(header.name + ":") |
1205 | Text(header.name + ":") |
| 1080 | .font(.system(.caption, design: .monospaced)) |
1206 | .font(appDensity.font(.caption)) |
| 1081 | .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) |
1207 | .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) |
| 1082 | Text(header.value) |
1208 | Text(header.value) |
| 1083 | .font(.system(.caption, design: .monospaced)) |
1209 | .font(appDensity.font(.caption)) |
| 1084 | .foregroundStyle(.primary) |
1210 | .foregroundStyle(.primary) |
| 1085 | .textSelection(.enabled) |
1211 | .textSelection(.enabled) |
| 1086 | } |
1212 | } |
| @@ -1090,10 +1216,15 @@ struct WebSectionView: View { |
| 1090 | } |
1216 | } |
| 1091 | |
1217 | |
| 1092 | CardView { |
1218 | CardView { |
| 1093 | Text("Redirects") |
1219 | HStack { |
| 1094 | .font(.system(.subheadline, design: .monospaced)) |
1220 | Text("Redirects") |
| 1095 | .fontWeight(.semibold) |
1221 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1096 | .foregroundStyle(.cyan) |
1222 | .foregroundStyle(.cyan) |
| |
1223 | Spacer() |
| |
1224 | if let finalURL { |
| |
1225 | AppCopyButton(value: finalURL, label: "Copy redirect URL") |
| |
1226 | } |
| |
1227 | } |
| 1097 | if redirectLoading { |
1228 | if redirectLoading { |
| 1098 | ProgressView("Tracing redirects…") |
1229 | ProgressView("Tracing redirects…") |
| 1099 | .appLoadingStyle() |
1230 | .appLoadingStyle() |
| @@ -1108,19 +1239,20 @@ struct WebSectionView: View { |
| 1108 | ForEach(redirects) { redirect in |
1239 | ForEach(redirects) { redirect in |
| 1109 | HStack(alignment: .top, spacing: 6) { |
1240 | HStack(alignment: .top, spacing: 6) { |
| 1110 | Text(redirect.stepLabel) |
1241 | Text(redirect.stepLabel) |
| 1111 | .font(.system(.caption, design: .monospaced)) |
1242 | .font(appDensity.font(.caption)) |
| 1112 | .foregroundStyle(.secondary) |
1243 | .foregroundStyle(.secondary) |
| 1113 | .frame(width: 16, alignment: .trailing) |
1244 | .frame(width: 16, alignment: .trailing) |
| 1114 | Text(redirect.statusCode) |
1245 | Text(redirect.statusCode) |
| 1115 | .font(.system(.caption, design: .monospaced)) |
1246 | .font(appDensity.font(.caption)) |
| 1116 | .foregroundStyle(.cyan) |
1247 | .foregroundStyle(.cyan) |
| 1117 | .frame(width: 36, alignment: .leading) |
1248 | .frame(width: 36, alignment: .leading) |
| 1118 | Text(redirect.url) |
1249 | Text(redirect.url) |
| 1119 | .font(.system(.caption, design: .monospaced)) |
1250 | .font(appDensity.font(.caption)) |
| 1120 | .textSelection(.enabled) |
1251 | .textSelection(.enabled) |
| |
1252 | AppCopyButton(value: redirect.url, label: "Copy redirect URL") |
| 1121 | if redirect.isFinal { |
1253 | if redirect.isFinal { |
| 1122 | Text("(final)") |
1254 | Text("(final)") |
| 1123 | .font(.system(.caption2, design: .monospaced)) |
1255 | .font(appDensity.font(.caption2)) |
| 1124 | .foregroundStyle(.secondary) |
1256 | .foregroundStyle(.secondary) |
| 1125 | } |
1257 | } |
| 1126 | } |
1258 | } |
| @@ -1132,14 +1264,20 @@ struct WebSectionView: View { |
| 1132 | } |
1264 | } |
| 1133 | |
1265 | |
| 1134 | struct EmailSectionView: View { |
1266 | struct EmailSectionView: View { |
| |
1267 | @Environment(\.appDensity) private var appDensity |
| |
1268 | @Binding var isCollapsed: Bool |
| 1135 | let rows: [EmailRowViewData] |
1269 | let rows: [EmailRowViewData] |
| 1136 | let loading: Bool |
1270 | let loading: Bool |
| 1137 | let error: String? |
1271 | let error: String? |
| 1138 | |
1272 | |
| 1139 | var body: some View { |
1273 | var body: some View { |
| 1140 | VStack(alignment: .leading, spacing: 12) { |
1274 | CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { |
| 1141 | SectionTitleView(title: "Email") |
| |
| 1142 | CardView { |
1275 | CardView { |
| |
1276 | HStack { |
| |
1277 | Spacer() |
| |
1278 | AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) |
| |
1279 | .opacity(loading ? 0 : 1) |
| |
1280 | } |
| 1143 | if loading { |
1281 | if loading { |
| 1144 | ProgressView("Checking email records…") |
1282 | ProgressView("Checking email records…") |
| 1145 | .appLoadingStyle() |
1283 | .appLoadingStyle() |
| @@ -1152,20 +1290,18 @@ struct EmailSectionView: View { |
| 1152 | VStack(alignment: .leading, spacing: 4) { |
1290 | VStack(alignment: .leading, spacing: 4) { |
| 1153 | HStack(spacing: 8) { |
1291 | HStack(spacing: 8) { |
| 1154 | Text(row.label) |
1292 | Text(row.label) |
| 1155 | .font(.system(.caption, design: .monospaced)) |
1293 | .font(appDensity.font(.caption)) |
| 1156 | .foregroundStyle(.cyan) |
1294 | .foregroundStyle(.cyan) |
| 1157 | .frame(width: 76, alignment: .leading) |
1295 | .frame(width: 76, alignment: .leading) |
| 1158 | Text(row.status) |
1296 | AppStatusBadgeView(model: emailRowBadge(row)) |
| 1159 | .font(.system(.caption, design: .monospaced)) |
| |
| 1160 | .foregroundStyle(ResultColors.color(for: row.statusTone)) |
| |
| 1161 | } |
1297 | } |
| 1162 | Text(row.detail) |
1298 | Text(row.detail) |
| 1163 | .font(.system(.caption2, design: .monospaced)) |
1299 | .font(appDensity.font(.caption2)) |
| 1164 | .foregroundStyle(.primary) |
1300 | .foregroundStyle(.primary) |
| 1165 | .textSelection(.enabled) |
1301 | .textSelection(.enabled) |
| 1166 | if let auxiliaryDetail = row.auxiliaryDetail { |
1302 | if let auxiliaryDetail = row.auxiliaryDetail { |
| 1167 | Text(auxiliaryDetail) |
1303 | Text(auxiliaryDetail) |
| 1168 | .font(.system(.caption2, design: .monospaced)) |
1304 | .font(appDensity.font(.caption2)) |
| 1169 | .foregroundStyle(.secondary) |
1305 | .foregroundStyle(.secondary) |
| 1170 | } |
1306 | } |
| 1171 | } |
1307 | } |
| @@ -1174,9 +1310,24 @@ struct EmailSectionView: View { |
| 1174 | } |
1310 | } |
| 1175 | } |
1311 | } |
| 1176 | } |
1312 | } |
| |
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 | } |
| 1177 | } |
1326 | } |
| 1178 | |
1327 | |
| 1179 | struct NetworkSectionView: View { |
1328 | struct NetworkSectionView: View { |
| |
1329 | @Environment(\.appDensity) private var appDensity |
| |
1330 | @Binding var isCollapsed: Bool |
| 1180 | let reachabilityRows: [ReachabilityRowViewData] |
1331 | let reachabilityRows: [ReachabilityRowViewData] |
| 1181 | let reachabilityLoading: Bool |
1332 | let reachabilityLoading: Bool |
| 1182 | let reachabilityError: String? |
1333 | let reachabilityError: String? |
| @@ -1196,13 +1347,10 @@ struct NetworkSectionView: View { |
| 1196 | let onScanCustomPorts: () -> Void |
1347 | let onScanCustomPorts: () -> Void |
| 1197 | |
1348 | |
| 1198 | var body: some View { |
1349 | var body: some View { |
| 1199 | VStack(alignment: .leading, spacing: 12) { |
1350 | CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) { |
| 1200 | SectionTitleView(title: "Network") |
| |
| 1201 | |
| |
| 1202 | CardView { |
1351 | CardView { |
| 1203 | Text("Reachability") |
1352 | Text("Reachability") |
| 1204 | .font(.system(.subheadline, design: .monospaced)) |
1353 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1205 | .fontWeight(.semibold) |
| |
| 1206 | .foregroundStyle(.cyan) |
1354 | .foregroundStyle(.cyan) |
| 1207 | if reachabilityLoading { |
1355 | if reachabilityLoading { |
| 1208 | ProgressView("Checking ports…") |
1356 | ProgressView("Checking ports…") |
| @@ -1213,14 +1361,12 @@ struct NetworkSectionView: View { |
| 1213 | ForEach(reachabilityRows) { row in |
1361 | ForEach(reachabilityRows) { row in |
| 1214 | HStack { |
1362 | HStack { |
| 1215 | Text(row.portLabel) |
1363 | Text(row.portLabel) |
| 1216 | .font(.system(.caption, design: .monospaced)) |
1364 | .font(appDensity.font(.caption)) |
| 1217 | Spacer() |
1365 | Spacer() |
| 1218 | Text(row.latencyLabel) |
1366 | Text(row.latencyLabel) |
| 1219 | .font(.system(.caption2, design: .monospaced)) |
1367 | .font(appDensity.font(.caption2)) |
| 1220 | .foregroundStyle(.secondary) |
1368 | .foregroundStyle(.secondary) |
| 1221 | Text(row.statusLabel) |
1369 | AppStatusBadgeView(model: reachabilityBadge(row)) |
| 1222 | .font(.system(.caption, design: .monospaced)) |
| |
| 1223 | .foregroundStyle(ResultColors.color(for: row.statusTone)) |
| |
| 1224 | } |
1370 | } |
| 1225 | } |
1371 | } |
| 1226 | } |
1372 | } |
| @@ -1228,8 +1374,7 @@ struct NetworkSectionView: View { |
| 1228 | |
1374 | |
| 1229 | CardView(allowsHorizontalScroll: false) { |
1375 | CardView(allowsHorizontalScroll: false) { |
| 1230 | Text("Location") |
1376 | Text("Location") |
| 1231 | .font(.system(.subheadline, design: .monospaced)) |
1377 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1232 | .fontWeight(.semibold) |
| |
| 1233 | .foregroundStyle(.cyan) |
1378 | .foregroundStyle(.cyan) |
| 1234 | if geolocationLoading { |
1379 | if geolocationLoading { |
| 1235 | ProgressView("Looking up location…") |
1380 | ProgressView("Looking up location…") |
| @@ -1260,13 +1405,12 @@ struct NetworkSectionView: View { |
| 1260 | |
1405 | |
| 1261 | CardView(allowsHorizontalScroll: false) { |
1406 | CardView(allowsHorizontalScroll: false) { |
| 1262 | Text("Port Scan") |
1407 | Text("Port Scan") |
| 1263 | .font(.system(.subheadline, design: .monospaced)) |
1408 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1264 | .fontWeight(.semibold) |
| |
| 1265 | .foregroundStyle(.cyan) |
1409 | .foregroundStyle(.cyan) |
| 1266 | |
1410 | |
| 1267 | if isCloudflareProxied { |
1411 | if isCloudflareProxied { |
| 1268 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") |
1412 | 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)) |
| 1270 | .foregroundStyle(.orange) |
1414 | .foregroundStyle(.orange) |
| 1271 | .fixedSize(horizontal: false, vertical: true) |
1415 | .fixedSize(horizontal: false, vertical: true) |
| 1272 | } |
1416 | } |
| @@ -1286,15 +1430,16 @@ struct NetworkSectionView: View { |
| 1286 | DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { |
1430 | DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { |
| 1287 | VStack(alignment: .leading, spacing: 10) { |
1431 | VStack(alignment: .leading, spacing: 10) { |
| 1288 | TextField("8888, 9000, 27017", text: $customPortInput) |
1432 | TextField("8888, 9000, 27017", text: $customPortInput) |
| 1289 | .font(.system(.caption, design: .monospaced)) |
1433 | .font(appDensity.font(.caption)) |
| 1290 | .textInputAutocapitalization(.never) |
1434 | .textInputAutocapitalization(.never) |
| 1291 | .autocorrectionDisabled() |
1435 | .autocorrectionDisabled() |
| 1292 | .keyboardType(.numberPad) |
1436 | .keyboardType(.numberPad) |
| 1293 | .padding(10) |
1437 | .padding(10) |
| 1294 | .background(Color(.systemGray6).opacity(0.5)) |
1438 | .background(Color(.systemGray6).opacity(0.5)) |
| 1295 | .cornerRadius(6) |
1439 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 1296 | |
1440 | |
| 1297 | Button("Scan") { |
1441 | Button("Scan") { |
| |
1442 | AppHaptics.refresh() |
| 1298 | onScanCustomPorts() |
1443 | onScanCustomPorts() |
| 1299 | } |
1444 | } |
| 1300 | .buttonStyle(.borderedProminent) |
1445 | .buttonStyle(.borderedProminent) |
| @@ -1316,9 +1461,23 @@ struct NetworkSectionView: View { |
| 1316 | } |
1461 | } |
| 1317 | } |
1462 | } |
| 1318 | } |
1463 | } |
| |
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 | } |
| 1319 | } |
1477 | } |
| 1320 | |
1478 | |
| 1321 | struct PortRowsView: View { |
1479 | struct PortRowsView: View { |
| |
1480 | @Environment(\.appDensity) private var appDensity |
| 1322 | let rows: [PortScanRowViewData] |
1481 | let rows: [PortScanRowViewData] |
| 1323 | |
1482 | |
| 1324 | var body: some View { |
1483 | var body: some View { |
| @@ -1326,47 +1485,61 @@ struct PortRowsView: View { |
| 1326 | MessageRowView(text: "No results", isError: false) |
1485 | MessageRowView(text: "No results", isError: false) |
| 1327 | } else { |
1486 | } else { |
| 1328 | ForEach(rows) { row in |
1487 | ForEach(rows) { row in |
| 1329 | VStack(alignment: .leading, spacing: 2) { |
1488 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { |
| 1330 | HStack { |
1489 | HStack { |
| 1331 | Text(row.portLabel) |
1490 | Text(row.portLabel) |
| 1332 | .font(.system(.caption, design: .monospaced)) |
1491 | .font(appDensity.font(.caption)) |
| 1333 | .frame(width: 52, alignment: .leading) |
1492 | .frame(width: 52, alignment: .leading) |
| 1334 | Text(row.service) |
1493 | Text(row.service) |
| 1335 | .font(.system(.caption, design: .monospaced)) |
1494 | .font(appDensity.font(.caption)) |
| 1336 | .foregroundStyle(.primary) |
1495 | .foregroundStyle(.primary) |
| 1337 | Spacer() |
1496 | Spacer() |
| 1338 | if let durationLabel = row.durationLabel { |
1497 | if let durationLabel = row.durationLabel { |
| 1339 | Text(durationLabel) |
1498 | Text(durationLabel) |
| 1340 | .font(.system(.caption2, design: .monospaced)) |
1499 | .font(appDensity.font(.caption2)) |
| 1341 | .foregroundStyle(.secondary) |
1500 | .foregroundStyle(.secondary) |
| 1342 | } |
1501 | } |
| 1343 | Text(row.statusLabel) |
1502 | AppStatusBadgeView(model: portBadge(row)) |
| 1344 | .font(.system(.caption2, design: .monospaced)) |
| |
| 1345 | .foregroundStyle(ResultColors.color(for: row.statusTone)) |
| |
| 1346 | } |
1503 | } |
| 1347 | if let banner = row.banner { |
1504 | if let banner = row.banner { |
| 1348 | Text(banner) |
1505 | Text(banner) |
| 1349 | .font(.system(.caption2, design: .monospaced)) |
1506 | .font(appDensity.font(.caption2)) |
| 1350 | .foregroundStyle(.secondary) |
1507 | .foregroundStyle(.secondary) |
| 1351 | .padding(.leading, 8) |
1508 | .padding(.leading, 8) |
| 1352 | } |
1509 | } |
| 1353 | } |
1510 | } |
| |
1511 | .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) |
| 1354 | } |
1512 | } |
| 1355 | } |
1513 | } |
| 1356 | } |
1514 | } |
| |
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 | } |
| 1357 | } |
1528 | } |
| 1358 | |
1529 | |
| 1359 | struct SectionTitleView: View { |
1530 | struct SectionTitleView: View { |
| |
1531 | @Environment(\.appDensity) private var appDensity |
| 1360 | let title: String |
1532 | let title: String |
| 1361 | |
1533 | |
| 1362 | var body: some View { |
1534 | var body: some View { |
| 1363 | Text(title) |
1535 | Text(title) |
| 1364 | .font(.system(.headline)) |
1536 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 1365 | .foregroundStyle(.white) |
1537 | .foregroundStyle(.white) |
| 1366 | } |
1538 | } |
| 1367 | } |
1539 | } |
| 1368 | |
1540 | |
| 1369 | struct CardView<Content: View>: View { |
1541 | struct CardView<Content: View>: View { |
| |
1542 | @Environment(\.appDensity) private var appDensity |
| 1370 | let allowsHorizontalScroll: Bool |
1543 | let allowsHorizontalScroll: Bool |
| 1371 | let content: Content |
1544 | let content: Content |
| 1372 | |
1545 | |
| @@ -1389,13 +1562,13 @@ struct CardView<Content: View>: View { |
| 1389 | } |
1562 | } |
| 1390 | } |
1563 | } |
| 1391 | .frame(maxWidth: .infinity, alignment: .leading) |
1564 | .frame(maxWidth: .infinity, alignment: .leading) |
| 1392 | .padding(10) |
1565 | .padding(appDensity.metrics.cardPadding) |
| 1393 | .background(Color(.systemGray6).opacity(0.5)) |
1566 | .background(Color(.systemGray6).opacity(0.5)) |
| 1394 | .cornerRadius(6) |
1567 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 1395 | } |
1568 | } |
| 1396 | |
1569 | |
| 1397 | private var cardContent: some View { |
1570 | private var cardContent: some View { |
| 1398 | VStack(alignment: .leading, spacing: 6) { |
1571 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 1399 | content |
1572 | content |
| 1400 | } |
1573 | } |
| 1401 | } |
1574 | } |
| @@ -1425,29 +1598,40 @@ struct MessageCardView: View { |
| 1425 | } |
1598 | } |
| 1426 | |
1599 | |
| 1427 | struct MessageRowView: View { |
1600 | struct MessageRowView: View { |
| |
1601 | @Environment(\.appDensity) private var appDensity |
| 1428 | let text: String |
1602 | let text: String |
| 1429 | let isError: Bool |
1603 | let isError: Bool |
| 1430 | |
1604 | |
| 1431 | var body: some View { |
1605 | var body: some View { |
| 1432 | Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") |
1606 | Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") |
| 1433 | .font(.system(.caption, design: .monospaced)) |
1607 | .font(appDensity.font(.caption)) |
| 1434 | .foregroundStyle(isError ? .red : .secondary) |
1608 | .foregroundStyle(isError ? .red : .secondary) |
| 1435 | } |
1609 | } |
| 1436 | } |
1610 | } |
| 1437 | |
1611 | |
| 1438 | struct LabeledValueRow: View { |
1612 | struct LabeledValueRow: View { |
| |
1613 | @Environment(\.appDensity) private var appDensity |
| 1439 | let row: InfoRowViewData |
1614 | let row: InfoRowViewData |
| 1440 | |
1615 | |
| 1441 | var body: some View { |
1616 | var body: some View { |
| 1442 | VStack(alignment: .leading, spacing: 2) { |
1617 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { |
| 1443 | Text(row.label) |
1618 | HStack(alignment: .top, spacing: 8) { |
| 1444 | .font(.system(.caption2, design: .monospaced)) |
1619 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { |
| 1445 | .foregroundStyle(.secondary) |
1620 | Text(row.label) |
| 1446 | Text(row.value) |
1621 | .font(appDensity.font(.caption2)) |
| 1447 | .font(.system(.caption, design: .monospaced)) |
1622 | .foregroundStyle(.secondary) |
| 1448 | .foregroundStyle(ResultColors.color(for: row.tone)) |
1623 | Text(row.value) |
| 1449 | .textSelection(.enabled) |
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 | } |
| 1450 | } |
1633 | } |
| |
1634 | .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) |
| 1451 | } |
1635 | } |
| 1452 | } |
1636 | } |
| 1453 | |
1637 | |
| @@ -1490,11 +1674,17 @@ private extension String { |
| 1490 | } |
1674 | } |
| 1491 | |
1675 | |
| 1492 | private struct SettingsView: View { |
1676 | private struct SettingsView: View { |
| |
1677 | @Environment(\.appDensity) private var appDensity |
| |
1678 | @Bindable var viewModel: DomainViewModel |
| 1493 | @AppStorage(DNSResolverOption.userDefaultsKey) |
1679 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 1494 | private var storedResolverURL = DNSResolverOption.defaultURLString |
1680 | private var storedResolverURL = DNSResolverOption.defaultURLString |
| |
1681 | @AppStorage(AppDensity.userDefaultsKey) |
| |
1682 | private var storedDensity = AppDensity.compact.rawValue |
| 1495 | |
1683 | |
| 1496 | @State private var resolverOption: DNSResolverOption = .cloudflare |
1684 | @State private var resolverOption: DNSResolverOption = .cloudflare |
| 1497 | @State private var customResolverURL = DNSResolverOption.defaultURLString |
1685 | @State private var customResolverURL = DNSResolverOption.defaultURLString |
| |
1686 | @State private var showClearHistoryConfirmation = false |
| |
1687 | @State private var showClearCacheConfirmation = false |
| 1498 | |
1688 | |
| 1499 | private var customResolverError: String? { |
1689 | private var customResolverError: String? { |
| 1500 | guard resolverOption == .custom else { |
1690 | guard resolverOption == .custom else { |
| @@ -1505,7 +1695,15 @@ private struct SettingsView: View { |
| 1505 | |
1695 | |
| 1506 | var body: some View { |
1696 | var body: some View { |
| 1507 | Form { |
1697 | 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") { |
| 1509 | Picker("Resolver", selection: $resolverOption) { |
1707 | Picker("Resolver", selection: $resolverOption) { |
| 1510 | ForEach(DNSResolverOption.allCases) { option in |
1708 | ForEach(DNSResolverOption.allCases) { option in |
| 1511 | Text(option.title).tag(option) |
1709 | Text(option.title).tag(option) |
| @@ -1520,13 +1718,45 @@ private struct SettingsView: View { |
| 1520 | |
1718 | |
| 1521 | if let customResolverError { |
1719 | if let customResolverError { |
| 1522 | Text(customResolverError) |
1720 | Text(customResolverError) |
| 1523 | .font(.caption) |
1721 | .font(appDensity.font(.caption, design: .default)) |
| 1524 | .foregroundStyle(.red) |
1722 | .foregroundStyle(.red) |
| 1525 | } |
1723 | } |
| 1526 | } |
1724 | } |
| 1527 | } |
1725 | } |
| |
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 | } |
| 1528 | } |
1742 | } |
| 1529 | .navigationTitle("Settings") |
1743 | .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 | } |
| 1530 | .onAppear { |
1760 | .onAppear { |
| 1531 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) |
1761 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| 1532 | resolverOption = DNSResolverOption.option(for: currentResolverURL) |
1762 | resolverOption = DNSResolverOption.option(for: currentResolverURL) |
| @@ -1544,6 +1774,10 @@ private struct SettingsView: View { |
| 1544 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) |
1774 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) |
| 1545 | } |
1775 | } |
| 1546 | } |
1776 | } |
| |
1777 | |
| |
1778 | private var appVersion: String { |
| |
1779 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0" |
| |
1780 | } |
| 1547 | } |
1781 | } |
| 1548 | |
1782 | |
| 1549 | #Preview { |
1783 | #Preview { |