Commit ede15c586a

ede15c586af71b8d6f2c190ffdad0324414cac06

parent: 376e7b55b4

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 21:17 UTC

fix: various bugs

- fix Email section to left align
- ensure container contents can overflow scroll but page cannot
- fix map width

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -265,7 +265,7 @@
265265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
266266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
267267 CODE_SIGN_STYLE = Automatic;
268 CURRENT_PROJECT_VERSION = 3;
268 CURRENT_PROJECT_VERSION = 4;
269269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
270270 ENABLE_PREVIEWS = YES;
271271 GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
282282 "$(inherited)",
283283 "@executable_path/Frameworks",
284284 );
285 MARKETING_VERSION = 1.2;
285 MARKETING_VERSION = 1.2.1;
286286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
287287 PRODUCT_NAME = "$(TARGET_NAME)";
288288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
301301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
302302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
303303 CODE_SIGN_STYLE = Automatic;
304 CURRENT_PROJECT_VERSION = 3;
304 CURRENT_PROJECT_VERSION = 4;
305305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
306306 ENABLE_PREVIEWS = YES;
307307 GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
318318 "$(inherited)",
319319 "@executable_path/Frameworks",
320320 );
321 MARKETING_VERSION = 1.2;
321 MARKETING_VERSION = 1.2.1;
322322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323323 PRODUCT_NAME = "$(TARGET_NAME)";
324324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +45 −32
@@ -7,7 +7,7 @@ struct ContentView: View {
77
88 var body: some View {
99 NavigationStack {
10 ScrollView {
10 ScrollView(.vertical) {
1111 VStack(spacing: 0) {
1212 inputSection
1313 if viewModel.hasRun {
@@ -225,7 +225,7 @@ struct ContentView: View {
225225 .background(Color(.systemGray6).opacity(0.5))
226226 .cornerRadius(6)
227227 } else {
228 VStack(alignment: .leading, spacing: 4) {
228 horizontallyScrollableCard {
229229 ForEach(viewModel.redirectChain) { hop in
230230 HStack(alignment: .top, spacing: 6) {
231231 Text("\(hop.stepNumber)")
@@ -248,9 +248,6 @@ struct ContentView: View {
248248 }
249249 }
250250 }
251 .padding(10)
252 .background(Color(.systemGray6).opacity(0.5))
253 .cornerRadius(6)
254251 }
255252 }
256253 .padding(.top, 16)
@@ -281,7 +278,7 @@ struct ContentView: View {
281278 }
282279
283280 private var ptrRow: some View {
284 VStack(alignment: .leading, spacing: 4) {
281 horizontallyScrollableCard {
285282 Text("PTR (Reverse DNS)")
286283 .font(.system(.subheadline, design: .monospaced))
287284 .fontWeight(.semibold)
@@ -302,13 +299,10 @@ struct ContentView: View {
302299 .foregroundStyle(.secondary)
303300 }
304301 }
305 .padding(10)
306 .background(Color(.systemGray6).opacity(0.5))
307 .cornerRadius(6)
308302 }
309303
310304 private func dnsRecordSection(_ section: DNSSection) -> some View {
311 VStack(alignment: .leading, spacing: 4) {
305 horizontallyScrollableCard {
312306 Text(section.recordType.rawValue)
313307 .font(.system(.subheadline, design: .monospaced))
314308 .fontWeight(.semibold)
@@ -334,9 +328,6 @@ struct ContentView: View {
334328 dnsRecordRows(section.wildcardRecords)
335329 }
336330 }
337 .padding(10)
338 .background(Color(.systemGray6).opacity(0.5))
339 .cornerRadius(6)
340331 }
341332
342333 private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
@@ -369,16 +360,14 @@ struct ContentView: View {
369360 } else if let error = viewModel.emailSecurityError {
370361 errorLabel(error)
371362 } else if let email = viewModel.emailSecurity {
372 VStack(alignment: .leading, spacing: 6) {
363 horizontallyScrollableCard(spacing: 6) {
373364 emailSecurityRow("SPF", record: email.spf)
374365 emailSecurityRow("DMARC", record: email.dmarc)
375366 emailSecurityRow("DKIM", record: email.dkim)
376367 }
377 .padding(10)
378 .background(Color(.systemGray6).opacity(0.5))
379 .cornerRadius(6)
380368 }
381369 }
370 .frame(maxWidth: .infinity, alignment: .leading)
382371 .padding(.top, 16)
383372 }
384373
@@ -434,7 +423,7 @@ struct ContentView: View {
434423 }
435424
436425 private func sslDetail(_ info: SSLCertificateInfo) -> some View {
437 VStack(alignment: .leading, spacing: 8) {
426 horizontallyScrollableCard(spacing: 8) {
438427 certRow("Common Name", info.commonName)
439428 certRow("Issuer", info.issuer)
440429
@@ -466,9 +455,6 @@ struct ContentView: View {
466455
467456 certRow("Chain Depth", "\(info.chainDepth)")
468457 }
469 .padding(10)
470 .background(Color(.systemGray6).opacity(0.5))
471 .cornerRadius(6)
472458 }
473459
474460 // MARK: - HTTP Headers
@@ -484,7 +470,7 @@ struct ContentView: View {
484470 } else if let error = viewModel.httpHeadersError {
485471 errorLabel(error)
486472 } else {
487 VStack(alignment: .leading, spacing: 4) {
473 horizontallyScrollableCard {
488474 ForEach(viewModel.httpHeaders) { header in
489475 HStack(alignment: .top, spacing: 4) {
490476 Text(header.name + ":")
@@ -497,9 +483,6 @@ struct ContentView: View {
497483 }
498484 }
499485 }
500 .padding(10)
501 .background(Color(.systemGray6).opacity(0.5))
502 .cornerRadius(6)
503486 }
504487 }
505488 .padding(.top, 16)
@@ -533,14 +516,17 @@ struct ContentView: View {
533516
534517 private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
535518 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 }
543528 }
529
544530 if let lat = geo.latitude, let lon = geo.longitude {
545531 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
546532 Map(initialPosition: .region(MKCoordinateRegion(
@@ -550,6 +536,7 @@ struct ContentView: View {
550536 Marker(geo.ip, coordinate: coordinate)
551537 }
552538 .mapStyle(.standard)
539 .frame(maxWidth: .infinity)
553540 .frame(height: 180)
554541 .cornerRadius(8)
555542 }
@@ -620,6 +607,32 @@ struct ContentView: View {
620607 }
621608 }
622609
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
623636 private func errorLabel(_ message: String) -> some View {
624637 Label(message, systemImage: "exclamationmark.triangle.fill")
625638 .font(.system(.caption, design: .monospaced))
DomainDig/HistoryView.swift +45 −32
@@ -65,7 +65,7 @@ struct HistoryDetailView: View {
6565 }()
6666
6767 var body: some View {
68 ScrollView {
68 ScrollView(.vertical) {
6969 VStack(alignment: .leading, spacing: 0) {
7070 cachedBanner
7171 reachabilitySection
@@ -157,7 +157,7 @@ struct HistoryDetailView: View {
157157 .background(Color(.systemGray6).opacity(0.5))
158158 .cornerRadius(6)
159159 } else {
160 VStack(alignment: .leading, spacing: 4) {
160 horizontallyScrollableCard {
161161 ForEach(entry.redirectChain) { hop in
162162 HStack(alignment: .top, spacing: 6) {
163163 Text("\(hop.stepNumber)")
@@ -180,9 +180,6 @@ struct HistoryDetailView: View {
180180 }
181181 }
182182 }
183 .padding(10)
184 .background(Color(.systemGray6).opacity(0.5))
185 .cornerRadius(6)
186183 }
187184 }
188185 }
@@ -195,7 +192,7 @@ struct HistoryDetailView: View {
195192 VStack(alignment: .leading, spacing: 12) {
196193 sectionHeader("DNS Records")
197194 ForEach(entry.dnsSections) { section in
198 VStack(alignment: .leading, spacing: 4) {
195 horizontallyScrollableCard {
199196 Text(section.recordType.rawValue)
200197 .font(.system(.subheadline, design: .monospaced))
201198 .fontWeight(.semibold)
@@ -220,12 +217,9 @@ struct HistoryDetailView: View {
220217 recordRows(section.wildcardRecords)
221218 }
222219 }
223 .padding(10)
224 .background(Color(.systemGray6).opacity(0.5))
225 .cornerRadius(6)
226220
227221 if section.recordType == .A {
228 VStack(alignment: .leading, spacing: 4) {
222 horizontallyScrollableCard {
229223 Text("PTR (Reverse DNS)")
230224 .font(.system(.subheadline, design: .monospaced))
231225 .fontWeight(.semibold)
@@ -242,9 +236,6 @@ struct HistoryDetailView: View {
242236 .foregroundStyle(.secondary)
243237 }
244238 }
245 .padding(10)
246 .background(Color(.systemGray6).opacity(0.5))
247 .cornerRadius(6)
248239 }
249240 }
250241 }
@@ -259,16 +250,14 @@ struct HistoryDetailView: View {
259250 VStack(alignment: .leading, spacing: 12) {
260251 if let email = entry.emailSecurity {
261252 sectionHeader("Email Security")
262 VStack(alignment: .leading, spacing: 6) {
253 horizontallyScrollableCard(spacing: 6) {
263254 historyEmailRow("SPF", record: email.spf)
264255 historyEmailRow("DMARC", record: email.dmarc)
265256 historyEmailRow("DKIM", record: email.dkim)
266257 }
267 .padding(10)
268 .background(Color(.systemGray6).opacity(0.5))
269 .cornerRadius(6)
270258 }
271259 }
260 .frame(maxWidth: .infinity, alignment: .leading)
272261 .padding(.top, 16)
273262 }
274263
@@ -310,7 +299,7 @@ struct HistoryDetailView: View {
310299 VStack(alignment: .leading, spacing: 12) {
311300 if let info = entry.sslInfo {
312301 sectionHeader("SSL / TLS Certificate")
313 VStack(alignment: .leading, spacing: 8) {
302 horizontallyScrollableCard(spacing: 8) {
314303 labelRow("Common Name", info.commonName)
315304 labelRow("Issuer", info.issuer)
316305
@@ -341,9 +330,6 @@ struct HistoryDetailView: View {
341330
342331 labelRow("Chain Depth", "\(info.chainDepth)")
343332 }
344 .padding(10)
345 .background(Color(.systemGray6).opacity(0.5))
346 .cornerRadius(6)
347333 }
348334 }
349335 .padding(.top, 16)
@@ -355,7 +341,7 @@ struct HistoryDetailView: View {
355341 VStack(alignment: .leading, spacing: 12) {
356342 if !entry.httpHeaders.isEmpty {
357343 sectionHeader("HTTP Headers")
358 VStack(alignment: .leading, spacing: 4) {
344 horizontallyScrollableCard {
359345 ForEach(entry.httpHeaders) { header in
360346 HStack(alignment: .top, spacing: 4) {
361347 Text(header.name + ":")
@@ -368,9 +354,6 @@ struct HistoryDetailView: View {
368354 }
369355 }
370356 }
371 .padding(10)
372 .background(Color(.systemGray6).opacity(0.5))
373 .cornerRadius(6)
374357 }
375358 }
376359 .padding(.top, 16)
@@ -383,14 +366,17 @@ struct HistoryDetailView: View {
383366 if let geo = entry.ipGeolocation {
384367 sectionHeader("IP Location")
385368 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 }
393378 }
379
394380 if let lat = geo.latitude, let lon = geo.longitude {
395381 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
396382 Map(initialPosition: .region(MKCoordinateRegion(
@@ -400,6 +386,7 @@ struct HistoryDetailView: View {
400386 Marker(geo.ip, coordinate: coordinate)
401387 }
402388 .mapStyle(.standard)
389 .frame(maxWidth: .infinity)
403390 .frame(height: 180)
404391 .cornerRadius(8)
405392 }
@@ -481,6 +468,32 @@ struct HistoryDetailView: View {
481468 }
482469 }
483470
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
484497 private func errorLabel(_ message: String) -> some View {
485498 Label(message, systemImage: "exclamationmark.triangle.fill")
486499 .font(.system(.caption, design: .monospaced))
claude.md deleted −171
@@ -1,171 +0,0 @@
1# DomainDig – Project Context
2
3## What this is
4An iOS utility app for querying DNS records, inspecting SSL/TLS certificates,
5checking HTTP headers, measuring TCP reachability, geolocating IP addresses,
6analyzing email security records, tracing redirect chains, performing reverse
7DNS lookups, and scanning common ports for any domain. Designed for developers,
8sysadmins, and anyone who manages or troubleshoots domains and servers.
9
10## Target user
11Technically literate adults — developers, IT/sysadmin, homelab enthusiasts.
12Comfortable with terms like A record, CNAME, MX, TTL, and TLS. The UI should be
13clean 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
29Query the following record types for any domain via DNS-over-HTTPS (use
30Cloudflare 1.1.1.1 — https://cloudflare-dns.com/dns-query):
31- A
32- AAAA
33- MX
34- NS
35- TXT
36- CNAME
37
38Display each record type in its own section. Show TTL alongside each result.
39If a record type returns no results, show "No records found" for that type
40rather than hiding the section entirely.
41
42After the apex query, also queries `*.{domain}` for A, AAAA, MX, and TXT.
43Wildcard results are shown as a sub-section beneath each record type, labelled
44with `*.{domain}`. Hidden if no wildcard records are returned.
45
46### SSL/TLS Certificate Check
47Connect to the domain on port 443 via URLSession and inspect the server's
48certificate 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
57Fire a HEAD request to `https://{domain}` and display all response headers.
58Header names in cyan, values in primary. Security-relevant headers highlighted
59in yellow: `strict-transport-security`, `x-frame-options`,
60`x-content-type-options`, `content-security-policy`, `referrer-policy`.
61Service: `HTTPHeadersService.swift`.
62
63### Reachability / TCP Latency
64Use `NWConnection` (Network framework) to attempt TCP connections on ports 443
65and 80. Measure time to `.ready` state. Display green/red dot, latency in ms,
66and reachable/unreachable status. Timeout after 5 seconds.
67Service: `ReachabilityService.swift`.
68
69### IP Geolocation with Map
70After DNS A record resolves, take the first IP and query
71`https://ipapi.co/{ip}/json/` for country, region, city, org, lat/lon.
72Display in an "IP Location" section with a SwiftUI `Map` (MapKit) centered on
73the coordinates with a `Marker`. Map height 180pt, `.standard` style.
74Service: `IPGeolocationService.swift`.
75
76### Email Security Analysis
77Parse SPF from existing TXT records (no extra query). Query `_dmarc.{domain}`
78for DMARC and try common DKIM selectors (`default`, `google`, `mail`) via DoH.
79Display green checkmark if found, red ✗ if not. Full record values truncated
80to 80 chars with tap-to-expand. Triggered after DNS completes.
81Service: `EmailSecurityService.swift`.
82
83### Reverse DNS / PTR Lookup
84After DNS A record resolves, construct reverse DNS name (reversed octets +
85`.in-addr.arpa`) and query PTR record via Cloudflare DoH. Displayed inline
86in the DNS Records section below the A record sub-section.
87Service: `ReverseDNSService.swift`.
88
89### Redirect Chain
90Fire HTTP request to `http://{domain}` with redirects disabled. Follow up to
9110 redirects manually, recording each hop's URL and status code. Display step
92number, status code in cyan, URL, and "(final)" on the last hop. Shows
93"No redirects — direct connection" if the first request returns 200.
94Service: `RedirectChainService.swift`.
95
96### Common Port Scanner
97Probe 10 common ports (21/FTP, 22/SSH, 25/SMTP, 80/HTTP, 443/HTTPS,
98587/SMTP-TLS, 3306/MySQL, 5432/PostgreSQL, 8080/HTTP-Alt, 8443/HTTPS-Alt)
99using `NWConnection` with 3-second timeout. All probes run concurrently.
100Green dot for open, grey dot for closed. Closed is expected — no error shown.
101Service: `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
115Store the last 20 searched domains locally using UserDefaults. Display them as
116a tappable list below the text field when no results are showing. Tapping a
117recent domain populates the text field and runs the lookup immediately. Include
118a "Clear" button to wipe history. Most recent at the top.
119
120### Saved domains
121Bookmark button (SF Symbol: `bookmark` / `bookmark.fill`) in the results
122toolbar area, next to the share button. Tapping saves/unsaves the current
123domain. Filled icon when saved. Saved domains viewable from a toolbar button
124that pushes to `SavedDomainsView` — a list of saved domains, tappable to run
125lookups, with swipe-to-delete and an Edit button for bulk deletion.
126Persisted in UserDefaults under key `savedDomains`.
127
128### Lookup history with cached results
129After each successful lookup, a snapshot of all results (DNS, SSL, HTTP headers,
130reachability, geolocation, email security, PTR, redirect chain, port scan) is
131saved to history in UserDefaults as JSON. Capped at 50 entries. `HistoryView`
132shows past lookups with domain and timestamp. Tapping shows full cached results
133in `HistoryDetailView` using the same layout, labelled as cached with the
134original timestamp. Model: `HistoryEntry` (Codable).
135
136### Share / export
137A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the
138results area. Formats the full results as plain text and presents the iOS share
139sheet via UIActivityViewController. The export includes: domain, timestamp,
140reachability, redirect chain, all DNS records with TTLs and PTR, email security
141records, SSL cert fields, HTTP headers, IP geolocation data, and port scan
142results.
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
165A developer pastes a domain, taps run, and within a couple of seconds sees a
166clean breakdown of TCP reachability, redirect chain, every DNS record type with
167reverse DNS, email security posture, the full SSL cert status, HTTP response
168headers with security headers highlighted, IP geolocation with a map, and a
169port scan of common services. It should feel like a native, polished version of
170running `dig`, `openssl s_client`, `curl -I`, `nmap`, and `whois` from the
171terminal.