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