diff options
| author | Christian Cleberg <[email protected]> | 2026-03-11 22:01:24 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-11 22:01:24 -0500 |
| commit | e11b7d29a6aa9e52f63fcb35ee7ed9127850fffe (patch) | |
| tree | 33252d28c1ef6b4ab4a38c0be793f22129696588 | |
| download | domain-dig-e11b7d29a6aa9e52f63fcb35ee7ed9127850fffe.tar.gz domain-dig-e11b7d29a6aa9e52f63fcb35ee7ed9127850fffe.tar.bz2 domain-dig-e11b7d29a6aa9e52f63fcb35ee7ed9127850fffe.zip | |
initial commit
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 343 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/project.xcworkspace/contents.xcworkspacedata | 7 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist | 14 | ||||
| -rw-r--r-- | DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json | 11 | ||||
| -rw-r--r-- | DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json | 36 | ||||
| -rw-r--r-- | DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png | bin | 0 -> 441131 bytes | |||
| -rw-r--r-- | DomainDig/Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 309 | ||||
| -rw-r--r-- | DomainDig/DNSLookupService.swift | 89 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 17 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 157 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 63 | ||||
| -rw-r--r-- | DomainDig/SSLCheckService.swift | 322 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | claude.md | 79 | ||||
| -rw-r--r-- | privacypolicy.md | 40 |
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 Binary files differnew file mode 100644 index 0000000..752f230 --- /dev/null +++ b/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png 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) + } +} @@ -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] |
