Commit ede15c586a
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -265,7 +265,7 @@ | ||
| 265 | 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 266 | 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 267 | 267 | CODE_SIGN_STYLE = Automatic; |
| 268 | CURRENT_PROJECT_VERSION = 3; | |
| 268 | CURRENT_PROJECT_VERSION = 4; | |
| 269 | 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 270 | 270 | ENABLE_PREVIEWS = YES; |
| 271 | 271 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -282,7 +282,7 @@ | ||
| 282 | 282 | "$(inherited)", |
| 283 | 283 | "@executable_path/Frameworks", |
| 284 | 284 | ); |
| 285 | MARKETING_VERSION = 1.2; | |
| 285 | MARKETING_VERSION = 1.2.1; | |
| 286 | 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 287 | 287 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 288 | 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -301,7 +301,7 @@ | ||
| 301 | 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 302 | 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 303 | 303 | CODE_SIGN_STYLE = Automatic; |
| 304 | CURRENT_PROJECT_VERSION = 3; | |
| 304 | CURRENT_PROJECT_VERSION = 4; | |
| 305 | 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 306 | 306 | ENABLE_PREVIEWS = YES; |
| 307 | 307 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -318,7 +318,7 @@ | ||
| 318 | 318 | "$(inherited)", |
| 319 | 319 | "@executable_path/Frameworks", |
| 320 | 320 | ); |
| 321 | MARKETING_VERSION = 1.2; | |
| 321 | MARKETING_VERSION = 1.2.1; | |
| 322 | 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 323 | 323 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 324 | 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +45 −32
| @@ -7,7 +7,7 @@ struct ContentView: View { | ||
| 7 | 7 | |
| 8 | 8 | var body: some View { |
| 9 | 9 | NavigationStack { |
| 10 | ScrollView { | |
| 10 | ScrollView(.vertical) { | |
| 11 | 11 | VStack(spacing: 0) { |
| 12 | 12 | inputSection |
| 13 | 13 | if viewModel.hasRun { |
| @@ -225,7 +225,7 @@ struct ContentView: View { | ||
| 225 | 225 | .background(Color(.systemGray6).opacity(0.5)) |
| 226 | 226 | .cornerRadius(6) |
| 227 | 227 | } else { |
| 228 | VStack(alignment: .leading, spacing: 4) { | |
| 228 | horizontallyScrollableCard { | |
| 229 | 229 | ForEach(viewModel.redirectChain) { hop in |
| 230 | 230 | HStack(alignment: .top, spacing: 6) { |
| 231 | 231 | Text("\(hop.stepNumber)") |
| @@ -248,9 +248,6 @@ struct ContentView: View { | ||
| 248 | 248 | } |
| 249 | 249 | } |
| 250 | 250 | } |
| 251 | .padding(10) | |
| 252 | .background(Color(.systemGray6).opacity(0.5)) | |
| 253 | .cornerRadius(6) | |
| 254 | 251 | } |
| 255 | 252 | } |
| 256 | 253 | .padding(.top, 16) |
| @@ -281,7 +278,7 @@ struct ContentView: View { | ||
| 281 | 278 | } |
| 282 | 279 | |
| 283 | 280 | private var ptrRow: some View { |
| 284 | VStack(alignment: .leading, spacing: 4) { | |
| 281 | horizontallyScrollableCard { | |
| 285 | 282 | Text("PTR (Reverse DNS)") |
| 286 | 283 | .font(.system(.subheadline, design: .monospaced)) |
| 287 | 284 | .fontWeight(.semibold) |
| @@ -302,13 +299,10 @@ struct ContentView: View { | ||
| 302 | 299 | .foregroundStyle(.secondary) |
| 303 | 300 | } |
| 304 | 301 | } |
| 305 | .padding(10) | |
| 306 | .background(Color(.systemGray6).opacity(0.5)) | |
| 307 | .cornerRadius(6) | |
| 308 | 302 | } |
| 309 | 303 | |
| 310 | 304 | private func dnsRecordSection(_ section: DNSSection) -> some View { |
| 311 | VStack(alignment: .leading, spacing: 4) { | |
| 305 | horizontallyScrollableCard { | |
| 312 | 306 | Text(section.recordType.rawValue) |
| 313 | 307 | .font(.system(.subheadline, design: .monospaced)) |
| 314 | 308 | .fontWeight(.semibold) |
| @@ -334,9 +328,6 @@ struct ContentView: View { | ||
| 334 | 328 | dnsRecordRows(section.wildcardRecords) |
| 335 | 329 | } |
| 336 | 330 | } |
| 337 | .padding(10) | |
| 338 | .background(Color(.systemGray6).opacity(0.5)) | |
| 339 | .cornerRadius(6) | |
| 340 | 331 | } |
| 341 | 332 | |
| 342 | 333 | private func dnsRecordRows(_ records: [DNSRecord]) -> some View { |
| @@ -369,16 +360,14 @@ struct ContentView: View { | ||
| 369 | 360 | } else if let error = viewModel.emailSecurityError { |
| 370 | 361 | errorLabel(error) |
| 371 | 362 | } else if let email = viewModel.emailSecurity { |
| 372 | VStack(alignment: .leading, spacing: 6) { | |
| 363 | horizontallyScrollableCard(spacing: 6) { | |
| 373 | 364 | emailSecurityRow("SPF", record: email.spf) |
| 374 | 365 | emailSecurityRow("DMARC", record: email.dmarc) |
| 375 | 366 | emailSecurityRow("DKIM", record: email.dkim) |
| 376 | 367 | } |
| 377 | .padding(10) | |
| 378 | .background(Color(.systemGray6).opacity(0.5)) | |
| 379 | .cornerRadius(6) | |
| 380 | 368 | } |
| 381 | 369 | } |
| 370 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 382 | 371 | .padding(.top, 16) |
| 383 | 372 | } |
| 384 | 373 | |
| @@ -434,7 +423,7 @@ struct ContentView: View { | ||
| 434 | 423 | } |
| 435 | 424 | |
| 436 | 425 | private func sslDetail(_ info: SSLCertificateInfo) -> some View { |
| 437 | VStack(alignment: .leading, spacing: 8) { | |
| 426 | horizontallyScrollableCard(spacing: 8) { | |
| 438 | 427 | certRow("Common Name", info.commonName) |
| 439 | 428 | certRow("Issuer", info.issuer) |
| 440 | 429 | |
| @@ -466,9 +455,6 @@ struct ContentView: View { | ||
| 466 | 455 | |
| 467 | 456 | certRow("Chain Depth", "\(info.chainDepth)") |
| 468 | 457 | } |
| 469 | .padding(10) | |
| 470 | .background(Color(.systemGray6).opacity(0.5)) | |
| 471 | .cornerRadius(6) | |
| 472 | 458 | } |
| 473 | 459 | |
| 474 | 460 | // MARK: - HTTP Headers |
| @@ -484,7 +470,7 @@ struct ContentView: View { | ||
| 484 | 470 | } else if let error = viewModel.httpHeadersError { |
| 485 | 471 | errorLabel(error) |
| 486 | 472 | } else { |
| 487 | VStack(alignment: .leading, spacing: 4) { | |
| 473 | horizontallyScrollableCard { | |
| 488 | 474 | ForEach(viewModel.httpHeaders) { header in |
| 489 | 475 | HStack(alignment: .top, spacing: 4) { |
| 490 | 476 | Text(header.name + ":") |
| @@ -497,9 +483,6 @@ struct ContentView: View { | ||
| 497 | 483 | } |
| 498 | 484 | } |
| 499 | 485 | } |
| 500 | .padding(10) | |
| 501 | .background(Color(.systemGray6).opacity(0.5)) | |
| 502 | .cornerRadius(6) | |
| 503 | 486 | } |
| 504 | 487 | } |
| 505 | 488 | .padding(.top, 16) |
| @@ -533,14 +516,17 @@ struct ContentView: View { | ||
| 533 | 516 | |
| 534 | 517 | private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { |
| 535 | 518 | VStack(alignment: .leading, spacing: 6) { |
| 536 | certRow("IP", geo.ip) | |
| 537 | if let org = geo.org { | |
| 538 | certRow("Org / ISP", org) | |
| 539 | } | |
| 540 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 541 | if !location.isEmpty { | |
| 542 | certRow("Location", location) | |
| 519 | horizontallyScrollableContent(spacing: 6) { | |
| 520 | certRow("IP", geo.ip) | |
| 521 | if let org = geo.org { | |
| 522 | certRow("Org / ISP", org) | |
| 523 | } | |
| 524 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 525 | if !location.isEmpty { | |
| 526 | certRow("Location", location) | |
| 527 | } | |
| 543 | 528 | } |
| 529 | ||
| 544 | 530 | if let lat = geo.latitude, let lon = geo.longitude { |
| 545 | 531 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) |
| 546 | 532 | Map(initialPosition: .region(MKCoordinateRegion( |
| @@ -550,6 +536,7 @@ struct ContentView: View { | ||
| 550 | 536 | Marker(geo.ip, coordinate: coordinate) |
| 551 | 537 | } |
| 552 | 538 | .mapStyle(.standard) |
| 539 | .frame(maxWidth: .infinity) | |
| 553 | 540 | .frame(height: 180) |
| 554 | 541 | .cornerRadius(8) |
| 555 | 542 | } |
| @@ -620,6 +607,32 @@ struct ContentView: View { | ||
| 620 | 607 | } |
| 621 | 608 | } |
| 622 | 609 | |
| 610 | private func horizontallyScrollableCard<Content: View>( | |
| 611 | spacing: CGFloat = 4, | |
| 612 | @ViewBuilder content: () -> Content | |
| 613 | ) -> some View { | |
| 614 | horizontallyScrollableContent(spacing: spacing) { | |
| 615 | content() | |
| 616 | } | |
| 617 | .padding(10) | |
| 618 | .background(Color(.systemGray6).opacity(0.5)) | |
| 619 | .cornerRadius(6) | |
| 620 | } | |
| 621 | ||
| 622 | private func horizontallyScrollableContent<Content: View>( | |
| 623 | spacing: CGFloat = 4, | |
| 624 | @ViewBuilder content: () -> Content | |
| 625 | ) -> some View { | |
| 626 | ScrollView(.horizontal) { | |
| 627 | VStack(alignment: .leading, spacing: spacing) { | |
| 628 | content() | |
| 629 | } | |
| 630 | .scrollTargetLayout() | |
| 631 | } | |
| 632 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | |
| 633 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 634 | } | |
| 635 | ||
| 623 | 636 | private func errorLabel(_ message: String) -> some View { |
| 624 | 637 | Label(message, systemImage: "exclamationmark.triangle.fill") |
| 625 | 638 | .font(.system(.caption, design: .monospaced)) |
DomainDig/HistoryView.swift +45 −32
| @@ -65,7 +65,7 @@ struct HistoryDetailView: View { | ||
| 65 | 65 | }() |
| 66 | 66 | |
| 67 | 67 | var body: some View { |
| 68 | ScrollView { | |
| 68 | ScrollView(.vertical) { | |
| 69 | 69 | VStack(alignment: .leading, spacing: 0) { |
| 70 | 70 | cachedBanner |
| 71 | 71 | reachabilitySection |
| @@ -157,7 +157,7 @@ struct HistoryDetailView: View { | ||
| 157 | 157 | .background(Color(.systemGray6).opacity(0.5)) |
| 158 | 158 | .cornerRadius(6) |
| 159 | 159 | } else { |
| 160 | VStack(alignment: .leading, spacing: 4) { | |
| 160 | horizontallyScrollableCard { | |
| 161 | 161 | ForEach(entry.redirectChain) { hop in |
| 162 | 162 | HStack(alignment: .top, spacing: 6) { |
| 163 | 163 | Text("\(hop.stepNumber)") |
| @@ -180,9 +180,6 @@ struct HistoryDetailView: View { | ||
| 180 | 180 | } |
| 181 | 181 | } |
| 182 | 182 | } |
| 183 | .padding(10) | |
| 184 | .background(Color(.systemGray6).opacity(0.5)) | |
| 185 | .cornerRadius(6) | |
| 186 | 183 | } |
| 187 | 184 | } |
| 188 | 185 | } |
| @@ -195,7 +192,7 @@ struct HistoryDetailView: View { | ||
| 195 | 192 | VStack(alignment: .leading, spacing: 12) { |
| 196 | 193 | sectionHeader("DNS Records") |
| 197 | 194 | ForEach(entry.dnsSections) { section in |
| 198 | VStack(alignment: .leading, spacing: 4) { | |
| 195 | horizontallyScrollableCard { | |
| 199 | 196 | Text(section.recordType.rawValue) |
| 200 | 197 | .font(.system(.subheadline, design: .monospaced)) |
| 201 | 198 | .fontWeight(.semibold) |
| @@ -220,12 +217,9 @@ struct HistoryDetailView: View { | ||
| 220 | 217 | recordRows(section.wildcardRecords) |
| 221 | 218 | } |
| 222 | 219 | } |
| 223 | .padding(10) | |
| 224 | .background(Color(.systemGray6).opacity(0.5)) | |
| 225 | .cornerRadius(6) | |
| 226 | 220 | |
| 227 | 221 | if section.recordType == .A { |
| 228 | VStack(alignment: .leading, spacing: 4) { | |
| 222 | horizontallyScrollableCard { | |
| 229 | 223 | Text("PTR (Reverse DNS)") |
| 230 | 224 | .font(.system(.subheadline, design: .monospaced)) |
| 231 | 225 | .fontWeight(.semibold) |
| @@ -242,9 +236,6 @@ struct HistoryDetailView: View { | ||
| 242 | 236 | .foregroundStyle(.secondary) |
| 243 | 237 | } |
| 244 | 238 | } |
| 245 | .padding(10) | |
| 246 | .background(Color(.systemGray6).opacity(0.5)) | |
| 247 | .cornerRadius(6) | |
| 248 | 239 | } |
| 249 | 240 | } |
| 250 | 241 | } |
| @@ -259,16 +250,14 @@ struct HistoryDetailView: View { | ||
| 259 | 250 | VStack(alignment: .leading, spacing: 12) { |
| 260 | 251 | if let email = entry.emailSecurity { |
| 261 | 252 | sectionHeader("Email Security") |
| 262 | VStack(alignment: .leading, spacing: 6) { | |
| 253 | horizontallyScrollableCard(spacing: 6) { | |
| 263 | 254 | historyEmailRow("SPF", record: email.spf) |
| 264 | 255 | historyEmailRow("DMARC", record: email.dmarc) |
| 265 | 256 | historyEmailRow("DKIM", record: email.dkim) |
| 266 | 257 | } |
| 267 | .padding(10) | |
| 268 | .background(Color(.systemGray6).opacity(0.5)) | |
| 269 | .cornerRadius(6) | |
| 270 | 258 | } |
| 271 | 259 | } |
| 260 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 272 | 261 | .padding(.top, 16) |
| 273 | 262 | } |
| 274 | 263 | |
| @@ -310,7 +299,7 @@ struct HistoryDetailView: View { | ||
| 310 | 299 | VStack(alignment: .leading, spacing: 12) { |
| 311 | 300 | if let info = entry.sslInfo { |
| 312 | 301 | sectionHeader("SSL / TLS Certificate") |
| 313 | VStack(alignment: .leading, spacing: 8) { | |
| 302 | horizontallyScrollableCard(spacing: 8) { | |
| 314 | 303 | labelRow("Common Name", info.commonName) |
| 315 | 304 | labelRow("Issuer", info.issuer) |
| 316 | 305 | |
| @@ -341,9 +330,6 @@ struct HistoryDetailView: View { | ||
| 341 | 330 | |
| 342 | 331 | labelRow("Chain Depth", "\(info.chainDepth)") |
| 343 | 332 | } |
| 344 | .padding(10) | |
| 345 | .background(Color(.systemGray6).opacity(0.5)) | |
| 346 | .cornerRadius(6) | |
| 347 | 333 | } |
| 348 | 334 | } |
| 349 | 335 | .padding(.top, 16) |
| @@ -355,7 +341,7 @@ struct HistoryDetailView: View { | ||
| 355 | 341 | VStack(alignment: .leading, spacing: 12) { |
| 356 | 342 | if !entry.httpHeaders.isEmpty { |
| 357 | 343 | sectionHeader("HTTP Headers") |
| 358 | VStack(alignment: .leading, spacing: 4) { | |
| 344 | horizontallyScrollableCard { | |
| 359 | 345 | ForEach(entry.httpHeaders) { header in |
| 360 | 346 | HStack(alignment: .top, spacing: 4) { |
| 361 | 347 | Text(header.name + ":") |
| @@ -368,9 +354,6 @@ struct HistoryDetailView: View { | ||
| 368 | 354 | } |
| 369 | 355 | } |
| 370 | 356 | } |
| 371 | .padding(10) | |
| 372 | .background(Color(.systemGray6).opacity(0.5)) | |
| 373 | .cornerRadius(6) | |
| 374 | 357 | } |
| 375 | 358 | } |
| 376 | 359 | .padding(.top, 16) |
| @@ -383,14 +366,17 @@ struct HistoryDetailView: View { | ||
| 383 | 366 | if let geo = entry.ipGeolocation { |
| 384 | 367 | sectionHeader("IP Location") |
| 385 | 368 | VStack(alignment: .leading, spacing: 6) { |
| 386 | labelRow("IP", geo.ip) | |
| 387 | if let org = geo.org { | |
| 388 | labelRow("Org / ISP", org) | |
| 389 | } | |
| 390 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 391 | if !location.isEmpty { | |
| 392 | labelRow("Location", location) | |
| 369 | horizontallyScrollableContent(spacing: 6) { | |
| 370 | labelRow("IP", geo.ip) | |
| 371 | if let org = geo.org { | |
| 372 | labelRow("Org / ISP", org) | |
| 373 | } | |
| 374 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 375 | if !location.isEmpty { | |
| 376 | labelRow("Location", location) | |
| 377 | } | |
| 393 | 378 | } |
| 379 | ||
| 394 | 380 | if let lat = geo.latitude, let lon = geo.longitude { |
| 395 | 381 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) |
| 396 | 382 | Map(initialPosition: .region(MKCoordinateRegion( |
| @@ -400,6 +386,7 @@ struct HistoryDetailView: View { | ||
| 400 | 386 | Marker(geo.ip, coordinate: coordinate) |
| 401 | 387 | } |
| 402 | 388 | .mapStyle(.standard) |
| 389 | .frame(maxWidth: .infinity) | |
| 403 | 390 | .frame(height: 180) |
| 404 | 391 | .cornerRadius(8) |
| 405 | 392 | } |
| @@ -481,6 +468,32 @@ struct HistoryDetailView: View { | ||
| 481 | 468 | } |
| 482 | 469 | } |
| 483 | 470 | |
| 471 | private func horizontallyScrollableCard<Content: View>( | |
| 472 | spacing: CGFloat = 4, | |
| 473 | @ViewBuilder content: () -> Content | |
| 474 | ) -> some View { | |
| 475 | horizontallyScrollableContent(spacing: spacing) { | |
| 476 | content() | |
| 477 | } | |
| 478 | .padding(10) | |
| 479 | .background(Color(.systemGray6).opacity(0.5)) | |
| 480 | .cornerRadius(6) | |
| 481 | } | |
| 482 | ||
| 483 | private func horizontallyScrollableContent<Content: View>( | |
| 484 | spacing: CGFloat = 4, | |
| 485 | @ViewBuilder content: () -> Content | |
| 486 | ) -> some View { | |
| 487 | ScrollView(.horizontal) { | |
| 488 | VStack(alignment: .leading, spacing: spacing) { | |
| 489 | content() | |
| 490 | } | |
| 491 | .scrollTargetLayout() | |
| 492 | } | |
| 493 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | |
| 494 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 495 | } | |
| 496 | ||
| 484 | 497 | private func errorLabel(_ message: String) -> some View { |
| 485 | 498 | Label(message, systemImage: "exclamationmark.triangle.fill") |
| 486 | 499 | .font(.system(.caption, design: .monospaced)) |
claude.md deleted −171
| @@ -1,171 +0,0 @@ | ||
| 1 | # DomainDig – Project Context | |
| 2 | ||
| 3 | ## What this is | |
| 4 | An iOS utility app for querying DNS records, inspecting SSL/TLS certificates, | |
| 5 | checking HTTP headers, measuring TCP reachability, geolocating IP addresses, | |
| 6 | analyzing email security records, tracing redirect chains, performing reverse | |
| 7 | DNS lookups, and scanning common ports for any domain. Designed for developers, | |
| 8 | sysadmins, and anyone who manages or troubleshoots domains and servers. | |
| 9 | ||
| 10 | ## Target user | |
| 11 | Technically literate adults — developers, IT/sysadmin, homelab enthusiasts. | |
| 12 | Comfortable with terms like A record, CNAME, MX, TTL, and TLS. The UI should be | |
| 13 | clean and information-dense, not hand-holding. | |
| 14 | ||
| 15 | ## Design principles | |
| 16 | - Minimal, utilitarian UI — this is a tool, not a consumer app | |
| 17 | - Results should be readable at a glance — use clear labels, monospace where | |
| 18 | appropriate for IPs and raw values | |
| 19 | - Dark mode preferred as the primary aesthetic | |
| 20 | - No onboarding, no tutorials, no splash screens | |
| 21 | - Fast — results should appear as soon as they're available, not after all | |
| 22 | lookups complete | |
| 23 | - Each result section loads independently with its own ProgressView | |
| 24 | - Errors shown inline per-section, never as global alerts | |
| 25 | ||
| 26 | ## Features | |
| 27 | ||
| 28 | ### DNS Lookup | |
| 29 | Query the following record types for any domain via DNS-over-HTTPS (use | |
| 30 | Cloudflare 1.1.1.1 — https://cloudflare-dns.com/dns-query): | |
| 31 | - A | |
| 32 | - AAAA | |
| 33 | - MX | |
| 34 | - NS | |
| 35 | - TXT | |
| 36 | - CNAME | |
| 37 | ||
| 38 | Display each record type in its own section. Show TTL alongside each result. | |
| 39 | If a record type returns no results, show "No records found" for that type | |
| 40 | rather than hiding the section entirely. | |
| 41 | ||
| 42 | After the apex query, also queries `*.{domain}` for A, AAAA, MX, and TXT. | |
| 43 | Wildcard results are shown as a sub-section beneath each record type, labelled | |
| 44 | with `*.{domain}`. Hidden if no wildcard records are returned. | |
| 45 | ||
| 46 | ### SSL/TLS Certificate Check | |
| 47 | Connect to the domain on port 443 via URLSession and inspect the server's | |
| 48 | certificate chain using URLSession delegate methods. Display: | |
| 49 | - Common name (CN) | |
| 50 | - Subject Alternative Names (SANs) | |
| 51 | - Issuer | |
| 52 | - Valid from / Valid until dates | |
| 53 | - Days until expiry — highlight in red if under 30 days, yellow if under 60 | |
| 54 | - Certificate chain depth | |
| 55 | ||
| 56 | ### HTTP Headers Check | |
| 57 | Fire a HEAD request to `https://{domain}` and display all response headers. | |
| 58 | Header names in cyan, values in primary. Security-relevant headers highlighted | |
| 59 | in yellow: `strict-transport-security`, `x-frame-options`, | |
| 60 | `x-content-type-options`, `content-security-policy`, `referrer-policy`. | |
| 61 | Service: `HTTPHeadersService.swift`. | |
| 62 | ||
| 63 | ### Reachability / TCP Latency | |
| 64 | Use `NWConnection` (Network framework) to attempt TCP connections on ports 443 | |
| 65 | and 80. Measure time to `.ready` state. Display green/red dot, latency in ms, | |
| 66 | and reachable/unreachable status. Timeout after 5 seconds. | |
| 67 | Service: `ReachabilityService.swift`. | |
| 68 | ||
| 69 | ### IP Geolocation with Map | |
| 70 | After DNS A record resolves, take the first IP and query | |
| 71 | `https://ipapi.co/{ip}/json/` for country, region, city, org, lat/lon. | |
| 72 | Display in an "IP Location" section with a SwiftUI `Map` (MapKit) centered on | |
| 73 | the coordinates with a `Marker`. Map height 180pt, `.standard` style. | |
| 74 | Service: `IPGeolocationService.swift`. | |
| 75 | ||
| 76 | ### Email Security Analysis | |
| 77 | Parse SPF from existing TXT records (no extra query). Query `_dmarc.{domain}` | |
| 78 | for DMARC and try common DKIM selectors (`default`, `google`, `mail`) via DoH. | |
| 79 | Display green checkmark if found, red ✗ if not. Full record values truncated | |
| 80 | to 80 chars with tap-to-expand. Triggered after DNS completes. | |
| 81 | Service: `EmailSecurityService.swift`. | |
| 82 | ||
| 83 | ### Reverse DNS / PTR Lookup | |
| 84 | After DNS A record resolves, construct reverse DNS name (reversed octets + | |
| 85 | `.in-addr.arpa`) and query PTR record via Cloudflare DoH. Displayed inline | |
| 86 | in the DNS Records section below the A record sub-section. | |
| 87 | Service: `ReverseDNSService.swift`. | |
| 88 | ||
| 89 | ### Redirect Chain | |
| 90 | Fire HTTP request to `http://{domain}` with redirects disabled. Follow up to | |
| 91 | 10 redirects manually, recording each hop's URL and status code. Display step | |
| 92 | number, status code in cyan, URL, and "(final)" on the last hop. Shows | |
| 93 | "No redirects — direct connection" if the first request returns 200. | |
| 94 | Service: `RedirectChainService.swift`. | |
| 95 | ||
| 96 | ### Common Port Scanner | |
| 97 | Probe 10 common ports (21/FTP, 22/SSH, 25/SMTP, 80/HTTP, 443/HTTPS, | |
| 98 | 587/SMTP-TLS, 3306/MySQL, 5432/PostgreSQL, 8080/HTTP-Alt, 8443/HTTPS-Alt) | |
| 99 | using `NWConnection` with 3-second timeout. All probes run concurrently. | |
| 100 | Green dot for open, grey dot for closed. Closed is expected — no error shown. | |
| 101 | Service: `PortScanService.swift`. | |
| 102 | ||
| 103 | ### Results layout | |
| 104 | - Domain input at the top — large text field, keyboard shows on launch | |
| 105 | - Run button to trigger all lookups simultaneously | |
| 106 | - Results displayed in clearly separated sections: | |
| 107 | Reachability → Redirect Chain → DNS Records (with PTR inline) → | |
| 108 | Email Security → SSL/TLS Certificate → HTTP Headers → IP Location → | |
| 109 | Open Ports | |
| 110 | - Each section loads independently — don't block one section waiting for another | |
| 111 | - Email security, PTR, and IP geolocation are chained after DNS; all others | |
| 112 | run in parallel | |
| 113 | ||
| 114 | ### Recent searches | |
| 115 | Store the last 20 searched domains locally using UserDefaults. Display them as | |
| 116 | a tappable list below the text field when no results are showing. Tapping a | |
| 117 | recent domain populates the text field and runs the lookup immediately. Include | |
| 118 | a "Clear" button to wipe history. Most recent at the top. | |
| 119 | ||
| 120 | ### Saved domains | |
| 121 | Bookmark button (SF Symbol: `bookmark` / `bookmark.fill`) in the results | |
| 122 | toolbar area, next to the share button. Tapping saves/unsaves the current | |
| 123 | domain. Filled icon when saved. Saved domains viewable from a toolbar button | |
| 124 | that pushes to `SavedDomainsView` — a list of saved domains, tappable to run | |
| 125 | lookups, with swipe-to-delete and an Edit button for bulk deletion. | |
| 126 | Persisted in UserDefaults under key `savedDomains`. | |
| 127 | ||
| 128 | ### Lookup history with cached results | |
| 129 | After each successful lookup, a snapshot of all results (DNS, SSL, HTTP headers, | |
| 130 | reachability, geolocation, email security, PTR, redirect chain, port scan) is | |
| 131 | saved to history in UserDefaults as JSON. Capped at 50 entries. `HistoryView` | |
| 132 | shows past lookups with domain and timestamp. Tapping shows full cached results | |
| 133 | in `HistoryDetailView` using the same layout, labelled as cached with the | |
| 134 | original timestamp. Model: `HistoryEntry` (Codable). | |
| 135 | ||
| 136 | ### Share / export | |
| 137 | A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the | |
| 138 | results area. Formats the full results as plain text and presents the iOS share | |
| 139 | sheet via UIActivityViewController. The export includes: domain, timestamp, | |
| 140 | reachability, redirect chain, all DNS records with TTLs and PTR, email security | |
| 141 | records, SSL cert fields, HTTP headers, IP geolocation data, and port scan | |
| 142 | results. | |
| 143 | ||
| 144 | ## Technical constraints | |
| 145 | - SwiftUI, iOS only | |
| 146 | - Fully offline except for network requests (DNS-over-HTTPS, SSL, HTTP HEAD, | |
| 147 | TCP connections, geolocation API) | |
| 148 | - No accounts, no analytics, no ads | |
| 149 | - No third-party dependencies — URLSession, Network, MapKit only | |
| 150 | - Targets latest iOS | |
| 151 | ||
| 152 | ## Architecture | |
| 153 | - `Models.swift` — All data models (DNS, SSL, HTTP headers, reachability, | |
| 154 | geolocation, history entry), all Codable for persistence | |
| 155 | - `DomainViewModel.swift` — `@Observable` view model orchestrating all lookups, | |
| 156 | managing state, history, saved domains, recent searches, and export | |
| 157 | - `ContentView.swift` — Main screen with input, all result sections, toolbar | |
| 158 | - `SavedDomainsView.swift` — Saved domains list with edit/delete | |
| 159 | - `HistoryView.swift` — History list + `HistoryDetailView` for cached results | |
| 160 | - Services: `DNSLookupService`, `SSLCheckService`, `HTTPHeadersService`, | |
| 161 | `ReachabilityService`, `IPGeolocationService`, `EmailSecurityService`, | |
| 162 | `ReverseDNSService`, `RedirectChainService`, `PortScanService` | |
| 163 | ||
| 164 | ## What good looks like | |
| 165 | A developer pastes a domain, taps run, and within a couple of seconds sees a | |
| 166 | clean breakdown of TCP reachability, redirect chain, every DNS record type with | |
| 167 | reverse DNS, email security posture, the full SSL cert status, HTTP response | |
| 168 | headers with security headers highlighted, IP geolocation with a map, and a | |
| 169 | port scan of common services. It should feel like a native, polished version of | |
| 170 | running `dig`, `openssl s_client`, `curl -I`, `nmap`, and `whois` from the | |
| 171 | terminal. | |