├── .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 |
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 |
--------------------------------------------------------------------------------