Commit e11b7d29a6

e11b7d29a6aa9e52f63fcb35ee7ed9127850fffe

Verified · cmc

cmc <hello@cleberg.net> · 2026-03-12 03:01 UTC

initial commit

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj added +343
@@ -0,0 +1,343 @@
1// !$*UTF8*$!
2{
3 archiveVersion = 1;
4 classes = {
5 };
6 objectVersion = 77;
7 objects = {
8
9/* Begin PBXFileReference section */
10 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
11/* End PBXFileReference section */
12
13/* Begin PBXFileSystemSynchronizedRootGroup section */
14 8B78006B2F6090E300933221 /* DomainDig */ = {
15 isa = PBXFileSystemSynchronizedRootGroup;
16 path = DomainDig;
17 sourceTree = "<group>";
18 };
19/* End PBXFileSystemSynchronizedRootGroup section */
20
21/* Begin PBXFrameworksBuildPhase section */
22 8B7800662F6090E300933221 /* Frameworks */ = {
23 isa = PBXFrameworksBuildPhase;
24 buildActionMask = 2147483647;
25 files = (
26 );
27 runOnlyForDeploymentPostprocessing = 0;
28 };
29/* End PBXFrameworksBuildPhase section */
30
31/* Begin PBXGroup section */
32 8B7800602F6090E300933221 = {
33 isa = PBXGroup;
34 children = (
35 8B78006B2F6090E300933221 /* DomainDig */,
36 8B78006A2F6090E300933221 /* Products */,
37 );
38 sourceTree = "<group>";
39 };
40 8B78006A2F6090E300933221 /* Products */ = {
41 isa = PBXGroup;
42 children = (
43 8B7800692F6090E300933221 /* DomainDig.app */,
44 );
45 name = Products;
46 sourceTree = "<group>";
47 };
48/* End PBXGroup section */
49
50/* Begin PBXNativeTarget section */
51 8B7800682F6090E300933221 /* DomainDig */ = {
52 isa = PBXNativeTarget;
53 buildConfigurationList = 8B7800742F6090E400933221 /* Build configuration list for PBXNativeTarget "DomainDig" */;
54 buildPhases = (
55 8B7800652F6090E300933221 /* Sources */,
56 8B7800662F6090E300933221 /* Frameworks */,
57 8B7800672F6090E300933221 /* Resources */,
58 );
59 buildRules = (
60 );
61 dependencies = (
62 );
63 fileSystemSynchronizedGroups = (
64 8B78006B2F6090E300933221 /* DomainDig */,
65 );
66 name = DomainDig;
67 packageProductDependencies = (
68 );
69 productName = DomainDig;
70 productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
71 productType = "com.apple.product-type.application";
72 };
73/* End PBXNativeTarget section */
74
75/* Begin PBXProject section */
76 8B7800612F6090E300933221 /* Project object */ = {
77 isa = PBXProject;
78 attributes = {
79 BuildIndependentTargetsInParallel = 1;
80 LastSwiftUpdateCheck = 2630;
81 LastUpgradeCheck = 2630;
82 TargetAttributes = {
83 8B7800682F6090E300933221 = {
84 CreatedOnToolsVersion = 26.3;
85 };
86 };
87 };
88 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
89 developmentRegion = en;
90 hasScannedForEncodings = 0;
91 knownRegions = (
92 en,
93 Base,
94 );
95 mainGroup = 8B7800602F6090E300933221;
96 minimizedProjectReferenceProxies = 1;
97 preferredProjectObjectVersion = 77;
98 productRefGroup = 8B78006A2F6090E300933221 /* Products */;
99 projectDirPath = "";
100 projectRoot = "";
101 targets = (
102 8B7800682F6090E300933221 /* DomainDig */,
103 );
104 };
105/* End PBXProject section */
106
107/* Begin PBXResourcesBuildPhase section */
108 8B7800672F6090E300933221 /* Resources */ = {
109 isa = PBXResourcesBuildPhase;
110 buildActionMask = 2147483647;
111 files = (
112 );
113 runOnlyForDeploymentPostprocessing = 0;
114 };
115/* End PBXResourcesBuildPhase section */
116
117/* Begin PBXSourcesBuildPhase section */
118 8B7800652F6090E300933221 /* Sources */ = {
119 isa = PBXSourcesBuildPhase;
120 buildActionMask = 2147483647;
121 files = (
122 );
123 runOnlyForDeploymentPostprocessing = 0;
124 };
125/* End PBXSourcesBuildPhase section */
126
127/* Begin XCBuildConfiguration section */
128 8B7800722F6090E400933221 /* Debug */ = {
129 isa = XCBuildConfiguration;
130 buildSettings = {
131 ALWAYS_SEARCH_USER_PATHS = NO;
132 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
133 CLANG_ANALYZER_NONNULL = YES;
134 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
135 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
136 CLANG_ENABLE_MODULES = YES;
137 CLANG_ENABLE_OBJC_ARC = YES;
138 CLANG_ENABLE_OBJC_WEAK = YES;
139 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
140 CLANG_WARN_BOOL_CONVERSION = YES;
141 CLANG_WARN_COMMA = YES;
142 CLANG_WARN_CONSTANT_CONVERSION = YES;
143 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
144 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
145 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
146 CLANG_WARN_EMPTY_BODY = YES;
147 CLANG_WARN_ENUM_CONVERSION = YES;
148 CLANG_WARN_INFINITE_RECURSION = YES;
149 CLANG_WARN_INT_CONVERSION = YES;
150 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
151 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
152 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
153 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
154 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
155 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
156 CLANG_WARN_STRICT_PROTOTYPES = YES;
157 CLANG_WARN_SUSPICIOUS_MOVE = YES;
158 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
159 CLANG_WARN_UNREACHABLE_CODE = YES;
160 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
161 COPY_PHASE_STRIP = NO;
162 DEBUG_INFORMATION_FORMAT = dwarf;
163 DEVELOPMENT_TEAM = ZCNAX3VL9D;
164 ENABLE_STRICT_OBJC_MSGSEND = YES;
165 ENABLE_TESTABILITY = YES;
166 ENABLE_USER_SCRIPT_SANDBOXING = YES;
167 GCC_C_LANGUAGE_STANDARD = gnu17;
168 GCC_DYNAMIC_NO_PIC = NO;
169 GCC_NO_COMMON_BLOCKS = YES;
170 GCC_OPTIMIZATION_LEVEL = 0;
171 GCC_PREPROCESSOR_DEFINITIONS = (
172 "DEBUG=1",
173 "$(inherited)",
174 );
175 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
176 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
177 GCC_WARN_UNDECLARED_SELECTOR = YES;
178 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
179 GCC_WARN_UNUSED_FUNCTION = YES;
180 GCC_WARN_UNUSED_VARIABLE = YES;
181 IPHONEOS_DEPLOYMENT_TARGET = 26.2;
182 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
183 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
184 MTL_FAST_MATH = YES;
185 ONLY_ACTIVE_ARCH = YES;
186 SDKROOT = iphoneos;
187 SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
188 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
189 };
190 name = Debug;
191 };
192 8B7800732F6090E400933221 /* Release */ = {
193 isa = XCBuildConfiguration;
194 buildSettings = {
195 ALWAYS_SEARCH_USER_PATHS = NO;
196 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
197 CLANG_ANALYZER_NONNULL = YES;
198 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
199 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
200 CLANG_ENABLE_MODULES = YES;
201 CLANG_ENABLE_OBJC_ARC = YES;
202 CLANG_ENABLE_OBJC_WEAK = YES;
203 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
204 CLANG_WARN_BOOL_CONVERSION = YES;
205 CLANG_WARN_COMMA = YES;
206 CLANG_WARN_CONSTANT_CONVERSION = YES;
207 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
208 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
209 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
210 CLANG_WARN_EMPTY_BODY = YES;
211 CLANG_WARN_ENUM_CONVERSION = YES;
212 CLANG_WARN_INFINITE_RECURSION = YES;
213 CLANG_WARN_INT_CONVERSION = YES;
214 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
215 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
216 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
217 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
218 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
219 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
220 CLANG_WARN_STRICT_PROTOTYPES = YES;
221 CLANG_WARN_SUSPICIOUS_MOVE = YES;
222 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
223 CLANG_WARN_UNREACHABLE_CODE = YES;
224 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
225 COPY_PHASE_STRIP = NO;
226 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
227 DEVELOPMENT_TEAM = ZCNAX3VL9D;
228 ENABLE_NS_ASSERTIONS = NO;
229 ENABLE_STRICT_OBJC_MSGSEND = YES;
230 ENABLE_USER_SCRIPT_SANDBOXING = YES;
231 GCC_C_LANGUAGE_STANDARD = gnu17;
232 GCC_NO_COMMON_BLOCKS = YES;
233 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
234 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
235 GCC_WARN_UNDECLARED_SELECTOR = YES;
236 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
237 GCC_WARN_UNUSED_FUNCTION = YES;
238 GCC_WARN_UNUSED_VARIABLE = YES;
239 IPHONEOS_DEPLOYMENT_TARGET = 26.2;
240 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
241 MTL_ENABLE_DEBUG_INFO = NO;
242 MTL_FAST_MATH = YES;
243 SDKROOT = iphoneos;
244 SWIFT_COMPILATION_MODE = wholemodule;
245 VALIDATE_PRODUCT = YES;
246 };
247 name = Release;
248 };
249 8B7800752F6090E400933221 /* Debug */ = {
250 isa = XCBuildConfiguration;
251 buildSettings = {
252 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
253 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
254 CODE_SIGN_STYLE = Automatic;
255 CURRENT_PROJECT_VERSION = 1;
256 DEVELOPMENT_TEAM = ZCNAX3VL9D;
257 ENABLE_PREVIEWS = YES;
258 GENERATE_INFOPLIST_FILE = YES;
259 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
260 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
261 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
262 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
263 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
264 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
265 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
266 IPHONEOS_DEPLOYMENT_TARGET = 26.0;
267 LD_RUNPATH_SEARCH_PATHS = (
268 "$(inherited)",
269 "@executable_path/Frameworks",
270 );
271 MARKETING_VERSION = 1.0;
272 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
273 PRODUCT_NAME = "$(TARGET_NAME)";
274 STRING_CATALOG_GENERATE_SYMBOLS = YES;
275 SWIFT_APPROACHABLE_CONCURRENCY = YES;
276 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
277 SWIFT_EMIT_LOC_STRINGS = YES;
278 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
279 SWIFT_VERSION = 5.0;
280 TARGETED_DEVICE_FAMILY = "1,2";
281 };
282 name = Debug;
283 };
284 8B7800762F6090E400933221 /* Release */ = {
285 isa = XCBuildConfiguration;
286 buildSettings = {
287 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
288 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
289 CODE_SIGN_STYLE = Automatic;
290 CURRENT_PROJECT_VERSION = 1;
291 DEVELOPMENT_TEAM = ZCNAX3VL9D;
292 ENABLE_PREVIEWS = YES;
293 GENERATE_INFOPLIST_FILE = YES;
294 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
295 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
296 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
297 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
298 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
299 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
300 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
301 IPHONEOS_DEPLOYMENT_TARGET = 26.0;
302 LD_RUNPATH_SEARCH_PATHS = (
303 "$(inherited)",
304 "@executable_path/Frameworks",
305 );
306 MARKETING_VERSION = 1.0;
307 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
308 PRODUCT_NAME = "$(TARGET_NAME)";
309 STRING_CATALOG_GENERATE_SYMBOLS = YES;
310 SWIFT_APPROACHABLE_CONCURRENCY = YES;
311 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
312 SWIFT_EMIT_LOC_STRINGS = YES;
313 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
314 SWIFT_VERSION = 5.0;
315 TARGETED_DEVICE_FAMILY = "1,2";
316 };
317 name = Release;
318 };
319/* End XCBuildConfiguration section */
320
321/* Begin XCConfigurationList section */
322 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */ = {
323 isa = XCConfigurationList;
324 buildConfigurations = (
325 8B7800722F6090E400933221 /* Debug */,
326 8B7800732F6090E400933221 /* Release */,
327 );
328 defaultConfigurationIsVisible = 0;
329 defaultConfigurationName = Release;
330 };
331 8B7800742F6090E400933221 /* Build configuration list for PBXNativeTarget "DomainDig" */ = {
332 isa = XCConfigurationList;
333 buildConfigurations = (
334 8B7800752F6090E400933221 /* Debug */,
335 8B7800762F6090E400933221 /* Release */,
336 );
337 defaultConfigurationIsVisible = 0;
338 defaultConfigurationName = Release;
339 };
340/* End XCConfigurationList section */
341 };
342 rootObject = 8B7800612F6090E300933221 /* Project object */;
343}
DomainDig.xcodeproj/project.xcworkspace/contents.xcworkspacedata added +7
@@ -0,0 +1,7 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<Workspace
3 version = "1.0">
4 <FileRef
5 location = "self:">
6 </FileRef>
7</Workspace>
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist added +14
@@ -0,0 +1,14 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>SchemeUserState</key>
6 <dict>
7 <key>DomainDig.xcscheme_^#shared#^_</key>
8 <dict>
9 <key>orderHint</key>
10 <integer>0</integer>
11 </dict>
12 </dict>
13</dict>
14</plist>
DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json added +11
@@ -0,0 +1,11 @@
1{
2 "colors" : [
3 {
4 "idiom" : "universal"
5 }
6 ],
7 "info" : {
8 "author" : "xcode",
9 "version" : 1
10 }
11}
DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json added +36
@@ -0,0 +1,36 @@
1{
2 "images" : [
3 {
4 "filename" : "domaindig.png",
5 "idiom" : "universal",
6 "platform" : "ios",
7 "size" : "1024x1024"
8 },
9 {
10 "appearances" : [
11 {
12 "appearance" : "luminosity",
13 "value" : "dark"
14 }
15 ],
16 "idiom" : "universal",
17 "platform" : "ios",
18 "size" : "1024x1024"
19 },
20 {
21 "appearances" : [
22 {
23 "appearance" : "luminosity",
24 "value" : "tinted"
25 }
26 ],
27 "idiom" : "universal",
28 "platform" : "ios",
29 "size" : "1024x1024"
30 }
31 ],
32 "info" : {
33 "author" : "xcode",
34 "version" : 1
35 }
36}
DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png added

Binary file not shown.

DomainDig/Assets.xcassets/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "info" : {
3 "author" : "xcode",
4 "version" : 1
5 }
6}
DomainDig/ContentView.swift added +309
@@ -0,0 +1,309 @@
1import SwiftUI
2
3struct ContentView: View {
4 @State private var viewModel = DomainViewModel()
5 @FocusState private var domainFieldFocused: Bool
6
7 var body: some View {
8 NavigationStack {
9 ScrollView {
10 VStack(spacing: 0) {
11 inputSection
12 if viewModel.hasRun {
13 if viewModel.resultsLoaded {
14 HStack {
15 Spacer()
16 Button {
17 shareResults()
18 } label: {
19 Image(systemName: "square.and.arrow.up")
20 .font(.system(.body))
21 .foregroundStyle(.secondary)
22 }
23 }
24 .padding(.top, 8)
25 }
26 dnsResultsSection
27 sslResultsSection
28 } else if !viewModel.recentSearches.isEmpty {
29 recentSearchesSection
30 }
31 }
32 .padding(.horizontal)
33 .padding(.bottom, 32)
34 }
35 .background(Color.black)
36 .navigationTitle("DomainDig")
37 .toolbarColorScheme(.dark, for: .navigationBar)
38 .preferredColorScheme(.dark)
39 }
40 .onAppear {
41 domainFieldFocused = true
42 }
43 }
44
45 // MARK: - Input
46
47 private var inputSection: some View {
48 VStack(spacing: 12) {
49 TextField("e.g. cleberg.net", text: $viewModel.domain)
50 .font(.system(.title3, design: .monospaced))
51 .textInputAutocapitalization(.never)
52 .autocorrectionDisabled()
53 .keyboardType(.URL)
54 .padding(12)
55 .background(Color(.systemGray6))
56 .cornerRadius(8)
57 .focused($domainFieldFocused)
58 .onSubmit { viewModel.run() }
59
60 Button {
61 domainFieldFocused = false
62 viewModel.run()
63 } label: {
64 Text("Run")
65 .font(.headline)
66 .frame(maxWidth: .infinity)
67 .padding(.vertical, 12)
68 }
69 .buttonStyle(.borderedProminent)
70 .disabled(viewModel.trimmedDomain.isEmpty)
71 }
72 .padding(.vertical, 16)
73 }
74
75 // MARK: - Recent Searches
76
77 private var recentSearchesSection: some View {
78 VStack(alignment: .leading, spacing: 8) {
79 HStack {
80 Text("RECENT")
81 .font(.system(.caption2, design: .monospaced))
82 .foregroundStyle(.secondary)
83 Spacer()
84 Button("Clear") {
85 viewModel.clearRecentSearches()
86 }
87 .font(.system(.caption2, design: .monospaced))
88 .foregroundStyle(.secondary)
89 }
90
91 ForEach(viewModel.recentSearches, id: \.self) { domain in
92 Button {
93 viewModel.domain = domain
94 domainFieldFocused = false
95 viewModel.run()
96 } label: {
97 Text(domain)
98 .font(.system(.callout, design: .monospaced))
99 .foregroundStyle(.primary)
100 .frame(maxWidth: .infinity, alignment: .leading)
101 .padding(.vertical, 6)
102 .padding(.horizontal, 10)
103 .background(Color(.systemGray6).opacity(0.5))
104 .cornerRadius(6)
105 }
106 }
107 }
108 .padding(.top, 8)
109 }
110
111 // MARK: - DNS Results
112
113 private var dnsResultsSection: some View {
114 VStack(alignment: .leading, spacing: 12) {
115 sectionHeader("DNS Records")
116
117 if viewModel.dnsLoading {
118 ProgressView("Querying DNS…")
119 .frame(maxWidth: .infinity, alignment: .center)
120 .padding()
121 } else if let error = viewModel.dnsError {
122 errorLabel(error)
123 } else {
124 ForEach(viewModel.dnsSections) { section in
125 dnsRecordSection(section)
126 }
127 }
128 }
129 .padding(.top, 8)
130 }
131
132 private func dnsRecordSection(_ section: DNSSection) -> some View {
133 VStack(alignment: .leading, spacing: 4) {
134 Text(section.recordType.rawValue)
135 .font(.system(.subheadline, design: .monospaced))
136 .fontWeight(.semibold)
137 .foregroundStyle(.cyan)
138
139 if let error = section.error {
140 errorLabel(error)
141 } else if section.records.isEmpty {
142 Text("No records found")
143 .font(.system(.caption, design: .monospaced))
144 .foregroundStyle(.secondary)
145 } else {
146 dnsRecordRows(section.records)
147 }
148
149 // Wildcard sub-section (only shown when records exist)
150 if !section.wildcardRecords.isEmpty {
151 Text("*.\(viewModel.searchedDomain)")
152 .font(.system(.caption, design: .monospaced))
153 .fontWeight(.medium)
154 .foregroundStyle(.cyan.opacity(0.7))
155 .padding(.top, 4)
156
157 dnsRecordRows(section.wildcardRecords)
158 }
159 }
160 .padding(10)
161 .background(Color(.systemGray6).opacity(0.5))
162 .cornerRadius(6)
163 }
164
165 private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
166 ForEach(records) { record in
167 HStack(alignment: .top) {
168 Text(record.value)
169 .font(.system(.caption, design: .monospaced))
170 .foregroundStyle(.primary)
171 .textSelection(.enabled)
172 Spacer()
173 Text("TTL \(record.ttl)")
174 .font(.system(.caption2, design: .monospaced))
175 .foregroundStyle(.secondary)
176 }
177 }
178 }
179
180 // MARK: - SSL Results
181
182 private var sslResultsSection: some View {
183 VStack(alignment: .leading, spacing: 12) {
184 sectionHeader("SSL / TLS Certificate")
185
186 if viewModel.sslLoading {
187 ProgressView("Checking certificate…")
188 .frame(maxWidth: .infinity, alignment: .center)
189 .padding()
190 } else if let error = viewModel.sslError {
191 errorLabel(error)
192 } else if let info = viewModel.sslInfo {
193 sslDetail(info)
194 }
195 }
196 .padding(.top, 16)
197 }
198
199 private func sslDetail(_ info: SSLCertificateInfo) -> some View {
200 VStack(alignment: .leading, spacing: 8) {
201 certRow("Common Name", info.commonName)
202 certRow("Issuer", info.issuer)
203
204 // SANs
205 VStack(alignment: .leading, spacing: 2) {
206 Text("SANs")
207 .font(.system(.caption2, design: .monospaced))
208 .foregroundStyle(.secondary)
209 ForEach(info.subjectAltNames, id: \.self) { san in
210 Text(san)
211 .font(.system(.caption, design: .monospaced))
212 .textSelection(.enabled)
213 }
214 }
215
216 let formatter = DateFormatter.certDate
217 certRow("Valid From", formatter.string(from: info.validFrom))
218 certRow("Valid Until", formatter.string(from: info.validUntil))
219
220 HStack {
221 Text("Days Until Expiry")
222 .font(.system(.caption2, design: .monospaced))
223 .foregroundStyle(.secondary)
224 Spacer()
225 Text("\(info.daysUntilExpiry)")
226 .font(.system(.caption, design: .monospaced))
227 .fontWeight(.bold)
228 .foregroundStyle(expiryColor(info.daysUntilExpiry))
229 }
230
231 certRow("Chain Depth", "\(info.chainDepth)")
232 }
233 .padding(10)
234 .background(Color(.systemGray6).opacity(0.5))
235 .cornerRadius(6)
236 }
237
238 // MARK: - Helpers
239
240 private func sectionHeader(_ title: String) -> some View {
241 Text(title)
242 .font(.system(.headline, design: .default))
243 .foregroundStyle(.white)
244 }
245
246 private func certRow(_ label: String, _ value: String) -> some View {
247 VStack(alignment: .leading, spacing: 2) {
248 Text(label)
249 .font(.system(.caption2, design: .monospaced))
250 .foregroundStyle(.secondary)
251 Text(value)
252 .font(.system(.caption, design: .monospaced))
253 .textSelection(.enabled)
254 }
255 }
256
257 private func errorLabel(_ message: String) -> some View {
258 Label(message, systemImage: "exclamationmark.triangle.fill")
259 .font(.system(.caption, design: .monospaced))
260 .foregroundStyle(.red)
261 .padding(8)
262 }
263
264 private func expiryColor(_ days: Int) -> Color {
265 if days < 30 { return .red }
266 if days < 60 { return .yellow }
267 return .green
268 }
269
270 private func shareResults() {
271 let text = viewModel.exportText()
272
273 // Write to a named temp file so "Save to Files" uses a proper filename
274 let dateFmt = DateFormatter()
275 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
276 let timestamp = dateFmt.string(from: Date())
277 let filename = "\(timestamp)_domaindigresults.txt"
278 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
279
280 do {
281 try text.write(to: tempURL, atomically: true, encoding: .utf8)
282 } catch {
283 return
284 }
285
286 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
287 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
288 let rootVC = windowScene.keyWindow?.rootViewController else { return }
289 var presenter = rootVC
290 while let presented = presenter.presentedViewController {
291 presenter = presented
292 }
293 activityVC.popoverPresentationController?.sourceView = presenter.view
294 presenter.present(activityVC, animated: true)
295 }
296}
297
298private extension DateFormatter {
299 static let certDate: DateFormatter = {
300 let f = DateFormatter()
301 f.dateStyle = .medium
302 f.timeStyle = .short
303 return f
304 }()
305}
306
307#Preview {
308 ContentView()
309}
DomainDig/DNSLookupService.swift added +89
@@ -0,0 +1,89 @@
1import Foundation
2
3struct DNSLookupService {
4 private static let baseURL = "https://cloudflare-dns.com/dns-query"
5
6 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
7 var components = URLComponents(string: baseURL)!
8 components.queryItems = [
9 URLQueryItem(name: "name", value: domain),
10 URLQueryItem(name: "type", value: String(recordType.queryType))
11 ]
12
13 var request = URLRequest(url: components.url!)
14 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
15
16 let (data, response) = try await URLSession.shared.data(for: request)
17
18 guard let httpResponse = response as? HTTPURLResponse,
19 httpResponse.statusCode == 200 else {
20 throw URLError(.badServerResponse)
21 }
22
23 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
24
25 guard let answers = dnsResponse.Answer else {
26 return []
27 }
28
29 // Filter answers to only include the requested type
30 return answers
31 .filter { $0.type == recordType.queryType }
32 .map { answer in
33 let value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
34 return DNSRecord(value: value, ttl: answer.TTL)
35 }
36 }
37
38 /// Record types that support wildcard queries.
39 private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
40
41 static func lookupAll(domain: String) async -> [DNSSection] {
42 // Each task returns (recordType, apex records, wildcard records).
43 typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?)
44
45 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
46 for recordType in DNSRecordType.allCases {
47 group.addTask {
48 var apexRecords: [DNSRecord] = []
49 var wildcardRecords: [DNSRecord] = []
50 var lookupError: String?
51
52 // Apex query
53 do {
54 apexRecords = try await lookup(domain: domain, recordType: recordType)
55 } catch {
56 lookupError = error.localizedDescription
57 }
58
59 // Wildcard query (only for applicable types, and only if apex didn't fail)
60 if wildcardTypes.contains(recordType) && lookupError == nil {
61 do {
62 wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType)
63 } catch {
64 // Wildcard failure is non-fatal; just leave empty
65 }
66 }
67
68 return (recordType, apexRecords, wildcardRecords, lookupError)
69 }
70 }
71
72 var sections: [DNSSection] = []
73 for await result in group {
74 sections.append(DNSSection(
75 recordType: result.type,
76 records: result.records,
77 wildcardRecords: result.wildcard,
78 error: result.error
79 ))
80 }
81
82 // Sort to maintain consistent order
83 let order = DNSRecordType.allCases
84 return sections.sorted { a, b in
85 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
86 }
87 }
88 }
89}
DomainDig/DomainDigApp.swift added +17
@@ -0,0 +1,17 @@
1//
2// DomainDigApp.swift
3// DomainDig
4//
5// Created by cmc on 2026-03-10.
6//
7
8import SwiftUI
9
10@main
11struct DomainDigApp: App {
12 var body: some Scene {
13 WindowGroup {
14 ContentView()
15 }
16 }
17}
DomainDig/DomainViewModel.swift added +157
@@ -0,0 +1,157 @@
1import Foundation
2import SwiftUI
3
4@MainActor
5@Observable
6final class DomainViewModel {
7 var domain: String = ""
8
9 var dnsSections: [DNSSection] = []
10 var dnsLoading = false
11 var dnsError: String?
12
13 var sslInfo: SSLCertificateInfo?
14 var sslLoading = false
15 var sslError: String?
16
17 var hasRun = false
18 private(set) var searchedDomain: String = ""
19
20 private static let recentSearchesKey = "recentSearches"
21 private static let maxRecent = 20
22
23 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
24
25 var trimmedDomain: String {
26 domain
27 .trimmingCharacters(in: .whitespacesAndNewlines)
28 .replacingOccurrences(of: "https://", with: "")
29 .replacingOccurrences(of: "http://", with: "")
30 .components(separatedBy: "/").first ?? ""
31 }
32
33 func run() {
34 let target = trimmedDomain
35 guard !target.isEmpty else { return }
36
37 addRecentSearch(target)
38 searchedDomain = target
39 hasRun = true
40 dnsSections = []
41 dnsError = nil
42 dnsLoading = true
43 sslInfo = nil
44 sslError = nil
45 sslLoading = true
46
47 Task {
48 await withTaskGroup(of: Void.self) { group in
49 group.addTask { @MainActor in
50 await self.runDNS(domain: target)
51 }
52 group.addTask { @MainActor in
53 await self.runSSL(domain: target)
54 }
55 }
56 }
57 }
58
59 private func runDNS(domain: String) async {
60 do {
61 let sections = await DNSLookupService.lookupAll(domain: domain)
62 dnsSections = sections
63 }
64 dnsLoading = false
65 }
66
67 private func runSSL(domain: String) async {
68 do {
69 let info = try await SSLCheckService.check(domain: domain)
70 sslInfo = info
71 } catch {
72 sslError = error.localizedDescription
73 }
74 sslLoading = false
75 }
76
77 /// True when both lookups have finished (regardless of success/failure).
78 var resultsLoaded: Bool {
79 hasRun && !dnsLoading && !sslLoading
80 }
81
82 // MARK: - Export
83
84 func exportText() -> String {
85 let dateFmt = DateFormatter()
86 dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
87 let now = dateFmt.string(from: Date())
88
89 var lines: [String] = [
90 "DomainDig Export",
91 "Domain: \(searchedDomain)",
92 "Date: \(now)",
93 "",
94 "DNS Records",
95 "-----------"
96 ]
97
98 for section in dnsSections {
99 lines.append(section.recordType.rawValue)
100 if let error = section.error {
101 lines.append(" Error: \(error)")
102 } else if section.records.isEmpty {
103 lines.append(" No records found")
104 } else {
105 for record in section.records {
106 lines.append(" \(record.value) TTL \(record.ttl)")
107 }
108 }
109 if !section.wildcardRecords.isEmpty {
110 lines.append("*.\(searchedDomain)")
111 for record in section.wildcardRecords {
112 lines.append(" \(record.value) TTL \(record.ttl)")
113 }
114 }
115 }
116
117 if let info = sslInfo {
118 let certDateFmt = DateFormatter()
119 certDateFmt.dateStyle = .medium
120 certDateFmt.timeStyle = .none
121
122 lines.append("")
123 lines.append("SSL / TLS Certificate")
124 lines.append("---------------------")
125 lines.append("Common Name: \(info.commonName)")
126 lines.append("Issuer: \(info.issuer)")
127 lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))")
128 lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))")
129 lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
130 lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
131 lines.append("Chain Depth: \(info.chainDepth)")
132 } else if let error = sslError {
133 lines.append("")
134 lines.append("SSL / TLS Certificate")
135 lines.append("---------------------")
136 lines.append("Error: \(error)")
137 }
138
139 return lines.joined(separator: "\n")
140 }
141
142 // MARK: - Recent Searches
143
144 private func addRecentSearch(_ domain: String) {
145 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
146 recentSearches.insert(domain, at: 0)
147 if recentSearches.count > Self.maxRecent {
148 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
149 }
150 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
151 }
152
153 func clearRecentSearches() {
154 recentSearches.removeAll()
155 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
156 }
157}
DomainDig/Models.swift added +63
@@ -0,0 +1,63 @@
1import Foundation
2
3// MARK: - DNS Models
4
5enum DNSRecordType: String, CaseIterable {
6 case A
7 case AAAA
8 case MX
9 case NS
10 case TXT
11 case CNAME
12
13 var queryType: Int {
14 switch self {
15 case .A: return 1
16 case .AAAA: return 28
17 case .MX: return 15
18 case .NS: return 2
19 case .TXT: return 16
20 case .CNAME: return 5
21 }
22 }
23}
24
25struct DNSRecord: Identifiable {
26 let id = UUID()
27 let value: String
28 let ttl: Int
29}
30
31struct DNSSection: Identifiable {
32 let id = UUID()
33 let recordType: DNSRecordType
34 var records: [DNSRecord]
35 var wildcardRecords: [DNSRecord] = []
36 var error: String?
37}
38
39// MARK: - SSL Models
40
41struct SSLCertificateInfo {
42 let commonName: String
43 let subjectAltNames: [String]
44 let issuer: String
45 let validFrom: Date
46 let validUntil: Date
47 let daysUntilExpiry: Int
48 let chainDepth: Int
49}
50
51// MARK: - Cloudflare DNS-over-HTTPS Response
52
53struct CloudflareDNSResponse: Decodable {
54 let Status: Int
55 let Answer: [CloudflareDNSAnswer]?
56
57 struct CloudflareDNSAnswer: Decodable {
58 let name: String
59 let type: Int
60 let TTL: Int
61 let data: String
62 }
63}
DomainDig/SSLCheckService.swift added +322
@@ -0,0 +1,322 @@
1import Foundation
2import Security
3
4struct SSLCheckService {
5
6 static func check(domain: String) async throws -> SSLCertificateInfo {
7 let delegate = SSLSessionDelegate()
8 let session = URLSession(
9 configuration: .ephemeral,
10 delegate: delegate,
11 delegateQueue: nil
12 )
13 defer { session.invalidateAndCancel() }
14
15 let url = URL(string: "https://\(domain)")!
16 let request = URLRequest(url: url, timeoutInterval: 10)
17
18 // We only need to establish the connection to grab the cert
19 _ = try await session.data(for: request)
20
21 guard let trust = delegate.serverTrust else {
22 throw SSLError.noCertificate
23 }
24
25 return try extractCertificateInfo(from: trust)
26 }
27
28 private static func extractCertificateInfo(from trust: SecTrust) throws -> SSLCertificateInfo {
29 let chainCount = SecTrustGetCertificateCount(trust)
30 guard chainCount > 0,
31 let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
32 let leaf = certChain.first else {
33 throw SSLError.noCertificate
34 }
35
36 // Common Name — use subject summary (available on iOS)
37 let commonName = SecCertificateCopySubjectSummary(leaf) as String? ?? "Unknown"
38
39 // Validity dates
40 let validFrom: Date
41 let validUntil: Date
42
43 if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
44 validFrom = notBefore
45 } else {
46 validFrom = Date.distantPast
47 }
48
49 if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
50 validUntil = notAfter
51 } else {
52 validUntil = Date.distantFuture
53 }
54
55 let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
56
57 // Parse the DER-encoded certificate to extract SANs and Issuer
58 let derData = SecCertificateCopyData(leaf) as Data
59 let parsed = DERCertificateParser.parse(derData)
60
61 let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
62
63 // Issuer: prefer parsed issuer, fall back to chain's next cert summary
64 var issuer = parsed.issuerCommonName ?? "Unknown"
65 if issuer == "Unknown" && certChain.count > 1 {
66 let issuerCert = certChain[1]
67 if let issuerSummary = SecCertificateCopySubjectSummary(issuerCert) as String? {
68 issuer = issuerSummary
69 }
70 }
71
72 return SSLCertificateInfo(
73 commonName: commonName,
74 subjectAltNames: sans,
75 issuer: issuer,
76 validFrom: validFrom,
77 validUntil: validUntil,
78 daysUntilExpiry: daysUntilExpiry,
79 chainDepth: Int(chainCount)
80 )
81 }
82}
83
84// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
85
86private enum DERCertificateParser {
87 struct Result {
88 var issuerCommonName: String?
89 var subjectAltNames: [String] = []
90 }
91
92 static func parse(_ data: Data) -> Result {
93 var result = Result()
94 let bytes = [UInt8](data)
95
96 // X.509 structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
97 // tbsCertificate: SEQUENCE { version, serialNumber, signature, issuer, validity, subject, ... extensions }
98 guard let tbsRange = readSequence(bytes, offset: 0),
99 let tbsContent = readSequence(bytes, offset: tbsRange.contentStart) else {
100 return result
101 }
102
103 var offset = tbsContent.contentStart
104
105 // Skip version (explicit tag [0]) if present
106 if offset < bytes.count && (bytes[offset] & 0xE0) == 0xA0 {
107 if let tagLen = readTagAndLength(bytes, offset: offset) {
108 offset = tagLen.contentStart + tagLen.length
109 }
110 }
111
112 // Skip serialNumber
113 if let serial = readTagAndLength(bytes, offset: offset) {
114 offset = serial.contentStart + serial.length
115 }
116
117 // Skip signature algorithm
118 if let sigAlg = readTagAndLength(bytes, offset: offset) {
119 offset = sigAlg.contentStart + sigAlg.length
120 }
121
122 // Issuer — a SEQUENCE of SETs of attribute type-value pairs
123 if let issuerSeq = readTagAndLength(bytes, offset: offset) {
124 result.issuerCommonName = extractCommonName(bytes, sequenceStart: issuerSeq.contentStart, length: issuerSeq.length)
125 offset = issuerSeq.contentStart + issuerSeq.length
126 }
127
128 // Skip validity
129 if let validity = readTagAndLength(bytes, offset: offset) {
130 offset = validity.contentStart + validity.length
131 }
132
133 // Skip subject
134 if let subject = readTagAndLength(bytes, offset: offset) {
135 offset = subject.contentStart + subject.length
136 }
137
138 // Skip subjectPublicKeyInfo
139 if let spki = readTagAndLength(bytes, offset: offset) {
140 offset = spki.contentStart + spki.length
141 }
142
143 // Extensions are in an explicit tag [3]
144 while offset < tbsContent.contentStart + tbsContent.length {
145 if bytes[offset] == 0xA3 {
146 if let extWrapper = readTagAndLength(bytes, offset: offset) {
147 // Inside is a SEQUENCE of SEQUENCE extensions
148 if let extsSeq = readTagAndLength(bytes, offset: extWrapper.contentStart) {
149 result.subjectAltNames = extractSANs(bytes, sequenceStart: extsSeq.contentStart, length: extsSeq.length)
150 }
151 }
152 break
153 }
154 // Skip optional implicit tags (issuerUniqueID [1], subjectUniqueID [2])
155 if let tl = readTagAndLength(bytes, offset: offset) {
156 offset = tl.contentStart + tl.length
157 } else {
158 break
159 }
160 }
161
162 return result
163 }
164
165 // OID for commonName: 2.5.4.3 = 55 04 03
166 private static let cnOID: [UInt8] = [0x55, 0x04, 0x03]
167
168 // OID for subjectAltName: 2.5.29.17 = 55 1D 11
169 private static let sanOID: [UInt8] = [0x55, 0x1D, 0x11]
170
171 private static func extractCommonName(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> String? {
172 let end = sequenceStart + length
173 var pos = sequenceStart
174 while pos < end {
175 // Each SET in the issuer
176 guard let setTL = readTagAndLength(bytes, offset: pos) else { break }
177 let setEnd = setTL.contentStart + setTL.length
178
179 // Inside the SET is a SEQUENCE with OID + value
180 if let seqTL = readTagAndLength(bytes, offset: setTL.contentStart) {
181 let seqEnd = seqTL.contentStart + seqTL.length
182 if let oidTL = readTagAndLength(bytes, offset: seqTL.contentStart) {
183 let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
184 if oidBytes == cnOID {
185 let valueStart = oidTL.contentStart + oidTL.length
186 if let valueTL = readTagAndLength(bytes, offset: valueStart) {
187 let strBytes = bytes[valueTL.contentStart..<valueTL.contentStart + valueTL.length]
188 return String(bytes: strBytes, encoding: .utf8)
189 }
190 }
191 _ = seqEnd // suppress unused warning
192 }
193 }
194 pos = setEnd
195 }
196 return nil
197 }
198
199 private static func extractSANs(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> [String] {
200 let end = sequenceStart + length
201 var pos = sequenceStart
202 var sans: [String] = []
203
204 while pos < end {
205 guard let extSeq = readTagAndLength(bytes, offset: pos) else { break }
206 let extEnd = extSeq.contentStart + extSeq.length
207
208 // Each extension is SEQUENCE { OID, [critical], value }
209 if let oidTL = readTagAndLength(bytes, offset: extSeq.contentStart) {
210 let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
211 if oidBytes == sanOID {
212 var valuePos = oidTL.contentStart + oidTL.length
213 // Skip optional critical BOOLEAN
214 if valuePos < extEnd && bytes[valuePos] == 0x01 {
215 if let boolTL = readTagAndLength(bytes, offset: valuePos) {
216 valuePos = boolTL.contentStart + boolTL.length
217 }
218 }
219 // The value is an OCTET STRING wrapping a SEQUENCE of GeneralNames
220 if let octetTL = readTagAndLength(bytes, offset: valuePos) {
221 if let sanSeq = readTagAndLength(bytes, offset: octetTL.contentStart) {
222 let sanEnd = sanSeq.contentStart + sanSeq.length
223 var sanPos = sanSeq.contentStart
224 while sanPos < sanEnd {
225 guard let nameTL = readTagAndLength(bytes, offset: sanPos) else { break }
226 // Context tag [2] = dNSName (IA5String)
227 if (bytes[sanPos] & 0x1F) == 2 {
228 let nameBytes = bytes[nameTL.contentStart..<nameTL.contentStart + nameTL.length]
229 if let name = String(bytes: nameBytes, encoding: .ascii) {
230 sans.append(name)
231 }
232 }
233 sanPos = nameTL.contentStart + nameTL.length
234 }
235 }
236 }
237 }
238 }
239 pos = extEnd
240 }
241 return sans
242 }
243
244 private struct TLV {
245 let contentStart: Int
246 let length: Int
247 }
248
249 private static func readSequence(_ bytes: [UInt8], offset: Int) -> TLV? {
250 guard offset < bytes.count, bytes[offset] == 0x30 else { return nil }
251 return readTagAndLength(bytes, offset: offset)
252 }
253
254 private static func readTagAndLength(_ bytes: [UInt8], offset: Int) -> TLV? {
255 guard offset < bytes.count else { return nil }
256 var pos = offset + 1 // skip tag byte
257 guard pos < bytes.count else { return nil }
258
259 let firstLen = bytes[pos]
260 pos += 1
261
262 let length: Int
263 if firstLen < 0x80 {
264 length = Int(firstLen)
265 } else {
266 let numBytes = Int(firstLen & 0x7F)
267 guard numBytes > 0, numBytes <= 4, pos + numBytes <= bytes.count else { return nil }
268 var len = 0
269 for i in 0..<numBytes {
270 len = (len << 8) | Int(bytes[pos + i])
271 }
272 pos += numBytes
273 length = len
274 }
275
276 return TLV(contentStart: pos, length: length)
277 }
278}
279
280enum SSLError: LocalizedError {
281 case noCertificate
282 case connectionFailed
283
284 var errorDescription: String? {
285 switch self {
286 case .noCertificate:
287 return "No certificate found"
288 case .connectionFailed:
289 return "Failed to connect to server"
290 }
291 }
292}
293
294final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
295 private let lock = NSLock()
296 private var _serverTrust: SecTrust?
297
298 var serverTrust: SecTrust? {
299 lock.lock()
300 defer { lock.unlock() }
301 return _serverTrust
302 }
303
304 func urlSession(
305 _ session: URLSession,
306 didReceive challenge: URLAuthenticationChallenge,
307 completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
308 ) {
309 guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
310 let trust = challenge.protectionSpace.serverTrust else {
311 completionHandler(.performDefaultHandling, nil)
312 return
313 }
314
315 lock.lock()
316 _serverTrust = trust
317 lock.unlock()
318
319 let credential = URLCredential(trust: trust)
320 completionHandler(.useCredential, credential)
321 }
322}
LICENSE added +21
@@ -0,0 +1,21 @@
1MIT License
2
3Copyright (c) 2026 Christian Cleberg
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
claude.md added +79
@@ -0,0 +1,79 @@
1# DomainDig – Project Context
2
3## What this is
4An iOS utility app for querying DNS records and inspecting SSL/TLS certificates
5for any domain. Designed for developers, sysadmins, and anyone who manages or
6troubleshoots domains and servers.
7
8## Target user
9Technically literate adults — developers, IT/sysadmin, homelab enthusiasts.
10Comfortable with terms like A record, CNAME, MX, TTL, and TLS. The UI should be
11clean and information-dense, not hand-holding.
12
13## Design principles
14- Minimal, utilitarian UI — this is a tool, not a consumer app
15- Results should be readable at a glance — use clear labels, monospace where
16 appropriate for IPs and raw values
17- Dark mode preferred as the primary aesthetic
18- No onboarding, no tutorials, no splash screens
19- Fast — results should appear as soon as they're available, not after all
20 lookups complete
21
22## Features
23
24### DNS Lookup
25Query the following record types for any domain via DNS-over-HTTPS (use
26Cloudflare 1.1.1.1 — https://cloudflare-dns.com/dns-query):
27- A
28- AAAA
29- MX
30- NS
31- TXT
32- CNAME
33
34Display each record type in its own section. Show TTL alongside each result.
35If a record type returns no results, show "No records found" for that type
36rather than hiding the section entirely.
37
38### SSL/TLS Certificate Check
39Connect to the domain on port 443 via URLSession and inspect the server's
40certificate chain using URLSession delegate methods. Display:
41- Common name (CN)
42- Subject Alternative Names (SANs)
43- Issuer
44- Valid from / Valid until dates
45- Days until expiry — highlight in red if under 30 days, yellow if under 60
46- Certificate chain depth
47
48### Results layout
49- Domain input at the top — large text field, keyboard shows on launch
50- Run button to trigger both DNS and SSL lookups simultaneously
51- DNS and SSL results displayed in clearly separated sections below
52- Each section loads independently — don't block SSL results waiting for DNS
53 or vice versa
54
55## Technical constraints
56- SwiftUI, iOS only
57- Fully offline except for DNS-over-HTTPS requests and SSL connections
58- No accounts, no analytics, no ads
59- No third-party dependencies — URLSession and Network framework only
60- Targets latest iOS
61
62### Recent searches
63Store the last 20 searched domains locally using UserDefaults. Display them as
64a tappable list below the text field when no results are showing. Tapping a
65recent domain populates the text field and runs the lookup immediately. Include
66a "Clear" button to wipe history. Most recent at the top.
67
68### Share / export
69A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the
70results area. Formats the full DNS and SSL results as plain text and presents
71the iOS share sheet via ShareLink or UIActivityViewController. The export
72should include the domain, timestamp, all DNS records with TTLs, and all SSL
73cert fields.
74
75## What good looks like
76A developer pastes a domain, taps run, and within a couple of seconds sees a
77clean breakdown of every DNS record type and the full SSL cert status. It should
78feel like a native, polished version of running `dig` and `openssl s_client`
79from the terminal.
privacypolicy.md added +40
@@ -0,0 +1,40 @@
1# Privacy Policy
2
3**DomainDig**
4Last updated: March 11, 2026
5
6## Overview
7
8DomainDig is a children's educational app designed for ages 4–5. This privacy
9policy explains how the app handles data.
10
11## Data Collection
12
13DomainDig does not collect, store, or transmit any personal data. The app has no
14user accounts, no analytics, no advertising, and no internet connection
15requirement.
16
17## Children's Privacy
18
19DomainDig is designed for young children and fully complies with the Children's
20Online Privacy Protection Act (COPPA). No information is collected from any
21user, including children.
22
23## Third-Party Services
24
25DomainDig does not use any third-party services, SDKs, or APIs that collect data.
26
27## Device Permissions
28
29DomainDig does not request access to your camera, microphone, contacts, location,
30or any other device data. Audio is generated on-device using Apple's built-in
31text-to-speech system.
32
33## Changes to This Policy
34
35If this policy changes in a future update, the updated policy will be posted
36here with a revised date.
37
38## Contact
39
40If you have any questions about this policy, contact: privacy@cleberg.net