craft-native 0.0.175 → 0.0.176

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/android/src/index.d.ts +19 -0
  2. package/dist/android/src/index.js +19 -2
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +51 -0
  5. package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
  6. package/dist/android/templates/CraftNative.kt.template +17 -0
  7. package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
  8. package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
  9. package/dist/android/templates/CraftNativeServices.kt.template +31 -1
  10. package/dist/android/templates/MainActivityNative.kt.template +986 -304
  11. package/dist/android/templates/build.gradle.kts.app.template +4 -2
  12. package/dist/api/mobile.d.ts +31 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +203 -2
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/src/index.d.ts +19 -0
  20. package/dist/ios/src/index.js +7 -1
  21. package/dist/ios/templates/CraftApp.swift +51 -1
  22. package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
  23. package/dist/ios/templates/CraftHybrid.swift +10 -0
  24. package/dist/ios/templates/CraftNativeActions.swift +49 -1
  25. package/dist/ios/templates/CraftNativeFlatList.swift +13 -21
  26. package/dist/ios/templates/CraftNativeMutation.swift +1 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +393 -55
  28. package/dist/ios/templates/project.yml.template +1 -0
  29. package/dist/macos/templates/CraftNativeMutation.swift +1 -1
  30. package/dist/macos/templates/native_layout.zig +389 -17
  31. package/dist/mobile.js +66 -0
  32. package/package.json +1 -1
@@ -0,0 +1,1010 @@
1
+ import Foundation
2
+ import UIKit
3
+ import MetricKit
4
+ import MachO
5
+ import Network
6
+
7
+ // MARK: - Crash reporting (bughq)
8
+ //
9
+ // `crashReporting` in craft.config.json turns this on: the app's crashes,
10
+ // hangs and uncaught JavaScript errors become bughq events (https://bughq.org,
11
+ // or a self-hosted host), sent from an on-disk outbox so a crash or an error
12
+ // captured offline is delivered when the phone is next online.
13
+ //
14
+ // Three ways a failure is caught, because no single one sees everything:
15
+ //
16
+ // * In process, at the moment of the crash: POSIX signal handlers (a Swift
17
+ // trap is SIGTRAP, a bad pointer SIGSEGV/SIGBUS) and the uncaught
18
+ // NSException handler. They write a small report file and let the crash
19
+ // continue, so iOS still records it. The report is sent on the next launch.
20
+ // The signal handler runs with the process in an unknown state, so it only
21
+ // touches memory allocated at install time and calls async-signal-safe
22
+ // functions (open, write, close, vm_read_overwrite): no Swift strings, no
23
+ // arrays, no allocation.
24
+ // * MetricKit: iOS's own crash and hang diagnostics (MXCrashDiagnostic,
25
+ // MXHangDiagnostic), delivered on a later launch. It sees what an in-process
26
+ // handler cannot (a watchdog kill, a crash in a frame the handler could not
27
+ // walk) and is the only source of hangs. A crash both paths saw is sent once:
28
+ // the in-process report records its signature and the MetricKit copy is
29
+ // dropped.
30
+ // * JavaScript: an uncaught exception in a native screen or the app script
31
+ // (JavaScriptCore's exceptionHandler, CraftNativeScreen.swift), and events
32
+ // the page or a screen hands over through `craft.crashReporter.capture`.
33
+ //
34
+ // Frames are sent as `image + offset` with the image's UUID. bughq turns them
35
+ // into function names and lines with the dSYM symbols uploaded from the build
36
+ // (Xcode Cloud's ci_post_xcodebuild.sh), so nothing here needs the symbols.
37
+ //
38
+ // Only the app's own user id (`craft.crashReporter.setUser`) identifies a
39
+ // person. No email, name or health data is read or sent.
40
+
41
+ /// Report file written by the signal handler, read on the next launch.
42
+ private let craftCrashMaxFrames = 128
43
+ private let craftCrashMaxImagesWithCrashInfo = 64
44
+
45
+ // Everything the signal handler reads, allocated at install time.
46
+ private var craftCrashPath: UnsafeMutablePointer<CChar>?
47
+ private var craftCrashScratch: UnsafeMutablePointer<UInt8>?
48
+ private var craftCrashFrames: UnsafeMutablePointer<UInt64>?
49
+ private var craftCrashInfoSections: UnsafeMutablePointer<UInt64>?
50
+ private var craftCrashInfoCount: Int32 = 0
51
+ private var craftCrashInProgress: Int32 = 0
52
+ private var craftCrashExceptionRecorded: Int32 = 0
53
+ private var craftCrashPreviousActions: UnsafeMutablePointer<sigaction>?
54
+ private var craftCrashAltStack: UnsafeMutableRawPointer?
55
+ private var craftPreviousExceptionHandler: (@convention(c) (NSException) -> Void)?
56
+ /// The signals a crash arrives as. A switch rather than an array, so the
57
+ /// handler can read it without touching a Swift collection.
58
+ private let craftCrashSignalCount = 7
59
+ @inline(__always)
60
+ private func craftCrashSignalAt(_ index: Int) -> Int32 {
61
+ switch index {
62
+ case 0: return SIGABRT
63
+ case 1: return SIGBUS
64
+ case 2: return SIGFPE
65
+ case 3: return SIGILL
66
+ case 4: return SIGSEGV
67
+ case 5: return SIGTRAP
68
+ default: return SIGSYS
69
+ }
70
+ }
71
+
72
+ /// Strips pointer-authentication bits from a return address. Apps build arm64
73
+ /// (no PAC), but system libraries on an arm64e phone sign theirs.
74
+ @inline(__always)
75
+ private func craftStrip(_ address: UInt64) -> UInt64 {
76
+ #if targetEnvironment(simulator)
77
+ return address
78
+ #else
79
+ return address & 0x0000_000F_FFFF_FFFF
80
+ #endif
81
+ }
82
+
83
+ @inline(__always)
84
+ private func craftWriteBytes(_ fd: Int32, _ bytes: UnsafePointer<UInt8>, _ count: Int) {
85
+ var written = 0
86
+ while written < count {
87
+ let n = Darwin.write(fd, bytes + written, count - written)
88
+ if n <= 0 { return }
89
+ written += n
90
+ }
91
+ }
92
+
93
+ /// Writes a static C string (a literal: no allocation).
94
+ @inline(__always)
95
+ private func craftWriteLiteral(_ fd: Int32, _ text: StaticString) {
96
+ craftWriteBytes(fd, text.utf8Start, text.utf8CodeUnitCount)
97
+ }
98
+
99
+ /// Writes `value` as lowercase hex, through the preallocated scratch buffer.
100
+ private func craftWriteHex(_ fd: Int32, _ value: UInt64) {
101
+ guard let scratch = craftCrashScratch else { return }
102
+ let digits: StaticString = "0123456789abcdef"
103
+ var v = value
104
+ var i = 31
105
+ repeat {
106
+ scratch[i] = digits.utf8Start[Int(v & 0xF)]
107
+ v >>= 4
108
+ i -= 1
109
+ } while v != 0 && i > 1
110
+ scratch[i] = UInt8(ascii: "x")
111
+ scratch[i - 1] = UInt8(ascii: "0")
112
+ craftWriteBytes(fd, UnsafePointer(scratch + (i - 1)), 32 - (i - 1))
113
+ }
114
+
115
+ private func craftWriteDecimal(_ fd: Int32, _ value: Int64) {
116
+ guard let scratch = craftCrashScratch else { return }
117
+ var v = value < 0 ? UInt64(-value) : UInt64(value)
118
+ var i = 31
119
+ repeat {
120
+ scratch[i] = UInt8(ascii: "0") + UInt8(v % 10)
121
+ v /= 10
122
+ i -= 1
123
+ } while v != 0 && i > 0
124
+ if value < 0 { scratch[i] = UInt8(ascii: "-"); i -= 1 }
125
+ craftWriteBytes(fd, UnsafePointer(scratch + i + 1), 31 - i)
126
+ }
127
+
128
+ /// Reads `size` bytes at `address` without faulting on a bad pointer.
129
+ @inline(__always)
130
+ private func craftSafeRead(_ address: UInt64, _ into: UnsafeMutableRawPointer, _ size: Int) -> Bool {
131
+ var outSize: vm_size_t = 0
132
+ let result = vm_read_overwrite(mach_task_self_, vm_address_t(address), vm_size_t(size), vm_address_t(UInt(bitPattern: into)), &outSize)
133
+ return result == KERN_SUCCESS && outSize == vm_size_t(size)
134
+ }
135
+
136
+ /// The messages iOS's crash reporter would show: a Swift runtime trap writes
137
+ /// "Fatal error: Unexpectedly found nil…" into its image's `__crash_info`
138
+ /// section before trapping, and that is the only place it survives.
139
+ private func craftWriteCrashInfoMessages(_ fd: Int32) {
140
+ guard let sections = craftCrashInfoSections, let scratch = craftCrashScratch else { return }
141
+ // crashreporter_annotations_t: version, message (+8), signature_string,
142
+ // backtrace, message2 (+32). Two plain calls, not a loop over an array
143
+ // literal, which would allocate.
144
+ for index in 0..<Int(craftCrashInfoCount) {
145
+ craftWriteCrashInfoString(fd, sections[index] + 8, scratch)
146
+ craftWriteCrashInfoString(fd, sections[index] + 32, scratch)
147
+ }
148
+ }
149
+
150
+ private func craftWriteCrashInfoString(_ fd: Int32, _ field: UInt64, _ scratch: UnsafeMutablePointer<UInt8>) {
151
+ var pointer: UInt64 = 0
152
+ guard craftSafeRead(field, &pointer, 8), pointer != 0 else { return }
153
+ craftWriteLiteral(fd, "message ")
154
+ // Copied in 32-byte reads so a short string near the end of a page cannot fault.
155
+ var offset: UInt64 = 0
156
+ while offset < 1024 {
157
+ guard craftSafeRead(pointer + offset, scratch, 32) else { break }
158
+ var length = 0
159
+ while length < 32 && scratch[length] != 0 && scratch[length] != 10 { length += 1 }
160
+ craftWriteBytes(fd, scratch, length)
161
+ if length < 32 { break }
162
+ offset += 32
163
+ }
164
+ craftWriteLiteral(fd, "\n")
165
+ }
166
+
167
+ /// Walks the crashed thread's frame-pointer chain from its saved registers.
168
+ private func craftCollectFrames(_ context: UnsafeMutableRawPointer?) -> Int {
169
+ guard let frames = craftCrashFrames, let context else { return 0 }
170
+ let uc = context.assumingMemoryBound(to: ucontext_t.self)
171
+ guard let mcontext = uc.pointee.uc_mcontext else { return 0 }
172
+ #if arch(arm64)
173
+ let pc = UInt64(mcontext.pointee.__ss.__pc)
174
+ var fp = UInt64(mcontext.pointee.__ss.__fp)
175
+ let lr = UInt64(mcontext.pointee.__ss.__lr)
176
+ #elseif arch(x86_64)
177
+ let pc = UInt64(mcontext.pointee.__ss.__rip)
178
+ var fp = UInt64(mcontext.pointee.__ss.__rbp)
179
+ let lr: UInt64 = 0
180
+ #else
181
+ return 0
182
+ #endif
183
+ var count = 0
184
+ frames[count] = craftStrip(pc); count += 1
185
+ var record: (UInt64, UInt64) = (0, 0)
186
+ var first = true
187
+ while count < craftCrashMaxFrames, fp != 0, fp & 0x7 == 0 {
188
+ guard craftSafeRead(fp, &record, 16) else { break }
189
+ let returnAddress = craftStrip(record.1)
190
+ // In a leaf function the caller is only in the link register: the
191
+ // first saved return address then belongs to the caller's caller.
192
+ if first, lr != 0, craftStrip(lr) != returnAddress, count < craftCrashMaxFrames {
193
+ frames[count] = craftStrip(lr); count += 1
194
+ }
195
+ first = false
196
+ if returnAddress == 0 { break }
197
+ frames[count] = returnAddress; count += 1
198
+ if record.0 <= fp { break } // stacks grow down; a frame pointer must move up
199
+ fp = record.0
200
+ }
201
+ return count
202
+ }
203
+
204
+ private func craftSignalHandler(_ signal: Int32, _ info: UnsafeMutablePointer<__siginfo>?, _ context: UnsafeMutableRawPointer?) {
205
+ let first = craftCrashInProgress == 0
206
+ craftCrashInProgress = 1
207
+ if first, craftCrashExceptionRecorded == 0, let path = craftCrashPath {
208
+ let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
209
+ if fd >= 0 {
210
+ craftWriteLiteral(fd, "craft-crash 1\nkind signal\nsignal ")
211
+ craftWriteDecimal(fd, Int64(signal))
212
+ craftWriteLiteral(fd, "\ncode ")
213
+ craftWriteDecimal(fd, Int64(info?.pointee.si_code ?? 0))
214
+ craftWriteLiteral(fd, "\naddress ")
215
+ craftWriteHex(fd, UInt64(UInt(bitPattern: info?.pointee.si_addr)))
216
+ craftWriteLiteral(fd, "\ntime ")
217
+ craftWriteDecimal(fd, Int64(time(nil)))
218
+ craftWriteLiteral(fd, "\n")
219
+ craftWriteCrashInfoMessages(fd)
220
+ let count = craftCollectFrames(context)
221
+ if let frames = craftCrashFrames {
222
+ for index in 0..<count {
223
+ craftWriteLiteral(fd, "frame ")
224
+ craftWriteHex(fd, frames[index])
225
+ craftWriteLiteral(fd, "\n")
226
+ }
227
+ }
228
+ close(fd)
229
+ }
230
+ }
231
+ // Hand the signal back to whoever had it before (or the default action),
232
+ // so the process still dies the way iOS expects and MetricKit sees it.
233
+ var restored = false
234
+ if let previous = craftCrashPreviousActions {
235
+ for index in 0..<craftCrashSignalCount where craftCrashSignalAt(index) == signal {
236
+ sigaction(signal, previous + index, nil)
237
+ restored = true
238
+ }
239
+ }
240
+ if !restored { Darwin.signal(signal, SIG_DFL) }
241
+ raise(signal)
242
+ }
243
+
244
+ private func craftUncaughtException(_ exception: NSException) {
245
+ CraftCrashReporter.shared.recordUncaughtException(exception)
246
+ craftPreviousExceptionHandler?(exception)
247
+ }
248
+
249
+ /// A binary loaded in this process, as a crash's frames are mapped to it.
250
+ private struct CraftImage: Codable {
251
+ let name: String
252
+ let uuid: String
253
+ let base: UInt64
254
+ let size: UInt64
255
+ let inApp: Bool
256
+ }
257
+
258
+ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
259
+ static let shared = CraftCrashReporter()
260
+ static let sdkName = "bughq.craft.ios"
261
+
262
+ private(set) var enabled = false
263
+ private var settings: CraftConfig.CrashReporting?
264
+ private let queue = DispatchQueue(label: "dev.craft.crash-reporter", qos: .utility)
265
+ private var directory: URL!
266
+ private var outbox: URL!
267
+ private var pathMonitor: NWPathMonitor?
268
+ private var online = true
269
+ private var flushing = false
270
+ private var retryAt = Date.distantPast
271
+ private var failures = 0
272
+ private var breadcrumbs: [[String: Any]] = []
273
+ private var breadcrumbsDirty = false
274
+ private var userId: String?
275
+ private var lastJavaScriptError: (key: String, at: Date)?
276
+ private lazy var urlSession: URLSession = {
277
+ let configuration = URLSessionConfiguration.ephemeral
278
+ configuration.timeoutIntervalForRequest = 20
279
+ configuration.timeoutIntervalForResource = 30
280
+ configuration.waitsForConnectivity = false
281
+ return URLSession(configuration: configuration)
282
+ }()
283
+
284
+ let sessionId = UUID().uuidString.lowercased()
285
+ let sessionStart = Date()
286
+ private let executableName = Bundle.main.executableURL?.lastPathComponent ?? ""
287
+ private let bundlePath = Bundle.main.bundlePath
288
+
289
+ var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" }
290
+ var appBuild: String { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" }
291
+ var release: String {
292
+ if let given = settings?.release, !given.isEmpty { return given }
293
+ return appBuild.isEmpty ? appVersion : "\(appVersion)+\(appBuild)"
294
+ }
295
+ var environment: String { settings?.environment ?? "production" }
296
+
297
+ // MARK: Start
298
+
299
+ /// Installs everything. Called first thing in didFinishLaunching, so a
300
+ /// crash during the rest of launch is caught too.
301
+ func start(_ config: CraftConfig) {
302
+ guard !enabled, let settings = config.crashReporting, !settings.key.isEmpty else { return }
303
+ self.settings = settings
304
+ enabled = true
305
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
306
+ directory = support.appendingPathComponent("craft-crash", isDirectory: true)
307
+ outbox = directory.appendingPathComponent("outbox", isDirectory: true)
308
+ try? FileManager.default.createDirectory(at: outbox, withIntermediateDirectories: true)
309
+ userId = UserDefaults.standard.string(forKey: "craft.crash.user")
310
+
311
+ installInProcessHandlers()
312
+ queue.async {
313
+ // The previous launch's crash first: its report names images,
314
+ // breadcrumbs and a session that this launch is about to replace.
315
+ self.collectPreviousCrash()
316
+ self.recordSession()
317
+ self.pruneOutbox()
318
+ if settings.sessions != false { self.enqueueSession(status: "ok") }
319
+ }
320
+ MXMetricManager.shared.add(self)
321
+ startNetworkMonitor()
322
+ let center = NotificationCenter.default
323
+ center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
324
+ self?.queue.async { self?.flush() }
325
+ }
326
+ center.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil) { [weak self] _ in
327
+ self?.queue.async { self?.saveBreadcrumbs() }
328
+ }
329
+ #if DEBUG
330
+ scheduleDebugCrashTest()
331
+ #endif
332
+ }
333
+
334
+ private func installInProcessHandlers() {
335
+ let path = directory.appendingPathComponent("pending.crash").path
336
+ craftCrashPath = strdup(path)
337
+ craftCrashScratch = UnsafeMutablePointer<UInt8>.allocate(capacity: 64)
338
+ craftCrashFrames = UnsafeMutablePointer<UInt64>.allocate(capacity: craftCrashMaxFrames)
339
+ craftCrashInfoSections = UnsafeMutablePointer<UInt64>.allocate(capacity: craftCrashMaxImagesWithCrashInfo)
340
+ craftCrashInfoCount = 0
341
+
342
+ // Its own stack, so a stack overflow can still run the handler.
343
+ let stackSize = Int(SIGSTKSZ) * 4
344
+ craftCrashAltStack = UnsafeMutableRawPointer.allocate(byteCount: stackSize, alignment: 16)
345
+ var stack = stack_t(ss_sp: craftCrashAltStack, ss_size: stackSize, ss_flags: 0)
346
+ sigaltstack(&stack, nil)
347
+
348
+ let previous = UnsafeMutablePointer<sigaction>.allocate(capacity: craftCrashSignalCount)
349
+ for index in 0..<craftCrashSignalCount {
350
+ let signal = craftCrashSignalAt(index)
351
+ var action = sigaction()
352
+ action.__sigaction_u.__sa_sigaction = craftSignalHandler
353
+ action.sa_flags = SA_SIGINFO | SA_ONSTACK
354
+ sigemptyset(&action.sa_mask)
355
+ sigaction(signal, &action, previous + index)
356
+ }
357
+ craftCrashPreviousActions = previous
358
+
359
+ craftPreviousExceptionHandler = NSGetUncaughtExceptionHandler()
360
+ NSSetUncaughtExceptionHandler(craftUncaughtException)
361
+ }
362
+
363
+ /// Called on the crashing thread for an uncaught Objective-C exception,
364
+ /// before abort(). The process is still sane, so this may allocate; it
365
+ /// writes the same report file the signal handler would, and tells the
366
+ /// handler (which abort() reaches next) that the crash is recorded.
367
+ func recordUncaughtException(_ exception: NSException) {
368
+ guard enabled, craftCrashPath != nil else { return }
369
+ var lines = ["craft-crash 1", "kind nsexception", "signal \(SIGABRT)", "time \(Int(Date().timeIntervalSince1970))"]
370
+ lines.append("exception \(exception.name.rawValue)")
371
+ if let reason = exception.reason {
372
+ lines.append("message \(reason.replacingOccurrences(of: "\n", with: " ").prefix(1024))")
373
+ }
374
+ for address in exception.callStackReturnAddresses.prefix(craftCrashMaxFrames) {
375
+ lines.append("frame 0x\(String(craftStrip(address.uint64Value), radix: 16))")
376
+ }
377
+ try? (lines.joined(separator: "\n") + "\n").write(toFile: directory.appendingPathComponent("pending.crash").path, atomically: false, encoding: .utf8)
378
+ craftCrashExceptionRecorded = 1
379
+ }
380
+
381
+ // MARK: Session
382
+
383
+ /// This launch's facts the next launch needs to read a crash: the loaded
384
+ /// images (their addresses change every launch) and the session's release.
385
+ private func recordSession() {
386
+ var images: [CraftImage] = []
387
+ var sections = 0
388
+ for index in 0..<_dyld_image_count() {
389
+ guard let header = _dyld_get_image_header(index), let rawName = _dyld_get_image_name(index) else { continue }
390
+ let path = String(cString: rawName)
391
+ let info = Self.imageInfo(header)
392
+ images.append(CraftImage(
393
+ name: (path as NSString).lastPathComponent,
394
+ uuid: info.uuid,
395
+ base: UInt64(UInt(bitPattern: header)),
396
+ size: info.textSize,
397
+ inApp: path.hasPrefix(bundlePath)
398
+ ))
399
+ var size: UInt = 0
400
+ let header64 = UnsafeRawPointer(header).assumingMemoryBound(to: mach_header_64.self)
401
+ if sections < craftCrashMaxImagesWithCrashInfo,
402
+ let section = getsectiondata(header64, "__DATA", "__crash_info", &size), size >= 40 {
403
+ craftCrashInfoSections?[sections] = UInt64(UInt(bitPattern: section))
404
+ sections += 1
405
+ craftCrashInfoCount = Int32(sections)
406
+ }
407
+ }
408
+ let info: [String: Any] = [
409
+ "id": sessionId,
410
+ "startedAt": Self.iso(sessionStart),
411
+ "release": release,
412
+ "version": appVersion,
413
+ "build": appBuild,
414
+ "environment": environment,
415
+ ]
416
+ write(info, to: "session.json")
417
+ if let data = try? JSONEncoder().encode(images) {
418
+ try? data.write(to: directory.appendingPathComponent("images.json"), options: .atomic)
419
+ }
420
+ breadcrumbs = []
421
+ breadcrumbsDirty = true
422
+ saveBreadcrumbs()
423
+ }
424
+
425
+ private static func imageInfo(_ header: UnsafePointer<mach_header>) -> (uuid: String, textSize: UInt64) {
426
+ var uuid = ""
427
+ var textSize: UInt64 = 0
428
+ let is64 = header.pointee.magic == MH_MAGIC_64
429
+ var cursor = UnsafeRawPointer(header) + (is64 ? MemoryLayout<mach_header_64>.size : MemoryLayout<mach_header>.size)
430
+ for _ in 0..<header.pointee.ncmds {
431
+ let command = cursor.assumingMemoryBound(to: load_command.self).pointee
432
+ if command.cmd == LC_UUID {
433
+ let bytes = cursor.assumingMemoryBound(to: uuid_command.self).pointee.uuid
434
+ uuid = UUID(uuid: bytes).uuidString
435
+ } else if command.cmd == LC_SEGMENT_64 {
436
+ let segment = cursor.assumingMemoryBound(to: segment_command_64.self).pointee
437
+ let name = withUnsafeBytes(of: segment.segname) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
438
+ if name == "__TEXT" { textSize = segment.vmsize }
439
+ }
440
+ cursor += Int(command.cmdsize)
441
+ }
442
+ return (uuid, textSize)
443
+ }
444
+
445
+ // MARK: The previous launch's crash
446
+
447
+ private func collectPreviousCrash() {
448
+ let reportURL = directory.appendingPathComponent("pending.crash")
449
+ guard let text = try? String(contentsOf: reportURL, encoding: .utf8) else { return }
450
+ try? FileManager.default.removeItem(at: reportURL)
451
+ let previousSession = read("session.json") ?? [:]
452
+ let previousCrumbs = (try? JSONSerialization.jsonObject(with: Data(contentsOf: directory.appendingPathComponent("breadcrumbs.json")))) as? [[String: Any]] ?? []
453
+ let images = (try? JSONDecoder().decode([CraftImage].self, from: Data(contentsOf: directory.appendingPathComponent("images.json")))) ?? []
454
+
455
+ var signal: Int32 = 0
456
+ var code = 0
457
+ var faultAddress = ""
458
+ var kind = "signal"
459
+ var exceptionName: String?
460
+ var messages: [String] = []
461
+ var addresses: [UInt64] = []
462
+ var crashedAt = Date()
463
+ for line in text.split(separator: "\n") {
464
+ let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
465
+ guard parts.count == 2 else { continue }
466
+ switch parts[0] {
467
+ case "kind": kind = parts[1]
468
+ case "signal": signal = Int32(parts[1]) ?? 0
469
+ case "code": code = Int(parts[1]) ?? 0
470
+ case "address": faultAddress = parts[1]
471
+ case "time": crashedAt = Date(timeIntervalSince1970: TimeInterval(parts[1]) ?? Date().timeIntervalSince1970)
472
+ case "exception": exceptionName = parts[1]
473
+ case "message": if !parts[1].isEmpty { messages.append(parts[1]) }
474
+ case "frame": if let value = UInt64(parts[1].dropFirst(2), radix: 16) { addresses.append(value) }
475
+ default: break
476
+ }
477
+ }
478
+ let frames = addresses.map { frame(for: $0, in: images) }
479
+ let signalName = Self.signalName(signal)
480
+ let type = exceptionName ?? signalName
481
+ var message = messages.first(where: { $0.contains("Fatal error") || $0.contains("fatal error") }) ?? messages.first ?? ""
482
+ if message.isEmpty {
483
+ message = Self.signalDescription(signal, code: code, address: faultAddress)
484
+ }
485
+ var exception: [String: Any] = [
486
+ "mechanism": kind == "nsexception" ? "nsexception" : "signal",
487
+ "handled": false,
488
+ "signal": signalName,
489
+ "frames": frames,
490
+ ]
491
+ if kind == "signal", !faultAddress.isEmpty { exception["code"] = "\(Self.signalCodeName(signal, code)) at \(faultAddress)" }
492
+ let release = previousSession["release"] as? String ?? self.release
493
+ var event = baseEvent(release: release, sessionId: previousSession["id"] as? String, sessionStart: previousSession["startedAt"] as? String)
494
+ event["type"] = type
495
+ event["message"] = message
496
+ event["level"] = "fatal"
497
+ event["timestamp"] = Self.iso(crashedAt)
498
+ event["exception"] = exception
499
+ event["breadcrumbs"] = Array(previousCrumbs.suffix(50))
500
+ event["tags"] = ["mechanism": exception["mechanism"] as? String ?? "signal"]
501
+ if let version = previousSession["version"] as? String, let build = previousSession["build"] as? String {
502
+ var contexts = event["contexts"] as? [String: Any] ?? [:]
503
+ var app = contexts["app"] as? [String: Any] ?? [:]
504
+ app["version"] = version
505
+ app["build"] = build
506
+ contexts["app"] = app
507
+ event["contexts"] = contexts
508
+ }
509
+ rememberCrashSignature(frames: frames, release: release)
510
+ enqueue("errors", event)
511
+ }
512
+
513
+ private func frame(for address: UInt64, in images: [CraftImage]) -> [String: Any] {
514
+ var frame: [String: Any] = ["instruction_addr": "0x\(String(address, radix: 16))"]
515
+ if let image = images.first(where: { address >= $0.base && address < $0.base + max($0.size, 1) }) {
516
+ frame["image"] = image.name
517
+ frame["image_uuid"] = image.uuid
518
+ frame["image_offset"] = address - image.base
519
+ frame["in_app"] = image.inApp
520
+ } else {
521
+ frame["in_app"] = false
522
+ }
523
+ return frame
524
+ }
525
+
526
+ // MARK: MetricKit
527
+
528
+ func didReceive(_ payloads: [MXDiagnosticPayload]) {
529
+ queue.async {
530
+ for payload in payloads {
531
+ for crash in payload.crashDiagnostics ?? [] { self.handleMetricKit(crash.jsonRepresentation(), hang: false) }
532
+ for hang in payload.hangDiagnostics ?? [] { self.handleMetricKit(hang.jsonRepresentation(), hang: true) }
533
+ }
534
+ }
535
+ }
536
+
537
+ /// One MetricKit diagnostic, from its JSON form (the same shape on every
538
+ /// iOS version, and testable from a recorded payload).
539
+ func handleMetricKit(_ json: Data, hang: Bool) {
540
+ guard let diagnostic = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any] else { return }
541
+ let meta = diagnostic["diagnosticMetaData"] as? [String: Any] ?? [:]
542
+ let tree = diagnostic["callStackTree"] as? [String: Any] ?? [:]
543
+ let stacks = tree["callStacks"] as? [[String: Any]] ?? []
544
+ let attributed = stacks.first(where: { $0["threadAttributed"] as? Bool == true }) ?? stacks.first
545
+ var frames: [[String: Any]] = []
546
+ var node = (attributed?["callStackRootFrames"] as? [[String: Any]])?.first
547
+ while let current = node, frames.count < craftCrashMaxFrames {
548
+ let name = current["binaryName"] as? String ?? "???"
549
+ var frame: [String: Any] = [
550
+ "image": name,
551
+ "in_app": name == executableName || bundledFrameworkNames.contains(name),
552
+ ]
553
+ if let uuid = current["binaryUUID"] as? String { frame["image_uuid"] = uuid }
554
+ if let offset = (current["offsetIntoBinaryTextSegment"] as? NSNumber)?.uint64Value { frame["image_offset"] = offset }
555
+ if let address = (current["address"] as? NSNumber)?.uint64Value { frame["instruction_addr"] = "0x\(String(address, radix: 16))" }
556
+ frames.append(frame)
557
+ node = (current["subFrames"] as? [[String: Any]])?.first
558
+ }
559
+ guard !frames.isEmpty else { return }
560
+ let version = meta["appVersion"] as? String ?? appVersion
561
+ let build = meta["appBuildVersion"] as? String ?? appBuild
562
+ let release = settings?.release.flatMap { $0.isEmpty ? nil : $0 } ?? (build.isEmpty ? version : "\(version)+\(build)")
563
+
564
+ var event = baseEvent(release: release, sessionId: nil, sessionStart: nil)
565
+ var contexts = event["contexts"] as? [String: Any] ?? [:]
566
+ var app = contexts["app"] as? [String: Any] ?? [:]
567
+ app["version"] = version
568
+ app["build"] = build
569
+ contexts["app"] = app
570
+ if let device = meta["deviceType"] as? String {
571
+ var deviceContext = contexts["device"] as? [String: Any] ?? [:]
572
+ deviceContext["model"] = device
573
+ contexts["device"] = deviceContext
574
+ }
575
+ if let os = meta["osVersion"] as? String {
576
+ contexts["os"] = ["name": "iOS", "version": os]
577
+ event["os"] = os.replacingOccurrences(of: "iPhone OS", with: "iOS")
578
+ }
579
+ event["contexts"] = contexts
580
+ event["tags"] = ["mechanism": hang ? "metrickit-hang" : "metrickit"]
581
+
582
+ if hang {
583
+ let duration = meta["hangDuration"] as? String ?? ""
584
+ event["type"] = "AppHang"
585
+ event["message"] = duration.isEmpty ? "The app stopped responding" : "The app stopped responding for \(duration)"
586
+ event["level"] = "error"
587
+ event["exception"] = ["mechanism": "hang", "handled": true, "frames": frames]
588
+ } else {
589
+ if isKnownCrash(frames: frames, release: release) { return }
590
+ let signal = (meta["signal"] as? NSNumber)?.int32Value ?? 0
591
+ let reason = (diagnostic["objectiveCexceptionReason"] ?? meta["objectiveCexceptionReason"]) as? [String: Any]
592
+ let exceptionName = reason?["exceptionName"] as? String
593
+ event["type"] = exceptionName ?? Self.signalName(signal)
594
+ event["message"] = (reason?["composedMessage"] as? String)
595
+ ?? (meta["terminationReason"] as? String)
596
+ ?? Self.signalDescription(signal, code: 0, address: "")
597
+ event["level"] = "fatal"
598
+ var exception: [String: Any] = ["mechanism": "metrickit", "handled": false, "signal": Self.signalName(signal), "frames": frames]
599
+ if let type = (meta["exceptionType"] as? NSNumber)?.intValue { exception["code"] = Self.machExceptionName(type) }
600
+ event["exception"] = exception
601
+ }
602
+ enqueue("errors", event)
603
+ }
604
+
605
+ private lazy var bundledFrameworkNames: Set<String> = {
606
+ let frameworks = Bundle.main.privateFrameworksURL
607
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: frameworks?.path ?? "")) ?? []
608
+ return Set(names.map { ($0 as NSString).deletingPathExtension })
609
+ }()
610
+
611
+ // A crash the in-process handler already reported (same release, same top
612
+ // in-app frames) is not sent again when MetricKit delivers its copy.
613
+ private func signature(frames: [[String: Any]], release: String) -> String? {
614
+ let top = frames.filter { $0["in_app"] as? Bool == true }.prefix(4).compactMap { frame -> String? in
615
+ guard let uuid = frame["image_uuid"] as? String, let offset = frame["image_offset"] as? UInt64 else { return nil }
616
+ return "\(uuid.uppercased())+\(offset)"
617
+ }
618
+ return top.isEmpty ? nil : "\(release)|\(top.joined(separator: ","))"
619
+ }
620
+
621
+ private func rememberCrashSignature(frames: [[String: Any]], release: String) {
622
+ guard let signature = signature(frames: frames, release: release) else { return }
623
+ var known = UserDefaults.standard.dictionary(forKey: "craft.crash.signatures") as? [String: Double] ?? [:]
624
+ let cutoff = Date().addingTimeInterval(-14 * 86_400).timeIntervalSince1970
625
+ known = known.filter { $0.value > cutoff }
626
+ known[signature] = Date().timeIntervalSince1970
627
+ UserDefaults.standard.set(known, forKey: "craft.crash.signatures")
628
+ }
629
+
630
+ private func isKnownCrash(frames: [[String: Any]], release: String) -> Bool {
631
+ guard let signature = signature(frames: frames, release: release) else { return false }
632
+ let known = UserDefaults.standard.dictionary(forKey: "craft.crash.signatures") as? [String: Double] ?? [:]
633
+ return known[signature] != nil
634
+ }
635
+
636
+ // MARK: JavaScript
637
+
638
+ /// An uncaught exception in a native screen or the app script. The screen
639
+ /// keeps running (or falls back to the web page), so this is an error, not
640
+ /// a crash.
641
+ func captureJavaScript(name: String?, message: String, stack: String, route: String, isAppScript: Bool) {
642
+ guard enabled else { return }
643
+ queue.async {
644
+ let key = "\(name ?? "")|\(message)|\(stack.prefix(200))"
645
+ if let last = self.lastJavaScriptError, last.key == key, Date().timeIntervalSince(last.at) < 5 { return }
646
+ self.lastJavaScriptError = (key, Date())
647
+ var event = self.baseEvent(release: self.release, sessionId: self.sessionId, sessionStart: Self.iso(self.sessionStart))
648
+ event["type"] = (name?.isEmpty == false ? name : nil) ?? "Error"
649
+ event["message"] = message
650
+ if !stack.isEmpty, stack != "undefined" { event["stack"] = stack }
651
+ event["level"] = "error"
652
+ event["url"] = "app://\(route)"
653
+ event["breadcrumbs"] = Array(self.breadcrumbs.suffix(50))
654
+ event["tags"] = ["mechanism": "jsc", "runtime": isAppScript ? "app-script" : "screen", "route": route]
655
+ event["framework"] = "stx-native"
656
+ self.enqueue("errors", event)
657
+ }
658
+ }
659
+
660
+ /// An event the page or a screen built (with @bughq/sdk) and handed over,
661
+ /// so it shares the outbox, the session and the device context.
662
+ func capture(_ given: [String: Any]) {
663
+ guard enabled else { return }
664
+ queue.async {
665
+ var event = self.baseEvent(release: self.release, sessionId: self.sessionId, sessionStart: Self.iso(self.sessionStart))
666
+ for (key, value) in given where !(value is NSNull) {
667
+ switch key {
668
+ case "key", "project", "session", "release", "environment", "os", "platform": continue
669
+ case "contexts":
670
+ // The device's own facts win over what a web view guesses.
671
+ var contexts = value as? [String: Any] ?? [:]
672
+ for (name, context) in event["contexts"] as? [String: Any] ?? [:] { contexts[name] = context }
673
+ event["contexts"] = contexts
674
+ case "user":
675
+ if self.userId == nil { event["user"] = value }
676
+ default:
677
+ event[key] = value
678
+ }
679
+ }
680
+ if event["type"] == nil { event["type"] = "Error" }
681
+ if event["message"] == nil { return }
682
+ self.enqueue("errors", event)
683
+ }
684
+ }
685
+
686
+ /// Send what is queued now (the page asked, e.g. before a sign-out).
687
+ func requestFlush() {
688
+ guard enabled else { return }
689
+ queue.async { self.flush() }
690
+ }
691
+
692
+ func setUser(_ id: String?) {
693
+ let value = id.flatMap { $0.isEmpty ? nil : String($0.prefix(128)) }
694
+ userId = value
695
+ UserDefaults.standard.set(value, forKey: "craft.crash.user")
696
+ }
697
+
698
+ func addBreadcrumb(_ crumb: [String: Any]) {
699
+ guard enabled else { return }
700
+ queue.async {
701
+ var crumb = crumb
702
+ if crumb["timestamp"] == nil { crumb["timestamp"] = Self.iso(Date()) }
703
+ if let message = crumb["message"] as? String, message.count > 300 { crumb["message"] = String(message.prefix(300)) }
704
+ self.breadcrumbs.append(crumb)
705
+ if self.breadcrumbs.count > 100 { self.breadcrumbs.removeFirst(self.breadcrumbs.count - 100) }
706
+ // Kept on disk, so a crash's report carries the trail that led to it.
707
+ if !self.breadcrumbsDirty {
708
+ self.breadcrumbsDirty = true
709
+ self.queue.asyncAfter(deadline: .now() + 1) { self.saveBreadcrumbs() }
710
+ }
711
+ }
712
+ }
713
+
714
+ /// A screen came on screen.
715
+ func screenAppeared(_ route: String) {
716
+ addBreadcrumb(["type": "navigation", "category": "navigation", "message": route, "data": ["to": route]])
717
+ }
718
+
719
+ /// A request a native screen made failed or was refused.
720
+ func requestFailed(method: String, url: URL, status: Int?, error: String?) {
721
+ var data: [String: Any] = ["method": method, "url": Self.maskURL(url)]
722
+ if let status { data["status"] = status }
723
+ if let error { data["error"] = error }
724
+ addBreadcrumb(["type": "http", "category": "fetch", "level": status.map { $0 >= 500 ? "error" : "warning" } ?? "error", "data": data])
725
+ }
726
+
727
+ /// The path with ids templated and no query string: which call failed, not
728
+ /// whose data it carried.
729
+ static func maskURL(_ url: URL) -> String {
730
+ let path = url.path.split(separator: "/", omittingEmptySubsequences: false).map { segment -> String in
731
+ let s = String(segment)
732
+ if s.isEmpty { return s }
733
+ if s.allSatisfy(\.isNumber) { return "{n}" }
734
+ if s.count >= 4, s.contains(where: \.isLetter), s.contains(where: \.isNumber) { return "{id}" }
735
+ return s
736
+ }.joined(separator: "/")
737
+ return "\(url.host ?? "")\(path)\(url.query == nil ? "" : "?…")"
738
+ }
739
+
740
+ private func saveBreadcrumbs() {
741
+ guard breadcrumbsDirty, let directory else { return }
742
+ breadcrumbsDirty = false
743
+ if let data = try? JSONSerialization.data(withJSONObject: breadcrumbs) {
744
+ try? data.write(to: directory.appendingPathComponent("breadcrumbs.json"), options: .atomic)
745
+ }
746
+ }
747
+
748
+ /// What JavaScript is told about the reporter and the device.
749
+ func context() -> [String: Any] {
750
+ var info = baseEvent(release: release, sessionId: sessionId, sessionStart: Self.iso(sessionStart))
751
+ info["enabled"] = enabled
752
+ info.removeValue(forKey: "key")
753
+ return info
754
+ }
755
+
756
+ // MARK: Events
757
+
758
+ private func baseEvent(release: String, sessionId: String?, sessionStart: String?) -> [String: Any] {
759
+ let device = UIDevice.current
760
+ var event: [String: Any] = [
761
+ "platform": "ios",
762
+ "release": release,
763
+ "environment": environment,
764
+ "os": "iOS \(device.systemVersion)",
765
+ "framework": "craft",
766
+ "timestamp": Self.iso(Date()),
767
+ "sdk": ["name": Self.sdkName, "version": craftVersion],
768
+ "contexts": [
769
+ "app": [
770
+ "id": Bundle.main.bundleIdentifier ?? "",
771
+ "name": Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? (Bundle.main.infoDictionary?["CFBundleName"] as? String ?? ""),
772
+ "version": appVersion,
773
+ "build": appBuild,
774
+ ],
775
+ "device": [
776
+ "model": Self.deviceModel(),
777
+ "family": device.model,
778
+ "simulator": Self.isSimulator,
779
+ "locale": Locale.current.identifier,
780
+ ],
781
+ "os": ["name": "iOS", "version": device.systemVersion],
782
+ ],
783
+ ]
784
+ if let sessionId { event["session"] = ["id": sessionId, "startedAt": sessionStart ?? Self.iso(self.sessionStart)] }
785
+ if let userId { event["user"] = ["id": userId] }
786
+ return event
787
+ }
788
+
789
+ private var craftVersion: String { "{{CRAFT_VERSION}}" }
790
+
791
+ private func enqueue(_ endpoint: String, _ body: [String: Any]) {
792
+ guard let outbox, JSONSerialization.isValidJSONObject(["endpoint": endpoint, "body": body]),
793
+ let data = try? JSONSerialization.data(withJSONObject: ["endpoint": endpoint, "body": body]) else { return }
794
+ let name = String(format: "%013.0f-%@.json", Date().timeIntervalSince1970 * 1000, UUID().uuidString)
795
+ try? data.write(to: outbox.appendingPathComponent(name), options: .atomic)
796
+ flush()
797
+ }
798
+
799
+ private func enqueueSession(status: String) {
800
+ var session: [String: Any] = [
801
+ "id": sessionId,
802
+ "release": release,
803
+ "environment": environment,
804
+ "platform": "ios",
805
+ "status": status,
806
+ "started_at": Self.iso(sessionStart),
807
+ ]
808
+ if let userId { session["user_id"] = userId }
809
+ enqueue("sessions", ["sessions": [session]])
810
+ }
811
+
812
+ /// At most 200 queued items and none older than 14 days: a phone that is
813
+ /// offline for a month sends its recent history, not everything.
814
+ private func pruneOutbox() {
815
+ guard let outbox else { return }
816
+ let files = ((try? FileManager.default.contentsOfDirectory(atPath: outbox.path)) ?? []).filter { $0.hasSuffix(".json") }.sorted()
817
+ let cutoff = (Date().timeIntervalSince1970 - 14 * 86_400) * 1000
818
+ for (index, name) in files.enumerated() {
819
+ let stamp = Double(name.prefix(13)) ?? 0
820
+ if index < files.count - 200 || stamp < cutoff {
821
+ try? FileManager.default.removeItem(at: outbox.appendingPathComponent(name))
822
+ }
823
+ }
824
+ }
825
+
826
+ // MARK: Delivery
827
+
828
+ private func startNetworkMonitor() {
829
+ let monitor = NWPathMonitor()
830
+ monitor.pathUpdateHandler = { [weak self] path in
831
+ guard let self else { return }
832
+ self.queue.async {
833
+ let wasOnline = self.online
834
+ self.online = path.status == .satisfied
835
+ if self.online && !wasOnline {
836
+ // Back online: whatever waited goes now.
837
+ self.retryAt = .distantPast
838
+ self.failures = 0
839
+ self.flush()
840
+ }
841
+ }
842
+ }
843
+ monitor.start(queue: queue)
844
+ pathMonitor = monitor
845
+ }
846
+
847
+ /// Sends the outbox, oldest first, one at a time. Runs on `queue`.
848
+ func flush() {
849
+ guard enabled, !flushing, online, Date() >= retryAt, let outbox, let settings else { return }
850
+ let files = ((try? FileManager.default.contentsOfDirectory(atPath: outbox.path)) ?? []).filter { $0.hasSuffix(".json") }.sorted()
851
+ guard let next = files.first else { return }
852
+ let fileURL = outbox.appendingPathComponent(next)
853
+ guard let data = try? Data(contentsOf: fileURL),
854
+ let item = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
855
+ let endpoint = item["endpoint"] as? String, var body = item["body"] as? [String: Any] else {
856
+ try? FileManager.default.removeItem(at: fileURL)
857
+ flush()
858
+ return
859
+ }
860
+ body["key"] = settings.key
861
+ let host = (settings.host?.isEmpty == false ? settings.host! : "https://bughq.org").trimmingCharacters(in: CharacterSet(charactersIn: "/"))
862
+ guard let url = URL(string: "\(host)/\(endpoint == "sessions" ? "sessions" : "errors")"),
863
+ let payload = try? JSONSerialization.data(withJSONObject: body) else {
864
+ try? FileManager.default.removeItem(at: fileURL)
865
+ return
866
+ }
867
+ var request = URLRequest(url: url)
868
+ request.httpMethod = "POST"
869
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
870
+ request.setValue(settings.key, forHTTPHeaderField: "X-BugHQ-Key")
871
+ request.httpBody = payload
872
+ flushing = true
873
+ urlSession.dataTask(with: request) { [weak self] _, response, error in
874
+ guard let self else { return }
875
+ self.queue.async {
876
+ self.flushing = false
877
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
878
+ if error == nil, (200..<300).contains(status) || ((400..<500).contains(status) && status != 408 && status != 429) {
879
+ // Delivered, or refused for good (a 4xx will not change on retry).
880
+ try? FileManager.default.removeItem(at: fileURL)
881
+ self.failures = 0
882
+ self.flush()
883
+ } else {
884
+ // Offline, rate limited or the server is down: back off and keep it.
885
+ self.failures += 1
886
+ let delay = min(3600, 15 * pow(2, Double(min(self.failures, 8))))
887
+ self.retryAt = Date().addingTimeInterval(delay)
888
+ self.queue.asyncAfter(deadline: .now() + delay) { self.flush() }
889
+ }
890
+ }
891
+ }.resume()
892
+ }
893
+
894
+ // MARK: Helpers
895
+
896
+ private func write(_ object: [String: Any], to name: String) {
897
+ if let data = try? JSONSerialization.data(withJSONObject: object) {
898
+ try? data.write(to: directory.appendingPathComponent(name), options: .atomic)
899
+ }
900
+ }
901
+
902
+ private func read(_ name: String) -> [String: Any]? {
903
+ guard let data = try? Data(contentsOf: directory.appendingPathComponent(name)) else { return nil }
904
+ return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
905
+ }
906
+
907
+ static func iso(_ date: Date) -> String {
908
+ let formatter = ISO8601DateFormatter()
909
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
910
+ return formatter.string(from: date)
911
+ }
912
+
913
+ static var isSimulator: Bool {
914
+ #if targetEnvironment(simulator)
915
+ return true
916
+ #else
917
+ return false
918
+ #endif
919
+ }
920
+
921
+ /// `iPhone17,1`, not `iPhone`.
922
+ static func deviceModel() -> String {
923
+ if let simulated = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { return simulated }
924
+ var system = utsname()
925
+ uname(&system)
926
+ return withUnsafeBytes(of: system.machine) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
927
+ }
928
+
929
+ static func signalName(_ signal: Int32) -> String {
930
+ switch signal {
931
+ case SIGABRT: return "SIGABRT"
932
+ case SIGBUS: return "SIGBUS"
933
+ case SIGFPE: return "SIGFPE"
934
+ case SIGILL: return "SIGILL"
935
+ case SIGSEGV: return "SIGSEGV"
936
+ case SIGTRAP: return "SIGTRAP"
937
+ case SIGSYS: return "SIGSYS"
938
+ case SIGKILL: return "SIGKILL"
939
+ case 0: return "Crash"
940
+ default: return "Signal \(signal)"
941
+ }
942
+ }
943
+
944
+ static func signalCodeName(_ signal: Int32, _ code: Int) -> String {
945
+ switch (signal, Int32(code)) {
946
+ case (SIGSEGV, SEGV_MAPERR): return "SEGV_MAPERR"
947
+ case (SIGSEGV, SEGV_ACCERR): return "SEGV_ACCERR"
948
+ case (SIGBUS, BUS_ADRALN): return "BUS_ADRALN"
949
+ case (SIGBUS, BUS_ADRERR): return "BUS_ADRERR"
950
+ case (SIGFPE, FPE_INTDIV): return "FPE_INTDIV"
951
+ case (SIGILL, ILL_ILLOPC): return "ILL_ILLOPC"
952
+ default: return "code \(code)"
953
+ }
954
+ }
955
+
956
+ static func signalDescription(_ signal: Int32, code: Int, address: String) -> String {
957
+ switch signal {
958
+ case SIGSEGV, SIGBUS: return address.isEmpty ? "Bad memory access" : "Bad memory access at \(address)"
959
+ case SIGTRAP: return "The app stopped at a runtime trap"
960
+ case SIGABRT: return "The app aborted"
961
+ case SIGILL: return "Illegal instruction"
962
+ case SIGFPE: return "Arithmetic error"
963
+ case SIGKILL: return "The app was killed"
964
+ default: return "The app crashed with \(signalName(signal))"
965
+ }
966
+ }
967
+
968
+ static func machExceptionName(_ type: Int) -> String {
969
+ switch type {
970
+ case 1: return "EXC_BAD_ACCESS"
971
+ case 2: return "EXC_BAD_INSTRUCTION"
972
+ case 3: return "EXC_ARITHMETIC"
973
+ case 6: return "EXC_BREAKPOINT"
974
+ case 10: return "EXC_CRASH"
975
+ case 11: return "EXC_RESOURCE"
976
+ case 12: return "EXC_GUARD"
977
+ default: return "EXC_\(type)"
978
+ }
979
+ }
980
+
981
+ // MARK: Debug-only test crash
982
+
983
+ #if DEBUG
984
+ /// `-CraftCrashTest swift|signal|exception|js` as a launch argument (e.g.
985
+ /// `xcrun simctl launch <device> <bundle id> -CraftCrashTest swift`)
986
+ /// crashes the app, or throws in the app script, a few seconds after
987
+ /// launch, to check the whole pipeline. Compiled into Debug builds only:
988
+ /// a Release build has no code here and no way to reach it.
989
+ private func scheduleDebugCrashTest() {
990
+ guard let kind = UserDefaults.standard.string(forKey: "CraftCrashTest") else { return }
991
+ NSLog("[craft crash] test %@ scheduled", kind)
992
+ DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
993
+ self.addBreadcrumb(["type": "debug", "category": "crash-test", "message": "Crash test: \(kind)"])
994
+ self.queue.sync { self.saveBreadcrumbs() }
995
+ switch kind {
996
+ case "signal":
997
+ let pointer = UnsafeMutablePointer<Int>(bitPattern: 0x10)!
998
+ pointer.pointee = 1
999
+ case "exception":
1000
+ _ = NSArray().object(at: 4)
1001
+ case "js":
1002
+ CraftHybrid.shared.debugThrowInAppScript("Craft crash reporter test error")
1003
+ default:
1004
+ let missing: [String: Int] = [:]
1005
+ _ = missing["crash-test"]!
1006
+ }
1007
+ }
1008
+ }
1009
+ #endif
1010
+ }