@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.
- package/README.md +216 -4
- package/TileflowNativeAdmission.podspec +34 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrap.kt +144 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrapOkHttpNetwork.kt +89 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionCatalog.kt +137 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionDocumentScope.kt +22 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionEngine.kt +335 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionModuleProvider.kt +68 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionOkHttpNetwork.kt +86 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionProviderLease.kt +56 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionTypes.kt +85 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionUrl.kt +100 -0
- package/android/src/main/java/dev/tileflow/reactnative/HandlerAdmissionScheduler.kt +16 -0
- package/android/src/main/java/dev/tileflow/reactnative/MobileConfiguration.kt +95 -0
- package/android/src/main/java/dev/tileflow/reactnative/MobileConfigurationResources.kt +43 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentOkHttpNetwork.kt +101 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentRegistry.kt +136 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeSurfaceState.kt +115 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionModule.kt +236 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionPackage.kt +16 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeConfigurationModule.kt +43 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeDocumentsModule.kt +82 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeSurfaceModule.kt +277 -0
- package/android/src/main/res/raw/tileflow_configuration_keep.xml +3 -0
- package/dist/contract-DWs-lDPo.d.ts +144 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +6989 -0
- package/dist/internal/camera.d.ts +65 -0
- package/dist/internal/camera.js +325 -0
- package/dist/internal/hosted-binding.d.ts +18 -0
- package/dist/internal/hosted-binding.js +277 -0
- package/dist/internal/initial-view.d.ts +11 -0
- package/dist/internal/initial-view.js +8 -0
- package/dist/internal/interactions.d.ts +76 -0
- package/dist/internal/interactions.js +794 -0
- package/dist/internal/native-admission-bridge.d.ts +29 -0
- package/dist/internal/native-admission-bridge.js +351 -0
- package/dist/internal/native-admission.d.ts +78 -0
- package/dist/internal/native-admission.js +1614 -0
- package/dist/internal/native-appearance.d.ts +21 -0
- package/dist/internal/native-appearance.js +110 -0
- package/dist/internal/native-configuration-bridge.d.ts +9 -0
- package/dist/internal/native-configuration-bridge.js +343 -0
- package/dist/internal/session.d.ts +125 -0
- package/dist/internal/session.js +887 -0
- package/dist/internal/source-state.d.ts +11 -0
- package/dist/internal/source-state.js +26 -0
- package/dist/native-admission-contract-k7LKE1vW.d.ts +89 -0
- package/docs/native-admission.md +108 -0
- package/docs/native-configuration.md +210 -0
- package/docs/native-interactions.md +161 -0
- package/docs/native-location.md +148 -0
- package/ios/TFAdmissionBootstrap.h +17 -0
- package/ios/TFAdmissionBootstrap.mm +168 -0
- package/ios/TFAdmissionCatalog.h +13 -0
- package/ios/TFAdmissionCatalog.mm +193 -0
- package/ios/TFAdmissionDocumentScope.h +7 -0
- package/ios/TFAdmissionDocumentScope.mm +45 -0
- package/ios/TFAdmissionEngine.h +57 -0
- package/ios/TFAdmissionEngine.mm +371 -0
- package/ios/TFAdmissionInstallation.h +27 -0
- package/ios/TFAdmissionInstallation.mm +117 -0
- package/ios/TFAdmissionNetwork.h +16 -0
- package/ios/TFAdmissionNetwork.mm +268 -0
- package/ios/TFAdmissionURL.mm +116 -0
- package/ios/TFDocumentBounds.h +9 -0
- package/ios/TFDocumentBounds.mm +19 -0
- package/ios/TFMobileConfiguration.h +23 -0
- package/ios/TFMobileConfiguration.mm +139 -0
- package/ios/TFNativeAdmissionURLProtocol.mm +69 -0
- package/ios/TFNativeDocumentRegistry.h +28 -0
- package/ios/TFNativeDocumentRegistry.mm +193 -0
- package/ios/TFNativeDocumentURL.h +6 -0
- package/ios/TFNativeSurfaceState.h +28 -0
- package/ios/TFNativeSurfaceState.mm +128 -0
- package/ios/TileflowNativeAdmission.h +9 -0
- package/ios/TileflowNativeAdmission.mm +184 -0
- package/ios/TileflowNativeConfiguration.mm +41 -0
- package/ios/TileflowNativeDocuments.h +12 -0
- package/ios/TileflowNativeDocuments.mm +105 -0
- package/ios/TileflowNativeSurface.mm +403 -0
- package/ios/tileflow_native_admission.rb +45 -0
- package/package.json +51 -7
- package/react-native.config.cjs +13 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Application-owned foreground location
|
|
2
|
+
|
|
3
|
+
`@tileflow/react-native` does not own device location. A Tileflow `Map` renders normally without a
|
|
4
|
+
location permission, provider, hook or native location module. The application decides whether to
|
|
5
|
+
request foreground permission, which provider to use, when observation starts and stops, what fix is
|
|
6
|
+
displayed, whether recentering is offered, and what status or error UI is appropriate.
|
|
7
|
+
|
|
8
|
+
This guide describes a source-checkout recipe built only from the existing public `Map`, controlled
|
|
9
|
+
camera and annotation contracts. It is not a new Tileflow runtime API and it does not add a location
|
|
10
|
+
provider to the package.
|
|
11
|
+
|
|
12
|
+
## Boundary
|
|
13
|
+
|
|
14
|
+
The package deliberately does not provide any of the following:
|
|
15
|
+
|
|
16
|
+
- a Tileflow location component, hook, provider or permission API;
|
|
17
|
+
- an iOS or Android location permission declaration;
|
|
18
|
+
- a dependency on Expo Location, a geolocation package or another location provider;
|
|
19
|
+
- a background task, background permission or location-history store;
|
|
20
|
+
- a credential/session field for device location;
|
|
21
|
+
- a location analytics event or precise-coordinate telemetry path;
|
|
22
|
+
- a GeoIP fallback for device location;
|
|
23
|
+
- implicit permission requests or tracking when `Map` mounts.
|
|
24
|
+
|
|
25
|
+
The application must configure any native permission strings, manifest entries and provider setup
|
|
26
|
+
required by the provider it chooses. Those application settings are separate from Tileflow's native
|
|
27
|
+
Hosted credential configuration.
|
|
28
|
+
|
|
29
|
+
## Source-checkout recipe
|
|
30
|
+
|
|
31
|
+
`../harness/ForegroundLocationExample.tsx` is a compile-only example owned by this package. Its
|
|
32
|
+
provider boundary is injected:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
export type ApplicationForegroundLocationAdapter = Readonly<{
|
|
36
|
+
requestPermission(): Promise<
|
|
37
|
+
'granted-precise' | 'granted-approximate' | 'denied' | 'unavailable'
|
|
38
|
+
>;
|
|
39
|
+
observe(listener: (update: ApplicationLocationObservation) => void): () => void;
|
|
40
|
+
}>;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
No concrete provider is imported. The adapter is application code: it translates the provider's
|
|
44
|
+
permission and foreground observation APIs into this small recipe-specific shape. The example and
|
|
45
|
+
its state helper live under `harness/`, are absent from package exports, and are excluded from
|
|
46
|
+
packed runtime files.
|
|
47
|
+
|
|
48
|
+
## Explicit application state
|
|
49
|
+
|
|
50
|
+
The recipe models these states without unmounting the Map:
|
|
51
|
+
|
|
52
|
+
| State | Meaning | Location annotation |
|
|
53
|
+
| --------------------- | ------------------------------------------------------------ | ---------------------------- |
|
|
54
|
+
| `idle` | The application has not requested permission. | none |
|
|
55
|
+
| `requesting` | An explicit application action started a permission request. | none |
|
|
56
|
+
| `granted-precise` | Foreground policy allows precise observation. | one marker after a valid fix |
|
|
57
|
+
| `granted-approximate` | Foreground policy allows approximate observation. | one marker after a valid fix |
|
|
58
|
+
| `denied` | The permission request was denied. | none |
|
|
59
|
+
| `unavailable` | Permission/provider setup failed or emitted invalid data. | none |
|
|
60
|
+
| `revoked` | A previously granted observation reported revocation. | removed immediately |
|
|
61
|
+
|
|
62
|
+
Construction is inert: it neither requests permission nor starts observation. The example's **Use
|
|
63
|
+
my location** control is the only permission-request trigger. A rejected provider promise is reduced
|
|
64
|
+
to the application-level `unavailable` state; raw provider exceptions are not forwarded into Map
|
|
65
|
+
props or Tileflow callbacks.
|
|
66
|
+
|
|
67
|
+
## Validate before Map props
|
|
68
|
+
|
|
69
|
+
The adapter output is treated as untrusted runtime data even though the TypeScript adapter is typed.
|
|
70
|
+
The recipe accepts only a plain record with exactly `longitude`, `latitude` and `accuracy` data
|
|
71
|
+
properties. Longitude must be finite and within `[-180, 180]`, latitude finite and within
|
|
72
|
+
`[-90, 90]`, and accuracy finite and nonnegative. Accessors, extra provider fields, invalid
|
|
73
|
+
prototypes and non-finite values fail closed.
|
|
74
|
+
|
|
75
|
+
Only a validated fix is converted to an annotation:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
{
|
|
79
|
+
id: 'application-foreground-location',
|
|
80
|
+
kind: 'marker',
|
|
81
|
+
ariaLabel: 'Current location',
|
|
82
|
+
coordinate: [longitude, latitude],
|
|
83
|
+
data: {accuracyMeters: accuracy, precision: 'precise'},
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Approximate permission uses the same stable ID and an `Approximate current location` label. The
|
|
88
|
+
stable ID lets the existing annotation reconciler update one marker host instead of recreating a
|
|
89
|
+
new marker for every fix. Revocation, denial or provider failure removes the annotation by returning
|
|
90
|
+
an empty annotation collection.
|
|
91
|
+
|
|
92
|
+
The marker uses the existing accessible Tileflow annotation wrapper. This recipe does not add a
|
|
93
|
+
location-specific native marker component.
|
|
94
|
+
|
|
95
|
+
## Foreground observation lifetime
|
|
96
|
+
|
|
97
|
+
Observation starts only when both conditions are true:
|
|
98
|
+
|
|
99
|
+
1. the application is in the foreground; and
|
|
100
|
+
2. its current permission state is `granted-precise` or `granted-approximate`.
|
|
101
|
+
|
|
102
|
+
`AppState` is application-side orchestration in the example. Moving to background synchronously
|
|
103
|
+
retires the current observation token and calls the provider cleanup. Returning to foreground
|
|
104
|
+
starts a new observation only if the last application permission policy still allows it. Denied,
|
|
105
|
+
revoked and unavailable states do not restart observation automatically.
|
|
106
|
+
|
|
107
|
+
The React effect owns a replayable `mount()` epoch. Its cleanup immediately retires the current
|
|
108
|
+
observation and any pending permission/provider callback without terminally disposing the
|
|
109
|
+
controller. A StrictMode setup replay may therefore mount the same controller again; if the retained
|
|
110
|
+
settled permission state is granted and the application is foregrounded, observation restarts
|
|
111
|
+
without another permission prompt. If cleanup interrupted an in-flight permission request, that
|
|
112
|
+
request is retired and the last settled application state is restored instead of accepting its late
|
|
113
|
+
result. A real unmount or adapter replacement uses the same cleanup boundary, so late callbacks are
|
|
114
|
+
inert even though no timing heuristic distinguishes them from a replay. The recipe does not persist
|
|
115
|
+
fixes or observation history.
|
|
116
|
+
|
|
117
|
+
`dispose()` remains a separate terminal operation for an owner that will never mount again. The
|
|
118
|
+
React example does not use terminal disposal for ordinary effect cleanup.
|
|
119
|
+
|
|
120
|
+
## Recenter is explicit
|
|
121
|
+
|
|
122
|
+
A new fix changes only the application-owned annotation. It does **not** change camera state.
|
|
123
|
+
|
|
124
|
+
The example keeps the existing complete controlled `Map` view. An explicit **Recenter** action copies
|
|
125
|
+
the latest validated coordinate into that controlled view while preserving zoom, bearing and pitch.
|
|
126
|
+
The recipe uses no camera animation API or duration, so it introduces no animated movement for
|
|
127
|
+
reduced-motion users. Applications that later add their own animated camera affordance must apply
|
|
128
|
+
their own reduced-motion policy before doing so.
|
|
129
|
+
|
|
130
|
+
Gestures continue to update the same controlled view through `onViewChange`; receiving another
|
|
131
|
+
location fix never overwrites that user-driven camera state.
|
|
132
|
+
|
|
133
|
+
## Privacy and product scope
|
|
134
|
+
|
|
135
|
+
The sample keeps only the latest in-memory fix needed for its marker. It does not store history,
|
|
136
|
+
send coordinates to analytics, derive a device fix from GeoIP, start background tracking or attach
|
|
137
|
+
location to Tileflow sessions/admission. Status and error presentation are application UI.
|
|
138
|
+
|
|
139
|
+
Location also does not change the interaction presentation boundary. Tileflow annotations and
|
|
140
|
+
semantic targets remain primitives and data; the application still owns any popup, callout,
|
|
141
|
+
tooltip, sheet, panel, modal or other selection presentation.
|
|
142
|
+
|
|
143
|
+
## Qualification
|
|
144
|
+
|
|
145
|
+
The source files describe the intended application contract; they are not evidence that a provider,
|
|
146
|
+
permission prompt, simulator/device scenario, native build, package publication or Hosted service
|
|
147
|
+
was executed. Typecheck the source-checkout harness and qualify the chosen provider in the
|
|
148
|
+
application that owns its permission and privacy policy.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#import "TFAdmissionEngine.h"
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
// All registry mutations use the admission scheduler. Native networking reads
|
|
5
|
+
// only immutable request data, continuous deadlines, and atomic liveness.
|
|
6
|
+
@interface TFAdmissionBootstrap : NSObject
|
|
7
|
+
- (instancetype)initWithInstallation:(NSString *)installation scheduler:(id<TFAdmissionScheduler>)scheduler
|
|
8
|
+
network:(id<TFAdmissionNetwork>)network owns:(TFAdmissionStartGuard)owns;
|
|
9
|
+
- (void)registerContext:(NSString *)context mapId:(nullable NSString *)mapId;
|
|
10
|
+
- (void)startContext:(NSString *)context request:(NSString *)request url:(NSString *)url
|
|
11
|
+
credential:(NSString *)credential body:(NSString *)body completion:(void (^)(NSDictionary *_Nullable reply))completion;
|
|
12
|
+
- (void)cancelContext:(NSString *)context request:(NSString *)request;
|
|
13
|
+
- (void)retire:(NSString *)context;
|
|
14
|
+
- (void)lifecycle:(BOOL)foreground;
|
|
15
|
+
- (void)close;
|
|
16
|
+
@end
|
|
17
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#import "TFAdmissionBootstrap.h"
|
|
2
|
+
#import <atomic>
|
|
3
|
+
|
|
4
|
+
static void TFBootstrapInvalid(void) {
|
|
5
|
+
[NSException raise:@"TFNativeBootstrapInvalid" format:@"Invalid native bootstrap"];
|
|
6
|
+
}
|
|
7
|
+
static BOOL TFBootstrapString(id value, NSUInteger limit) { return [value isKindOfClass:NSString.class] && [(NSString *)value length] <= limit; }
|
|
8
|
+
static NSTextCheckingResult *TFBootstrapMatch(NSString *value, NSString *pattern) {
|
|
9
|
+
NSRegularExpression *expression = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil];
|
|
10
|
+
NSTextCheckingResult *match = [expression firstMatchInString:value options:0 range:NSMakeRange(0, value.length)];
|
|
11
|
+
return match && NSEqualRanges(match.range, NSMakeRange(0, value.length)) ? match : nil;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@interface TFBootstrapFlag : NSObject { @public std::atomic_bool value; }
|
|
15
|
+
@end
|
|
16
|
+
@implementation TFBootstrapFlag
|
|
17
|
+
- (instancetype)init { if ((self = [super init])) value.store(true); return self; }
|
|
18
|
+
@end
|
|
19
|
+
|
|
20
|
+
@class TFBootstrapWork;
|
|
21
|
+
@interface TFBootstrapContext : NSObject
|
|
22
|
+
@property (nonatomic, copy, nullable) NSString *mapId;
|
|
23
|
+
@property (nonatomic, copy, nullable) NSString *endpoint;
|
|
24
|
+
@property (nonatomic, copy, nullable) NSString *credential;
|
|
25
|
+
@property (nonatomic) unsigned long long lastRequest;
|
|
26
|
+
@property (nonatomic) TFBootstrapFlag *alive;
|
|
27
|
+
@property (nonatomic) NSMutableDictionary<NSString *, TFBootstrapWork *> *pending;
|
|
28
|
+
@end
|
|
29
|
+
@implementation TFBootstrapContext
|
|
30
|
+
- (instancetype)init { if ((self = [super init])) { _alive = [TFBootstrapFlag new]; _pending = [NSMutableDictionary dictionary]; } return self; }
|
|
31
|
+
- (NSString *)description { return @"TFBootstrapContext(redacted)"; }
|
|
32
|
+
@end
|
|
33
|
+
|
|
34
|
+
@interface TFBootstrapWork : NSObject
|
|
35
|
+
@property (nonatomic, copy) NSString *identifier;
|
|
36
|
+
@property (nonatomic) TFBootstrapContext *context;
|
|
37
|
+
@property (nonatomic) TFBootstrapFlag *alive;
|
|
38
|
+
@property (nonatomic) NSTimeInterval deadline;
|
|
39
|
+
@property (nonatomic, copy, nullable) void (^completion)(NSDictionary *_Nullable);
|
|
40
|
+
@property (nonatomic, nullable) id<TFAdmissionCancel> network;
|
|
41
|
+
@property (nonatomic, nullable) id<TFAdmissionCancel> timer;
|
|
42
|
+
@end
|
|
43
|
+
@implementation TFBootstrapWork
|
|
44
|
+
- (NSString *)description { return @"TFBootstrapWork(redacted)"; }
|
|
45
|
+
@end
|
|
46
|
+
|
|
47
|
+
@interface TFAdmissionBootstrap () {
|
|
48
|
+
std::atomic_bool _active;
|
|
49
|
+
std::atomic_bool _foreground;
|
|
50
|
+
}
|
|
51
|
+
@property (nonatomic, copy) NSString *installation;
|
|
52
|
+
@property (nonatomic) id<TFAdmissionScheduler> scheduler;
|
|
53
|
+
@property (nonatomic) id<TFAdmissionNetwork> network;
|
|
54
|
+
@property (nonatomic, copy) TFAdmissionStartGuard owns;
|
|
55
|
+
@property (nonatomic) NSMutableDictionary<NSString *, TFBootstrapContext *> *contexts;
|
|
56
|
+
@property (nonatomic) NSUInteger pendingCount;
|
|
57
|
+
@end
|
|
58
|
+
|
|
59
|
+
@implementation TFAdmissionBootstrap
|
|
60
|
+
- (instancetype)initWithInstallation:(NSString *)installation scheduler:(id<TFAdmissionScheduler>)scheduler network:(id<TFAdmissionNetwork>)network owns:(TFAdmissionStartGuard)owns {
|
|
61
|
+
if ((self = [super init])) {
|
|
62
|
+
_installation = [installation copy]; _scheduler = scheduler; _network = network; _owns = [owns copy];
|
|
63
|
+
_contexts = [NSMutableDictionary dictionary]; _active.store(true); _foreground.store(true);
|
|
64
|
+
}
|
|
65
|
+
return self;
|
|
66
|
+
}
|
|
67
|
+
- (NSString *)description { return @"TFAdmissionBootstrap(redacted)"; }
|
|
68
|
+
- (BOOL)validContext:(NSString *)context { return TFAdmissionValidToken(context) && [context hasPrefix:[self.installation stringByAppendingString:@"."]]; }
|
|
69
|
+
- (unsigned long long)sequence:(NSString *)request {
|
|
70
|
+
NSString *prefix = [self.installation stringByAppendingString:@"."];
|
|
71
|
+
if (!TFBootstrapString(request, 96) || ![request hasPrefix:prefix]) TFBootstrapInvalid();
|
|
72
|
+
NSString *suffix = [request substringFromIndex:prefix.length];
|
|
73
|
+
if (!TFBootstrapMatch(suffix, @"[1-9][0-9]{0,15}")) TFBootstrapInvalid();
|
|
74
|
+
unsigned long long value = suffix.longLongValue;
|
|
75
|
+
if (value > 9007199254740991ULL) TFBootstrapInvalid();
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
- (BOOL)owned { @try { return self.owns(); } @catch (NSException *exception) { return NO; } }
|
|
79
|
+
- (void)registerContext:(NSString *)context mapId:(NSString *)mapId {
|
|
80
|
+
if (!_active.load() || ![self owned] || ![self validContext:context] || self.contexts[context] || self.contexts.count >= 16 ||
|
|
81
|
+
(mapId && (!TFBootstrapString(mapId, 64) || !TFBootstrapMatch(mapId, @"map_[A-Za-z0-9_-]{16}")))) TFBootstrapInvalid();
|
|
82
|
+
TFBootstrapContext *binding = [TFBootstrapContext new]; binding.mapId = mapId;
|
|
83
|
+
self.contexts[context] = binding;
|
|
84
|
+
}
|
|
85
|
+
- (void)startContext:(NSString *)contextId request:(NSString *)requestId url:(NSString *)url credential:(NSString *)credential body:(NSString *)body completion:(void (^)(NSDictionary *))completion {
|
|
86
|
+
if (![self validContext:contextId]) TFBootstrapInvalid();
|
|
87
|
+
TFBootstrapContext *context = self.contexts[contextId];
|
|
88
|
+
if (!_active.load() || !_foreground.load() || ![self owned] || !context || !context.alive->value.load() || !context.mapId || self.pendingCount >= 32) TFBootstrapInvalid();
|
|
89
|
+
unsigned long long sequence = [self sequence:requestId];
|
|
90
|
+
if (sequence <= context.lastRequest || !TFBootstrapString(credential, 58) || !TFBootstrapMatch(credential, @"tf_public_[0-9a-f]{48}") || !TFBootstrapString(body, 2048)) TFBootstrapInvalid();
|
|
91
|
+
NSTextCheckingResult *match = TFBootstrapMatch(body, @"\\{\"mapId\":\"(map_[A-Za-z0-9_-]{16})\",\"sessionId\":\"([A-Za-z0-9._:-]{1,255})\",\"surfaceId\":\"([a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?)\"\\}");
|
|
92
|
+
if (!match || ![[body substringWithRange:[match rangeAtIndex:1]] isEqual:context.mapId]) TFBootstrapInvalid();
|
|
93
|
+
for (NSUInteger index = 2; index <= 3; index++) {
|
|
94
|
+
NSString *value = [body substringWithRange:[match rangeAtIndex:index]];
|
|
95
|
+
if ([value hasPrefix:@"tf_native_"] || TFBootstrapMatch(value, @"tf_public_[0-9a-f]{48}")) TFBootstrapInvalid();
|
|
96
|
+
}
|
|
97
|
+
NSString *clean = TFAdmissionCleanURL(url);
|
|
98
|
+
if (![clean isEqual:[TFAdmissionOrigin(clean) stringByAppendingString:@"/v1/sessions/start"]] ||
|
|
99
|
+
(context.endpoint && (![context.endpoint isEqual:clean] || ![context.credential isEqual:credential]))) TFBootstrapInvalid();
|
|
100
|
+
// The controller supplies the initial validated binding; no later request
|
|
101
|
+
// may change its exact endpoint, port, or credential for this Map context.
|
|
102
|
+
context.endpoint = clean; context.credential = credential; context.lastRequest = sequence;
|
|
103
|
+
TFBootstrapWork *work = [TFBootstrapWork new];
|
|
104
|
+
work.identifier = requestId; work.context = context; work.alive = [TFBootstrapFlag new];
|
|
105
|
+
work.deadline = [self.scheduler nowMs] + 30000; work.completion = completion;
|
|
106
|
+
context.pending[requestId] = work; self.pendingCount++;
|
|
107
|
+
work.timer = [self.scheduler after:30000 perform:^{ [self finish:work reply:nil]; }];
|
|
108
|
+
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:clean]
|
|
109
|
+
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:30];
|
|
110
|
+
request.HTTPMethod = @"POST"; request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];
|
|
111
|
+
request.HTTPShouldHandleCookies = NO;
|
|
112
|
+
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
|
113
|
+
[request setValue:@"no-store" forHTTPHeaderField:@"Cache-Control"];
|
|
114
|
+
[request setValue:credential forHTTPHeaderField:@"X-Tileflow-Mobile-Client"];
|
|
115
|
+
@try {
|
|
116
|
+
work.network = [self.network start:request mayStart:^BOOL { return [self mayStart:work]; } completion:^(NSHTTPURLResponse *response, NSData *bytes) {
|
|
117
|
+
[self.scheduler enqueue:^{
|
|
118
|
+
NSString *cacheControl = @"";
|
|
119
|
+
for (id name in response.allHeaderFields) {
|
|
120
|
+
if ([name isKindOfClass:NSString.class] && [(NSString *)name caseInsensitiveCompare:@"Cache-Control"] == NSOrderedSame) cacheControl = response.allHeaderFields[name];
|
|
121
|
+
}
|
|
122
|
+
BOOL valid = [self mayStart:work] && [response isKindOfClass:NSHTTPURLResponse.class] && response.statusCode >= 100 && response.statusCode <= 599 &&
|
|
123
|
+
TFBootstrapString(cacheControl, 1024) && [bytes isKindOfClass:NSData.class] && bytes.length <= 65536;
|
|
124
|
+
NSDictionary *reply = valid ? @{@"status": @(response.statusCode), @"cacheControl": cacheControl, @"bodyBase64": [bytes base64EncodedStringWithOptions:0]} : nil;
|
|
125
|
+
[self finish:work reply:reply];
|
|
126
|
+
}];
|
|
127
|
+
}];
|
|
128
|
+
if (!work.alive->value.load()) [work.network cancel];
|
|
129
|
+
} @catch (NSException *exception) { [self finish:work reply:nil]; }
|
|
130
|
+
}
|
|
131
|
+
- (BOOL)mayStart:(TFBootstrapWork *)work {
|
|
132
|
+
return _active.load() && _foreground.load() && work.alive->value.load() && work.context.alive->value.load() && [self.scheduler nowMs] < work.deadline && [self owned];
|
|
133
|
+
}
|
|
134
|
+
- (void)finish:(TFBootstrapWork *)work reply:(NSDictionary *)reply {
|
|
135
|
+
if (!work.alive->value.exchange(false)) return;
|
|
136
|
+
[work.context.pending removeObjectForKey:work.identifier]; self.pendingCount--;
|
|
137
|
+
[work.timer cancel]; work.timer = nil;
|
|
138
|
+
[work.network cancel]; work.network = nil;
|
|
139
|
+
void (^completion)(NSDictionary *) = work.completion; work.completion = nil;
|
|
140
|
+
@try { if (completion) completion(reply); } @catch (NSException *exception) { /* Consumers cannot reopen cancelled work. */ }
|
|
141
|
+
}
|
|
142
|
+
- (void)cancelContext:(NSString *)contextId request:(NSString *)requestId {
|
|
143
|
+
if (![self validContext:contextId]) TFBootstrapInvalid();
|
|
144
|
+
unsigned long long sequence = [self sequence:requestId];
|
|
145
|
+
TFBootstrapContext *context = self.contexts[contextId];
|
|
146
|
+
if (!context) return;
|
|
147
|
+
// Cancellation may arrive first. This bounded high-water mark prevents
|
|
148
|
+
// replay without accumulating retired-request tombstones.
|
|
149
|
+
context.lastRequest = MAX(context.lastRequest, sequence);
|
|
150
|
+
TFBootstrapWork *work = context.pending[requestId];
|
|
151
|
+
if (work) [self finish:work reply:nil];
|
|
152
|
+
}
|
|
153
|
+
- (void)retire:(NSString *)contextId {
|
|
154
|
+
TFBootstrapContext *context = self.contexts[contextId];
|
|
155
|
+
if (!context) return;
|
|
156
|
+
[self.contexts removeObjectForKey:contextId]; context.alive->value.store(false);
|
|
157
|
+
context.credential = nil; context.endpoint = nil;
|
|
158
|
+
for (TFBootstrapWork *work in context.pending.allValues) [self finish:work reply:nil];
|
|
159
|
+
}
|
|
160
|
+
- (void)lifecycle:(BOOL)foreground {
|
|
161
|
+
_foreground.store(foreground);
|
|
162
|
+
if (!foreground) for (TFBootstrapContext *context in self.contexts.allValues) for (TFBootstrapWork *work in context.pending.allValues) [self finish:work reply:nil];
|
|
163
|
+
}
|
|
164
|
+
- (void)close {
|
|
165
|
+
_active.store(false);
|
|
166
|
+
for (NSString *context in self.contexts.allKeys) [self retire:context];
|
|
167
|
+
}
|
|
168
|
+
@end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
// Resource identity only; the session controller remains the admission authority.
|
|
6
|
+
@interface TFAdmissionCatalog : NSObject
|
|
7
|
+
@property (nonatomic, readonly, copy) NSDictionary<NSString *, NSDictionary *> *resources;
|
|
8
|
+
- (instancetype)initWithResources:(NSArray<NSDictionary *> *)resources;
|
|
9
|
+
- (TFAdmissionCatalog *)extending:(NSArray<NSDictionary *> *)resources;
|
|
10
|
+
- (nullable NSDictionary *)find:(NSString *)url;
|
|
11
|
+
@end
|
|
12
|
+
|
|
13
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#import "TFAdmissionCatalog.h"
|
|
2
|
+
#import "TFAdmissionEngine.h"
|
|
3
|
+
#import <cmath>
|
|
4
|
+
|
|
5
|
+
static void TFCatalogInvalid(void) {
|
|
6
|
+
@throw [NSException exceptionWithName:@"TFAdmissionCatalogInvalid" reason:@"Invalid native resource catalog" userInfo:nil];
|
|
7
|
+
}
|
|
8
|
+
static BOOL TFText(id value, NSUInteger maximum) {
|
|
9
|
+
return [value isKindOfClass:NSString.class] && [(NSString *)value length] > 0 && [(NSString *)value length] <= maximum;
|
|
10
|
+
}
|
|
11
|
+
static NSRegularExpression *TFPattern(NSString *pattern) {
|
|
12
|
+
NSRegularExpression *result = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil];
|
|
13
|
+
if (!result) TFCatalogInvalid();
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
static BOOL TFMatches(NSString *pattern, NSString *value) {
|
|
17
|
+
return [TFPattern(pattern) numberOfMatchesInString:value options:0 range:NSMakeRange(0, value.length)] == 1;
|
|
18
|
+
}
|
|
19
|
+
static NSDictionary<NSString *, NSString *> *TFSlots(void) {
|
|
20
|
+
NSString *decimal = @"(?:0|[1-9][0-9]{0,9})";
|
|
21
|
+
NSString *coordinate = @"-?(?:0|[1-9][0-9]{0,7})(?:\\.[0-9]{1,16})?";
|
|
22
|
+
return @{
|
|
23
|
+
@"z": @"(?:0|[1-9][0-9]?)", @"x": decimal, @"y": decimal,
|
|
24
|
+
@"ratio": @"(?:@2x|@3x)?", @"quadkey": @"[0-3]{0,30}", @"prefix": @"[0-9a-f]{2}",
|
|
25
|
+
@"bbox-epsg-3857": [@[coordinate, coordinate, coordinate, coordinate] componentsJoinedByString:@","],
|
|
26
|
+
@"fontstack": @"(?:[A-Za-z0-9_.~,+-]|%[0-9a-fA-F]{2}){1,768}",
|
|
27
|
+
@"range": [NSString stringWithFormat:@"%@-%@", decimal, decimal]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@interface TFAdmissionCatalogRule : NSObject
|
|
32
|
+
@property (nonatomic, copy) NSDictionary *resource;
|
|
33
|
+
@property (nonatomic, nullable) NSRegularExpression *pattern;
|
|
34
|
+
@property (nonatomic, copy) NSArray<NSString *> *names;
|
|
35
|
+
@end
|
|
36
|
+
@implementation TFAdmissionCatalogRule
|
|
37
|
+
- (NSString *)description { return @"TFAdmissionCatalogRule(redacted)"; }
|
|
38
|
+
@end
|
|
39
|
+
|
|
40
|
+
static TFAdmissionCatalogRule *TFRule(NSDictionary *input) {
|
|
41
|
+
if (![input isKindOfClass:NSDictionary.class] || input.count > 5 || !TFText(input[@"url"], 1920) ||
|
|
42
|
+
![@[@"style", @"tilejson", @"tile", @"sprite", @"glyph", @"font"] containsObject:input[@"scope"]]) TFCatalogInvalid();
|
|
43
|
+
NSString *url = [input[@"url"] copy], *scope = [input[@"scope"] copy];
|
|
44
|
+
NSString *tileset = input[@"tilesetId"], *family = input[@"template"];
|
|
45
|
+
if (([scope isEqual:@"tile"] || [scope isEqual:@"tilejson"]) && !tileset) TFCatalogInvalid();
|
|
46
|
+
if (tileset && (!TFText(tileset, 255) || !TFMatches(@"\\A[A-Za-z0-9._:-]{1,255}\\z", tileset) ||
|
|
47
|
+
[tileset.lowercaseString containsString:@"tf_native_"] || [tileset.lowercaseString containsString:@"tf_public_"])) TFCatalogInvalid();
|
|
48
|
+
NSMutableDictionary *resource = [@{@"url": url, @"scope": scope} mutableCopy];
|
|
49
|
+
if (tileset) resource[@"tilesetId"] = [tileset copy];
|
|
50
|
+
TFAdmissionCatalogRule *rule = [TFAdmissionCatalogRule new];
|
|
51
|
+
if (!family) {
|
|
52
|
+
if (input[@"fontStacks"] || [url containsString:@"{"] || [url containsString:@"}"]) TFCatalogInvalid();
|
|
53
|
+
TFAdmissionCleanURL(url);
|
|
54
|
+
rule.resource = [resource copy]; rule.names = @[];
|
|
55
|
+
return rule;
|
|
56
|
+
}
|
|
57
|
+
if (![@[@"tile", @"glyphs"] containsObject:family] || ![url hasPrefix:@"https://"]) TFCatalogInvalid();
|
|
58
|
+
NSRange path = [url rangeOfString:@"/" options:0 range:NSMakeRange(8, url.length - 8)];
|
|
59
|
+
if (path.location == NSNotFound || [[url substringToIndex:path.location] rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"{}"]].location != NSNotFound) TFCatalogInvalid();
|
|
60
|
+
NSRegularExpression *placeholder = TFPattern(@"\\{([a-z0-9-]+)\\}");
|
|
61
|
+
NSArray<NSTextCheckingResult *> *matches = [placeholder matchesInString:url options:0 range:NSMakeRange(0, url.length)];
|
|
62
|
+
if (!matches.count || matches.count > 16) TFCatalogInvalid();
|
|
63
|
+
NSString *masked = [placeholder stringByReplacingMatchesInString:url options:0 range:NSMakeRange(0, url.length) withTemplate:@"native-template-slot"];
|
|
64
|
+
if ([masked containsString:@"{"] || [masked containsString:@"}"]) TFCatalogInvalid();
|
|
65
|
+
TFAdmissionCleanURL(masked);
|
|
66
|
+
NSMutableArray<NSString *> *names = [NSMutableArray array];
|
|
67
|
+
NSMutableString *pattern = [NSMutableString stringWithString:@"\\A"];
|
|
68
|
+
NSDictionary *slots = TFSlots();
|
|
69
|
+
NSUInteger cursor = 0;
|
|
70
|
+
for (NSTextCheckingResult *match in matches) {
|
|
71
|
+
NSString *name = [url substringWithRange:[match rangeAtIndex:1]];
|
|
72
|
+
if (!slots[name] || (names.count && match.range.location == cursor)) TFCatalogInvalid();
|
|
73
|
+
BOOL glyphSlot = [name isEqual:@"fontstack"] || [name isEqual:@"range"];
|
|
74
|
+
if ([family isEqual:@"glyphs"] != glyphSlot) TFCatalogInvalid();
|
|
75
|
+
[pattern appendString:[NSRegularExpression escapedPatternForString:[url substringWithRange:NSMakeRange(cursor, match.range.location - cursor)]]];
|
|
76
|
+
[pattern appendFormat:@"(%@)", slots[name]];
|
|
77
|
+
[names addObject:name]; cursor = NSMaxRange(match.range);
|
|
78
|
+
}
|
|
79
|
+
[pattern appendString:[NSRegularExpression escapedPatternForString:[url substringFromIndex:cursor]]];
|
|
80
|
+
[pattern appendString:@"\\z"];
|
|
81
|
+
if ([family isEqual:@"tile"]) {
|
|
82
|
+
if (![scope isEqual:@"tile"] || input[@"fontStacks"]) TFCatalogInvalid();
|
|
83
|
+
} else {
|
|
84
|
+
NSArray *stacks = input[@"fontStacks"];
|
|
85
|
+
if (![scope isEqual:@"glyph"] || ![names containsObject:@"fontstack"] || ![names containsObject:@"range"] ||
|
|
86
|
+
![stacks isKindOfClass:NSArray.class] || !stacks.count || stacks.count > 16) TFCatalogInvalid();
|
|
87
|
+
NSMutableArray *snapshot = [NSMutableArray array];
|
|
88
|
+
for (NSString *stack in stacks) {
|
|
89
|
+
if (!TFText(stack, 256) || [snapshot containsObject:stack] ||
|
|
90
|
+
![stack isEqual:[stack stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]] ||
|
|
91
|
+
[stack rangeOfCharacterFromSet:NSCharacterSet.controlCharacterSet].location != NSNotFound ||
|
|
92
|
+
[stack rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"\\/?#%&="]].location != NSNotFound ||
|
|
93
|
+
[stack.lowercaseString containsString:@"tf_native_"] || [stack.lowercaseString containsString:@"tf_public_"]) TFCatalogInvalid();
|
|
94
|
+
for (NSString *part in [stack componentsSeparatedByString:@","]) {
|
|
95
|
+
if (!part.length || ![part isEqual:[part stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]]) TFCatalogInvalid();
|
|
96
|
+
}
|
|
97
|
+
[snapshot addObject:[stack copy]];
|
|
98
|
+
}
|
|
99
|
+
resource[@"fontStacks"] = [snapshot copy];
|
|
100
|
+
}
|
|
101
|
+
resource[@"template"] = [family copy];
|
|
102
|
+
rule.resource = [resource copy]; rule.pattern = TFPattern(pattern); rule.names = [names copy];
|
|
103
|
+
return rule;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static BOOL TFRuleMatches(TFAdmissionCatalogRule *rule, NSString *url) {
|
|
107
|
+
if (!rule.pattern) return [rule.resource[@"url"] isEqual:url];
|
|
108
|
+
NSTextCheckingResult *match = [rule.pattern firstMatchInString:url options:0 range:NSMakeRange(0, url.length)];
|
|
109
|
+
if (!match) return NO;
|
|
110
|
+
NSMutableDictionary<NSString *, NSString *> *values = [NSMutableDictionary dictionary];
|
|
111
|
+
for (NSUInteger index = 0; index < rule.names.count; index++) {
|
|
112
|
+
NSString *name = rule.names[index], *value = [url substringWithRange:[match rangeAtIndex:index + 1]];
|
|
113
|
+
if (values[name] && ![values[name] isEqual:value]) return NO;
|
|
114
|
+
values[name] = value;
|
|
115
|
+
}
|
|
116
|
+
NSUInteger zoom = values[@"z"] ? [values[@"z"] integerValue] : 30;
|
|
117
|
+
if (zoom > 30) return NO;
|
|
118
|
+
for (NSString *name in @[@"x", @"y"]) if (values[name] && [values[name] longLongValue] >= (1LL << zoom)) return NO;
|
|
119
|
+
if (values[@"quadkey"] && values[@"z"] && values[@"quadkey"].length != zoom) return NO;
|
|
120
|
+
if (values[@"bbox-epsg-3857"]) {
|
|
121
|
+
NSArray *numbers = [values[@"bbox-epsg-3857"] componentsSeparatedByString:@","];
|
|
122
|
+
for (NSString *number in numbers) if (!std::isfinite(number.doubleValue) || std::abs(number.doubleValue) > 20037508.343) return NO;
|
|
123
|
+
if ([numbers[0] doubleValue] >= [numbers[2] doubleValue] || [numbers[1] doubleValue] >= [numbers[3] doubleValue]) return NO;
|
|
124
|
+
}
|
|
125
|
+
if (values[@"range"]) {
|
|
126
|
+
NSArray *pair = [values[@"range"] componentsSeparatedByString:@"-"];
|
|
127
|
+
long long start = [pair[0] longLongValue], end = [pair[1] longLongValue];
|
|
128
|
+
if (start % 256 != 0 || end != start + 255 || end > 1114111) return NO;
|
|
129
|
+
}
|
|
130
|
+
if (values[@"fontstack"]) {
|
|
131
|
+
NSString *stack = values[@"fontstack"].stringByRemovingPercentEncoding;
|
|
132
|
+
if (!stack || ![rule.resource[@"fontStacks"] containsObject:stack]) return NO;
|
|
133
|
+
}
|
|
134
|
+
return YES;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static NSDictionary *TFMergeExtension(NSDictionary *previous, NSDictionary *next) {
|
|
138
|
+
if ([previous isEqual:next]) return previous;
|
|
139
|
+
if (![previous[@"scope"] isEqual:next[@"scope"]] ||
|
|
140
|
+
!((previous[@"tilesetId"] == nil && next[@"tilesetId"] == nil) || [previous[@"tilesetId"] isEqual:next[@"tilesetId"]]) ||
|
|
141
|
+
!((previous[@"template"] == nil && next[@"template"] == nil) || [previous[@"template"] isEqual:next[@"template"]]) ||
|
|
142
|
+
![previous[@"scope"] isEqual:@"glyph"] || ![previous[@"template"] isEqual:@"glyphs"]) TFCatalogInvalid();
|
|
143
|
+
NSArray *before = previous[@"fontStacks"], *after = next[@"fontStacks"];
|
|
144
|
+
if (![before isKindOfClass:NSArray.class] || ![after isKindOfClass:NSArray.class] || after.count > 16) TFCatalogInvalid();
|
|
145
|
+
for (NSString *stack in before) if (![after containsObject:stack]) TFCatalogInvalid();
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@interface TFAdmissionCatalog ()
|
|
150
|
+
@property (nonatomic, readwrite, copy) NSDictionary<NSString *, NSDictionary *> *resources;
|
|
151
|
+
@property (nonatomic, copy) NSArray<TFAdmissionCatalogRule *> *rules;
|
|
152
|
+
@end
|
|
153
|
+
@implementation TFAdmissionCatalog
|
|
154
|
+
- (instancetype)initWithResources:(NSArray<NSDictionary *> *)resources {
|
|
155
|
+
if ((self = [super init])) {
|
|
156
|
+
@try {
|
|
157
|
+
if (![resources isKindOfClass:NSArray.class] || resources.count > 128) TFCatalogInvalid();
|
|
158
|
+
NSMutableDictionary *snapshots = [NSMutableDictionary dictionary];
|
|
159
|
+
NSMutableArray *rules = [NSMutableArray array];
|
|
160
|
+
for (NSDictionary *resource in resources) {
|
|
161
|
+
TFAdmissionCatalogRule *rule = TFRule(resource);
|
|
162
|
+
NSString *url = rule.resource[@"url"];
|
|
163
|
+
if (snapshots[url]) TFCatalogInvalid();
|
|
164
|
+
snapshots[url] = rule.resource; [rules addObject:rule];
|
|
165
|
+
}
|
|
166
|
+
_resources = [snapshots copy]; _rules = [rules copy];
|
|
167
|
+
} @catch (NSException *exception) { TFCatalogInvalid(); }
|
|
168
|
+
}
|
|
169
|
+
return self;
|
|
170
|
+
}
|
|
171
|
+
- (TFAdmissionCatalog *)extending:(NSArray<NSDictionary *> *)resources {
|
|
172
|
+
TFAdmissionCatalog *incoming = [[TFAdmissionCatalog alloc] initWithResources:resources];
|
|
173
|
+
NSMutableDictionary *merged = [self.resources mutableCopy];
|
|
174
|
+
for (NSString *url in incoming.resources) {
|
|
175
|
+
NSDictionary *resource = incoming.resources[url];
|
|
176
|
+
merged[url] = merged[url] ? TFMergeExtension(merged[url], resource) : resource;
|
|
177
|
+
}
|
|
178
|
+
return [[TFAdmissionCatalog alloc] initWithResources:merged.allValues];
|
|
179
|
+
}
|
|
180
|
+
- (NSDictionary *)find:(NSString *)url {
|
|
181
|
+
@try { TFAdmissionCleanURL(url); } @catch (NSException *exception) { return nil; }
|
|
182
|
+
NSDictionary *found = nil;
|
|
183
|
+
for (TFAdmissionCatalogRule *rule in self.rules) {
|
|
184
|
+
if (!TFRuleMatches(rule, url)) continue;
|
|
185
|
+
if (found) TFCatalogInvalid();
|
|
186
|
+
NSMutableDictionary *identity = [@{@"url": url, @"scope": rule.resource[@"scope"]} mutableCopy];
|
|
187
|
+
if (rule.resource[@"tilesetId"]) identity[@"tilesetId"] = rule.resource[@"tilesetId"];
|
|
188
|
+
found = [identity copy];
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
192
|
+
- (NSString *)description { return @"TFAdmissionCatalog(redacted)"; }
|
|
193
|
+
@end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#import "TFAdmissionDocumentScope.h"
|
|
2
|
+
#import <atomic>
|
|
3
|
+
|
|
4
|
+
@interface TFAdmissionDocumentOperation : NSObject <TFAdmissionCancel> {
|
|
5
|
+
std::atomic_bool _finished;
|
|
6
|
+
}
|
|
7
|
+
@property (nonatomic, strong, nullable) id<TFAdmissionCancel> request;
|
|
8
|
+
@property (nonatomic, copy, nullable) TFAdmissionNetworkCompletion completion;
|
|
9
|
+
- (void)finish:(nullable NSHTTPURLResponse *)response body:(nullable NSData *)body;
|
|
10
|
+
@end
|
|
11
|
+
@implementation TFAdmissionDocumentOperation
|
|
12
|
+
- (instancetype)init { if ((self = [super init])) _finished.store(false); return self; }
|
|
13
|
+
- (void)finish:(NSHTTPURLResponse *)response body:(NSData *)body {
|
|
14
|
+
if (_finished.exchange(true)) return;
|
|
15
|
+
TFAdmissionNetworkCompletion completion = self.completion; self.completion = nil;
|
|
16
|
+
if (completion) completion(response, body);
|
|
17
|
+
}
|
|
18
|
+
- (void)cancel {
|
|
19
|
+
@try { [self.request cancel]; }
|
|
20
|
+
@finally {
|
|
21
|
+
// The admission network owns physical cleanup; this is the document's logical result.
|
|
22
|
+
[self finish:nil body:nil];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
- (NSString *)description { return @"TFAdmissionDocumentOperation(redacted)"; }
|
|
26
|
+
@end
|
|
27
|
+
|
|
28
|
+
TFNativeDocumentScope *TFCreateAdmissionDocumentScope(TFAdmissionEngine *engine, NSString *context) {
|
|
29
|
+
TFAdmissionStartGuard active = [engine contextGuard:context];
|
|
30
|
+
return [[TFNativeDocumentScope alloc] initWithContext:context active:active load:^id<TFAdmissionCancel>(NSURLRequest *request, TFAdmissionNetworkCompletion completion) {
|
|
31
|
+
NSString *url = TFAdmissionCleanURL(request.URL.absoluteString);
|
|
32
|
+
NSMutableURLRequest *tagged = [request mutableCopy];
|
|
33
|
+
tagged.URL = [NSURL URLWithString:[url stringByAppendingFormat:@"%@__tf_native_context=%@", [url containsString:@"?"] ? @"&" : @"?", context]];
|
|
34
|
+
TFAdmissionDocumentOperation *operation = [TFAdmissionDocumentOperation new];
|
|
35
|
+
operation.completion = completion;
|
|
36
|
+
operation.request = [engine request:tagged response:^(NSHTTPURLResponse *response, NSData *body) {
|
|
37
|
+
[operation finish:response body:body];
|
|
38
|
+
} failure:^{ [operation finish:nil body:nil]; } delegate:^id<TFAdmissionCancel>(NSURLRequest *request, TFAdmissionNetworkCompletion completion) {
|
|
39
|
+
// Unprotected preparation uses the independent credential-free document channel.
|
|
40
|
+
completion(nil, nil);
|
|
41
|
+
return [TFAdmissionDocumentOperation new];
|
|
42
|
+
}];
|
|
43
|
+
return operation;
|
|
44
|
+
}];
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
@protocol TFAdmissionCancel <NSObject>
|
|
6
|
+
- (void)cancel;
|
|
7
|
+
@end
|
|
8
|
+
|
|
9
|
+
@protocol TFAdmissionScheduler <NSObject>
|
|
10
|
+
- (NSTimeInterval)nowMs;
|
|
11
|
+
- (void)enqueue:(dispatch_block_t)block;
|
|
12
|
+
- (id<TFAdmissionCancel>)after:(NSTimeInterval)milliseconds perform:(dispatch_block_t)block;
|
|
13
|
+
@end
|
|
14
|
+
|
|
15
|
+
typedef BOOL (^TFAdmissionStartGuard)(void);
|
|
16
|
+
typedef void (^TFAdmissionNetworkCompletion)(NSHTTPURLResponse *_Nullable response, NSData *_Nullable body);
|
|
17
|
+
typedef id<TFAdmissionCancel> _Nonnull (^TFAdmissionDelegate)(NSURLRequest *request, TFAdmissionNetworkCompletion completion);
|
|
18
|
+
|
|
19
|
+
@protocol TFAdmissionNetwork <NSObject>
|
|
20
|
+
- (id<TFAdmissionCancel>)start:(NSURLRequest *)request
|
|
21
|
+
mayStart:(TFAdmissionStartGuard)guard
|
|
22
|
+
completion:(TFAdmissionNetworkCompletion)completion;
|
|
23
|
+
@end
|
|
24
|
+
|
|
25
|
+
// Internal per-installation registry. Only the JavaScript controller admits
|
|
26
|
+
// commercial requests; native sequence values identify transport messages.
|
|
27
|
+
@interface TFAdmissionEngine : NSObject
|
|
28
|
+
- (instancetype)initWithInstallation:(NSString *)installation
|
|
29
|
+
scheduler:(id<TFAdmissionScheduler>)scheduler
|
|
30
|
+
network:(id<TFAdmissionNetwork>)network
|
|
31
|
+
owns:(TFAdmissionStartGuard)owns
|
|
32
|
+
emit:(void (^)(NSDictionary *event))emit;
|
|
33
|
+
- (NSString *)registerMap:(nullable NSString *)mapId resources:(NSArray<NSDictionary *> *)resources;
|
|
34
|
+
- (NSUInteger)extendContext:(NSString *)context resources:(NSArray<NSDictionary *> *)resources;
|
|
35
|
+
- (TFAdmissionStartGuard)contextGuard:(NSString *)context;
|
|
36
|
+
- (id<TFAdmissionCancel>)request:(NSURLRequest *)request
|
|
37
|
+
response:(void (^)(NSHTTPURLResponse *response, NSData *body))response
|
|
38
|
+
failure:(dispatch_block_t)failure
|
|
39
|
+
delegate:(TFAdmissionDelegate)delegate;
|
|
40
|
+
- (NSUInteger)completeContext:(NSString *)context generation:(NSUInteger)generation
|
|
41
|
+
batch:(NSString *)batch results:(NSArray<NSDictionary *> *)results;
|
|
42
|
+
- (void)retire:(NSString *)context;
|
|
43
|
+
- (void)lifecycle:(BOOL)foreground;
|
|
44
|
+
- (void)close;
|
|
45
|
+
- (BOOL)isOwner;
|
|
46
|
+
@end
|
|
47
|
+
|
|
48
|
+
FOUNDATION_EXPORT NSString *const TFAdmissionGrantHeader;
|
|
49
|
+
FOUNDATION_EXPORT BOOL TFAdmissionHasReservedContext(NSString *url);
|
|
50
|
+
FOUNDATION_EXPORT NSDictionary<NSString *, NSString *> *TFAdmissionStripContext(NSString *url);
|
|
51
|
+
FOUNDATION_EXPORT NSString *TFAdmissionCleanURL(NSString *url);
|
|
52
|
+
FOUNDATION_EXPORT NSString *TFAdmissionOrigin(NSString *url);
|
|
53
|
+
FOUNDATION_EXPORT BOOL TFAdmissionStyleMatchesMap(NSString *url, NSString *mapId);
|
|
54
|
+
FOUNDATION_EXPORT BOOL TFAdmissionValidToken(id value);
|
|
55
|
+
FOUNDATION_EXPORT NSError *TFAdmissionSafeError(void);
|
|
56
|
+
|
|
57
|
+
NS_ASSUME_NONNULL_END
|