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.
- package/dist/android/src/index.d.ts +19 -0
- package/dist/android/src/index.js +19 -2
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +51 -0
- package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
- package/dist/android/templates/CraftNative.kt.template +17 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
- package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
- package/dist/android/templates/CraftNativeServices.kt.template +31 -1
- package/dist/android/templates/MainActivityNative.kt.template +986 -304
- package/dist/android/templates/build.gradle.kts.app.template +4 -2
- package/dist/api/mobile.d.ts +31 -0
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +203 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +19 -0
- package/dist/ios/src/index.js +7 -1
- package/dist/ios/templates/CraftApp.swift +51 -1
- package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
- package/dist/ios/templates/CraftHybrid.swift +10 -0
- package/dist/ios/templates/CraftNativeActions.swift +49 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +13 -21
- package/dist/ios/templates/CraftNativeMutation.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +393 -55
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/macos/templates/CraftNativeMutation.swift +1 -1
- package/dist/macos/templates/native_layout.zig +389 -17
- package/dist/mobile.js +66 -0
- 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
|
+
}
|