Commit ede15c586a
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -265,7 +265,7 @@ | |||
| 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 267 | CODE_SIGN_STYLE = Automatic; | 267 | CODE_SIGN_STYLE = Automatic; |
| 268 | CURRENT_PROJECT_VERSION = 3; | 268 | CURRENT_PROJECT_VERSION = 4; |
| 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 270 | ENABLE_PREVIEWS = YES; | 270 | ENABLE_PREVIEWS = YES; |
| 271 | GENERATE_INFOPLIST_FILE = YES; | 271 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -282,7 +282,7 @@ | |||
| 282 | "$(inherited)", | 282 | "$(inherited)", |
| 283 | "@executable_path/Frameworks", | 283 | "@executable_path/Frameworks", |
| 284 | ); | 284 | ); |
| 285 | MARKETING_VERSION = 1.2; | 285 | MARKETING_VERSION = 1.2.1; |
| 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 287 | PRODUCT_NAME = "$(TARGET_NAME)"; | 287 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -301,7 +301,7 @@ | |||
| 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 303 | CODE_SIGN_STYLE = Automatic; | 303 | CODE_SIGN_STYLE = Automatic; |
| 304 | CURRENT_PROJECT_VERSION = 3; | 304 | CURRENT_PROJECT_VERSION = 4; |
| 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 306 | ENABLE_PREVIEWS = YES; | 306 | ENABLE_PREVIEWS = YES; |
| 307 | GENERATE_INFOPLIST_FILE = YES; | 307 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -318,7 +318,7 @@ | |||
| 318 | "$(inherited)", | 318 | "$(inherited)", |
| 319 | "@executable_path/Frameworks", | 319 | "@executable_path/Frameworks", |
| 320 | ); | 320 | ); |
| 321 | MARKETING_VERSION = 1.2; | 321 | MARKETING_VERSION = 1.2.1; |
| 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 323 | PRODUCT_NAME = "$(TARGET_NAME)"; | 323 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +45 −32
| @@ -7,7 +7,7 @@ struct ContentView: View { | |||
| 7 | 7 | ||
| 8 | var body: some View { | 8 | var body: some View { |
| 9 | NavigationStack { | 9 | NavigationStack { |
| 10 | ScrollView { | 10 | ScrollView(.vertical) { |
| 11 | VStack(spacing: 0) { | 11 | VStack(spacing: 0) { |
| 12 | inputSection | 12 | inputSection |
| 13 | if viewModel.hasRun { | 13 | if viewModel.hasRun { |
| @@ -225,7 +225,7 @@ struct ContentView: View { | |||
| 225 | .background(Color(.systemGray6).opacity(0.5)) | 225 | .background(Color(.systemGray6).opacity(0.5)) |
| 226 | .cornerRadius(6) | 226 | .cornerRadius(6) |
| 227 | } else { | 227 | } else { |
| 228 | VStack(alignment: .leading, spacing: 4) { | 228 | horizontallyScrollableCard { |
| 229 | ForEach(viewModel.redirectChain) { hop in | 229 | ForEach(viewModel.redirectChain) { hop in |
| 230 | HStack(alignment: .top, spacing: 6) { | 230 | HStack(alignment: .top, spacing: 6) { |
| 231 | Text("\(hop.stepNumber)") | 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 | .padding(.top, 16) | 253 | .padding(.top, 16) |
| @@ -281,7 +278,7 @@ struct ContentView: View { | |||
| 281 | } | 278 | } |
| 282 | 279 | ||
| 283 | private var ptrRow: some View { | 280 | private var ptrRow: some View { |
| 284 | VStack(alignment: .leading, spacing: 4) { | 281 | horizontallyScrollableCard { |
| 285 | Text("PTR (Reverse DNS)") | 282 | Text("PTR (Reverse DNS)") |
| 286 | .font(.system(.subheadline, design: .monospaced)) | 283 | .font(.system(.subheadline, design: .monospaced)) |
| 287 | .fontWeight(.semibold) | 284 | .fontWeight(.semibold) |
| @@ -302,13 +299,10 @@ struct ContentView: View { | |||
| 302 | .foregroundStyle(.secondary) | 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 | private func dnsRecordSection(_ section: DNSSection) -> some View { | 304 | private func dnsRecordSection(_ section: DNSSection) -> some View { |
| 311 | VStack(alignment: .leading, spacing: 4) { | 305 | horizontallyScrollableCard { |
| 312 | Text(section.recordType.rawValue) | 306 | Text(section.recordType.rawValue) |
| 313 | .font(.system(.subheadline, design: .monospaced)) | 307 | .font(.system(.subheadline, design: .monospaced)) |
| 314 | .fontWeight(.semibold) | 308 | .fontWeight(.semibold) |
| @@ -334,9 +328,6 @@ struct ContentView: View { | |||
| 334 | dnsRecordRows(section.wildcardRecords) | 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 | private func dnsRecordRows(_ records: [DNSRecord]) -> some View { | 333 | private func dnsRecordRows(_ records: [DNSRecord]) -> some View { |
| @@ -369,16 +360,14 @@ struct ContentView: View { | |||
| 369 | } else if let error = viewModel.emailSecurityError { | 360 | } else if let error = viewModel.emailSecurityError { |
| 370 | errorLabel(error) | 361 | errorLabel(error) |
| 371 | } else if let email = viewModel.emailSecurity { | 362 | } else if let email = viewModel.emailSecurity { |
| 372 | VStack(alignment: .leading, spacing: 6) { | 363 | horizontallyScrollableCard(spacing: 6) { |
| 373 | emailSecurityRow("SPF", record: email.spf) | 364 | emailSecurityRow("SPF", record: email.spf) |
| 374 | emailSecurityRow("DMARC", record: email.dmarc) | 365 | emailSecurityRow("DMARC", record: email.dmarc) |
| 375 | emailSecurityRow("DKIM", record: email.dkim) | 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 | .padding(.top, 16) | 371 | .padding(.top, 16) |
| 383 | } | 372 | } |
| 384 | 373 | ||
| @@ -434,7 +423,7 @@ struct ContentView: View { | |||
| 434 | } | 423 | } |
| 435 | 424 | ||
| 436 | private func sslDetail(_ info: SSLCertificateInfo) -> some View { | 425 | private func sslDetail(_ info: SSLCertificateInfo) -> some View { |
| 437 | VStack(alignment: .leading, spacing: 8) { | 426 | horizontallyScrollableCard(spacing: 8) { |
| 438 | certRow("Common Name", info.commonName) | 427 | certRow("Common Name", info.commonName) |
| 439 | certRow("Issuer", info.issuer) | 428 | certRow("Issuer", info.issuer) |
| 440 | 429 | ||
| @@ -466,9 +455,6 @@ struct ContentView: View { | |||
| 466 | 455 | ||
| 467 | certRow("Chain Depth", "\(info.chainDepth)") | 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 | // MARK: - HTTP Headers | 460 | // MARK: - HTTP Headers |
| @@ -484,7 +470,7 @@ struct ContentView: View { | |||
| 484 | } else if let error = viewModel.httpHeadersError { | 470 | } else if let error = viewModel.httpHeadersError { |
| 485 | errorLabel(error) | 471 | errorLabel(error) |
| 486 | } else { | 472 | } else { |
| 487 | VStack(alignment: .leading, spacing: 4) { | 473 | horizontallyScrollableCard { |
| 488 | ForEach(viewModel.httpHeaders) { header in | 474 | ForEach(viewModel.httpHeaders) { header in |
| 489 | HStack(alignment: .top, spacing: 4) { | 475 | HStack(alignment: .top, spacing: 4) { |
| 490 | Text(header.name + ":") | 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 | .padding(.top, 16) | 488 | .padding(.top, 16) |
| @@ -533,14 +516,17 @@ struct ContentView: View { | |||
| 533 | 516 | ||
| 534 | private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { | 517 | private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { |
| 535 | VStack(alignment: .leading, spacing: 6) { | 518 | VStack(alignment: .leading, spacing: 6) { |
| 536 | certRow("IP", geo.ip) | 519 | horizontallyScrollableContent(spacing: 6) { |
| 537 | if let org = geo.org { | 520 | certRow("IP", geo.ip) |
| 538 | certRow("Org / ISP", org) | 521 | if let org = geo.org { |
| 539 | } | 522 | certRow("Org / ISP", org) |
| 540 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | 523 | } |
| 541 | if !location.isEmpty { | 524 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") |
| 542 | certRow("Location", location) | 525 | if !location.isEmpty { |
| 526 | certRow("Location", location) | ||
| 527 | } | ||
| 543 | } | 528 | } |
| 529 | |||
| 544 | if let lat = geo.latitude, let lon = geo.longitude { | 530 | if let lat = geo.latitude, let lon = geo.longitude { |
| 545 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) | 531 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) |
| 546 | Map(initialPosition: .region(MKCoordinateRegion( | 532 | Map(initialPosition: .region(MKCoordinateRegion( |
| @@ -550,6 +536,7 @@ struct ContentView: View { | |||
| 550 | Marker(geo.ip, coordinate: coordinate) | 536 | Marker(geo.ip, coordinate: coordinate) |
| 551 | } | 537 | } |
| 552 | .mapStyle(.standard) | 538 | .mapStyle(.standard) |
| 539 | .frame(maxWidth: .infinity) | ||
| 553 | .frame(height: 180) | 540 | .frame(height: 180) |
| 554 | .cornerRadius(8) | 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 | private func errorLabel(_ message: String) -> some View { | 636 | private func errorLabel(_ message: String) -> some View { |
| 624 | Label(message, systemImage: "exclamationmark.triangle.fill") | 637 | Label(message, systemImage: "exclamationmark.triangle.fill") |
| 625 | .font(.system(.caption, design: .monospaced)) | 638 | .font(.system(.caption, design: .monospaced)) |
DomainDig/HistoryView.swift +45 −32
| @@ -65,7 +65,7 @@ struct HistoryDetailView: View { | |||
| 65 | }() | 65 | }() |
| 66 | 66 | ||
| 67 | var body: some View { | 67 | var body: some View { |
| 68 | ScrollView { | 68 | ScrollView(.vertical) { |
| 69 | VStack(alignment: .leading, spacing: 0) { | 69 | VStack(alignment: .leading, spacing: 0) { |
| 70 | cachedBanner | 70 | cachedBanner |
| 71 | reachabilitySection | 71 | reachabilitySection |
| @@ -157,7 +157,7 @@ struct HistoryDetailView: View { | |||
| 157 | .background(Color(.systemGray6).opacity(0.5)) | 157 | .background(Color(.systemGray6).opacity(0.5)) |
| 158 | .cornerRadius(6) | 158 | .cornerRadius(6) |
| 159 | } else { | 159 | } else { |
| 160 | VStack(alignment: .leading, spacing: 4) { | 160 | horizontallyScrollableCard { |
| 161 | ForEach(entry.redirectChain) { hop in | 161 | ForEach(entry.redirectChain) { hop in |
| 162 | HStack(alignment: .top, spacing: 6) { | 162 | HStack(alignment: .top, spacing: 6) { |
| 163 | Text("\(hop.stepNumber)") | 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 | VStack(alignment: .leading, spacing: 12) { | 192 | VStack(alignment: .leading, spacing: 12) { |
| 196 | sectionHeader("DNS Records") | 193 | sectionHeader("DNS Records") |
| 197 | ForEach(entry.dnsSections) { section in | 194 | ForEach(entry.dnsSections) { section in |
| 198 | VStack(alignment: .leading, spacing: 4) { | 195 | horizontallyScrollableCard { |
| 199 | Text(section.recordType.rawValue) | 196 | Text(section.recordType.rawValue) |
| 200 | .font(.system(.subheadline, design: .monospaced)) | 197 | .font(.system(.subheadline, design: .monospaced)) |
| 201 | .fontWeight(.semibold) | 198 | .fontWeight(.semibold) |
| @@ -220,12 +217,9 @@ struct HistoryDetailView: View { | |||
| 220 | recordRows(section.wildcardRecords) | 217 | recordRows(section.wildcardRecords) |
| 221 | } | 218 | } |
| 222 | } | 219 | } |
| 223 | .padding(10) | ||
| 224 | .background(Color(.systemGray6).opacity(0.5)) | ||
| 225 | .cornerRadius(6) | ||
| 226 | 220 | ||
| 227 | if section.recordType == .A { | 221 | if section.recordType == .A { |
| 228 | VStack(alignment: .leading, spacing: 4) { | 222 | horizontallyScrollableCard { |
| 229 | Text("PTR (Reverse DNS)") | 223 | Text("PTR (Reverse DNS)") |
| 230 | .font(.system(.subheadline, design: .monospaced)) | 224 | .font(.system(.subheadline, design: .monospaced)) |
| 231 | .fontWeight(.semibold) | 225 | .fontWeight(.semibold) |
| @@ -242,9 +236,6 @@ struct HistoryDetailView: View { | |||
| 242 | .foregroundStyle(.secondary) | 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 | VStack(alignment: .leading, spacing: 12) { | 250 | VStack(alignment: .leading, spacing: 12) { |
| 260 | if let email = entry.emailSecurity { | 251 | if let email = entry.emailSecurity { |
| 261 | sectionHeader("Email Security") | 252 | sectionHeader("Email Security") |
| 262 | VStack(alignment: .leading, spacing: 6) { | 253 | horizontallyScrollableCard(spacing: 6) { |
| 263 | historyEmailRow("SPF", record: email.spf) | 254 | historyEmailRow("SPF", record: email.spf) |
| 264 | historyEmailRow("DMARC", record: email.dmarc) | 255 | historyEmailRow("DMARC", record: email.dmarc) |
| 265 | historyEmailRow("DKIM", record: email.dkim) | 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 | .padding(.top, 16) | 261 | .padding(.top, 16) |
| 273 | } | 262 | } |
| 274 | 263 | ||
| @@ -310,7 +299,7 @@ struct HistoryDetailView: View { | |||
| 310 | VStack(alignment: .leading, spacing: 12) { | 299 | VStack(alignment: .leading, spacing: 12) { |
| 311 | if let info = entry.sslInfo { | 300 | if let info = entry.sslInfo { |
| 312 | sectionHeader("SSL / TLS Certificate") | 301 | sectionHeader("SSL / TLS Certificate") |
| 313 | VStack(alignment: .leading, spacing: 8) { | 302 | horizontallyScrollableCard(spacing: 8) { |
| 314 | labelRow("Common Name", info.commonName) | 303 | labelRow("Common Name", info.commonName) |
| 315 | labelRow("Issuer", info.issuer) | 304 | labelRow("Issuer", info.issuer) |
| 316 | 305 | ||
| @@ -341,9 +330,6 @@ struct HistoryDetailView: View { | |||
| 341 | 330 | ||
| 342 | labelRow("Chain Depth", "\(info.chainDepth)") | 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 | .padding(.top, 16) | 335 | .padding(.top, 16) |
| @@ -355,7 +341,7 @@ struct HistoryDetailView: View { | |||
| 355 | VStack(alignment: .leading, spacing: 12) { | 341 | VStack(alignment: .leading, spacing: 12) { |
| 356 | if !entry.httpHeaders.isEmpty { | 342 | if !entry.httpHeaders.isEmpty { |
| 357 | sectionHeader("HTTP Headers") | 343 | sectionHeader("HTTP Headers") |
| 358 | VStack(alignment: .leading, spacing: 4) { | 344 | horizontallyScrollableCard { |
| 359 | ForEach(entry.httpHeaders) { header in | 345 | ForEach(entry.httpHeaders) { header in |
| 360 | HStack(alignment: .top, spacing: 4) { | 346 | HStack(alignment: .top, spacing: 4) { |
| 361 | Text(header.name + ":") | 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 | .padding(.top, 16) | 359 | .padding(.top, 16) |
| @@ -383,14 +366,17 @@ struct HistoryDetailView: View { | |||
| 383 | if let geo = entry.ipGeolocation { | 366 | if let geo = entry.ipGeolocation { |
| 384 | sectionHeader("IP Location") | 367 | sectionHeader("IP Location") |
| 385 | VStack(alignment: .leading, spacing: 6) { | 368 | VStack(alignment: .leading, spacing: 6) { |
| 386 | labelRow("IP", geo.ip) | 369 | horizontallyScrollableContent(spacing: 6) { |
| 387 | if let org = geo.org { | 370 | labelRow("IP", geo.ip) |
| 388 | labelRow("Org / ISP", org) | 371 | if let org = geo.org { |
| 389 | } | 372 | labelRow("Org / ISP", org) |
| 390 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | 373 | } |
| 391 | if !location.isEmpty { | 374 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") |
| 392 | labelRow("Location", location) | 375 | if !location.isEmpty { |
| 376 | labelRow("Location", location) | ||
| 377 | } | ||
| 393 | } | 378 | } |
| 379 | |||
| 394 | if let lat = geo.latitude, let lon = geo.longitude { | 380 | if let lat = geo.latitude, let lon = geo.longitude { |
| 395 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) | 381 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) |
| 396 | Map(initialPosition: .region(MKCoordinateRegion( | 382 | Map(initialPosition: .region(MKCoordinateRegion( |
| @@ -400,6 +386,7 @@ struct HistoryDetailView: View { | |||
| 400 | Marker(geo.ip, coordinate: coordinate) | 386 | Marker(geo.ip, coordinate: coordinate) |
| 401 | } | 387 | } |
| 402 | .mapStyle(.standard) | 388 | .mapStyle(.standard) |
| 389 | .frame(maxWidth: .infinity) | ||
| 403 | .frame(height: 180) | 390 | .frame(height: 180) |
| 404 | .cornerRadius(8) | 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 | private func errorLabel(_ message: String) -> some View { | 497 | private func errorLabel(_ message: String) -> some View { |
| 485 | Label(message, systemImage: "exclamationmark.triangle.fill") | 498 | Label(message, systemImage: "exclamationmark.triangle.fill") |
| 486 | .font(.system(.caption, design: .monospaced)) | 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. | ||