├── .gitignore ├── Package.swift ├── README.md ├── Sources └── EngineeringMode │ ├── EngineeringMode.swift │ ├── EngineeringModeMainView.swift │ ├── MetricsView.swift │ ├── NetworkView.swift │ ├── NotificationsView.swift │ ├── PermissionsView.swift │ └── UserDefaultsView.swift └── Tests └── EngineeringModeTests └── EngineeringModeTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | .cursor 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "EngineeringMode", 8 | platforms: [ 9 | .iOS(.v16), 10 | .macOS(.v13), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "EngineeringMode", 16 | targets: ["EngineeringMode"]) 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package, defining a module or a test suite. 20 | // Targets can depend on other targets in this package and products from dependencies. 21 | .target( 22 | name: "EngineeringMode" 23 | ), 24 | .testTarget( 25 | name: "EngineeringModeTests", 26 | dependencies: ["EngineeringMode"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🛠️ Engineering Mode 2 | EngineeringMode is a highly customizable iOS package to make debugging common things like Notifications, UserDefaults, Permissions and Networking easier. 3 | 4 |
5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | ## Usage 14 | 15 | `EngineeringMode` can be added to any SwiftUI view easily. Typically, it's used with a [Sheet](https://developer.apple.com/design/human-interface-guidelines/sheets). 16 | 17 | 18 | ### **Basic usage with a sheet** 19 | ```swift 20 | import EngineeringMode 21 | 22 | //... 23 | 24 | .sheet(isPresented: $showingEngineeringModeSheet) { 25 | EngineeringMode() 26 | } 27 | ``` 28 | 29 | ### **Custom Views** 30 | 31 | To add a custom view to the existing Engineering Mode screen, just pass in `customViews` and `customViewTitles`. Optionally, if you want Custom Views to show before the other views, then add `showCustomViewsFirst`. 32 | 33 | ```swift 34 | EngineeringMode( 35 | customViews: [AnyView], 36 | customViewTitles: [String], 37 | showCustomViewsFirst: Bool 38 | ) 39 | ``` 40 | 41 | ⚠️ Important: 42 | - `customViews` takes in an `AnyView` - please cast it to that. 43 | - `customViews` and `customViewTitles` should have the same number of array elements! Each custom view should have a title, otherwise the app will crash. 44 | 45 | **Example** 46 | 47 | ```swift 48 | EngineeringMode( 49 | customViews: [AnyView(MyCustomView())], 50 | customViewTitles: ["Test"], 51 | showCustomViewsFirst: true 52 | ) 53 | ``` 54 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/EngineeringMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EngineeringMode.swift 3 | // 4 | // Created by Ananay Arora on 6/29/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @available(iOS 15.0, *) 10 | public struct EngineeringMode: View { 11 | 12 | @State private var viewSelection = 0 13 | @State var defaultViews: [AnyView] = [ 14 | AnyView(UserDefaultsView()), 15 | AnyView(NotificationsView()), 16 | AnyView(PermissionsView()), 17 | AnyView(NetworkView()), 18 | AnyView(MetricsView()) 19 | ] 20 | @State var defaultViewTitles: [String] = [ 21 | "User Defaults", 22 | "Notifications", 23 | "Permissions", 24 | "Network", 25 | "MetricKit" 26 | ] 27 | 28 | @State var customViews: [AnyView] = [] 29 | @State var customViewTitles: [String] 30 | @State var showCustomViewsFirst: Bool = true 31 | 32 | public init( 33 | customViews: [AnyView] = [], customViewTitles: [String] = [], showCustomViewsFirst: Bool = true 34 | ) { 35 | 36 | guard customViews.count == customViewTitles.count else { 37 | fatalError( 38 | "Arguments `customViews` and `customViewTitles` must have the same number of array items. Please pass in a title for each Custom View!" 39 | ) 40 | } 41 | 42 | self.customViews = customViews 43 | self.customViewTitles = customViewTitles 44 | self.showCustomViewsFirst = showCustomViewsFirst 45 | 46 | } 47 | 48 | public var body: some View { 49 | 50 | VStack(alignment: .leading) { 51 | HStack { 52 | Text("🛠️ Engineering Mode") 53 | .font(.headline) 54 | .fontWeight(.bold) 55 | .padding(.top, 25) 56 | .padding(.leading, 25) 57 | Spacer() 58 | Picker("View", selection: $viewSelection) { 59 | if showCustomViewsFirst { 60 | ForEach(Array(customViewTitles.enumerated()), id: \.offset) { index, title in 61 | Text(title) 62 | .tag(index) 63 | } 64 | ForEach(Array(defaultViewTitles.enumerated()), id: \.offset) { index, title in 65 | Text(title) 66 | .tag(index + customViewTitles.count) 67 | } 68 | } else { 69 | ForEach(Array(defaultViewTitles.enumerated()), id: \.offset) { index, title in 70 | Text(title) 71 | .tag(index) 72 | } 73 | ForEach(Array(customViewTitles.enumerated()), id: \.offset) { index, title in 74 | Text(title) 75 | .tag(index + defaultViewTitles.count) 76 | } 77 | } 78 | } 79 | .pickerStyle(.automatic) 80 | .padding(.top, 25) 81 | .padding(.trailing, 10) 82 | } 83 | 84 | if showCustomViewsFirst == false { 85 | if viewSelection < defaultViewTitles.count { 86 | defaultViews[viewSelection] 87 | } else { 88 | customViews[viewSelection - defaultViewTitles.count] 89 | } 90 | } else { 91 | if viewSelection < customViewTitles.count { 92 | customViews[viewSelection] 93 | } else { 94 | defaultViews[viewSelection - customViewTitles.count] 95 | } 96 | } 97 | 98 | } 99 | 100 | } 101 | } 102 | 103 | @available(iOS 15, *) 104 | struct EngineeringMode_Previews: PreviewProvider { 105 | @available(iOS 15.0, *) 106 | static var previews: some View { 107 | EngineeringMode() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/EngineeringModeMainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EngineeringModeMainView.swift 3 | // 4 | // Created by Ananay Arora on 6/29/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct EngineeringModeMainView: View { 10 | let bundle = Bundle.main 11 | 12 | 13 | var body: some View { 14 | List { 15 | Section(header: Text("App Information")) { 16 | KeyValueRow(key: "Name", value: bundle.displayName ?? "") 17 | KeyValueRow(key: "Version", value: bundle.version ?? "") 18 | KeyValueRow(key: "Build Number", value: bundle.buildNumber ?? "") 19 | KeyValueRow(key: "Bundle Identifier", value: bundle.bundleIdentifier ?? "") 20 | KeyValueRow(key: "Minimum OS Version", value: bundle.minimumOSVersion ?? "") 21 | KeyValueRow(key: "Device Family", value: bundle.deviceFamilyDescription ?? "") 22 | } 23 | } 24 | .listStyle(InsetGroupedListStyle()) 25 | } 26 | } 27 | 28 | struct KeyValueRow: View { 29 | let key: String 30 | let value: String 31 | 32 | var body: some View { 33 | HStack { 34 | Text(key) 35 | .fontWeight(.bold) 36 | Spacer() 37 | Text(value) 38 | .foregroundColor(.secondary) 39 | } 40 | } 41 | } 42 | 43 | extension Bundle { 44 | var displayName: String? { 45 | return infoDictionary?["CFBundleDisplayName"] as? String 46 | } 47 | 48 | var version: String? { 49 | return infoDictionary?["CFBundleShortVersionString"] as? String 50 | } 51 | 52 | var buildNumber: String? { 53 | return infoDictionary?["CFBundleVersion"] as? String 54 | } 55 | 56 | var minimumOSVersion: String? { 57 | return infoDictionary?["MinimumOSVersion"] as? String 58 | } 59 | 60 | var deviceFamilyDescription: String? { 61 | guard let deviceFamily = infoDictionary?["UIDeviceFamily"] as? [Int] else { 62 | return nil 63 | } 64 | 65 | var description = "" 66 | for family in deviceFamily { 67 | switch family { 68 | case 1: description += "iPhone, " 69 | case 2: description += "iPad, " 70 | case 3: description += "iPod touch, " 71 | case 4: description += "Apple TV, " 72 | case 5: description += "Apple Watch, " 73 | case 6: description += "CarPlay, " 74 | case 7: description += "Mac, " 75 | default: break 76 | } 77 | } 78 | 79 | if !description.isEmpty { 80 | description.removeLast(2) // Remove the trailing comma and space 81 | } 82 | 83 | return description 84 | } 85 | } 86 | 87 | 88 | @available(iOS 15, *) 89 | struct EngineeringModeMainView_Previews: PreviewProvider { 90 | @available(iOS 15.0, *) 91 | static var previews: some View { 92 | EngineeringModeMainView() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/MetricsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetricsView.swift 3 | // EngineeringMode 4 | // 5 | // Created by Vishrut Jha on 2/8/25. 6 | // 7 | 8 | import Charts 9 | import MetricKit 10 | import SwiftData 11 | import SwiftUI 12 | 13 | @available(iOS 17, *) 14 | @Model 15 | final class MetricRecord { 16 | var timestamp: Date 17 | 18 | // Performance Metrics 19 | var launchTime: Double 20 | var memoryUsage: Double 21 | var hangTime: Double 22 | var appExitNormalCount: Int 23 | var appExitAbnormalCount: Int 24 | var appRunTime: TimeInterval 25 | 26 | // Battery Metrics 27 | var cpuTime: Double 28 | var gpuTime: Double 29 | var locationActivityTime: Double 30 | var networkTransferUp: Double 31 | var networkTransferDown: Double 32 | var displayOnTime: Double 33 | 34 | // Disk Metrics 35 | var diskWritesCount: Double 36 | var diskReadCount: Double 37 | 38 | // Animation Metrics 39 | var scrollHitchTimeRatio: Double 40 | 41 | init(timestamp: Date = .now) { 42 | self.timestamp = timestamp 43 | self.launchTime = 0 44 | self.memoryUsage = 0 45 | self.hangTime = 0 46 | self.appExitNormalCount = 0 47 | self.appExitAbnormalCount = 0 48 | self.appRunTime = 0 49 | self.cpuTime = 0 50 | self.gpuTime = 0 51 | self.locationActivityTime = 0 52 | self.networkTransferUp = 0 53 | self.networkTransferDown = 0 54 | self.displayOnTime = 0 55 | self.diskWritesCount = 0 56 | self.diskReadCount = 0 57 | self.scrollHitchTimeRatio = 0 58 | } 59 | } 60 | 61 | @available(iOS 17, *) 62 | class MetricsManager: NSObject, MXMetricManagerSubscriber { 63 | static let shared = MetricsManager() 64 | var modelContext: ModelContext? 65 | 66 | override init() { 67 | super.init() 68 | MXMetricManager.shared.add(self) 69 | } 70 | 71 | func didReceive(_ payloads: [MXMetricPayload]) { 72 | guard let context = modelContext else { return } 73 | 74 | for payload in payloads { 75 | let metrics = MetricRecord(timestamp: payload.timeStampEnd) 76 | 77 | // App Launch & Responsiveness 78 | if let launchMetrics = payload.applicationLaunchMetrics { 79 | metrics.launchTime = launchMetrics.histogrammedTimeToFirstDraw 80 | .bucketEnumerator.allObjects 81 | .compactMap { ($0 as? MXHistogramBucket)?.bucketEnd.value } 82 | .reduce(0.0, +) 83 | } 84 | 85 | // Memory 86 | if let memoryMetrics = payload.memoryMetrics { 87 | metrics.memoryUsage = memoryMetrics.peakMemoryUsage.value 88 | } 89 | 90 | // App Responsiveness 91 | if let responsivenessMetrics = payload 92 | .applicationResponsivenessMetrics 93 | { 94 | metrics.hangTime = responsivenessMetrics 95 | .histogrammedApplicationHangTime.bucketEnumerator.allObjects 96 | .compactMap { ($0 as? MXHistogramBucket)?.bucketEnd.value } 97 | .reduce(0.0, +) 98 | } 99 | 100 | // App Exit 101 | if let exitMetrics = payload.applicationExitMetrics { 102 | metrics.appExitNormalCount = 103 | exitMetrics.backgroundExitData.cumulativeNormalAppExitCount 104 | metrics.appExitAbnormalCount = 105 | exitMetrics.backgroundExitData.cumulativeAbnormalExitCount 106 | } 107 | 108 | // CPU & GPU 109 | if let cpuMetrics = payload.cpuMetrics { 110 | metrics.cpuTime = cpuMetrics.cumulativeCPUTime.value 111 | } 112 | 113 | if let gpuMetrics = payload.gpuMetrics { 114 | metrics.gpuTime = gpuMetrics.cumulativeGPUTime.value 115 | } 116 | 117 | // Network 118 | if let networkMetrics = payload.networkTransferMetrics { 119 | metrics.networkTransferUp = 120 | networkMetrics.cumulativeCellularUpload.value 121 | metrics.networkTransferDown = 122 | networkMetrics.cumulativeCellularDownload.value 123 | } 124 | 125 | // Location 126 | if let locationMetrics = payload.locationActivityMetrics { 127 | metrics.locationActivityTime = 128 | locationMetrics.cumulativeBestAccuracyForNavigationTime 129 | .value 130 | } 131 | 132 | // Disk I/O 133 | if let diskMetrics = payload.diskIOMetrics { 134 | metrics.diskWritesCount = 135 | diskMetrics.cumulativeLogicalWrites.value 136 | } 137 | 138 | // Animation 139 | if let animationMetrics = payload.animationMetrics { 140 | metrics.scrollHitchTimeRatio = 141 | animationMetrics.scrollHitchTimeRatio.value 142 | } 143 | 144 | context.insert(metrics) 145 | 146 | if let context = modelContext { 147 | do { 148 | try context.save() 149 | } catch { 150 | print("Failed to save MetricRecord: \(error)") 151 | } 152 | } 153 | 154 | } 155 | } 156 | } 157 | 158 | struct ChartContainer: View { 159 | let title: String 160 | let content: Content 161 | let legendItems: [(color: Color, label: String)] 162 | 163 | init( 164 | title: String, legendItems: [(color: Color, label: String)], 165 | @ViewBuilder content: () -> Content 166 | ) { 167 | self.title = title 168 | self.legendItems = legendItems 169 | self.content = content() 170 | } 171 | 172 | var body: some View { 173 | VStack(alignment: .leading, spacing: 8) { 174 | Text(title) 175 | .font(.headline) 176 | 177 | content 178 | .frame(height: 200) 179 | .chartXAxis { 180 | AxisMarks(values: .stride(by: .day)) { _ in 181 | AxisGridLine() 182 | AxisTick() 183 | AxisValueLabel(format: .dateTime.day().month()) 184 | } 185 | } 186 | .chartYAxis { 187 | AxisMarks { _ in 188 | AxisGridLine() 189 | AxisTick() 190 | AxisValueLabel() 191 | } 192 | } 193 | 194 | // Legend 195 | if !legendItems.isEmpty { 196 | HStack(spacing: 16) { 197 | ForEach(legendItems.indices, id: \.self) { index in 198 | HStack(spacing: 4) { 199 | Circle() 200 | .fill(legendItems[index].color) 201 | .frame(width: 8, height: 8) 202 | Text(legendItems[index].label) 203 | .font(.caption) 204 | .foregroundColor(.secondary) 205 | } 206 | } 207 | } 208 | .padding(.top, 4) 209 | } 210 | } 211 | .padding() 212 | .background(Color.gray.opacity(0.1)) 213 | .cornerRadius(10) 214 | .shadow(radius: 2, y: 1) 215 | } 216 | } 217 | 218 | @available(iOS 17, *) 219 | struct MetricsContentView: View { 220 | @Environment(\.modelContext) private var modelContext 221 | 222 | static var sevenDaysAgo: Date { 223 | Calendar.current.date(byAdding: .day, value: -7, to: .now)! 224 | } 225 | 226 | @Query( 227 | filter: #Predicate { record in 228 | record.timestamp > sevenDaysAgo 229 | }, 230 | sort: \MetricRecord.timestamp 231 | ) private var metrics: [MetricRecord] 232 | 233 | var body: some View { 234 | ScrollView { 235 | VStack(spacing: 20) { 236 | // Performance Metrics 237 | ChartContainer( 238 | title: "Launch Time", 239 | legendItems: [ 240 | (color: .blue, label: "Launch Duration") 241 | ] 242 | ) { 243 | Chart(metrics) { metric in 244 | LineMark( 245 | x: .value("Date", metric.timestamp), 246 | y: .value("Time", metric.launchTime) 247 | ) 248 | .foregroundStyle(.blue) 249 | } 250 | } 251 | 252 | ChartContainer( 253 | title: "Memory Usage", 254 | legendItems: [ 255 | (color: .green, label: "Peak Memory") 256 | ] 257 | ) { 258 | Chart(metrics) { metric in 259 | BarMark( 260 | x: .value("Date", metric.timestamp), 261 | y: .value("Memory", metric.memoryUsage) 262 | ) 263 | .foregroundStyle(.green) 264 | } 265 | } 266 | 267 | // Battery Metrics 268 | ChartContainer( 269 | title: "CPU & GPU Time", 270 | legendItems: [ 271 | (color: .red, label: "CPU Time"), 272 | (color: .orange, label: "GPU Time"), 273 | ] 274 | ) { 275 | Chart(metrics) { metric in 276 | LineMark( 277 | x: .value("Date", metric.timestamp), 278 | y: .value("CPU Time", metric.cpuTime) 279 | ) 280 | .foregroundStyle(.red) 281 | LineMark( 282 | x: .value("Date", metric.timestamp), 283 | y: .value("GPU Time", metric.gpuTime) 284 | ) 285 | .foregroundStyle(.orange) 286 | } 287 | } 288 | 289 | // Network Metrics 290 | ChartContainer( 291 | title: "Network Transfer", 292 | legendItems: [ 293 | (color: .blue, label: "Upload"), 294 | (color: .green, label: "Download"), 295 | ] 296 | ) { 297 | Chart(metrics) { metric in 298 | BarMark( 299 | x: .value("Date", metric.timestamp), 300 | y: .value("Upload", metric.networkTransferUp) 301 | ) 302 | .foregroundStyle(.blue) 303 | BarMark( 304 | x: .value("Date", metric.timestamp), 305 | y: .value("Download", metric.networkTransferDown) 306 | ) 307 | .foregroundStyle(.green) 308 | } 309 | } 310 | 311 | // Disk I/O Metrics 312 | ChartContainer( 313 | title: "Disk Activity", 314 | legendItems: [ 315 | (color: .purple, label: "Writes") 316 | ] 317 | ) { 318 | Chart(metrics) { metric in 319 | LineMark( 320 | x: .value("Date", metric.timestamp), 321 | y: .value("Writes", Double(metric.diskWritesCount)) 322 | ) 323 | .foregroundStyle(.purple) 324 | } 325 | } 326 | 327 | MetricsStatsView(metrics: metrics) 328 | } 329 | .padding() 330 | } 331 | .onAppear { 332 | MetricsManager.shared.modelContext = modelContext 333 | } 334 | } 335 | } 336 | 337 | @available(iOS 17, *) 338 | struct MetricsStatsView: View { 339 | let metrics: [MetricRecord] 340 | 341 | var latestMetric: MetricRecord? { 342 | metrics.last 343 | } 344 | 345 | var body: some View { 346 | VStack(alignment: .leading, spacing: 16) { 347 | Text("Additional Statistics") 348 | .font(.headline) 349 | 350 | VStack(alignment: .leading, spacing: 12) { 351 | // App Exits 352 | StatRow( 353 | title: "Normal App Exits", 354 | value: "\(latestMetric?.appExitNormalCount ?? 0)" 355 | ) 356 | StatRow( 357 | title: "Abnormal App Exits", 358 | value: "\(latestMetric?.appExitAbnormalCount ?? 0)" 359 | ) 360 | 361 | // Location 362 | StatRow( 363 | title: "Location Activity Time", 364 | value: String( 365 | format: "%.2f s", 366 | latestMetric?.locationActivityTime ?? 0) 367 | ) 368 | 369 | // App Runtime 370 | StatRow( 371 | title: "Total Runtime", 372 | value: String( 373 | format: "%.2f s", 374 | latestMetric?.appRunTime ?? 0) 375 | ) 376 | 377 | // Display 378 | StatRow( 379 | title: "Display On Time", 380 | value: String( 381 | format: "%.2f s", 382 | latestMetric?.displayOnTime ?? 0) 383 | ) 384 | 385 | // Animation 386 | StatRow( 387 | title: "Scroll Hitch Ratio", 388 | value: String( 389 | format: "%.3f", 390 | latestMetric?.scrollHitchTimeRatio ?? 0) 391 | ) 392 | } 393 | .padding() 394 | .background(Color.gray.opacity(0.1)) 395 | .cornerRadius(10) 396 | } 397 | } 398 | } 399 | 400 | struct StatRow: View { 401 | let title: String 402 | let value: String 403 | 404 | var body: some View { 405 | HStack { 406 | Text(title) 407 | .foregroundColor(.secondary) 408 | Spacer() 409 | Text(value) 410 | .fontWeight(.medium) 411 | } 412 | } 413 | } 414 | 415 | struct MetricsView: View { 416 | var body: some View { 417 | NavigationStack { 418 | Group { 419 | if #available(iOS 17, *) { 420 | MetricsContentView() 421 | .modelContainer(for: MetricRecord.self) 422 | 423 | } else { 424 | VStack(spacing: 16) { 425 | Image(systemName: "exclamationmark.triangle") 426 | .font(.largeTitle) 427 | .foregroundColor(.orange) 428 | 429 | Text("iOS 17 Required") 430 | .font(.title2) 431 | .fontWeight(.semibold) 432 | 433 | Text( 434 | "App Metrics visualization is only available on iOS 17 and above." 435 | ) 436 | .multilineTextAlignment(.center) 437 | .foregroundColor(.secondary) 438 | } 439 | .padding() 440 | } 441 | } 442 | .navigationTitle("App Metrics (MetricKit)") 443 | } 444 | } 445 | } 446 | 447 | @available(iOS 17, *) 448 | extension MetricRecord { 449 | static var previewData: [MetricRecord] { 450 | let calendar = Calendar.current 451 | let now = Date() 452 | 453 | return (0..<7).map { dayOffset in 454 | let record = MetricRecord( 455 | timestamp: calendar.date( 456 | byAdding: .day, value: -dayOffset, to: now)! 457 | ) 458 | 459 | // Performance Metrics 460 | record.launchTime = Double.random(in: 0.8...2.5) 461 | record.memoryUsage = Double.random(in: 150...450) 462 | record.hangTime = Double.random(in: 0...0.3) 463 | 464 | // App Exit Stats 465 | record.appExitNormalCount = Int.random(in: 5...15) 466 | record.appExitAbnormalCount = Int.random(in: 0...2) 467 | 468 | // Runtime Stats 469 | record.appRunTime = Double.random(in: 1800...7200) // 30 mins to 2 hours 470 | record.displayOnTime = Double.random(in: 900...3600) // 15 mins to 1 hour 471 | 472 | // Resource Usage 473 | record.cpuTime = Double.random(in: 20...80) 474 | record.gpuTime = Double.random(in: 10...40) 475 | 476 | // Network (in MB) 477 | record.networkTransferUp = Double.random(in: 5...50) 478 | record.networkTransferDown = Double.random(in: 20...200) 479 | 480 | // Location & Animation 481 | record.locationActivityTime = Double.random(in: 0...600) // 0-10 minutes 482 | record.scrollHitchTimeRatio = Double.random(in: 0...0.05) 483 | 484 | // Disk I/O (in MB) 485 | record.diskWritesCount = Double.random(in: 1...10) 486 | 487 | return record 488 | } 489 | } 490 | } 491 | 492 | @available(iOS 17, *) 493 | struct MetricsPreviewContainer: View { 494 | var body: some View { 495 | let config = ModelConfiguration(isStoredInMemoryOnly: true) 496 | let container = try! ModelContainer( 497 | for: MetricRecord.self, configurations: config) 498 | 499 | // Insert preview data and save immediately 500 | let context = container.mainContext 501 | for record in MetricRecord.previewData { 502 | context.insert(record) 503 | } 504 | try? context.save() 505 | 506 | return MetricsContentView() 507 | .modelContainer(container) 508 | } 509 | } 510 | 511 | struct MetricsPreviewFallback: View { 512 | var body: some View { 513 | Text("iOS 17 or later is required") 514 | .font(.headline) 515 | .foregroundStyle(.secondary) 516 | } 517 | } 518 | 519 | #Preview { 520 | NavigationStack { 521 | ViewThatFits { 522 | if #available(iOS 17, *) { 523 | MetricsPreviewContainer() 524 | } else { 525 | MetricsPreviewFallback() 526 | } 527 | } 528 | .navigationTitle("App Metrics (MetricKit)") 529 | } 530 | } 531 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/NetworkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkView.swift 3 | // 4 | // Created by Ananay Arora on 6/29/23. 5 | // 6 | 7 | 8 | import SwiftUI 9 | import SystemConfiguration.CaptiveNetwork 10 | import Network 11 | 12 | struct NetworkView: View { 13 | @State private var isPinging = false 14 | @State private var pingResult = "" 15 | @State private var ipAddress = "" 16 | @State private var isConnectedViaWiFi = false 17 | @State private var urlInput = "" 18 | @State private var networkLog: [String] = [] 19 | 20 | var body: some View { 21 | VStack { 22 | List { 23 | Section(header: Text("Network Information")) { 24 | Text("Public IP Address: \(ipAddress)") 25 | Text("Connection: \(isConnectedViaWiFi ? "Wi-Fi" : "Cellular")") 26 | } 27 | 28 | Section(header: Text("HTTP Request")) { 29 | TextField("URL", text: $urlInput) 30 | .disableAutocorrection(true) 31 | .autocapitalization(.none) 32 | .padding(.horizontal) 33 | 34 | Button(action: { 35 | makeRequest() 36 | }) { 37 | Text("Make Request") 38 | } 39 | .disabled(urlInput.isEmpty || isPinging) 40 | } 41 | 42 | Section(header: Text("Ping Network")) { 43 | Button(action: { 44 | ping() 45 | }) { 46 | Text(isPinging ? "Pinging Network..." : "Ping Network") 47 | } 48 | .disabled(isPinging) 49 | } 50 | 51 | if (networkLog.count > 0) { 52 | Section(header: Text("Network Logs")) { 53 | ForEach(networkLog, id: \.self) { logItem in 54 | Text(logItem) 55 | .font(.subheadline) 56 | .foregroundColor(.secondary) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | .onAppear { 63 | fetchIPAddress() 64 | checkConnection() 65 | } 66 | } 67 | 68 | func nlog(text: String) { 69 | let formatter = DateFormatter() 70 | formatter.dateFormat = "[MM/dd/yy HH:mm:ss]:" 71 | let timestamp = formatter.string(from: Date()) 72 | let logEntry = "\(timestamp) \(text)" 73 | networkLog.insert(logEntry, at: 0) 74 | } 75 | 76 | 77 | func fetchIPAddress() { 78 | let task = URLSession.shared.dataTask(with: URL(string: "https://api.ipify.org/")!) { data, response, error in 79 | guard let data = data, error == nil else { 80 | return 81 | } 82 | 83 | if let ipAddress = String(data: data, encoding: .utf8) { 84 | DispatchQueue.main.async { 85 | self.ipAddress = ipAddress 86 | } 87 | } 88 | } 89 | task.resume() 90 | } 91 | 92 | func checkConnection() { 93 | let monitor = NWPathMonitor() 94 | let queue = DispatchQueue(label: "NetworkMonitor") 95 | monitor.start(queue: queue) 96 | 97 | monitor.pathUpdateHandler = { path in 98 | DispatchQueue.main.async { 99 | self.isConnectedViaWiFi = path.usesInterfaceType(.wifi) 100 | } 101 | } 102 | } 103 | 104 | func ping() { 105 | guard let url = URL(string: "https://www.apple.com") else { 106 | return 107 | } 108 | 109 | isPinging = true 110 | pingResult = "" 111 | 112 | let task = URLSession.shared.dataTask(with: url) { _, response, error in 113 | DispatchQueue.main.async { 114 | nlog(text: error == nil ? "Ping successful" : "Ping failed: " + (error?.localizedDescription ?? "Unknown Error")) 115 | self.isPinging = false 116 | } 117 | } 118 | 119 | task.resume() 120 | } 121 | 122 | func makeRequest() { 123 | guard let url = URL(string: urlInput) else { 124 | return 125 | } 126 | 127 | isPinging = true 128 | pingResult = "" 129 | 130 | let task = URLSession.shared.dataTask(with: url) { data, response, error in 131 | DispatchQueue.main.async { 132 | if let error = error { 133 | nlog(text: "Request failed: \(error.localizedDescription)") 134 | } else { 135 | nlog(text: "Request successful to: \(url.absoluteString)") 136 | nlog(text: response.debugDescription) 137 | } 138 | self.isPinging = false 139 | } 140 | } 141 | 142 | task.resume() 143 | } 144 | } 145 | 146 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/NotificationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsView.swift 3 | // 4 | // Created by Ananay Arora on 6/29/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | /// Formats the Trigger Description 10 | func triggerDescriptionFormatter(_ trigger: UNNotificationTrigger) -> String { 11 | if let calendarTrigger = trigger as? UNCalendarNotificationTrigger { 12 | let date = calendarTrigger.nextTriggerDate() 13 | let dateFormatter = DateFormatter() 14 | dateFormatter.dateStyle = .short // Use .full instead of .short for additional information 15 | dateFormatter.timeStyle = .short 16 | return "Calendar Trigger: \(dateFormatter.string(from: date ?? Date()))" 17 | } else if let intervalTrigger = trigger 18 | as? UNTimeIntervalNotificationTrigger 19 | { 20 | let timeFormatter = DateComponentsFormatter() 21 | timeFormatter.unitsStyle = .abbreviated 22 | timeFormatter.zeroFormattingBehavior = .dropAll 23 | timeFormatter.allowedUnits = [.hour, .minute, .second] 24 | return 25 | "Time Interval Trigger: \(String(describing: timeFormatter.string(from: intervalTrigger.timeInterval)))" 26 | } else if trigger is UNLocationNotificationTrigger { 27 | return "Location Trigger" 28 | } else { 29 | return "Unknown Trigger" 30 | } 31 | } 32 | 33 | /// Returns all pending notifications 34 | func listAllNotifications() async -> [UNNotificationRequest] { 35 | return await withCheckedContinuation { continuation in 36 | UNUserNotificationCenter.current().getPendingNotificationRequests { 37 | pendingNotifications in 38 | continuation.resume(returning: pendingNotifications) 39 | } 40 | } 41 | } 42 | 43 | @available(iOS 15.0, *) 44 | struct NotificationsView: View { 45 | 46 | @State private var pendingNotificationRequests: [UNNotificationRequest] = [] 47 | 48 | var body: some View { 49 | VStack { 50 | if pendingNotificationRequests.count == 0 { 51 | Spacer() 52 | HStack { 53 | Spacer() 54 | Text("No pending notifications.") 55 | Spacer() 56 | } 57 | Spacer() 58 | } else { 59 | List(pendingNotificationRequests, id: \.identifier) { request in 60 | VStack(alignment: .leading, spacing: 8) { 61 | Text(request.content.title) 62 | .font(.headline) 63 | Text(request.content.body) 64 | .font(.subheadline) 65 | .foregroundColor(.secondary) 66 | Text("Identifier: \(request.identifier)") 67 | .font(.subheadline) 68 | .foregroundColor(.secondary) 69 | if let trigger = request.trigger { 70 | Text( 71 | "Trigger: \(triggerDescriptionFormatter(trigger))" 72 | ) 73 | .font(.subheadline) 74 | .foregroundColor(.secondary) 75 | } 76 | } 77 | } 78 | } 79 | } 80 | .task { 81 | pendingNotificationRequests = await listAllNotifications() 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/PermissionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionsView.swift 3 | // 4 | // Created by Ananay Arora on 6/29/23. 5 | // 6 | 7 | import AVFoundation 8 | import AuthenticationServices 9 | import Contacts 10 | import CoreBluetooth 11 | import CoreLocation 12 | import CoreMotion 13 | import CoreTelephony 14 | import EventKit 15 | import HealthKit 16 | import HomeKit 17 | import Intents 18 | import MediaPlayer 19 | import Photos 20 | import Speech 21 | import SwiftUI 22 | import UserNotifications 23 | 24 | enum PermissionStatusInfo: String { 25 | case granted = "Granted" 26 | case denied = "Denied" 27 | case restricted = "Restricted" 28 | case notDetermined = "Not Determined" 29 | case limited = "Limited" 30 | case determined = "Determined" 31 | case provisional = "Provisional" 32 | case ephemeral = "Ephemeral" 33 | case unavailable = "Unavailable" 34 | case unavailableOldOS = "Unavailable on iOS < 16.0" 35 | case notApplicable = "N/A" 36 | case unknown = "Unknown" 37 | 38 | var color: Color { 39 | switch self { 40 | case .granted: 41 | return .green 42 | case .limited, .determined, .provisional: 43 | return .yellow 44 | case .notApplicable, .unavailable, .unavailableOldOS: 45 | return .primary 46 | default: 47 | return .red 48 | } 49 | } 50 | 51 | static func fromRawStatus(_ status: String) -> PermissionStatusInfo { 52 | switch status { 53 | case "authorized", "allowedAlways", "sharingAuthorized", 54 | "notRestricted", "authorizedAlways", 55 | "authorizedWhenInUse": 56 | return .granted 57 | case "denied", "sharingDenied": 58 | return .denied 59 | case "restricted": 60 | return .restricted 61 | case "notDetermined": 62 | return .notDetermined 63 | case "limited": 64 | return .limited 65 | case "determined": 66 | return .determined 67 | case "provisional": 68 | return .provisional 69 | case "ephemeral": 70 | return .ephemeral 71 | default: 72 | return .unknown 73 | } 74 | } 75 | } 76 | 77 | protocol AuthorizationStatus { 78 | var localizedStatus: PermissionStatusInfo { get } 79 | } 80 | 81 | extension AuthorizationStatus { 82 | var localizedStatus: PermissionStatusInfo { 83 | let label = String(describing: self) 84 | return PermissionStatusInfo.fromRawStatus(label) 85 | } 86 | } 87 | 88 | // Conform all status types to AuthorizationStatus 89 | extension AVAuthorizationStatus: AuthorizationStatus {} 90 | extension PHAuthorizationStatus: AuthorizationStatus {} 91 | extension CNAuthorizationStatus: AuthorizationStatus {} 92 | extension CLAuthorizationStatus: AuthorizationStatus {} 93 | extension EKAuthorizationStatus: AuthorizationStatus {} 94 | extension CMAuthorizationStatus: AuthorizationStatus {} 95 | extension HKAuthorizationStatus: AuthorizationStatus {} 96 | extension CBManagerAuthorization: AuthorizationStatus {} 97 | extension CTCellularDataRestrictedState: AuthorizationStatus {} 98 | extension SFSpeechRecognizerAuthorizationStatus: AuthorizationStatus {} 99 | extension MPMediaLibraryAuthorizationStatus: AuthorizationStatus {} 100 | extension UNAuthorizationStatus: AuthorizationStatus {} 101 | 102 | // Special case for HomeKit due to iOS version check 103 | extension HMHomeManagerAuthorizationStatus: AuthorizationStatus { 104 | var localizedStatus: PermissionStatusInfo { 105 | if #available(iOS 16.0, *) { 106 | switch self { 107 | case .authorized: 108 | return .granted 109 | case .restricted: 110 | return .restricted 111 | case .determined: 112 | return .determined 113 | default: 114 | return .unknown 115 | } 116 | } else { 117 | return .unavailableOldOS 118 | } 119 | } 120 | } 121 | 122 | struct PermissionStatus: Identifiable { 123 | let id = UUID() 124 | let title: String 125 | let status: PermissionStatusInfo 126 | } 127 | 128 | class HomeKitPermissionManager: NSObject, HMHomeManagerDelegate, 129 | ObservableObject 130 | { 131 | @Published var authorizationStatus: HMHomeManagerAuthorizationStatus = 132 | .determined 133 | private let homeManager = HMHomeManager() 134 | 135 | override init() { 136 | super.init() 137 | homeManager.delegate = self 138 | } 139 | 140 | func homeManagerDidUpdateAuthorization(_ manager: HMHomeManager) { 141 | if #available(iOS 16.0, *) { 142 | authorizationStatus = manager.authorizationStatus 143 | } 144 | } 145 | } 146 | 147 | class NotificationPermissionManager: ObservableObject { 148 | @Published var authorizationStatus: UNAuthorizationStatus = .notDetermined 149 | 150 | init() { 151 | Task { 152 | await updateAuthorizationStatus() 153 | } 154 | } 155 | 156 | func requestAuthorization() async { 157 | let center = UNUserNotificationCenter.current() 158 | do { 159 | try await center.requestAuthorization(options: [ 160 | .alert, .sound, .badge, .provisional, 161 | ]) 162 | // Update status after requesting authorization 163 | await updateAuthorizationStatus() 164 | } catch { 165 | print("Error requesting notification authorization: \(error)") 166 | } 167 | } 168 | 169 | func updateAuthorizationStatus() async { 170 | let center = UNUserNotificationCenter.current() 171 | let settings = await center.notificationSettings() 172 | await MainActor.run { 173 | self.authorizationStatus = settings.authorizationStatus 174 | } 175 | } 176 | 177 | // Helper computed property to get a user-friendly status string 178 | var localizedAuthorizationStatus: PermissionStatusInfo { 179 | switch authorizationStatus { 180 | case .authorized: 181 | return .granted 182 | case .denied: 183 | return .denied 184 | case .notDetermined: 185 | return .notDetermined 186 | case .provisional: 187 | return .provisional 188 | case .ephemeral: 189 | return .ephemeral 190 | @unknown default: 191 | return .unknown 192 | } 193 | } 194 | } 195 | 196 | struct PermissionsView: View { 197 | @StateObject private var homeKitManager = HomeKitPermissionManager() 198 | @StateObject private var notificationManager = 199 | NotificationPermissionManager() 200 | 201 | var permissions: [PermissionStatus] { 202 | [ 203 | PermissionStatus( 204 | title: "Camera", 205 | status: AVCaptureDevice.authorizationStatus(for: .video) 206 | .localizedStatus), 207 | PermissionStatus( 208 | title: "Push Notifications", 209 | status: notificationManager.localizedAuthorizationStatus), 210 | PermissionStatus( 211 | title: "Microphone", 212 | status: AVCaptureDevice.authorizationStatus(for: .audio) 213 | .localizedStatus), 214 | PermissionStatus( 215 | title: "Photo Library", 216 | status: PHPhotoLibrary.authorizationStatus().localizedStatus), 217 | PermissionStatus( 218 | title: "Contacts", 219 | status: CNContactStore.authorizationStatus(for: .contacts) 220 | .localizedStatus), 221 | PermissionStatus( 222 | title: "Location", 223 | status: CLLocationManager().authorizationStatus.localizedStatus), 224 | PermissionStatus( 225 | title: "Calendar", 226 | status: EKEventStore.authorizationStatus(for: .event) 227 | .localizedStatus), 228 | PermissionStatus( 229 | title: "Reminders", 230 | status: EKEventStore.authorizationStatus(for: .reminder) 231 | .localizedStatus), 232 | PermissionStatus( 233 | title: "Motion & Fitness", 234 | status: CMMotionActivityManager.authorizationStatus() 235 | .localizedStatus), 236 | PermissionStatus( 237 | title: "Health", 238 | status: HKHealthStore.isHealthDataAvailable() 239 | ? HKHealthStore().authorizationStatus( 240 | for: .activitySummaryType() 241 | ).localizedStatus 242 | : .unavailable), 243 | PermissionStatus( 244 | title: "HomeKit", 245 | status: { 246 | if #available(iOS 16.0, *) { 247 | return HMHomeManager().authorizationStatus 248 | .localizedStatus 249 | } else { 250 | return .unavailableOldOS 251 | } 252 | }()), 253 | PermissionStatus( 254 | title: "Bluetooth", 255 | status: CBManager.authorization.localizedStatus), 256 | PermissionStatus( 257 | title: "Cellular Data", 258 | status: CTCellularData().restrictedState.localizedStatus), 259 | PermissionStatus( 260 | title: "Siri & Dictation", 261 | status: SFSpeechRecognizer.authorizationStatus().localizedStatus 262 | ), 263 | PermissionStatus( 264 | title: "Face ID or Touch ID", status: .notApplicable), 265 | PermissionStatus( 266 | title: "Speech Recognition", 267 | status: SFSpeechRecognizer.authorizationStatus().localizedStatus 268 | ), 269 | PermissionStatus(title: "CalDAV & CardDAV", status: .notApplicable), 270 | PermissionStatus( 271 | title: "Music Library", 272 | status: MPMediaLibrary.authorizationStatus().localizedStatus), 273 | PermissionStatus(title: "Apple Music", status: .notApplicable), 274 | PermissionStatus( 275 | title: "Home & Lock Screen Widgets", status: .notApplicable), 276 | ] 277 | } 278 | 279 | var body: some View { 280 | List(permissions) { permission in 281 | HStack { 282 | Text(permission.title) 283 | Spacer() 284 | Text(permission.status.rawValue) 285 | .foregroundColor(permission.status.color) 286 | } 287 | } 288 | .listStyle(InsetGroupedListStyle()) 289 | .navigationTitle("Permissions") 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /Sources/EngineeringMode/UserDefaultsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsView.swift 3 | // 4 | // Created by Ananay Arora on 6/29/23. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct UserDefaultItem: Identifiable { 10 | let id = UUID() 11 | let key: String 12 | let value: Any 13 | } 14 | 15 | @available(iOS 15.0, *) 16 | struct UserDefaultsView: View { 17 | @State private var userDefaultsData: [UserDefaultItem] = [] 18 | @State private var expandedItems: Set = [] 19 | 20 | var body: some View { 21 | List { 22 | ForEach(userDefaultsData) { item in 23 | DisclosureGroup( 24 | isExpanded: Binding( 25 | get: { expandedItems.contains(item.id) }, 26 | set: { isExpanded in 27 | if isExpanded { 28 | expandedItems.insert(item.id) 29 | } else { 30 | expandedItems.remove(item.id) 31 | } 32 | } 33 | ) 34 | ) { 35 | Text(String(describing: item.value)) 36 | .font(.subheadline) 37 | .foregroundColor(.secondary) 38 | .padding(.leading) 39 | } label: { 40 | Text(item.key) 41 | .font(.headline) 42 | } 43 | } 44 | } 45 | .onAppear { 46 | fetchUserDefaultsData() 47 | } 48 | } 49 | 50 | /** 51 | * Fetches the user defaults and adds it to the State array. 52 | */ 53 | func fetchUserDefaultsData() { 54 | userDefaultsData = UserDefaults.standard.dictionaryRepresentation() 55 | .map { UserDefaultItem(key: $0.key, value: $0.value) } 56 | .sorted { $0.key < $1.key } 57 | } 58 | } 59 | 60 | #Preview { 61 | if #available(iOS 15.0, *) { 62 | UserDefaultsView() 63 | .onAppear { 64 | let defaults = UserDefaults.standard 65 | defaults.set("test@example.com", forKey: "userEmail") 66 | defaults.set(true, forKey: "isLoggedIn") 67 | } 68 | } else { 69 | Text("UserDefaultsView is not available on this platform.") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/EngineeringModeTests/EngineeringModeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import EngineeringMode 3 | 4 | final class EngineeringModeTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------