summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj343
-rw-r--r--DomainDig.xcodeproj/project.xcworkspace/contents.xcworkspacedata7
-rw-r--r--DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist14
-rw-r--r--DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json36
-rw-r--r--DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.pngbin0 -> 441131 bytes
-rw-r--r--DomainDig/Assets.xcassets/Contents.json6
-rw-r--r--DomainDig/ContentView.swift309
-rw-r--r--DomainDig/DNSLookupService.swift89
-rw-r--r--DomainDig/DomainDigApp.swift17
-rw-r--r--DomainDig/DomainViewModel.swift157
-rw-r--r--DomainDig/Models.swift63
-rw-r--r--DomainDig/SSLCheckService.swift322
-rw-r--r--LICENSE21
-rw-r--r--claude.md79
-rw-r--r--privacypolicy.md40
16 files changed, 1514 insertions, 0 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..706ba0f
--- /dev/null
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -0,0 +1,343 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXFileReference section */
+ 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 8B78006B2F6090E300933221 /* DomainDig */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = DomainDig;
+ sourceTree = "<group>";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 8B7800662F6090E300933221 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 8B7800602F6090E300933221 = {
+ isa = PBXGroup;
+ children = (
+ 8B78006B2F6090E300933221 /* DomainDig */,
+ 8B78006A2F6090E300933221 /* Products */,
+ );
+ sourceTree = "<group>";
+ };
+ 8B78006A2F6090E300933221 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 8B7800692F6090E300933221 /* DomainDig.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 8B7800682F6090E300933221 /* DomainDig */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8B7800742F6090E400933221 /* Build configuration list for PBXNativeTarget "DomainDig" */;
+ buildPhases = (
+ 8B7800652F6090E300933221 /* Sources */,
+ 8B7800662F6090E300933221 /* Frameworks */,
+ 8B7800672F6090E300933221 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 8B78006B2F6090E300933221 /* DomainDig */,
+ );
+ name = DomainDig;
+ packageProductDependencies = (
+ );
+ productName = DomainDig;
+ productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 8B7800612F6090E300933221 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2630;
+ TargetAttributes = {
+ 8B7800682F6090E300933221 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ };
+ };
+ buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 8B7800602F6090E300933221;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 8B78006A2F6090E300933221 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 8B7800682F6090E300933221 /* DomainDig */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 8B7800672F6090E300933221 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 8B7800652F6090E300933221 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 8B7800722F6090E400933221 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 8B7800732F6090E400933221 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 8B7800752F6090E400933221 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 8B7800762F6090E400933221 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8B7800722F6090E400933221 /* Debug */,
+ 8B7800732F6090E400933221 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 8B7800742F6090E400933221 /* Build configuration list for PBXNativeTarget "DomainDig" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8B7800752F6090E400933221 /* Debug */,
+ 8B7800762F6090E400933221 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 8B7800612F6090E300933221 /* Project object */;
+}
diff --git a/DomainDig.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/DomainDig.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/DomainDig.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 0000000..bd2f39c
--- /dev/null
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>SchemeUserState</key>
+ <dict>
+ <key>DomainDig.xcscheme_^#shared#^_</key>
+ <dict>
+ <key>orderHint</key>
+ <integer>0</integer>
+ </dict>
+ </dict>
+</dict>
+</plist>
diff --git a/DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json b/DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json b/DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..87ffca2
--- /dev/null
+++ b/DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,36 @@
+{
+ "images" : [
+ {
+ "filename" : "domaindig.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png b/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png
new file mode 100644
index 0000000..752f230
--- /dev/null
+++ b/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png
Binary files differ
diff --git a/DomainDig/Assets.xcassets/Contents.json b/DomainDig/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/DomainDig/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
new file mode 100644
index 0000000..b6588b4
--- /dev/null
+++ b/DomainDig/ContentView.swift
@@ -0,0 +1,309 @@
+import SwiftUI
+
+struct ContentView: View {
+ @State private var viewModel = DomainViewModel()
+ @FocusState private var domainFieldFocused: Bool
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 0) {
+ inputSection
+ if viewModel.hasRun {
+ if viewModel.resultsLoaded {
+ HStack {
+ Spacer()
+ Button {
+ shareResults()
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(.body))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.top, 8)
+ }
+ dnsResultsSection
+ sslResultsSection
+ } else if !viewModel.recentSearches.isEmpty {
+ recentSearchesSection
+ }
+ }
+ .padding(.horizontal)
+ .padding(.bottom, 32)
+ }
+ .background(Color.black)
+ .navigationTitle("DomainDig")
+ .toolbarColorScheme(.dark, for: .navigationBar)
+ .preferredColorScheme(.dark)
+ }
+ .onAppear {
+ domainFieldFocused = true
+ }
+ }
+
+ // MARK: - Input
+
+ private var inputSection: some View {
+ VStack(spacing: 12) {
+ TextField("e.g. cleberg.net", text: $viewModel.domain)
+ .font(.system(.title3, design: .monospaced))
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ .padding(12)
+ .background(Color(.systemGray6))
+ .cornerRadius(8)
+ .focused($domainFieldFocused)
+ .onSubmit { viewModel.run() }
+
+ Button {
+ domainFieldFocused = false
+ viewModel.run()
+ } label: {
+ Text("Run")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.trimmedDomain.isEmpty)
+ }
+ .padding(.vertical, 16)
+ }
+
+ // MARK: - Recent Searches
+
+ private var recentSearchesSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("RECENT")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button("Clear") {
+ viewModel.clearRecentSearches()
+ }
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
+ ForEach(viewModel.recentSearches, id: \.self) { domain in
+ Button {
+ viewModel.domain = domain
+ domainFieldFocused = false
+ viewModel.run()
+ } label: {
+ Text(domain)
+ .font(.system(.callout, design: .monospaced))
+ .foregroundStyle(.primary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(Color(.systemGray6).opacity(0.5))
+ .cornerRadius(6)
+ }
+ }
+ }
+ .padding(.top, 8)
+ }
+
+ // MARK: - DNS Results
+
+ private var dnsResultsSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ sectionHeader("DNS Records")
+
+ if viewModel.dnsLoading {
+ ProgressView("Querying DNS…")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding()
+ } else if let error = viewModel.dnsError {
+ errorLabel(error)
+ } else {
+ ForEach(viewModel.dnsSections) { section in
+ dnsRecordSection(section)
+ }
+ }
+ }
+ .padding(.top, 8)
+ }
+
+ private func dnsRecordSection(_ section: DNSSection) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(section.recordType.rawValue)
+ .font(.system(.subheadline, design: .monospaced))
+ .fontWeight(.semibold)
+ .foregroundStyle(.cyan)
+
+ if let error = section.error {
+ errorLabel(error)
+ } else if section.records.isEmpty {
+ Text("No records found")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ } else {
+ dnsRecordRows(section.records)
+ }
+
+ // Wildcard sub-section (only shown when records exist)
+ if !section.wildcardRecords.isEmpty {
+ Text("*.\(viewModel.searchedDomain)")
+ .font(.system(.caption, design: .monospaced))
+ .fontWeight(.medium)
+ .foregroundStyle(.cyan.opacity(0.7))
+ .padding(.top, 4)
+
+ dnsRecordRows(section.wildcardRecords)
+ }
+ }
+ .padding(10)
+ .background(Color(.systemGray6).opacity(0.5))
+ .cornerRadius(6)
+ }
+
+ private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
+ ForEach(records) { record in
+ HStack(alignment: .top) {
+ Text(record.value)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ Spacer()
+ Text("TTL \(record.ttl)")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ // MARK: - SSL Results
+
+ private var sslResultsSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ sectionHeader("SSL / TLS Certificate")
+
+ if viewModel.sslLoading {
+ ProgressView("Checking certificate…")
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding()
+ } else if let error = viewModel.sslError {
+ errorLabel(error)
+ } else if let info = viewModel.sslInfo {
+ sslDetail(info)
+ }
+ }
+ .padding(.top, 16)
+ }
+
+ private func sslDetail(_ info: SSLCertificateInfo) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ certRow("Common Name", info.commonName)
+ certRow("Issuer", info.issuer)
+
+ // SANs
+ VStack(alignment: .leading, spacing: 2) {
+ Text("SANs")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ ForEach(info.subjectAltNames, id: \.self) { san in
+ Text(san)
+ .font(.system(.caption, design: .monospaced))
+ .textSelection(.enabled)
+ }
+ }
+
+ let formatter = DateFormatter.certDate
+ certRow("Valid From", formatter.string(from: info.validFrom))
+ certRow("Valid Until", formatter.string(from: info.validUntil))
+
+ HStack {
+ Text("Days Until Expiry")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(info.daysUntilExpiry)")
+ .font(.system(.caption, design: .monospaced))
+ .fontWeight(.bold)
+ .foregroundStyle(expiryColor(info.daysUntilExpiry))
+ }
+
+ certRow("Chain Depth", "\(info.chainDepth)")
+ }
+ .padding(10)
+ .background(Color(.systemGray6).opacity(0.5))
+ .cornerRadius(6)
+ }
+
+ // MARK: - Helpers
+
+ private func sectionHeader(_ title: String) -> some View {
+ Text(title)
+ .font(.system(.headline, design: .default))
+ .foregroundStyle(.white)
+ }
+
+ private func certRow(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label)
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Text(value)
+ .font(.system(.caption, design: .monospaced))
+ .textSelection(.enabled)
+ }
+ }
+
+ private func errorLabel(_ message: String) -> some View {
+ Label(message, systemImage: "exclamationmark.triangle.fill")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.red)
+ .padding(8)
+ }
+
+ private func expiryColor(_ days: Int) -> Color {
+ if days < 30 { return .red }
+ if days < 60 { return .yellow }
+ return .green
+ }
+
+ private func shareResults() {
+ let text = viewModel.exportText()
+
+ // Write to a named temp file so "Save to Files" uses a proper filename
+ let dateFmt = DateFormatter()
+ dateFmt.dateFormat = "yyyyMMdd_HHmmss"
+ let timestamp = dateFmt.string(from: Date())
+ let filename = "\(timestamp)_domaindigresults.txt"
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
+
+ do {
+ try text.write(to: tempURL, atomically: true, encoding: .utf8)
+ } catch {
+ return
+ }
+
+ let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
+ guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootVC = windowScene.keyWindow?.rootViewController else { return }
+ var presenter = rootVC
+ while let presented = presenter.presentedViewController {
+ presenter = presented
+ }
+ activityVC.popoverPresentationController?.sourceView = presenter.view
+ presenter.present(activityVC, animated: true)
+ }
+}
+
+private extension DateFormatter {
+ static let certDate: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .medium
+ f.timeStyle = .short
+ return f
+ }()
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift
new file mode 100644
index 0000000..85a5275
--- /dev/null
+++ b/DomainDig/DNSLookupService.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+struct DNSLookupService {
+ private static let baseURL = "https://cloudflare-dns.com/dns-query"
+
+ static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
+ var components = URLComponents(string: baseURL)!
+ components.queryItems = [
+ URLQueryItem(name: "name", value: domain),
+ URLQueryItem(name: "type", value: String(recordType.queryType))
+ ]
+
+ var request = URLRequest(url: components.url!)
+ request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ throw URLError(.badServerResponse)
+ }
+
+ let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
+
+ guard let answers = dnsResponse.Answer else {
+ return []
+ }
+
+ // Filter answers to only include the requested type
+ return answers
+ .filter { $0.type == recordType.queryType }
+ .map { answer in
+ let value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+ return DNSRecord(value: value, ttl: answer.TTL)
+ }
+ }
+
+ /// Record types that support wildcard queries.
+ private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
+
+ static func lookupAll(domain: String) async -> [DNSSection] {
+ // Each task returns (recordType, apex records, wildcard records).
+ typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?)
+
+ return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
+ for recordType in DNSRecordType.allCases {
+ group.addTask {
+ var apexRecords: [DNSRecord] = []
+ var wildcardRecords: [DNSRecord] = []
+ var lookupError: String?
+
+ // Apex query
+ do {
+ apexRecords = try await lookup(domain: domain, recordType: recordType)
+ } catch {
+ lookupError = error.localizedDescription
+ }
+
+ // Wildcard query (only for applicable types, and only if apex didn't fail)
+ if wildcardTypes.contains(recordType) && lookupError == nil {
+ do {
+ wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType)
+ } catch {
+ // Wildcard failure is non-fatal; just leave empty
+ }
+ }
+
+ return (recordType, apexRecords, wildcardRecords, lookupError)
+ }
+ }
+
+ var sections: [DNSSection] = []
+ for await result in group {
+ sections.append(DNSSection(
+ recordType: result.type,
+ records: result.records,
+ wildcardRecords: result.wildcard,
+ error: result.error
+ ))
+ }
+
+ // Sort to maintain consistent order
+ let order = DNSRecordType.allCases
+ return sections.sorted { a, b in
+ (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
+ }
+ }
+ }
+}
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
new file mode 100644
index 0000000..9c881d8
--- /dev/null
+++ b/DomainDig/DomainDigApp.swift
@@ -0,0 +1,17 @@
+//
+// DomainDigApp.swift
+// DomainDig
+//
+// Created by cmc on 2026-03-10.
+//
+
+import SwiftUI
+
+@main
+struct DomainDigApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
new file mode 100644
index 0000000..2965dde
--- /dev/null
+++ b/DomainDig/DomainViewModel.swift
@@ -0,0 +1,157 @@
+import Foundation
+import SwiftUI
+
+@MainActor
+@Observable
+final class DomainViewModel {
+ var domain: String = ""
+
+ var dnsSections: [DNSSection] = []
+ var dnsLoading = false
+ var dnsError: String?
+
+ var sslInfo: SSLCertificateInfo?
+ var sslLoading = false
+ var sslError: String?
+
+ var hasRun = false
+ private(set) var searchedDomain: String = ""
+
+ private static let recentSearchesKey = "recentSearches"
+ private static let maxRecent = 20
+
+ var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
+
+ var trimmedDomain: String {
+ domain
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: "https://", with: "")
+ .replacingOccurrences(of: "http://", with: "")
+ .components(separatedBy: "/").first ?? ""
+ }
+
+ func run() {
+ let target = trimmedDomain
+ guard !target.isEmpty else { return }
+
+ addRecentSearch(target)
+ searchedDomain = target
+ hasRun = true
+ dnsSections = []
+ dnsError = nil
+ dnsLoading = true
+ sslInfo = nil
+ sslError = nil
+ sslLoading = true
+
+ Task {
+ await withTaskGroup(of: Void.self) { group in
+ group.addTask { @MainActor in
+ await self.runDNS(domain: target)
+ }
+ group.addTask { @MainActor in
+ await self.runSSL(domain: target)
+ }
+ }
+ }
+ }
+
+ private func runDNS(domain: String) async {
+ do {
+ let sections = await DNSLookupService.lookupAll(domain: domain)
+ dnsSections = sections
+ }
+ dnsLoading = false
+ }
+
+ private func runSSL(domain: String) async {
+ do {
+ let info = try await SSLCheckService.check(domain: domain)
+ sslInfo = info
+ } catch {
+ sslError = error.localizedDescription
+ }
+ sslLoading = false
+ }
+
+ /// True when both lookups have finished (regardless of success/failure).
+ var resultsLoaded: Bool {
+ hasRun && !dnsLoading && !sslLoading
+ }
+
+ // MARK: - Export
+
+ func exportText() -> String {
+ let dateFmt = DateFormatter()
+ dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
+ let now = dateFmt.string(from: Date())
+
+ var lines: [String] = [
+ "DomainDig Export",
+ "Domain: \(searchedDomain)",
+ "Date: \(now)",
+ "",
+ "DNS Records",
+ "-----------"
+ ]
+
+ for section in dnsSections {
+ lines.append(section.recordType.rawValue)
+ if let error = section.error {
+ lines.append(" Error: \(error)")
+ } else if section.records.isEmpty {
+ lines.append(" No records found")
+ } else {
+ for record in section.records {
+ lines.append(" \(record.value) TTL \(record.ttl)")
+ }
+ }
+ if !section.wildcardRecords.isEmpty {
+ lines.append("*.\(searchedDomain)")
+ for record in section.wildcardRecords {
+ lines.append(" \(record.value) TTL \(record.ttl)")
+ }
+ }
+ }
+
+ if let info = sslInfo {
+ let certDateFmt = DateFormatter()
+ certDateFmt.dateStyle = .medium
+ certDateFmt.timeStyle = .none
+
+ lines.append("")
+ lines.append("SSL / TLS Certificate")
+ lines.append("---------------------")
+ lines.append("Common Name: \(info.commonName)")
+ lines.append("Issuer: \(info.issuer)")
+ lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))")
+ lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))")
+ lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
+ lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
+ lines.append("Chain Depth: \(info.chainDepth)")
+ } else if let error = sslError {
+ lines.append("")
+ lines.append("SSL / TLS Certificate")
+ lines.append("---------------------")
+ lines.append("Error: \(error)")
+ }
+
+ return lines.joined(separator: "\n")
+ }
+
+ // MARK: - Recent Searches
+
+ private func addRecentSearch(_ domain: String) {
+ recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
+ recentSearches.insert(domain, at: 0)
+ if recentSearches.count > Self.maxRecent {
+ recentSearches = Array(recentSearches.prefix(Self.maxRecent))
+ }
+ UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
+ }
+
+ func clearRecentSearches() {
+ recentSearches.removeAll()
+ UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
+ }
+}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
new file mode 100644
index 0000000..53b3c39
--- /dev/null
+++ b/DomainDig/Models.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+// MARK: - DNS Models
+
+enum DNSRecordType: String, CaseIterable {
+ case A
+ case AAAA
+ case MX
+ case NS
+ case TXT
+ case CNAME
+
+ var queryType: Int {
+ switch self {
+ case .A: return 1
+ case .AAAA: return 28
+ case .MX: return 15
+ case .NS: return 2
+ case .TXT: return 16
+ case .CNAME: return 5
+ }
+ }
+}
+
+struct DNSRecord: Identifiable {
+ let id = UUID()
+ let value: String
+ let ttl: Int
+}
+
+struct DNSSection: Identifiable {
+ let id = UUID()
+ let recordType: DNSRecordType
+ var records: [DNSRecord]
+ var wildcardRecords: [DNSRecord] = []
+ var error: String?
+}
+
+// MARK: - SSL Models
+
+struct SSLCertificateInfo {
+ let commonName: String
+ let subjectAltNames: [String]
+ let issuer: String
+ let validFrom: Date
+ let validUntil: Date
+ let daysUntilExpiry: Int
+ let chainDepth: Int
+}
+
+// MARK: - Cloudflare DNS-over-HTTPS Response
+
+struct CloudflareDNSResponse: Decodable {
+ let Status: Int
+ let Answer: [CloudflareDNSAnswer]?
+
+ struct CloudflareDNSAnswer: Decodable {
+ let name: String
+ let type: Int
+ let TTL: Int
+ let data: String
+ }
+}
diff --git a/DomainDig/SSLCheckService.swift b/DomainDig/SSLCheckService.swift
new file mode 100644
index 0000000..bff41c0
--- /dev/null
+++ b/DomainDig/SSLCheckService.swift
@@ -0,0 +1,322 @@
+import Foundation
+import Security
+
+struct SSLCheckService {
+
+ static func check(domain: String) async throws -> SSLCertificateInfo {
+ let delegate = SSLSessionDelegate()
+ let session = URLSession(
+ configuration: .ephemeral,
+ delegate: delegate,
+ delegateQueue: nil
+ )
+ defer { session.invalidateAndCancel() }
+
+ let url = URL(string: "https://\(domain)")!
+ let request = URLRequest(url: url, timeoutInterval: 10)
+
+ // We only need to establish the connection to grab the cert
+ _ = try await session.data(for: request)
+
+ guard let trust = delegate.serverTrust else {
+ throw SSLError.noCertificate
+ }
+
+ return try extractCertificateInfo(from: trust)
+ }
+
+ private static func extractCertificateInfo(from trust: SecTrust) throws -> SSLCertificateInfo {
+ let chainCount = SecTrustGetCertificateCount(trust)
+ guard chainCount > 0,
+ let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
+ let leaf = certChain.first else {
+ throw SSLError.noCertificate
+ }
+
+ // Common Name — use subject summary (available on iOS)
+ let commonName = SecCertificateCopySubjectSummary(leaf) as String? ?? "Unknown"
+
+ // Validity dates
+ let validFrom: Date
+ let validUntil: Date
+
+ if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
+ validFrom = notBefore
+ } else {
+ validFrom = Date.distantPast
+ }
+
+ if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
+ validUntil = notAfter
+ } else {
+ validUntil = Date.distantFuture
+ }
+
+ let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
+
+ // Parse the DER-encoded certificate to extract SANs and Issuer
+ let derData = SecCertificateCopyData(leaf) as Data
+ let parsed = DERCertificateParser.parse(derData)
+
+ let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
+
+ // Issuer: prefer parsed issuer, fall back to chain's next cert summary
+ var issuer = parsed.issuerCommonName ?? "Unknown"
+ if issuer == "Unknown" && certChain.count > 1 {
+ let issuerCert = certChain[1]
+ if let issuerSummary = SecCertificateCopySubjectSummary(issuerCert) as String? {
+ issuer = issuerSummary
+ }
+ }
+
+ return SSLCertificateInfo(
+ commonName: commonName,
+ subjectAltNames: sans,
+ issuer: issuer,
+ validFrom: validFrom,
+ validUntil: validUntil,
+ daysUntilExpiry: daysUntilExpiry,
+ chainDepth: Int(chainCount)
+ )
+ }
+}
+
+// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
+
+private enum DERCertificateParser {
+ struct Result {
+ var issuerCommonName: String?
+ var subjectAltNames: [String] = []
+ }
+
+ static func parse(_ data: Data) -> Result {
+ var result = Result()
+ let bytes = [UInt8](data)
+
+ // X.509 structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
+ // tbsCertificate: SEQUENCE { version, serialNumber, signature, issuer, validity, subject, ... extensions }
+ guard let tbsRange = readSequence(bytes, offset: 0),
+ let tbsContent = readSequence(bytes, offset: tbsRange.contentStart) else {
+ return result
+ }
+
+ var offset = tbsContent.contentStart
+
+ // Skip version (explicit tag [0]) if present
+ if offset < bytes.count && (bytes[offset] & 0xE0) == 0xA0 {
+ if let tagLen = readTagAndLength(bytes, offset: offset) {
+ offset = tagLen.contentStart + tagLen.length
+ }
+ }
+
+ // Skip serialNumber
+ if let serial = readTagAndLength(bytes, offset: offset) {
+ offset = serial.contentStart + serial.length
+ }
+
+ // Skip signature algorithm
+ if let sigAlg = readTagAndLength(bytes, offset: offset) {
+ offset = sigAlg.contentStart + sigAlg.length
+ }
+
+ // Issuer — a SEQUENCE of SETs of attribute type-value pairs
+ if let issuerSeq = readTagAndLength(bytes, offset: offset) {
+ result.issuerCommonName = extractCommonName(bytes, sequenceStart: issuerSeq.contentStart, length: issuerSeq.length)
+ offset = issuerSeq.contentStart + issuerSeq.length
+ }
+
+ // Skip validity
+ if let validity = readTagAndLength(bytes, offset: offset) {
+ offset = validity.contentStart + validity.length
+ }
+
+ // Skip subject
+ if let subject = readTagAndLength(bytes, offset: offset) {
+ offset = subject.contentStart + subject.length
+ }
+
+ // Skip subjectPublicKeyInfo
+ if let spki = readTagAndLength(bytes, offset: offset) {
+ offset = spki.contentStart + spki.length
+ }
+
+ // Extensions are in an explicit tag [3]
+ while offset < tbsContent.contentStart + tbsContent.length {
+ if bytes[offset] == 0xA3 {
+ if let extWrapper = readTagAndLength(bytes, offset: offset) {
+ // Inside is a SEQUENCE of SEQUENCE extensions
+ if let extsSeq = readTagAndLength(bytes, offset: extWrapper.contentStart) {
+ result.subjectAltNames = extractSANs(bytes, sequenceStart: extsSeq.contentStart, length: extsSeq.length)
+ }
+ }
+ break
+ }
+ // Skip optional implicit tags (issuerUniqueID [1], subjectUniqueID [2])
+ if let tl = readTagAndLength(bytes, offset: offset) {
+ offset = tl.contentStart + tl.length
+ } else {
+ break
+ }
+ }
+
+ return result
+ }
+
+ // OID for commonName: 2.5.4.3 = 55 04 03
+ private static let cnOID: [UInt8] = [0x55, 0x04, 0x03]
+
+ // OID for subjectAltName: 2.5.29.17 = 55 1D 11
+ private static let sanOID: [UInt8] = [0x55, 0x1D, 0x11]
+
+ private static func extractCommonName(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> String? {
+ let end = sequenceStart + length
+ var pos = sequenceStart
+ while pos < end {
+ // Each SET in the issuer
+ guard let setTL = readTagAndLength(bytes, offset: pos) else { break }
+ let setEnd = setTL.contentStart + setTL.length
+
+ // Inside the SET is a SEQUENCE with OID + value
+ if let seqTL = readTagAndLength(bytes, offset: setTL.contentStart) {
+ let seqEnd = seqTL.contentStart + seqTL.length
+ if let oidTL = readTagAndLength(bytes, offset: seqTL.contentStart) {
+ let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
+ if oidBytes == cnOID {
+ let valueStart = oidTL.contentStart + oidTL.length
+ if let valueTL = readTagAndLength(bytes, offset: valueStart) {
+ let strBytes = bytes[valueTL.contentStart..<valueTL.contentStart + valueTL.length]
+ return String(bytes: strBytes, encoding: .utf8)
+ }
+ }
+ _ = seqEnd // suppress unused warning
+ }
+ }
+ pos = setEnd
+ }
+ return nil
+ }
+
+ private static func extractSANs(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> [String] {
+ let end = sequenceStart + length
+ var pos = sequenceStart
+ var sans: [String] = []
+
+ while pos < end {
+ guard let extSeq = readTagAndLength(bytes, offset: pos) else { break }
+ let extEnd = extSeq.contentStart + extSeq.length
+
+ // Each extension is SEQUENCE { OID, [critical], value }
+ if let oidTL = readTagAndLength(bytes, offset: extSeq.contentStart) {
+ let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
+ if oidBytes == sanOID {
+ var valuePos = oidTL.contentStart + oidTL.length
+ // Skip optional critical BOOLEAN
+ if valuePos < extEnd && bytes[valuePos] == 0x01 {
+ if let boolTL = readTagAndLength(bytes, offset: valuePos) {
+ valuePos = boolTL.contentStart + boolTL.length
+ }
+ }
+ // The value is an OCTET STRING wrapping a SEQUENCE of GeneralNames
+ if let octetTL = readTagAndLength(bytes, offset: valuePos) {
+ if let sanSeq = readTagAndLength(bytes, offset: octetTL.contentStart) {
+ let sanEnd = sanSeq.contentStart + sanSeq.length
+ var sanPos = sanSeq.contentStart
+ while sanPos < sanEnd {
+ guard let nameTL = readTagAndLength(bytes, offset: sanPos) else { break }
+ // Context tag [2] = dNSName (IA5String)
+ if (bytes[sanPos] & 0x1F) == 2 {
+ let nameBytes = bytes[nameTL.contentStart..<nameTL.contentStart + nameTL.length]
+ if let name = String(bytes: nameBytes, encoding: .ascii) {
+ sans.append(name)
+ }
+ }
+ sanPos = nameTL.contentStart + nameTL.length
+ }
+ }
+ }
+ }
+ }
+ pos = extEnd
+ }
+ return sans
+ }
+
+ private struct TLV {
+ let contentStart: Int
+ let length: Int
+ }
+
+ private static func readSequence(_ bytes: [UInt8], offset: Int) -> TLV? {
+ guard offset < bytes.count, bytes[offset] == 0x30 else { return nil }
+ return readTagAndLength(bytes, offset: offset)
+ }
+
+ private static func readTagAndLength(_ bytes: [UInt8], offset: Int) -> TLV? {
+ guard offset < bytes.count else { return nil }
+ var pos = offset + 1 // skip tag byte
+ guard pos < bytes.count else { return nil }
+
+ let firstLen = bytes[pos]
+ pos += 1
+
+ let length: Int
+ if firstLen < 0x80 {
+ length = Int(firstLen)
+ } else {
+ let numBytes = Int(firstLen & 0x7F)
+ guard numBytes > 0, numBytes <= 4, pos + numBytes <= bytes.count else { return nil }
+ var len = 0
+ for i in 0..<numBytes {
+ len = (len << 8) | Int(bytes[pos + i])
+ }
+ pos += numBytes
+ length = len
+ }
+
+ return TLV(contentStart: pos, length: length)
+ }
+}
+
+enum SSLError: LocalizedError {
+ case noCertificate
+ case connectionFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .noCertificate:
+ return "No certificate found"
+ case .connectionFailed:
+ return "Failed to connect to server"
+ }
+ }
+}
+
+final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _serverTrust: SecTrust?
+
+ var serverTrust: SecTrust? {
+ lock.lock()
+ defer { lock.unlock() }
+ return _serverTrust
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
+ ) {
+ guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
+ let trust = challenge.protectionSpace.serverTrust else {
+ completionHandler(.performDefaultHandling, nil)
+ return
+ }
+
+ lock.lock()
+ _serverTrust = trust
+ lock.unlock()
+
+ let credential = URLCredential(trust: trust)
+ completionHandler(.useCredential, credential)
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fd9f61b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Christian Cleberg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/claude.md b/claude.md
new file mode 100644
index 0000000..4a90660
--- /dev/null
+++ b/claude.md
@@ -0,0 +1,79 @@
+# DomainDig – Project Context
+
+## What this is
+An iOS utility app for querying DNS records and inspecting SSL/TLS certificates
+for any domain. Designed for developers, sysadmins, and anyone who manages or
+troubleshoots domains and servers.
+
+## Target user
+Technically literate adults — developers, IT/sysadmin, homelab enthusiasts.
+Comfortable with terms like A record, CNAME, MX, TTL, and TLS. The UI should be
+clean and information-dense, not hand-holding.
+
+## Design principles
+- Minimal, utilitarian UI — this is a tool, not a consumer app
+- Results should be readable at a glance — use clear labels, monospace where
+ appropriate for IPs and raw values
+- Dark mode preferred as the primary aesthetic
+- No onboarding, no tutorials, no splash screens
+- Fast — results should appear as soon as they're available, not after all
+ lookups complete
+
+## Features
+
+### DNS Lookup
+Query the following record types for any domain via DNS-over-HTTPS (use
+Cloudflare 1.1.1.1 — https://cloudflare-dns.com/dns-query):
+- A
+- AAAA
+- MX
+- NS
+- TXT
+- CNAME
+
+Display each record type in its own section. Show TTL alongside each result.
+If a record type returns no results, show "No records found" for that type
+rather than hiding the section entirely.
+
+### SSL/TLS Certificate Check
+Connect to the domain on port 443 via URLSession and inspect the server's
+certificate chain using URLSession delegate methods. Display:
+- Common name (CN)
+- Subject Alternative Names (SANs)
+- Issuer
+- Valid from / Valid until dates
+- Days until expiry — highlight in red if under 30 days, yellow if under 60
+- Certificate chain depth
+
+### Results layout
+- Domain input at the top — large text field, keyboard shows on launch
+- Run button to trigger both DNS and SSL lookups simultaneously
+- DNS and SSL results displayed in clearly separated sections below
+- Each section loads independently — don't block SSL results waiting for DNS
+ or vice versa
+
+## Technical constraints
+- SwiftUI, iOS only
+- Fully offline except for DNS-over-HTTPS requests and SSL connections
+- No accounts, no analytics, no ads
+- No third-party dependencies — URLSession and Network framework only
+- Targets latest iOS
+
+### Recent searches
+Store the last 20 searched domains locally using UserDefaults. Display them as
+a tappable list below the text field when no results are showing. Tapping a
+recent domain populates the text field and runs the lookup immediately. Include
+a "Clear" button to wipe history. Most recent at the top.
+
+### Share / export
+A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the
+results area. Formats the full DNS and SSL results as plain text and presents
+the iOS share sheet via ShareLink or UIActivityViewController. The export
+should include the domain, timestamp, all DNS records with TTLs, and all SSL
+cert fields.
+
+## What good looks like
+A developer pastes a domain, taps run, and within a couple of seconds sees a
+clean breakdown of every DNS record type and the full SSL cert status. It should
+feel like a native, polished version of running `dig` and `openssl s_client`
+from the terminal.
diff --git a/privacypolicy.md b/privacypolicy.md
new file mode 100644
index 0000000..e887ee7
--- /dev/null
+++ b/privacypolicy.md
@@ -0,0 +1,40 @@
+# Privacy Policy
+
+**DomainDig**
+Last updated: March 11, 2026
+
+## Overview
+
+DomainDig is a children's educational app designed for ages 4–5. This privacy
+policy explains how the app handles data.
+
+## Data Collection
+
+DomainDig does not collect, store, or transmit any personal data. The app has no
+user accounts, no analytics, no advertising, and no internet connection
+requirement.
+
+## Children's Privacy
+
+DomainDig is designed for young children and fully complies with the Children's
+Online Privacy Protection Act (COPPA). No information is collected from any
+user, including children.
+
+## Third-Party Services
+
+DomainDig does not use any third-party services, SDKs, or APIs that collect data.
+
+## Device Permissions
+
+DomainDig does not request access to your camera, microphone, contacts, location,
+or any other device data. Audio is generated on-device using Apple's built-in
+text-to-speech system.
+
+## Changes to This Policy
+
+If this policy changes in a future update, the updated policy will be posted
+here with a revised date.
+
+## Contact
+
+If you have any questions about this policy, contact: [email protected]