@tileflow/react-native 0.0.0-bootstrap.0 → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +216 -4
  2. package/TileflowNativeAdmission.podspec +34 -0
  3. package/android/build.gradle +43 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrap.kt +144 -0
  6. package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrapOkHttpNetwork.kt +89 -0
  7. package/android/src/main/java/dev/tileflow/reactnative/AdmissionCatalog.kt +137 -0
  8. package/android/src/main/java/dev/tileflow/reactnative/AdmissionDocumentScope.kt +22 -0
  9. package/android/src/main/java/dev/tileflow/reactnative/AdmissionEngine.kt +335 -0
  10. package/android/src/main/java/dev/tileflow/reactnative/AdmissionModuleProvider.kt +68 -0
  11. package/android/src/main/java/dev/tileflow/reactnative/AdmissionOkHttpNetwork.kt +86 -0
  12. package/android/src/main/java/dev/tileflow/reactnative/AdmissionProviderLease.kt +56 -0
  13. package/android/src/main/java/dev/tileflow/reactnative/AdmissionTypes.kt +85 -0
  14. package/android/src/main/java/dev/tileflow/reactnative/AdmissionUrl.kt +100 -0
  15. package/android/src/main/java/dev/tileflow/reactnative/HandlerAdmissionScheduler.kt +16 -0
  16. package/android/src/main/java/dev/tileflow/reactnative/MobileConfiguration.kt +95 -0
  17. package/android/src/main/java/dev/tileflow/reactnative/MobileConfigurationResources.kt +43 -0
  18. package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentOkHttpNetwork.kt +101 -0
  19. package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentRegistry.kt +136 -0
  20. package/android/src/main/java/dev/tileflow/reactnative/NativeSurfaceState.kt +115 -0
  21. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionModule.kt +236 -0
  22. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionPackage.kt +16 -0
  23. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeConfigurationModule.kt +43 -0
  24. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeDocumentsModule.kt +82 -0
  25. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeSurfaceModule.kt +277 -0
  26. package/android/src/main/res/raw/tileflow_configuration_keep.xml +3 -0
  27. package/dist/contract-DWs-lDPo.d.ts +144 -0
  28. package/dist/index.d.ts +13 -0
  29. package/dist/index.js +6989 -0
  30. package/dist/internal/camera.d.ts +65 -0
  31. package/dist/internal/camera.js +325 -0
  32. package/dist/internal/hosted-binding.d.ts +18 -0
  33. package/dist/internal/hosted-binding.js +277 -0
  34. package/dist/internal/initial-view.d.ts +11 -0
  35. package/dist/internal/initial-view.js +8 -0
  36. package/dist/internal/interactions.d.ts +76 -0
  37. package/dist/internal/interactions.js +794 -0
  38. package/dist/internal/native-admission-bridge.d.ts +29 -0
  39. package/dist/internal/native-admission-bridge.js +351 -0
  40. package/dist/internal/native-admission.d.ts +78 -0
  41. package/dist/internal/native-admission.js +1614 -0
  42. package/dist/internal/native-appearance.d.ts +21 -0
  43. package/dist/internal/native-appearance.js +110 -0
  44. package/dist/internal/native-configuration-bridge.d.ts +9 -0
  45. package/dist/internal/native-configuration-bridge.js +343 -0
  46. package/dist/internal/session.d.ts +125 -0
  47. package/dist/internal/session.js +887 -0
  48. package/dist/internal/source-state.d.ts +11 -0
  49. package/dist/internal/source-state.js +26 -0
  50. package/dist/native-admission-contract-k7LKE1vW.d.ts +89 -0
  51. package/docs/native-admission.md +108 -0
  52. package/docs/native-configuration.md +210 -0
  53. package/docs/native-interactions.md +161 -0
  54. package/docs/native-location.md +148 -0
  55. package/ios/TFAdmissionBootstrap.h +17 -0
  56. package/ios/TFAdmissionBootstrap.mm +168 -0
  57. package/ios/TFAdmissionCatalog.h +13 -0
  58. package/ios/TFAdmissionCatalog.mm +193 -0
  59. package/ios/TFAdmissionDocumentScope.h +7 -0
  60. package/ios/TFAdmissionDocumentScope.mm +45 -0
  61. package/ios/TFAdmissionEngine.h +57 -0
  62. package/ios/TFAdmissionEngine.mm +371 -0
  63. package/ios/TFAdmissionInstallation.h +27 -0
  64. package/ios/TFAdmissionInstallation.mm +117 -0
  65. package/ios/TFAdmissionNetwork.h +16 -0
  66. package/ios/TFAdmissionNetwork.mm +268 -0
  67. package/ios/TFAdmissionURL.mm +116 -0
  68. package/ios/TFDocumentBounds.h +9 -0
  69. package/ios/TFDocumentBounds.mm +19 -0
  70. package/ios/TFMobileConfiguration.h +23 -0
  71. package/ios/TFMobileConfiguration.mm +139 -0
  72. package/ios/TFNativeAdmissionURLProtocol.mm +69 -0
  73. package/ios/TFNativeDocumentRegistry.h +28 -0
  74. package/ios/TFNativeDocumentRegistry.mm +193 -0
  75. package/ios/TFNativeDocumentURL.h +6 -0
  76. package/ios/TFNativeSurfaceState.h +28 -0
  77. package/ios/TFNativeSurfaceState.mm +128 -0
  78. package/ios/TileflowNativeAdmission.h +9 -0
  79. package/ios/TileflowNativeAdmission.mm +184 -0
  80. package/ios/TileflowNativeConfiguration.mm +41 -0
  81. package/ios/TileflowNativeDocuments.h +12 -0
  82. package/ios/TileflowNativeDocuments.mm +105 -0
  83. package/ios/TileflowNativeSurface.mm +403 -0
  84. package/ios/tileflow_native_admission.rb +45 -0
  85. package/package.json +51 -7
  86. package/react-native.config.cjs +13 -0
@@ -0,0 +1,371 @@
1
+ #import "TFAdmissionEngine.h"
2
+ #import "TFAdmissionCatalog.h"
3
+ #import <atomic>
4
+ #import <cmath>
5
+
6
+ static void TFInvalidAdmission(void) {
7
+ @throw [NSException exceptionWithName:@"TFNativeAdmissionInvalid" reason:@"Invalid native admission" userInfo:nil];
8
+ }
9
+ static BOOL TFString(id value, NSUInteger limit) { return [value isKindOfClass:NSString.class] && [(NSString *)value length] <= limit; }
10
+ static BOOL TFExactNumber(id value, double minimum, double maximum) {
11
+ return [value isKindOfClass:NSNumber.class] && CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID() &&
12
+ std::isfinite([value doubleValue]) && std::floor([value doubleValue]) == [value doubleValue] && [value doubleValue] >= minimum && [value doubleValue] <= maximum;
13
+ }
14
+ static BOOL TFGrantShape(NSString *value) {
15
+ if (!TFString(value, 24576)) return NO;
16
+ NSRegularExpression *expression = [NSRegularExpression regularExpressionWithPattern:@"^tf_native_v1\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$" options:0 error:nil];
17
+ return [expression numberOfMatchesInString:value options:0 range:NSMakeRange(0, value.length)] == 1;
18
+ }
19
+
20
+ @interface TFAdmissionFlag : NSObject { @public std::atomic_bool value; }
21
+ @end
22
+ @implementation TFAdmissionFlag
23
+ - (instancetype)init { if ((self = [super init])) value.store(true); return self; }
24
+ @end
25
+
26
+ @interface TFAdmissionCancellation : NSObject <TFAdmissionCancel> {
27
+ std::atomic_bool _cancelled;
28
+ }
29
+ @property (nonatomic, copy) dispatch_block_t action;
30
+ - (instancetype)initWithAction:(dispatch_block_t)action;
31
+ @end
32
+ @implementation TFAdmissionCancellation
33
+ - (instancetype)initWithAction:(dispatch_block_t)action { if ((self = [super init])) { _cancelled.store(false); _action = [action copy]; } return self; }
34
+ - (void)cancel { if (!_cancelled.exchange(true)) self.action(); }
35
+ - (NSString *)description { return @"TFAdmissionCancellation(redacted)"; }
36
+ @end
37
+
38
+ @class TFAdmissionWork;
39
+ @interface TFAdmissionContext : NSObject
40
+ @property (nonatomic, copy) NSString *identifier;
41
+ @property (nonatomic, copy, nullable) NSString *mapId;
42
+ @property (nonatomic) TFAdmissionCatalog *catalog;
43
+ @property (nonatomic) NSMutableDictionary<NSString *, TFAdmissionWork *> *work;
44
+ @property (nonatomic) NSMutableArray<NSString *> *order;
45
+ @property (nonatomic) TFAdmissionFlag *alive;
46
+ @property (nonatomic, copy, nullable) NSString *batch;
47
+ @property (nonatomic, nullable) NSArray<NSString *> *batchTickets;
48
+ @property (nonatomic, nullable) id<TFAdmissionCancel> batchTimer;
49
+ @property (nonatomic) NSUInteger sequence;
50
+ @end
51
+ @implementation TFAdmissionContext
52
+ - (instancetype)init {
53
+ if ((self = [super init])) { _work = [NSMutableDictionary dictionary]; _order = [NSMutableArray array]; _alive = [TFAdmissionFlag new]; }
54
+ return self;
55
+ }
56
+ - (NSString *)description { return @"TFAdmissionContext(redacted)"; }
57
+ @end
58
+
59
+ @interface TFAdmissionWork : NSObject
60
+ @property (nonatomic, copy) NSString *identifier;
61
+ @property (nonatomic) TFAdmissionContext *context;
62
+ @property (nonatomic) NSURLRequest *request;
63
+ @property (nonatomic) TFAdmissionFlag *alive;
64
+ @property (nonatomic) NSTimeInterval entered;
65
+ @property (atomic) NSTimeInterval deadline;
66
+ @property (nonatomic, copy) NSString *phase;
67
+ @property (nonatomic, copy) void (^response)(NSHTTPURLResponse *, NSData *);
68
+ @property (nonatomic, copy) dispatch_block_t failure;
69
+ @property (nonatomic, copy) TFAdmissionDelegate delegate;
70
+ @property (nonatomic, copy, nullable) dispatch_block_t releaseReservation;
71
+ @property (nonatomic, nullable) id<TFAdmissionCancel> cancellation;
72
+ @property (nonatomic, nullable) id<TFAdmissionCancel> timer;
73
+ @property (nonatomic, nullable) NSDictionary *authority;
74
+ @property (nonatomic) NSUInteger redirects;
75
+ @end
76
+ @implementation TFAdmissionWork
77
+ - (NSString *)description { return @"TFAdmissionWork(redacted)"; }
78
+ @end
79
+
80
+ @interface TFAdmissionEngine () {
81
+ std::atomic_bool _active;
82
+ std::atomic_bool _foreground;
83
+ std::atomic_bool _lossQueued;
84
+ std::atomic_uint _reservations;
85
+ }
86
+ @property (nonatomic, copy) NSString *installation;
87
+ @property (nonatomic) id<TFAdmissionScheduler> scheduler;
88
+ @property (nonatomic) id<TFAdmissionNetwork> network;
89
+ @property (nonatomic, copy) TFAdmissionStartGuard owns;
90
+ @property (nonatomic, copy) void (^emit)(NSDictionary *);
91
+ @property (nonatomic) NSMutableDictionary<NSString *, TFAdmissionContext *> *contexts;
92
+ @property (nonatomic) NSUInteger contextSequence;
93
+ @property (nonatomic) NSUInteger ticketSequence;
94
+ @end
95
+
96
+ @implementation TFAdmissionEngine
97
+ - (instancetype)initWithInstallation:(NSString *)installation scheduler:(id<TFAdmissionScheduler>)scheduler network:(id<TFAdmissionNetwork>)network owns:(TFAdmissionStartGuard)owns emit:(void (^)(NSDictionary *))emit {
98
+ if ((self = [super init])) {
99
+ _installation = [installation copy]; _scheduler = scheduler; _network = network; _owns = [owns copy]; _emit = [emit copy];
100
+ _contexts = [NSMutableDictionary dictionary]; _active.store(true); _foreground.store(true); _lossQueued.store(false); _reservations.store(0);
101
+ }
102
+ return self;
103
+ }
104
+ - (NSString *)description { return @"TFAdmissionEngine(redacted)"; }
105
+ - (NSString *)registerMap:(NSString *)mapId resources:(NSArray<NSDictionary *> *)resources {
106
+ if (![self isOwner] || self.contexts.count >= 16 || ![resources isKindOfClass:NSArray.class] || resources.count > 128 || self.contextSequence >= 9007199254740991ULL) TFInvalidAdmission();
107
+ if (mapId) {
108
+ NSRegularExpression *pattern = [NSRegularExpression regularExpressionWithPattern:@"^map_[A-Za-z0-9_-]{16}$" options:0 error:nil];
109
+ if (!TFString(mapId, 64) || [pattern numberOfMatchesInString:mapId options:0 range:NSMakeRange(0, mapId.length)] != 1) TFInvalidAdmission();
110
+ }
111
+ TFAdmissionCatalog *catalog = [[TFAdmissionCatalog alloc] initWithResources:resources];
112
+ TFAdmissionContext *context = [TFAdmissionContext new];
113
+ context.identifier = [NSString stringWithFormat:@"%@.%lu", self.installation, (unsigned long)++self.contextSequence];
114
+ context.mapId = mapId; context.catalog = catalog;
115
+ self.contexts[context.identifier] = context;
116
+ return context.identifier;
117
+ }
118
+ - (NSUInteger)extendContext:(NSString *)identifier resources:(NSArray<NSDictionary *> *)resources {
119
+ TFAdmissionContext *context = self.contexts[identifier];
120
+ if (!context || !context.alive->value.load() || ![self isOwner]) TFInvalidAdmission();
121
+ // Append-only identity permits replay of an acknowledgement and theme rollback.
122
+ TFAdmissionCatalog *next = [context.catalog extending:resources];
123
+ context.catalog = next;
124
+ return next.resources.count;
125
+ }
126
+ - (TFAdmissionStartGuard)contextGuard:(NSString *)identifier {
127
+ TFAdmissionContext *context = self.contexts[identifier];
128
+ if (!context || !context.alive->value.load() || ![self isOwner]) TFInvalidAdmission();
129
+ TFAdmissionFlag *alive = context.alive;
130
+ // Capture only the original atomic lifetime, never a mutable registry lookup off-thread.
131
+ return [^BOOL {
132
+ @try { return alive->value.load() && self->_foreground.load() && [self isOwner]; }
133
+ @catch (NSException *exception) { return NO; }
134
+ } copy];
135
+ }
136
+ - (NSDictionary *)resource:(NSString *)url context:(TFAdmissionContext *)context {
137
+ @try { return [context.catalog find:url]; }
138
+ @catch (NSException *exception) { return nil; }
139
+ }
140
+
141
+ - (id<TFAdmissionCancel>)request:(NSURLRequest *)request response:(void (^)(NSHTTPURLResponse *, NSData *))response failure:(dispatch_block_t)failure delegate:(TFAdmissionDelegate)delegate {
142
+ // Reserve before posting to the main scheduler, not after it drains.
143
+ // This bounds ingress as well as the per-context admitted work queues.
144
+ if (self->_reservations.fetch_add(1) >= 2048) {
145
+ self->_reservations.fetch_sub(1); failure();
146
+ return [[TFAdmissionCancellation alloc] initWithAction:^{}];
147
+ }
148
+ TFAdmissionFlag *reserved = [TFAdmissionFlag new];
149
+ dispatch_block_t release = ^{ if (reserved->value.exchange(false)) self->_reservations.fetch_sub(1); };
150
+ TFAdmissionFlag *alive = [TFAdmissionFlag new];
151
+ __block TFAdmissionWork *work = nil;
152
+ [self.scheduler enqueue:^{
153
+ if (!alive->value.load()) { release(); return; }
154
+ NSDictionary *tagged;
155
+ @try { tagged = TFAdmissionStripContext(request.URL.absoluteString); }
156
+ @catch (NSException *exception) { alive->value.store(false); release(); failure(); return; }
157
+ TFAdmissionContext *context = self.contexts[tagged[@"context"]];
158
+ if (!self->_foreground.load() || ![self isOwner] || !context || !context.alive->value.load() || ![self resource:tagged[@"url"] context:context] || context.work.count >= 128 ||
159
+ ![request.HTTPMethod isEqual:@"GET"] || request.HTTPBody || request.HTTPBodyStream ||
160
+ [request valueForHTTPHeaderField:TFAdmissionGrantHeader] != nil || self.ticketSequence >= 9007199254740991ULL) {
161
+ alive->value.store(false); release(); failure(); return;
162
+ }
163
+ NSMutableURLRequest *clean = [request mutableCopy];
164
+ clean.URL = [NSURL URLWithString:tagged[@"url"]];
165
+ work = [TFAdmissionWork new];
166
+ work.identifier = [NSString stringWithFormat:@"%lu", (unsigned long)++self.ticketSequence];
167
+ work.context = context; work.request = clean; work.alive = alive; work.entered = [self.scheduler nowMs]; work.phase = @"queue";
168
+ work.response = response; work.failure = failure; work.delegate = delegate; work.releaseReservation = release;
169
+ context.work[work.identifier] = work; [context.order addObject:work.identifier];
170
+ work.timer = [self.scheduler after:30000 perform:^{
171
+ if ([context.batchTickets containsObject:work.identifier]) [self retire:context.identifier];
172
+ else [self fail:work];
173
+ }];
174
+ [self.scheduler enqueue:^{ [self drain:context]; }];
175
+ }];
176
+ return [[TFAdmissionCancellation alloc] initWithAction:^{
177
+ if (!alive->value.exchange(false)) return;
178
+ [self.scheduler enqueue:^{
179
+ if (!work) return;
180
+ if ([work.context.batchTickets containsObject:work.identifier]) [self event:work.context kind:@"cancel" fields:@{@"tickets": @[work.identifier]}];
181
+ [self finish:work];
182
+ }];
183
+ }];
184
+ }
185
+
186
+ - (void)drain:(TFAdmissionContext *)context {
187
+ if (!context.alive->value.load() || !self->_foreground.load() || ![self isOwner] || context.batch) return;
188
+ NSMutableArray<TFAdmissionWork *> *selected = [NSMutableArray array];
189
+ for (NSString *identifier in context.order) {
190
+ TFAdmissionWork *work = context.work[identifier];
191
+ if (work.alive->value.load() && [work.phase isEqual:@"queue"]) [selected addObject:work];
192
+ if (selected.count == 8) break;
193
+ }
194
+ if (!selected.count) return;
195
+ if (context.sequence >= 9007199254740991ULL) { [self retire:context.identifier]; return; }
196
+ NSString *batch = [NSString stringWithFormat:@"%lu", (unsigned long)++context.sequence];
197
+ context.batch = batch;
198
+ NSMutableArray *tickets = [NSMutableArray array], *identifiers = [NSMutableArray array];
199
+ for (TFAdmissionWork *work in selected) {
200
+ work.phase = @"javascript";
201
+ [tickets addObject:@{@"ticket": work.identifier, @"url": work.request.URL.absoluteString}];
202
+ [identifiers addObject:work.identifier];
203
+ }
204
+ context.batchTickets = [identifiers copy];
205
+ context.batchTimer = [self.scheduler after:30000 perform:^{ if ([context.batch isEqual:batch]) [self retire:context.identifier]; }];
206
+ [self event:context kind:@"batch" fields:@{@"batch": batch, @"tickets": tickets}];
207
+ }
208
+
209
+ - (BOOL)authority:(NSDictionary *)authority allows:(NSDictionary *)resource context:(TFAdmissionContext *)context {
210
+ return resource && [authority[@"mapId"] isEqual:context.mapId] && [authority[@"resourceOrigins"] containsObject:TFAdmissionOrigin(resource[@"url"])] &&
211
+ [authority[@"resourceScopes"] containsObject:resource[@"scope"]] &&
212
+ (![resource[@"scope"] isEqual:@"style"] || TFAdmissionStyleMatchesMap(resource[@"url"], context.mapId)) &&
213
+ (!resource[@"tilesetId"] || [authority[@"tilesetIds"] containsObject:resource[@"tilesetId"]]);
214
+ }
215
+ - (BOOL)validAuthority:(NSDictionary *)authority {
216
+ if (![authority isKindOfClass:NSDictionary.class] || !TFGrantShape(authority[@"grant"]) || !TFString(authority[@"mapId"], 64)) return NO;
217
+ for (NSString *key in @[@"resourceOrigins", @"resourceScopes", @"tilesetIds"]) {
218
+ NSArray *array = authority[key];
219
+ NSUInteger maximum = [key isEqual:@"resourceOrigins"] ? 2 : [key isEqual:@"resourceScopes"] ? 6 : 18;
220
+ if (![array isKindOfClass:NSArray.class] || array.count > maximum) return NO;
221
+ for (id value in array) if (!TFString(value, 2048)) return NO;
222
+ }
223
+ return YES;
224
+ }
225
+
226
+ - (NSUInteger)completeContext:(NSString *)identifier generation:(NSUInteger)generation batch:(NSString *)batch results:(NSArray<NSDictionary *> *)results {
227
+ TFAdmissionContext *context = self.contexts[identifier];
228
+ if (!context || !context.alive->value.load() || !context.batch || generation != 1 || ![context.batch isEqual:batch] || ![self isOwner]) return 0;
229
+ if (![results isKindOfClass:NSArray.class] || results.count > 8 || results.count != context.batchTickets.count) { [self retire:identifier]; return 0; }
230
+ for (NSUInteger index = 0; index < results.count; index++) {
231
+ if (![results[index] isKindOfClass:NSDictionary.class] || ![results[index][@"ticket"] isEqual:context.batchTickets[index]]) { [self retire:identifier]; return 0; }
232
+ }
233
+ [context.batchTimer cancel]; context.batchTimer = nil; context.batch = nil; context.batchTickets = nil;
234
+ NSUInteger accepted = 0;
235
+ for (NSDictionary *result in results) {
236
+ TFAdmissionWork *work = context.work[result[@"ticket"]];
237
+ if (!work) continue;
238
+ if (!work.alive->value.load()) { [self finish:work]; continue; }
239
+ if (!self->_foreground.load() || [self.scheduler nowMs] - work.entered >= 30000) { [self fail:work]; continue; }
240
+ if ([result[@"kind"] isEqual:@"delegate"] && !context.mapId) {
241
+ work.phase = @"network"; work.deadline = work.entered + 30000;
242
+ if (![self canStart:work]) { [self fail:work]; continue; }
243
+ @try {
244
+ work.cancellation = work.delegate(work.request, ^(NSHTTPURLResponse *response, NSData *body) {
245
+ [self.scheduler enqueue:^{ [self received:work url:work.request.URL response:response body:body]; }];
246
+ });
247
+ if (!work.alive->value.load()) [work.cancellation cancel];
248
+ accepted++;
249
+ } @catch (NSException *exception) { [self fail:work]; }
250
+ continue;
251
+ }
252
+ NSDictionary *authority = result[@"authority"];
253
+ NSDictionary *resource = [self resource:work.request.URL.absoluteString context:context];
254
+ if (![result[@"kind"] isEqual:@"grant"] || !context.mapId || !TFExactNumber(result[@"validForMs"], 1, 900000) ||
255
+ ![self validAuthority:authority] || ![self authority:authority allows:resource context:context]) { [self fail:work]; continue; }
256
+ work.authority = [authority copy];
257
+ // The budget is already based on the controller's original local
258
+ // deadline. Anchor it earlier, at enqueue, not at bridge receipt.
259
+ work.deadline = MIN(work.entered + 30000, work.entered + [result[@"validForMs"] doubleValue] - 1000);
260
+ if ([self start:work url:work.request.URL]) accepted++;
261
+ }
262
+ [self.scheduler enqueue:^{ [self drain:context]; }];
263
+ return accepted;
264
+ }
265
+
266
+ - (BOOL)canStart:(TFAdmissionWork *)work {
267
+ if (!work.alive->value.load() || !work.context.alive->value.load() || !self->_active.load() || !self->_foreground.load() || [self.scheduler nowMs] >= work.deadline) return NO;
268
+ if (!self.owns()) { [self loseOwnership]; return NO; }
269
+ return YES;
270
+ }
271
+ - (BOOL)start:(TFAdmissionWork *)work url:(NSURL *)url {
272
+ NSDictionary *resource = [self resource:url.absoluteString context:work.context];
273
+ if (!resource || !work.authority || ![self authority:work.authority allows:resource context:work.context] || ![self canStart:work]) { [self fail:work]; return NO; }
274
+ NSMutableURLRequest *request = [work.request mutableCopy];
275
+ request.URL = url;
276
+ request.timeoutInterval = MAX(0.001, (work.deadline - [self.scheduler nowMs]) / 1000.0);
277
+ [request setValue:work.authority[@"grant"] forHTTPHeaderField:TFAdmissionGrantHeader];
278
+ work.phase = @"network";
279
+ @try {
280
+ work.cancellation = [self.network start:request mayStart:^BOOL { return [self canStart:work]; }
281
+ completion:^(NSHTTPURLResponse *response, NSData *body) {
282
+ [self.scheduler enqueue:^{ [self received:work url:url response:response body:body]; }];
283
+ }];
284
+ if (!work.alive->value.load()) [work.cancellation cancel];
285
+ return YES;
286
+ } @catch (NSException *exception) { [self fail:work]; return NO; }
287
+ }
288
+
289
+ - (void)received:(TFAdmissionWork *)work url:(NSURL *)url response:(NSHTTPURLResponse *)response body:(NSData *)body {
290
+ if (!work.alive->value.load() || !work.context.alive->value.load()) return;
291
+ if (![self isOwner]) return;
292
+ if (!response || !body || body.length > 8388608 || !self->_foreground.load() || [self.scheduler nowMs] >= work.deadline) { [self fail:work]; return; }
293
+ [self event:work.context kind:@"response" fields:@{@"status": @(response.statusCode)}];
294
+ if (work.authority && [@[@301, @302, @303, @307, @308] containsObject:@(response.statusCode)]) {
295
+ if (++work.redirects > 3) { [self fail:work]; return; }
296
+ NSString *location = [response valueForHTTPHeaderField:@"Location"];
297
+ @try {
298
+ if (!TFString(location, 2048) || !location.length || [location containsString:@"\\"]) TFInvalidAdmission();
299
+ NSString *target = [NSURL URLWithString:location relativeToURL:url].absoluteURL.absoluteString;
300
+ if (TFAdmissionHasReservedContext(target)) {
301
+ NSDictionary *tagged = TFAdmissionStripContext(target);
302
+ if (![tagged[@"context"] isEqual:work.context.identifier]) TFInvalidAdmission();
303
+ target = tagged[@"url"];
304
+ } else TFAdmissionCleanURL(target);
305
+ if (![TFAdmissionOrigin(target) isEqual:TFAdmissionOrigin(work.request.URL.absoluteString)]) TFInvalidAdmission();
306
+ [self start:work url:[NSURL URLWithString:target]];
307
+ } @catch (NSException *exception) { [self fail:work]; }
308
+ return;
309
+ }
310
+ if (!work.alive->value.exchange(false)) return;
311
+ [self finish:work];
312
+ work.response(response, body);
313
+ }
314
+ - (void)finish:(TFAdmissionWork *)work {
315
+ work.alive->value.store(false);
316
+ [work.cancellation cancel]; work.cancellation = nil;
317
+ [work.timer cancel]; work.timer = nil; work.authority = nil;
318
+ [work.context.work removeObjectForKey:work.identifier]; [work.context.order removeObject:work.identifier];
319
+ if (work.releaseReservation) work.releaseReservation();
320
+ work.releaseReservation = nil;
321
+ [self.scheduler enqueue:^{ [self drain:work.context]; }];
322
+ }
323
+ - (void)fail:(TFAdmissionWork *)work {
324
+ BOOL notify = work.alive->value.exchange(false);
325
+ [self finish:work];
326
+ if (notify) work.failure();
327
+ }
328
+ - (void)retire:(NSString *)identifier {
329
+ TFAdmissionContext *context = self.contexts[identifier];
330
+ if (!context) return;
331
+ [self.contexts removeObjectForKey:identifier]; context.alive->value.store(false);
332
+ [context.batchTimer cancel]; context.batchTimer = nil; context.batch = nil; context.batchTickets = nil;
333
+ for (TFAdmissionWork *work in context.work.allValues) [self fail:work];
334
+ [self event:context kind:@"retired" fields:@{@"code": @"NATIVE_ADMISSION_CANCELLED"}];
335
+ }
336
+ - (void)lifecycle:(BOOL)foreground {
337
+ self->_foreground.store(foreground);
338
+ if (!foreground) for (TFAdmissionContext *context in self.contexts.allValues) {
339
+ if (context.batchTickets.count) [self event:context kind:@"cancel" fields:@{@"tickets": context.batchTickets}];
340
+ for (TFAdmissionWork *work in context.work.allValues) [self fail:work];
341
+ }
342
+ [self safeEmit:@{@"kind": @"lifecycle", @"installation": self.installation, @"foreground": @(foreground)}];
343
+ if (foreground) for (TFAdmissionContext *context in self.contexts.allValues) [self.scheduler enqueue:^{ [self drain:context]; }];
344
+ }
345
+ - (void)close {
346
+ self->_active.store(false);
347
+ for (NSString *identifier in self.contexts.allKeys) [self retire:identifier];
348
+ }
349
+ - (BOOL)isOwner {
350
+ if (!self->_active.load()) return NO;
351
+ if (!self.owns()) { [self loseOwnership]; return NO; }
352
+ return YES;
353
+ }
354
+ - (void)loseOwnership {
355
+ self->_active.store(false);
356
+ if (self->_lossQueued.exchange(true)) return;
357
+ [self.scheduler enqueue:^{
358
+ [self close];
359
+ [self safeEmit:@{@"kind": @"ownershipLost", @"installation": self.installation}];
360
+ }];
361
+ }
362
+ - (void)event:(TFAdmissionContext *)context kind:(NSString *)kind fields:(NSDictionary *)fields {
363
+ NSMutableDictionary *event = [@{@"kind": kind, @"installation": self.installation, @"context": context.identifier, @"generation": @1} mutableCopy];
364
+ [event addEntriesFromDictionary:fields];
365
+ [self safeEmit:event];
366
+ }
367
+ - (void)safeEmit:(NSDictionary *)event {
368
+ @try { self.emit(event); }
369
+ @catch (NSException *exception) { [self close]; }
370
+ }
371
+ @end
@@ -0,0 +1,27 @@
1
+ #import "TFAdmissionEngine.h"
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+ @class MLNNetworkConfiguration;
5
+ @interface TFAdmissionInstallation : NSObject
6
+ @property (nonatomic, readonly) NSString *identifier;
7
+ @property (nonatomic, readonly) TFAdmissionEngine *engine;
8
+ - (instancetype)initWithEmitter:(void (^)(NSDictionary *event))emit;
9
+ // Private deterministic native harness seams; not exposed over React Native.
10
+ - (instancetype)initWithScheduler:(id<TFAdmissionScheduler>)scheduler
11
+ network:(nullable id<TFAdmissionNetwork>)network
12
+ emit:(void (^)(NSDictionary *event))emit;
13
+ - (instancetype)initWithConfiguration:(MLNNetworkConfiguration *)configuration
14
+ scheduler:(id<TFAdmissionScheduler>)scheduler
15
+ network:(nullable id<TFAdmissionNetwork>)network
16
+ emit:(void (^)(NSDictionary *event))emit;
17
+ - (BOOL)ownsConfiguration;
18
+ - (NSDictionary *)remove;
19
+ - (id<TFAdmissionCancel>)delegateRequest:(NSURLRequest *)request
20
+ mayStart:(TFAdmissionStartGuard)guard completion:(TFAdmissionNetworkCompletion)completion;
21
+ @end
22
+
23
+ @interface TFNativeAdmissionURLProtocol : NSURLProtocol
24
+ + (nullable TFAdmissionInstallation *)activeInstallation;
25
+ + (void)setActiveInstallation:(nullable TFAdmissionInstallation *)installation;
26
+ @end
27
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,117 @@
1
+ #import "TFAdmissionInstallation.h"
2
+ #import "TFAdmissionNetwork.h"
3
+ #import <MapLibre/MLNNetworkConfiguration.h>
4
+
5
+ @interface TFAdmissionInstallation ()
6
+ @property (nonatomic, readwrite) NSString *identifier;
7
+ @property (nonatomic, readwrite) TFAdmissionEngine *engine;
8
+ @property (nonatomic) MLNNetworkConfiguration *manager;
9
+ @property (nonatomic) NSURLSessionConfiguration *previous;
10
+ @property (nonatomic) NSURLSessionConfiguration *installed;
11
+ @property (nonatomic) NSArray<Class> *protocols;
12
+ @property (nonatomic) NSDictionary *additionalHeaders;
13
+ @property (nonatomic, nullable) id<MLNNetworkConfigurationDelegate> previousDelegate;
14
+ @property (nonatomic, nullable) TFAdmissionURLSessionNetwork *protectedNetwork;
15
+ @property (nonatomic) TFAdmissionURLSessionNetwork *previousNetwork;
16
+ @property (atomic) BOOL active;
17
+ @property (nonatomic, nullable) NSDictionary *removal;
18
+ @end
19
+
20
+ @implementation TFAdmissionInstallation
21
+ - (instancetype)initWithEmitter:(void (^)(NSDictionary *))emit {
22
+ return [self initWithScheduler:[TFContinuousAdmissionScheduler new] network:nil emit:emit];
23
+ }
24
+ - (instancetype)initWithScheduler:(id<TFAdmissionScheduler>)scheduler network:(id<TFAdmissionNetwork>)network emit:(void (^)(NSDictionary *))emit {
25
+ return [self initWithConfiguration:MLNNetworkConfiguration.sharedManager scheduler:scheduler network:network emit:emit];
26
+ }
27
+ - (instancetype)initWithConfiguration:(MLNNetworkConfiguration *)configuration scheduler:(id<TFAdmissionScheduler>)scheduler network:(id<TFAdmissionNetwork>)network emit:(void (^)(NSDictionary *))emit {
28
+ if ((self = [super init])) {
29
+ if ([TFNativeAdmissionURLProtocol activeInstallation]) [self invalid];
30
+ _manager = configuration;
31
+ _previous = _manager.sessionConfiguration;
32
+ _previousDelegate = _manager.delegate;
33
+ // A custom session factory bypasses sessionConfiguration.
34
+ if (!_manager || !_previous || _previous.identifier || [_previousDelegate respondsToSelector:@selector(sessionForNetworkConfiguration:)] ||
35
+ [_previous.protocolClasses containsObject:TFNativeAdmissionURLProtocol.class]) [self invalid];
36
+ for (NSString *name in _previous.HTTPAdditionalHeaders) {
37
+ if ([name caseInsensitiveCompare:TFAdmissionGrantHeader] == NSOrderedSame) [self invalid];
38
+ }
39
+ @try {
40
+ _identifier = NSUUID.UUID.UUIDString;
41
+ _installed = [_previous copy];
42
+ _protocols = [@[TFNativeAdmissionURLProtocol.class] arrayByAddingObjectsFromArray:_previous.protocolClasses ?: @[]];
43
+ _installed.protocolClasses = _protocols;
44
+ _additionalHeaders = [_installed.HTTPAdditionalHeaders copy] ?: @{};
45
+ _previousNetwork = [[TFAdmissionURLSessionNetwork alloc] initWithConfiguration:_previous followsRedirects:YES];
46
+ if (!network) {
47
+ NSURLSessionConfiguration *privateConfiguration = NSURLSessionConfiguration.ephemeralSessionConfiguration;
48
+ privateConfiguration.protocolClasses = @[];
49
+ privateConfiguration.URLCache = nil;
50
+ privateConfiguration.HTTPCookieStorage = nil;
51
+ privateConfiguration.URLCredentialStorage = nil;
52
+ privateConfiguration.HTTPShouldSetCookies = NO;
53
+ privateConfiguration.HTTPAdditionalHeaders = nil;
54
+ privateConfiguration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
55
+ privateConfiguration.HTTPMaximumConnectionsPerHost = 1;
56
+ _protectedNetwork = [[TFAdmissionURLSessionNetwork alloc] initWithConfiguration:privateConfiguration followsRedirects:NO];
57
+ network = _protectedNetwork;
58
+ }
59
+ if (!network || !_previousNetwork) [self invalid];
60
+ _active = YES;
61
+ __weak TFAdmissionInstallation *weakSelf = self;
62
+ _engine = [[TFAdmissionEngine alloc] initWithInstallation:_identifier scheduler:scheduler network:network
63
+ owns:^BOOL { return [weakSelf ownsConfiguration]; } emit:emit];
64
+ [TFNativeAdmissionURLProtocol setActiveInstallation:self];
65
+ _manager.sessionConfiguration = _installed;
66
+ if (![self ownsConfiguration]) [self invalid];
67
+ } @catch (NSException *exception) {
68
+ // A setter can mutate before throwing. Restore by identity, not
69
+ // by whether its call returned, and never overwrite a later owner.
70
+ @try { [self remove]; } @catch (NSException *cleanupError) {
71
+ [self.engine close]; [self.protectedNetwork close]; [self.previousNetwork close];
72
+ }
73
+ self.active = NO;
74
+ if ([TFNativeAdmissionURLProtocol activeInstallation] == self) [TFNativeAdmissionURLProtocol setActiveInstallation:nil];
75
+ [self invalid];
76
+ }
77
+ }
78
+ return self;
79
+ }
80
+ - (NSString *)description { return @"TFAdmissionInstallation(redacted)"; }
81
+ - (BOOL)matchesConfiguration {
82
+ NSURLSessionConfiguration *current = self.manager.sessionConfiguration;
83
+ return self.installed && current == self.installed && self.manager.delegate == self.previousDelegate &&
84
+ [current.protocolClasses isEqual:self.protocols] && [(current.HTTPAdditionalHeaders ?: @{}) isEqual:self.additionalHeaders] &&
85
+ [TFNativeAdmissionURLProtocol activeInstallation] == self;
86
+ }
87
+ - (BOOL)ownsConfiguration {
88
+ @try { return self.active && [self matchesConfiguration]; }
89
+ @catch (NSException *exception) { return NO; }
90
+ }
91
+ - (NSDictionary *)remove {
92
+ if (self.removal) return self.removal;
93
+ BOOL owned = [self matchesConfiguration];
94
+ self.active = NO;
95
+ [self.engine close];
96
+ [self.protectedNetwork close]; [self.previousNetwork close];
97
+ BOOL restored = NO;
98
+ if (owned) {
99
+ @try { self.manager.sessionConfiguration = self.previous; }
100
+ @catch (NSException *exception) {
101
+ if (self.manager.sessionConfiguration == self.installed) [self invalid];
102
+ }
103
+ NSURLSessionConfiguration *after = self.manager.sessionConfiguration;
104
+ if (after == self.installed) [self invalid];
105
+ restored = after == self.previous;
106
+ }
107
+ if ([TFNativeAdmissionURLProtocol activeInstallation] == self) [TFNativeAdmissionURLProtocol setActiveInstallation:nil];
108
+ self.removal = @{@"removed": @(restored), @"ownershipLost": @(!restored)};
109
+ return self.removal;
110
+ }
111
+ - (id<TFAdmissionCancel>)delegateRequest:(NSURLRequest *)request mayStart:(TFAdmissionStartGuard)guard completion:(TFAdmissionNetworkCompletion)completion {
112
+ return [self.previousNetwork start:request mayStart:guard completion:completion];
113
+ }
114
+ - (void)invalid {
115
+ @throw [NSException exceptionWithName:@"TFNativeAdmissionOwnership" reason:@"Native network ownership is unavailable" userInfo:nil];
116
+ }
117
+ @end
@@ -0,0 +1,16 @@
1
+ #import "TFAdmissionEngine.h"
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+ @interface TFContinuousAdmissionScheduler : NSObject <TFAdmissionScheduler>
5
+ @end
6
+
7
+ @interface TFAdmissionURLSessionNetwork : NSObject <TFAdmissionNetwork>
8
+ - (instancetype)initWithConfiguration:(NSURLSessionConfiguration *)configuration followsRedirects:(BOOL)followsRedirects;
9
+ - (instancetype)initWithConfiguration:(NSURLSessionConfiguration *)configuration followsRedirects:(BOOL)followsRedirects
10
+ responseByteLimit:(NSUInteger)responseByteLimit queueDepth:(NSUInteger)queueDepth concurrency:(NSUInteger)concurrency;
11
+ // Internal deterministic harness seam; callers must provide a serial queue.
12
+ - (instancetype)initWithConfiguration:(NSURLSessionConfiguration *)configuration followsRedirects:(BOOL)followsRedirects
13
+ responseByteLimit:(NSUInteger)responseByteLimit queueDepth:(NSUInteger)queueDepth concurrency:(NSUInteger)concurrency workQueue:(dispatch_queue_t)workQueue;
14
+ - (void)close;
15
+ @end
16
+ NS_ASSUME_NONNULL_END