@logbrew/react-native 0.1.10 → 0.1.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.
@@ -1,6 +1,11 @@
1
1
  #import "LBRNFatalStoreModule.h"
2
2
 
3
+ #import "LBRNEventRecordStore.h"
3
4
  #import "LBRNFatalRecordStore.h"
5
+ #import "LBRNPrivateStorage.h"
6
+
7
+ #import <CommonCrypto/CommonDigest.h>
8
+ #import <TargetConditionals.h>
4
9
 
5
10
  #ifdef RCT_NEW_ARCH_ENABLED
6
11
  #import <LogBrewReactNativeSpec/LogBrewReactNativeSpec.h>
@@ -11,6 +16,51 @@ static NSDictionary *LBRNStorageError(void)
11
16
  return @{ @"status" : @"storage_error" };
12
17
  }
13
18
 
19
+ static BOOL LBRNPrepareProtectedDirectory(NSURL *directoryURL)
20
+ {
21
+ NSError *writeError = nil;
22
+ if (![directoryURL setResourceValue:@YES
23
+ forKey:NSURLIsExcludedFromBackupKey
24
+ error:&writeError]
25
+ || writeError != nil) {
26
+ return NO;
27
+ }
28
+ #if TARGET_OS_IPHONE
29
+ writeError = nil;
30
+ if (![directoryURL setResourceValue:NSFileProtectionCompleteUntilFirstUserAuthentication
31
+ forKey:NSURLFileProtectionKey
32
+ error:&writeError]
33
+ || writeError != nil) {
34
+ return NO;
35
+ }
36
+ #endif
37
+ NSNumber *excluded = nil;
38
+ NSError *readError = nil;
39
+ return [directoryURL getResourceValue:&excluded
40
+ forKey:NSURLIsExcludedFromBackupKey
41
+ error:&readError]
42
+ && readError == nil && excluded.boolValue;
43
+ }
44
+
45
+ static NSString *LBRNQueueHash(NSString *queueKey)
46
+ {
47
+ if (![queueKey isKindOfClass:[NSString class]]
48
+ || [queueKey stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]].length == 0) {
49
+ return nil;
50
+ }
51
+ NSData *data = [queueKey dataUsingEncoding:NSUTF8StringEncoding];
52
+ if (data.length == 0 || data.length > 4096) {
53
+ return nil;
54
+ }
55
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
56
+ CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
57
+ NSMutableString *value = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
58
+ for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index += 1) {
59
+ [value appendFormat:@"%02x", digest[index]];
60
+ }
61
+ return value;
62
+ }
63
+
14
64
  static NSDictionary *LBRNNormalizeRecord(NSDictionary *record)
15
65
  {
16
66
  if (![record isKindOfClass:[NSDictionary class]]) {
@@ -44,6 +94,8 @@ static NSDictionary *LBRNNormalizeRecord(NSDictionary *record)
44
94
  <NativeLogBrewFatalStoreSpec>
45
95
  #endif
46
96
  @property (nonatomic, nullable) LBRNFatalRecordStore *store;
97
+ @property (nonatomic, nullable) NSURL *eventStoreParentURL;
98
+ @property (nonatomic) NSMutableDictionary<NSString *, LBRNEventRecordStore *> *eventStores;
47
99
  @end
48
100
 
49
101
  @implementation LBRNFatalStoreModule
@@ -62,23 +114,13 @@ RCT_EXPORT_MODULE(LogBrewFatalStore)
62
114
  NSURL *baseURL =
63
115
  [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory
64
116
  inDomains:NSUserDomainMask].firstObject;
65
- if (baseURL != nil) {
117
+ if (baseURL != nil && LBRNPreparePrivateRootDirectory(baseURL)) {
118
+ _eventStoreParentURL = baseURL;
119
+ _eventStores = [NSMutableDictionary dictionary];
66
120
  NSURL *directoryURL = [baseURL URLByAppendingPathComponent:@"LogBrewFatalJS"
67
121
  isDirectory:YES];
68
122
  LBRNFatalDirectoryPreparation directoryPreparation = ^BOOL(NSURL *preparedURL) {
69
- NSError *writeError = nil;
70
- if (![preparedURL setResourceValue:@YES
71
- forKey:NSURLIsExcludedFromBackupKey
72
- error:&writeError]
73
- || writeError != nil) {
74
- return NO;
75
- }
76
- NSNumber *excluded = nil;
77
- NSError *readError = nil;
78
- return [preparedURL getResourceValue:&excluded
79
- forKey:NSURLIsExcludedFromBackupKey
80
- error:&readError]
81
- && readError == nil && excluded.boolValue;
123
+ return LBRNPrepareProtectedDirectory(preparedURL);
82
124
  };
83
125
  _store = [[LBRNFatalRecordStore alloc]
84
126
  initWithDirectoryURL:directoryURL
@@ -88,6 +130,30 @@ RCT_EXPORT_MODULE(LogBrewFatalStore)
88
130
  return self;
89
131
  }
90
132
 
133
+ - (nullable LBRNEventRecordStore *)eventStoreForQueueKey:(NSString *)queueKey
134
+ {
135
+ NSString *queueHash = LBRNQueueHash(queueKey);
136
+ if (queueHash == nil || self.eventStoreParentURL == nil) {
137
+ return nil;
138
+ }
139
+ @synchronized(self) {
140
+ LBRNEventRecordStore *existing = self.eventStores[queueHash];
141
+ if (existing != nil) {
142
+ return existing;
143
+ }
144
+ NSURL *directoryURL = [self.eventStoreParentURL
145
+ URLByAppendingPathComponent:[@"LogBrewEventsV1-" stringByAppendingString:queueHash]
146
+ isDirectory:YES];
147
+ LBRNEventRecordStore *created = [[LBRNEventRecordStore alloc]
148
+ initWithDirectoryURL:directoryURL
149
+ directoryPreparation:^BOOL(NSURL *preparedURL) {
150
+ return LBRNPrepareProtectedDirectory(preparedURL);
151
+ }];
152
+ self.eventStores[queueHash] = created;
153
+ return created;
154
+ }
155
+ }
156
+
91
157
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(writeFatalRecord:(NSDictionary *)record)
92
158
  {
93
159
  if (self.store == nil) {
@@ -136,6 +202,85 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(discardFatalRecord)
136
202
  }
137
203
  }
138
204
 
205
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(loadEventRecords:(NSString *)queueKey)
206
+ {
207
+ LBRNEventRecordStore *eventStore = [self eventStoreForQueueKey:queueKey];
208
+ if (eventStore == nil) {
209
+ return LBRNStorageError();
210
+ }
211
+ @try {
212
+ return [eventStore loadRecords];
213
+ } @catch (__unused NSException *exception) {
214
+ return LBRNStorageError();
215
+ }
216
+ }
217
+
218
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(appendEventRecord:(NSString *)queueKey
219
+ serializedEvent:(NSString *)serializedEvent
220
+ eventBytes:(double)eventBytes)
221
+ {
222
+ LBRNEventRecordStore *eventStore = [self eventStoreForQueueKey:queueKey];
223
+ if (eventStore == nil) {
224
+ return LBRNStorageError();
225
+ }
226
+ @try {
227
+ return [eventStore appendSerializedEvent:serializedEvent eventBytes:@(eventBytes)];
228
+ } @catch (__unused NSException *exception) {
229
+ return LBRNStorageError();
230
+ }
231
+ }
232
+
233
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(acknowledgeEventRecords:(NSString *)queueKey
234
+ count:(double)count)
235
+ {
236
+ LBRNEventRecordStore *eventStore = [self eventStoreForQueueKey:queueKey];
237
+ if (eventStore == nil) {
238
+ return LBRNStorageError();
239
+ }
240
+ @try {
241
+ return [eventStore acknowledgeRecordCount:@(count)];
242
+ } @catch (__unused NSException *exception) {
243
+ return LBRNStorageError();
244
+ }
245
+ }
246
+
247
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(purgeEventRecords:(NSString *)queueKey)
248
+ {
249
+ LBRNEventRecordStore *eventStore = [self eventStoreForQueueKey:queueKey];
250
+ if (eventStore == nil) {
251
+ return LBRNStorageError();
252
+ }
253
+ @try {
254
+ return [eventStore purgeRecords];
255
+ } @catch (__unused NSException *exception) {
256
+ return LBRNStorageError();
257
+ }
258
+ }
259
+
260
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(closeEventStore:(NSString *)queueKey)
261
+ {
262
+ NSString *queueHash = LBRNQueueHash(queueKey);
263
+ if (queueHash == nil) {
264
+ return LBRNStorageError();
265
+ }
266
+ LBRNEventRecordStore *eventStore = nil;
267
+ @synchronized(self) {
268
+ eventStore = self.eventStores[queueHash];
269
+ }
270
+ if (eventStore == nil) {
271
+ return @{ @"status" : @"closed" };
272
+ }
273
+ @try {
274
+ return [eventStore closeStore];
275
+ } @catch (__unused NSException *exception) {
276
+ return LBRNStorageError();
277
+ } @finally {
278
+ @synchronized(self) {
279
+ [self.eventStores removeObjectForKey:queueHash];
280
+ }
281
+ }
282
+ }
283
+
139
284
  #ifdef RCT_NEW_ARCH_ENABLED
140
285
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
141
286
  (const facebook::react::ObjCTurboModule::InitParams &)params
@@ -0,0 +1,7 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ FOUNDATION_EXPORT BOOL LBRNPreparePrivateRootDirectory(NSURL *directoryURL);
6
+
7
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,38 @@
1
+ #import "LBRNPrivateStorage.h"
2
+
3
+ #import <errno.h>
4
+ #import <fcntl.h>
5
+ #import <sys/stat.h>
6
+ #import <unistd.h>
7
+
8
+ BOOL LBRNPreparePrivateRootDirectory(NSURL *directoryURL)
9
+ {
10
+ if (!directoryURL.isFileURL) {
11
+ return NO;
12
+ }
13
+ const char *path = directoryURL.fileSystemRepresentation;
14
+ struct stat pathInfo;
15
+ if (lstat(path, &pathInfo) != 0) {
16
+ if (errno != ENOENT || mkdir(path, 0700) != 0) {
17
+ return NO;
18
+ }
19
+ } else if (!S_ISDIR(pathInfo.st_mode) || S_ISLNK(pathInfo.st_mode)) {
20
+ return NO;
21
+ }
22
+
23
+ int directoryFD = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
24
+ if (directoryFD < 0) {
25
+ return NO;
26
+ }
27
+ struct stat openedInfo;
28
+ BOOL prepared = fstat(directoryFD, &openedInfo) == 0
29
+ && S_ISDIR(openedInfo.st_mode)
30
+ && fchmod(directoryFD, 0700) == 0
31
+ && lstat(path, &pathInfo) == 0
32
+ && S_ISDIR(pathInfo.st_mode)
33
+ && !S_ISLNK(pathInfo.st_mode)
34
+ && openedInfo.st_dev == pathInfo.st_dev
35
+ && openedInfo.st_ino == pathInfo.st_ino;
36
+ close(directoryFD);
37
+ return prepared;
38
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@logbrew/react-native",
3
- "version": "0.1.10",
4
- "description": "React Native screen, error, trace, action, and network timeline helpers for LogBrew.",
3
+ "version": "0.1.11",
4
+ "description": "React Native offline delivery, screen, error, trace, action, and network timeline helpers for LogBrew.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
7
7
  "module": "./index.js",
@@ -138,6 +138,7 @@
138
138
  "global-errors.cjs",
139
139
  "promise-rejections.cjs",
140
140
  "fatal-replay.cjs",
141
+ "persistent-delivery.native.js",
141
142
  "global-errors.js",
142
143
  "global-errors.native.js",
143
144
  "global-errors.d.ts",
@@ -169,6 +170,10 @@
169
170
  "ios/LBRNFatalRecordStore.m",
170
171
  "ios/LBRNFatalStoreModule.h",
171
172
  "ios/LBRNFatalStoreModule.mm",
173
+ "ios/LBRNPrivateStorage.h",
174
+ "ios/LBRNPrivateStorage.m",
175
+ "ios/LBRNEventRecordStore.h",
176
+ "ios/LBRNEventRecordStore.m",
172
177
  "react-native.config.js",
173
178
  "src",
174
179
  "README.md",
@@ -0,0 +1,282 @@
1
+ import { SdkError } from "@logbrew/sdk";
2
+ import { NativeModules, TurboModuleRegistry } from "react-native";
3
+
4
+ const ACTIVE_QUEUE_KEYS = new Set();
5
+ const MAX_NATIVE_QUEUE_BYTES = 4 * 1024 * 1024;
6
+ const MAX_NATIVE_QUEUE_EVENTS = 1000;
7
+ const NATIVE_STORE_METHODS = [
8
+ "acknowledgeEventRecords",
9
+ "appendEventRecord",
10
+ "closeEventStore",
11
+ "loadEventRecords",
12
+ "purgeEventRecords"
13
+ ];
14
+ const PERSISTENT_QUEUE_MODES = new Set(["auto", "disabled", "required"]);
15
+
16
+ export function resolveReactNativePersistentEventStore({
17
+ authKey,
18
+ eventStore,
19
+ hasExplicitPersistentQueue = false,
20
+ maxQueueBytes,
21
+ maxQueueSize,
22
+ persistentQueue = "auto"
23
+ }) {
24
+ validatePersistentQueueMode(persistentQueue);
25
+ if (eventStore !== undefined) {
26
+ if (hasExplicitPersistentQueue) {
27
+ throw new SdkError(
28
+ "configuration_error",
29
+ "eventStore and persistentQueue are mutually exclusive"
30
+ );
31
+ }
32
+ return { eventStore, abort() {}, release() {} };
33
+ }
34
+ if (persistentQueue === "disabled") {
35
+ return { eventStore: undefined, abort() {}, release() {} };
36
+ }
37
+
38
+ const nativeStore = defaultNativeStore();
39
+ if (!nativeStore) {
40
+ if (persistentQueue === "required") {
41
+ throw new SdkError(
42
+ "configuration_error",
43
+ "React Native persistent queue requires the linked LogBrew native module"
44
+ );
45
+ }
46
+ return { eventStore: undefined, abort() {}, release() {} };
47
+ }
48
+ validateNativeQueueLimits({ maxQueueBytes, maxQueueSize });
49
+ if (ACTIVE_QUEUE_KEYS.has(authKey)) {
50
+ throw new SdkError(
51
+ "configuration_error",
52
+ "React Native persistent queue already has an active client for this key"
53
+ );
54
+ }
55
+
56
+ ACTIVE_QUEUE_KEYS.add(authKey);
57
+ let released = false;
58
+ const release = () => {
59
+ if (!released) {
60
+ released = true;
61
+ ACTIVE_QUEUE_KEYS.delete(authKey);
62
+ }
63
+ };
64
+ const nativeEventStore = createNativeEventStore(nativeStore, authKey, release);
65
+ return {
66
+ eventStore: nativeEventStore,
67
+ abort() {
68
+ try {
69
+ nativeEventStore.close();
70
+ } catch {
71
+ release();
72
+ }
73
+ },
74
+ release
75
+ };
76
+ }
77
+
78
+ export function purgeReactNativePersistentQueue({ apiKey, clientKey } = {}) {
79
+ const authKey = clientKey ?? apiKey;
80
+ if (typeof authKey !== "string" || authKey.trim() === "") {
81
+ throw new SdkError(
82
+ "configuration_error",
83
+ "purgeLogBrewReactNativePersistentQueue requires clientKey or apiKey"
84
+ );
85
+ }
86
+ if (ACTIVE_QUEUE_KEYS.has(authKey)) {
87
+ throw new SdkError(
88
+ "persistence_error",
89
+ "cannot purge a React Native persistent queue while its client is active"
90
+ );
91
+ }
92
+ const nativeStore = defaultNativeStore();
93
+ if (!nativeStore) {
94
+ throw new SdkError(
95
+ "configuration_error",
96
+ "React Native persistent queue requires the linked LogBrew native module"
97
+ );
98
+ }
99
+ let failure;
100
+ try {
101
+ requireStatus(
102
+ "purge",
103
+ callNative(nativeStore, "purgeEventRecords", authKey),
104
+ "purged"
105
+ );
106
+ } catch (error) {
107
+ failure = error;
108
+ }
109
+ try {
110
+ requireStatus(
111
+ "close",
112
+ callNative(nativeStore, "closeEventStore", authKey),
113
+ "closed"
114
+ );
115
+ } catch (error) {
116
+ failure ??= error;
117
+ }
118
+ if (failure) {
119
+ throw failure;
120
+ }
121
+ }
122
+
123
+ function createNativeEventStore(nativeStore, authKey, release) {
124
+ return {
125
+ load() {
126
+ const result = requireStatus(
127
+ "load",
128
+ callNative(nativeStore, "loadEventRecords", authKey),
129
+ "loaded"
130
+ );
131
+ if (!Array.isArray(result.records)) {
132
+ throw persistenceFailure("load");
133
+ }
134
+ return result.records.map((record) => {
135
+ if (!record
136
+ || Array.isArray(record)
137
+ || typeof record !== "object"
138
+ || typeof record.serializedEvent !== "string"
139
+ || !Number.isSafeInteger(record.eventBytes)
140
+ || record.eventBytes <= 0) {
141
+ throw persistenceFailure("load");
142
+ }
143
+ let event;
144
+ try {
145
+ event = JSON.parse(record.serializedEvent);
146
+ } catch {
147
+ throw persistenceFailure("load");
148
+ }
149
+ return {
150
+ event,
151
+ eventBytes: record.eventBytes,
152
+ serializedEvent: record.serializedEvent
153
+ };
154
+ });
155
+ },
156
+ append(record) {
157
+ requireStatus(
158
+ "append",
159
+ callNative(
160
+ nativeStore,
161
+ "appendEventRecord",
162
+ authKey,
163
+ record.serializedEvent,
164
+ record.eventBytes
165
+ ),
166
+ "appended"
167
+ );
168
+ },
169
+ acknowledge(count) {
170
+ requireStatus(
171
+ "acknowledge",
172
+ callNative(nativeStore, "acknowledgeEventRecords", authKey, count),
173
+ "acknowledged"
174
+ );
175
+ },
176
+ purge() {
177
+ requireStatus(
178
+ "purge",
179
+ callNative(nativeStore, "purgeEventRecords", authKey),
180
+ "purged"
181
+ );
182
+ },
183
+ close() {
184
+ try {
185
+ requireStatus(
186
+ "close",
187
+ callNative(nativeStore, "closeEventStore", authKey),
188
+ "closed"
189
+ );
190
+ } finally {
191
+ release();
192
+ }
193
+ }
194
+ };
195
+ }
196
+
197
+ function defaultNativeStore() {
198
+ try {
199
+ const nativeStore = TurboModuleRegistry?.get?.("LogBrewFatalStore")
200
+ ?? NativeModules?.LogBrewFatalStore;
201
+ return NATIVE_STORE_METHODS.every((method) => typeof nativeStore?.[method] === "function")
202
+ ? nativeStore
203
+ : undefined;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ function validateNativeQueueLimits({ maxQueueBytes, maxQueueSize }) {
210
+ if (maxQueueBytes !== undefined
211
+ && (!Number.isSafeInteger(maxQueueBytes)
212
+ || maxQueueBytes <= 0
213
+ || maxQueueBytes > MAX_NATIVE_QUEUE_BYTES)) {
214
+ throw new SdkError(
215
+ "configuration_error",
216
+ `persistent React Native maxQueueBytes must be at most ${MAX_NATIVE_QUEUE_BYTES}`
217
+ );
218
+ }
219
+ if (maxQueueSize !== undefined
220
+ && (!Number.isSafeInteger(maxQueueSize)
221
+ || maxQueueSize <= 0
222
+ || maxQueueSize > MAX_NATIVE_QUEUE_EVENTS)) {
223
+ throw new SdkError(
224
+ "configuration_error",
225
+ `persistent React Native maxQueueSize must be at most ${MAX_NATIVE_QUEUE_EVENTS}`
226
+ );
227
+ }
228
+ }
229
+
230
+ function validatePersistentQueueMode(mode) {
231
+ if (!PERSISTENT_QUEUE_MODES.has(mode)) {
232
+ throw new SdkError(
233
+ "configuration_error",
234
+ "persistentQueue must be auto, required, or disabled"
235
+ );
236
+ }
237
+ }
238
+
239
+ function callNative(nativeStore, method, ...args) {
240
+ if (typeof nativeStore?.[method] !== "function") {
241
+ throw persistenceFailure(methodName(method));
242
+ }
243
+ try {
244
+ return nativeStore[method](...args);
245
+ } catch {
246
+ throw persistenceFailure(methodName(method));
247
+ }
248
+ }
249
+
250
+ function requireStatus(operation, result, expected) {
251
+ if (!result
252
+ || Array.isArray(result)
253
+ || typeof result !== "object"
254
+ || result.status !== expected) {
255
+ throw persistenceFailure(operation);
256
+ }
257
+ return result;
258
+ }
259
+
260
+ function methodName(method) {
261
+ switch (method) {
262
+ case "loadEventRecords":
263
+ return "load";
264
+ case "appendEventRecord":
265
+ return "append";
266
+ case "acknowledgeEventRecords":
267
+ return "acknowledge";
268
+ case "purgeEventRecords":
269
+ return "purge";
270
+ case "closeEventStore":
271
+ return "close";
272
+ default:
273
+ return "operation";
274
+ }
275
+ }
276
+
277
+ function persistenceFailure(operation) {
278
+ return new SdkError(
279
+ "persistence_error",
280
+ `React Native persistent queue ${operation} failed`
281
+ );
282
+ }
@@ -6,6 +6,15 @@ export interface Spec extends TurboModule {
6
6
  readFatalRecord(): CodegenTypes.UnsafeObject;
7
7
  acknowledgeFatalRecord(recordId: string): CodegenTypes.UnsafeObject;
8
8
  discardFatalRecord(): CodegenTypes.UnsafeObject;
9
+ loadEventRecords(queueKey: string): CodegenTypes.UnsafeObject;
10
+ appendEventRecord(
11
+ queueKey: string,
12
+ serializedEvent: string,
13
+ eventBytes: number
14
+ ): CodegenTypes.UnsafeObject;
15
+ acknowledgeEventRecords(queueKey: string, count: number): CodegenTypes.UnsafeObject;
16
+ purgeEventRecords(queueKey: string): CodegenTypes.UnsafeObject;
17
+ closeEventStore(queueKey: string): CodegenTypes.UnsafeObject;
9
18
  }
10
19
 
11
20
  export default TurboModuleRegistry.getEnforcing<Spec>("LogBrewFatalStore");