@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.11
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/README.md +260 -2
- package/android/build.gradle +35 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabModule.kt +445 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabSampler.kt +515 -0
- package/app.plugin.js +105 -0
- package/expo-module.config.json +9 -0
- package/ios/PerfLab.podspec +29 -0
- package/ios/PerfLabHermes.h +18 -0
- package/ios/PerfLabHermes.mm +64 -0
- package/ios/PerfLabModule.swift +371 -0
- package/ios/PerfLabSampler.swift +364 -0
- package/js/babel-transformer.js +71 -0
- package/js/hook.polyfill.js +333 -0
- package/js/renderer-switch.js +16 -0
- package/js/sdk/activation.ts +375 -0
- package/js/sdk/active.ts +62 -0
- package/js/sdk/artifacts.ts +632 -0
- package/js/sdk/boot.ts +21 -0
- package/js/sdk/causes.ts +932 -0
- package/js/sdk/core.ts +2403 -0
- package/js/sdk/cpuprof.ts +133 -0
- package/js/sdk/dormant.ts +77 -0
- package/js/sdk/env.d.ts +2 -0
- package/js/sdk/fiber.ts +591 -0
- package/js/sdk/interactions.ts +531 -0
- package/js/sdk/links.ts +201 -0
- package/js/sdk/nav.ts +383 -0
- package/js/sdk/net-install.ts +49 -0
- package/js/sdk/net.ts +684 -0
- package/js/sdk/profile-export.ts +257 -0
- package/js/sdk/profile.ts +539 -0
- package/js/sdk/push-install.ts +158 -0
- package/js/sdk/push.ts +819 -0
- package/js/sdk/retain-floor.ts +11 -0
- package/js/sdk/sampler.ts +236 -0
- package/js/sdk/screens.ts +586 -0
- package/js/sdk/stream.ts +125 -0
- package/js/zustand-vanilla.js +31 -0
- package/metro.js +238 -0
- package/package.json +26 -4
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#import "PerfLabHermes.h"
|
|
2
|
+
|
|
3
|
+
#include <fstream>
|
|
4
|
+
#include <sstream>
|
|
5
|
+
|
|
6
|
+
#include <hermes/hermes.h>
|
|
7
|
+
#include <jsi/jsi.h>
|
|
8
|
+
|
|
9
|
+
namespace fh = facebook::hermes;
|
|
10
|
+
namespace jsi = facebook::jsi;
|
|
11
|
+
|
|
12
|
+
static fh::IHermesRootAPI *rootAPI() {
|
|
13
|
+
return jsi::castInterface<fh::IHermesRootAPI>(fh::makeHermesRootAPI());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@implementation PerfLabHermes
|
|
17
|
+
|
|
18
|
+
+ (BOOL)startWithHz:(double)hz {
|
|
19
|
+
auto *api = rootAPI();
|
|
20
|
+
if (api == nullptr) {
|
|
21
|
+
return NO;
|
|
22
|
+
}
|
|
23
|
+
api->enableSamplingProfiler(hz);
|
|
24
|
+
return YES;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
+ (BOOL)stop {
|
|
28
|
+
auto *api = rootAPI();
|
|
29
|
+
if (api == nullptr) {
|
|
30
|
+
return NO;
|
|
31
|
+
}
|
|
32
|
+
api->disableSamplingProfiler();
|
|
33
|
+
return YES;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
+ (nullable NSDictionary<NSString *, id> *)dumpRuntime:(void *)runtime toPath:(NSString *)path {
|
|
37
|
+
std::string file = path.UTF8String;
|
|
38
|
+
NSString *format = nil;
|
|
39
|
+
auto *jsiRuntime = static_cast<jsi::Runtime *>(runtime);
|
|
40
|
+
auto *ihermes = jsiRuntime != nullptr ? jsi::castInterface<fh::IHermes>(jsiRuntime) : nullptr;
|
|
41
|
+
if (ihermes != nullptr) {
|
|
42
|
+
std::ofstream out(file, std::ios::out | std::ios::trunc);
|
|
43
|
+
if (!out) {
|
|
44
|
+
return nil;
|
|
45
|
+
}
|
|
46
|
+
ihermes->sampledTraceToStreamInDevToolsFormat(out);
|
|
47
|
+
format = @"cpuprofile";
|
|
48
|
+
} else {
|
|
49
|
+
auto *api = rootAPI();
|
|
50
|
+
if (api == nullptr) {
|
|
51
|
+
return nil;
|
|
52
|
+
}
|
|
53
|
+
api->dumpSampledTraceToFile(file);
|
|
54
|
+
format = @"hermes-trace-events";
|
|
55
|
+
}
|
|
56
|
+
NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil];
|
|
57
|
+
return @{
|
|
58
|
+
@"path" : path,
|
|
59
|
+
@"bytes" : attributes[NSFileSize] ?: @0,
|
|
60
|
+
@"format" : format,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@end
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
import Foundation
|
|
4
|
+
import UIKit
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
private let pointsOfInterest = OSLog(subsystem: "com.gigaverse.perflab", category: .pointsOfInterest)
|
|
8
|
+
private let logger = OSLog(subsystem: "com.gigaverse.perflab", category: "log")
|
|
9
|
+
|
|
10
|
+
// os_log truncates long messages, so long payloads go out in chunks.
|
|
11
|
+
private let logChunk = 800
|
|
12
|
+
|
|
13
|
+
// Created by Expo at app start whether or not the SDK measures: nothing here may
|
|
14
|
+
// do work before a function is called (a dormant build calls activationRead only).
|
|
15
|
+
public class PerfLabModule: Module {
|
|
16
|
+
private lazy var logQueue = DispatchQueue(label: "com.gigaverse.perflab.log")
|
|
17
|
+
private var logHandle: FileHandle?
|
|
18
|
+
private var spoolHandle: FileHandle?
|
|
19
|
+
private var samplerCreated = false
|
|
20
|
+
private lazy var sampler: PerfLabSampler = {
|
|
21
|
+
self.samplerCreated = true
|
|
22
|
+
return PerfLabSampler { [weak self] line in self?.writeLog(line) }
|
|
23
|
+
}()
|
|
24
|
+
|
|
25
|
+
public func definition() -> ModuleDefinition {
|
|
26
|
+
Name("PerfLab")
|
|
27
|
+
|
|
28
|
+
// Signpost names must be static, so the window name travels in the message.
|
|
29
|
+
Function("beginAsyncSection") { (name: String, cookie: Int) in
|
|
30
|
+
os_signpost(.begin, log: pointsOfInterest, name: "perf-lab", signpostID: OSSignpostID(UInt64(cookie)), "%{public}s", name)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Function("endAsyncSection") { (name: String, cookie: Int) in
|
|
34
|
+
os_signpost(.end, log: pointsOfInterest, name: "perf-lab", signpostID: OSSignpostID(UInt64(cookie)), "%{public}s", name)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Function("setCounter") { (name: String, value: Double) in
|
|
38
|
+
os_signpost(.event, log: pointsOfInterest, name: "perf-lab-counter", "%{public}s=%f", name, value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Function("isTracing") { () -> Bool in
|
|
42
|
+
pointsOfInterest.signpostsEnabled
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Function("log") { (message: String) in
|
|
46
|
+
self.writeLog(message)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Function("samplerConfigure") { (mode: String, intervalMs: Double) -> [String: Any] in
|
|
50
|
+
self.sampler.configure(mode: mode, intervalMs: intervalMs)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Function("samplerSample") { (reason: String) -> [String: Any]? in
|
|
54
|
+
self.sampler.sample(reason: reason)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Function("samplerRequest") { (reason: String) in
|
|
58
|
+
self.sampler.request(reason: reason)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
Function("samplerTake") { (reason: String) -> [String: Any]? in
|
|
62
|
+
self.sampler.take(reason: reason)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Function("samplerRecent") { (sinceT: Double) -> [[String: Any]] in
|
|
66
|
+
self.sampler.recent(since: sinceT)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Function("samplerNow") { () -> Double in
|
|
70
|
+
perfLabMonotonicMs()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
OnDestroy {
|
|
74
|
+
if self.samplerCreated {
|
|
75
|
+
_ = self.sampler.configure(mode: "off", intervalMs: 0)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Activation of a dormant build (JSON written by the SDK, read once at startup by the
|
|
80
|
+
// hook polyfill). Plain file read: no directory is created when there is none.
|
|
81
|
+
Function("activationRead") { () -> String? in
|
|
82
|
+
guard let file = Self.fileIfPresent("activation.json") else { return nil }
|
|
83
|
+
return try? String(contentsOf: file, encoding: .utf8)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Function("activationWrite") { (json: String) -> Bool in
|
|
87
|
+
guard let dir = Self.outputDir() else { return false }
|
|
88
|
+
do {
|
|
89
|
+
try json.write(to: dir.appendingPathComponent("activation.json"), atomically: true, encoding: .utf8)
|
|
90
|
+
return true
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// os_log only, never the log file: a dormant build's answer to `dump=1`.
|
|
97
|
+
Function("osLog") { (message: String) in
|
|
98
|
+
Self.emit(message)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Push journal (perf-lab/push-spool.jsonl): append is queued, read and replace wait for
|
|
102
|
+
// the queue so they see every earlier append.
|
|
103
|
+
Function("spoolAppend") { (record: String) in
|
|
104
|
+
self.logQueue.async {
|
|
105
|
+
if self.spoolHandle == nil, let dir = Self.outputDir() {
|
|
106
|
+
let file = dir.appendingPathComponent("push-spool.jsonl")
|
|
107
|
+
if !FileManager.default.fileExists(atPath: file.path) {
|
|
108
|
+
FileManager.default.createFile(atPath: file.path, contents: nil)
|
|
109
|
+
}
|
|
110
|
+
self.spoolHandle = try? FileHandle(forWritingTo: file)
|
|
111
|
+
_ = try? self.spoolHandle?.seekToEnd()
|
|
112
|
+
}
|
|
113
|
+
if let data = (record + "\n").data(using: .utf8) {
|
|
114
|
+
try? self.spoolHandle?.write(contentsOf: data)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Result of a `flush=1` link for the test driver: a transparent 1x1 pt element at the
|
|
120
|
+
// top-left of the key window, accessibility id `perflab.flushed`, label ok / error / off.
|
|
121
|
+
// Only ever created on request in a measuring process; replaced by the next flush.
|
|
122
|
+
Function("flushSignal") { (status: String, detail: String) in
|
|
123
|
+
Task { @MainActor in
|
|
124
|
+
Self.showSignal(tag: Self.flushSignalTag, id: "perflab.flushed", label: status, value: detail)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The answer to a driver command (GC before the action): the same kind of element, id `perflab.ack`, label `gc:<n> ok`.
|
|
129
|
+
Function("ackSignal") { (text: String) in
|
|
130
|
+
Task { @MainActor in
|
|
131
|
+
Self.showSignal(tag: Self.ackSignalTag, id: "perflab.ack", label: text, value: text)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Function("spoolRead") { () -> String? in
|
|
136
|
+
self.logQueue.sync {
|
|
137
|
+
guard let file = Self.fileIfPresent("push-spool.jsonl") else { return nil }
|
|
138
|
+
return Self.readTail(file, max: Self.spoolReadMax)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Function("spoolReplace") { (content: String) -> Bool in
|
|
143
|
+
self.logQueue.sync {
|
|
144
|
+
try? self.spoolHandle?.close()
|
|
145
|
+
self.spoolHandle = nil
|
|
146
|
+
guard let dir = Self.outputDir() else { return false }
|
|
147
|
+
let file = dir.appendingPathComponent("push-spool.jsonl")
|
|
148
|
+
if content.isEmpty {
|
|
149
|
+
try? FileManager.default.removeItem(at: file)
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
do {
|
|
153
|
+
try content.write(to: file, atomically: true, encoding: .utf8)
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// performance.now() in Hermes on iOS is steady_clock = mach_absolute_time (stops in sleep);
|
|
162
|
+
// the snapshot lets a run map it onto wall time and the trace clock.
|
|
163
|
+
Function("clockSnapshot") { () -> [String: Double] in
|
|
164
|
+
var info = mach_timebase_info_data_t()
|
|
165
|
+
mach_timebase_info(&info)
|
|
166
|
+
let machMs = Double(mach_absolute_time()) * Double(info.numer) / Double(info.denom) / 1_000_000
|
|
167
|
+
let continuousMs = Double(mach_continuous_time()) * Double(info.numer) / Double(info.denom) / 1_000_000
|
|
168
|
+
return [
|
|
169
|
+
"machAbsoluteMs": machMs,
|
|
170
|
+
"machContinuousMs": continuousMs,
|
|
171
|
+
"systemUptimeMs": ProcessInfo.processInfo.systemUptime * 1000,
|
|
172
|
+
"wallMs": Date().timeIntervalSince1970 * 1000,
|
|
173
|
+
"thermalState": Double(ProcessInfo.processInfo.thermalState.rawValue),
|
|
174
|
+
"lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled ? 1 : 0,
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Documents/perf-lab is pulled with `xcrun devicectl device copy from --domain-type appDataContainer`.
|
|
179
|
+
Function("writeText") { (fileName: String, content: String) -> String? in
|
|
180
|
+
guard let dir = Self.outputDir() else { return nil }
|
|
181
|
+
let file = dir.appendingPathComponent(fileName.replacingOccurrences(of: "/", with: "_"))
|
|
182
|
+
do {
|
|
183
|
+
try content.write(to: file, atomically: true, encoding: .utf8)
|
|
184
|
+
return file.path
|
|
185
|
+
} catch {
|
|
186
|
+
return nil
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Hermes sampling profiler (PerfLabHermes.mm). Sync functions run on the JS
|
|
191
|
+
// thread, which the per-runtime dump needs.
|
|
192
|
+
Function("cpuProfileStart") { (hz: Double) -> Bool in
|
|
193
|
+
PerfLabHermes.start(withHz: hz)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
Function("cpuProfileStop") { () -> Bool in
|
|
197
|
+
PerfLabHermes.stop()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A raw profile file of a run (plan 40 §3.6): gzip, sha256 of the gzip, PUT to the
|
|
201
|
+
// signed URL the API handed out. Called at flush only, never inside a measured window.
|
|
202
|
+
// status 0: no HTTP answer (missing file, network error, timeout).
|
|
203
|
+
// The file is memory-mapped (clean pages, not app memory) and checked against the API's cap
|
|
204
|
+
// before it is read; only the gzip is built in memory.
|
|
205
|
+
// NOT YET COMPILED: the size check and the mapped read (night of 2026-10-14).
|
|
206
|
+
AsyncFunction("artifactUpload") { (fileName: String, url: String, headers: [String: String], timeoutMs: Double, promise: Promise) in
|
|
207
|
+
guard let file = Self.fileIfPresent(fileName.replacingOccurrences(of: "/", with: "_")) else {
|
|
208
|
+
promise.resolve(["status": 0, "error": "file missing"])
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
let size = ((try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber)?.int64Value ?? 0
|
|
212
|
+
if size > Self.artifactMaxBytes {
|
|
213
|
+
promise.resolve(["status": 413, "error": "file is \(size) bytes, over \(Self.artifactMaxBytes)"])
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
guard let raw = try? Data(contentsOf: file, options: .alwaysMapped) else {
|
|
217
|
+
promise.resolve(["status": 0, "error": "file missing"])
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
guard let target = URL(string: url), let gz = Self.gzip(raw) else {
|
|
221
|
+
promise.resolve(["status": 0, "bytes": raw.count, "error": "gzip or url failed"])
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
let sha = SHA256.hash(data: gz).map { String(format: "%02x", $0) }.joined()
|
|
225
|
+
var request = URLRequest(url: target, timeoutInterval: max(1, timeoutMs / 1000))
|
|
226
|
+
request.httpMethod = "PUT"
|
|
227
|
+
for (key, value) in headers {
|
|
228
|
+
request.setValue(value, forHTTPHeaderField: key)
|
|
229
|
+
}
|
|
230
|
+
URLSession.shared.uploadTask(with: request, from: gz) { _, response, error in
|
|
231
|
+
var out: [String: Any] = [
|
|
232
|
+
"status": (response as? HTTPURLResponse)?.statusCode ?? 0,
|
|
233
|
+
"bytes": raw.count,
|
|
234
|
+
"gzBytes": gz.count,
|
|
235
|
+
"sha256": sha,
|
|
236
|
+
]
|
|
237
|
+
if let error {
|
|
238
|
+
out["error"] = error.localizedDescription
|
|
239
|
+
}
|
|
240
|
+
promise.resolve(out)
|
|
241
|
+
}.resume()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
Function("cpuProfileDump") { (fileName: String) -> [String: Any]? in
|
|
245
|
+
guard let dir = Self.outputDir() else { return nil }
|
|
246
|
+
let path = dir.appendingPathComponent(fileName.replacingOccurrences(of: "/", with: "_")).path
|
|
247
|
+
guard let runtime = try? self.appContext?.runtime else {
|
|
248
|
+
return PerfLabHermes.dumpRuntime(nil, toPath: path)
|
|
249
|
+
}
|
|
250
|
+
return runtime.withUnsafePointee { pointer in
|
|
251
|
+
PerfLabHermes.dumpRuntime(pointer, toPath: path)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The API parses at most 64 MiB of a raw profile file (MAX_RAW_BYTES in apps/api cpu-worker.ts).
|
|
257
|
+
private static let artifactMaxBytes: Int64 = 64 * 1024 * 1024
|
|
258
|
+
|
|
259
|
+
private static func outputDir() -> URL? {
|
|
260
|
+
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
|
|
261
|
+
let dir = docs.appendingPathComponent("perf-lab", isDirectory: true)
|
|
262
|
+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
263
|
+
return dir
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// RFC 1952 around Foundation's zlib codec, which writes raw DEFLATE (RFC 1951).
|
|
267
|
+
private static func gzip(_ data: Data) -> Data? {
|
|
268
|
+
guard let deflated = try? (data as NSData).compressed(using: .zlib) as Data else { return nil }
|
|
269
|
+
var out = Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff])
|
|
270
|
+
out.append(deflated)
|
|
271
|
+
var crc = crc32(data).littleEndian
|
|
272
|
+
var size = UInt32(truncatingIfNeeded: data.count).littleEndian
|
|
273
|
+
withUnsafeBytes(of: &crc) { out.append(contentsOf: $0) }
|
|
274
|
+
withUnsafeBytes(of: &size) { out.append(contentsOf: $0) }
|
|
275
|
+
return out
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
|
|
279
|
+
var c = UInt32(n)
|
|
280
|
+
for _ in 0..<8 {
|
|
281
|
+
c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1
|
|
282
|
+
}
|
|
283
|
+
return c
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private static func crc32(_ data: Data) -> UInt32 {
|
|
287
|
+
var c: UInt32 = 0xFFFF_FFFF
|
|
288
|
+
data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
|
|
289
|
+
for byte in buffer {
|
|
290
|
+
c = crcTable[Int((c ^ UInt32(byte)) & 0xFF)] ^ (c >> 8)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return c ^ 0xFFFF_FFFF
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private static let flushSignalTag = 0x9E7F_1A5
|
|
297
|
+
private static let ackSignalTag = 0x9E7F_1A6
|
|
298
|
+
|
|
299
|
+
@MainActor
|
|
300
|
+
private static func showSignal(tag: Int, id: String, label status: String, value detail: String) {
|
|
301
|
+
let windows = UIApplication.shared.connectedScenes
|
|
302
|
+
.compactMap { $0 as? UIWindowScene }
|
|
303
|
+
.flatMap { $0.windows }
|
|
304
|
+
guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first else { return }
|
|
305
|
+
window.viewWithTag(tag)?.removeFromSuperview()
|
|
306
|
+
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
307
|
+
label.tag = tag
|
|
308
|
+
label.text = status
|
|
309
|
+
label.textColor = .clear
|
|
310
|
+
label.backgroundColor = .clear
|
|
311
|
+
label.isUserInteractionEnabled = false
|
|
312
|
+
label.isAccessibilityElement = true
|
|
313
|
+
label.accessibilityIdentifier = id
|
|
314
|
+
label.accessibilityLabel = status
|
|
315
|
+
label.accessibilityValue = detail
|
|
316
|
+
window.addSubview(label)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The SDK keeps the journal near 2x its queue cap (16 MB); a larger file is read from its tail.
|
|
320
|
+
private static let spoolReadMax: UInt64 = 16 * 1024 * 1024
|
|
321
|
+
|
|
322
|
+
private static func readTail(_ file: URL, max: UInt64) -> String? {
|
|
323
|
+
guard let handle = try? FileHandle(forReadingFrom: file) else { return nil }
|
|
324
|
+
defer { try? handle.close() }
|
|
325
|
+
guard let size = try? handle.seekToEnd() else { return nil }
|
|
326
|
+
let start = size > max ? size - max : 0
|
|
327
|
+
guard (try? handle.seek(toOffset: start)) != nil else { return nil }
|
|
328
|
+
let data = (try? handle.readToEnd()) ?? Data()
|
|
329
|
+
let text = String(decoding: data, as: UTF8.self)
|
|
330
|
+
if start == 0 { return text }
|
|
331
|
+
guard let newline = text.firstIndex(of: "\n") else { return "" }
|
|
332
|
+
return String(text[text.index(after: newline)...])
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private static func fileIfPresent(_ name: String) -> URL? {
|
|
336
|
+
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
|
|
337
|
+
let file = docs.appendingPathComponent("perf-lab", isDirectory: true).appendingPathComponent(name)
|
|
338
|
+
return FileManager.default.fileExists(atPath: file.path) ? file : nil
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private static func emit(_ message: String) {
|
|
342
|
+
var start = message.startIndex
|
|
343
|
+
while start < message.endIndex {
|
|
344
|
+
let end = message.index(start, offsetBy: logChunk, limitedBy: message.endIndex) ?? message.endIndex
|
|
345
|
+
os_log("%{public}s", log: logger, type: .info, String(message[start..<end]))
|
|
346
|
+
start = end
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private func writeLog(_ message: String) {
|
|
351
|
+
Self.emit(message)
|
|
352
|
+
appendToLogFile(message)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func appendToLogFile(_ message: String) {
|
|
356
|
+
let line = "\(Date().timeIntervalSince1970 * 1000) \(message)\n"
|
|
357
|
+
logQueue.async {
|
|
358
|
+
if self.logHandle == nil, let dir = Self.outputDir() {
|
|
359
|
+
let file = dir.appendingPathComponent("log.txt")
|
|
360
|
+
if !FileManager.default.fileExists(atPath: file.path) {
|
|
361
|
+
FileManager.default.createFile(atPath: file.path, contents: nil)
|
|
362
|
+
}
|
|
363
|
+
self.logHandle = try? FileHandle(forWritingTo: file)
|
|
364
|
+
_ = try? self.logHandle?.seekToEnd()
|
|
365
|
+
}
|
|
366
|
+
if let data = line.data(using: .utf8) {
|
|
367
|
+
try? self.logHandle?.write(contentsOf: data)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|