@glassly/crust 0.4.0-beta.1025 → 0.4.0-beta.1052

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.
@@ -605,7 +605,9 @@
605
605
  }
606
606
  }
607
607
  const sendRequest = g.__sendRequest;
608
- const result = await sendRequest("fetch", "request", [{ url, method, headers, body: bodyString, bodyBase64 }]);
608
+ const result = await sendRequest("fetch", "request", [
609
+ { url, method, headers, body: bodyString, bodyBase64 }
610
+ ]);
609
611
  const bodyText = typeof result.body === "string" ? result.body : "";
610
612
  const responseHeaders = new Map(Object.entries(result.headers ?? {}));
611
613
  class ResponseLike {
@@ -24,10 +24,8 @@ import MapboxNavigationCore
24
24
  /// - iOS v3 is **Combine publisher-based**, not observer-based. We hold
25
25
  /// `AnyCancellable`s instead of registering observer objects.
26
26
  /// - Routing is `async`: `routingProvider().calculateRoutes(options:)`.
27
- /// - Simulation is a **CoreConfig location source** (`.simulation`), chosen at
28
- /// provider-construction time — not a separate replay session object. Because
29
- /// the source is fixed when the provider is built, switching sim on/off
30
- /// rebuilds the provider (see `makeProvider`).
27
+ /// - Simulation is a CoreConfig location source. Reconfigure the one retained
28
+ /// provider between trips; Mapbox forbids simultaneous provider instances.
31
29
  /// - Mapbox owns off-route detection + auto-reroute on iOS too, same as
32
30
  /// Android — we observe, we don't re-derive.
33
31
  ///
@@ -55,19 +53,23 @@ final class NavigationManager: NSObject {
55
53
  private var onLocation: LocationCallback?
56
54
  private var onRoute: RouteCallback?
57
55
 
58
- // The Mapbox engine. Rebuilt per-trip because the location source
59
- // (live vs simulation) is baked into CoreConfig at construction time.
56
+ // Mapbox permits exactly one provider, including while old async work unwinds.
57
+ // Keep it for this singleton's lifetime and reconfigure it between trips.
58
+ private var tripGeneration = UUID()
59
+ private var startCompletion: StartCompletion?
60
60
  private var provider: MapboxNavigationProvider?
61
- private var mapboxNavigation: MapboxNavigation? { provider?.mapboxNavigation }
61
+ private var mapboxNavigation: MapboxNavigation? {
62
+ provider?.mapboxNavigation
63
+ }
62
64
 
63
- // Combine subscriptions — torn down on stop().
65
+ /// Combine subscriptions — torn down on stop().
64
66
  private var cancellables = Set<AnyCancellable>()
65
67
 
66
- // First-fix gate. Mapbox has no synchronous "current location" getter; the
67
- // device position arrives only via the locationMatching publisher AFTER the
68
- // session starts. So we start the session, capture the first fix, THEN
69
- // request the route from that real origin. Mirrors Android's
70
- // `pendingRouteRequest`. nil once fired (one-shot).
68
+ /// First-fix gate. Mapbox has no synchronous "current location" getter; the
69
+ /// device position arrives only via the locationMatching publisher AFTER the
70
+ /// session starts. So we start the session, capture the first fix, THEN
71
+ /// request the route from that real origin. Mirrors Android's
72
+ /// `pendingRouteRequest`. nil once fired (one-shot).
71
73
  private var pendingRouteRequest: ((_ originLat: Double, _ originLng: Double) -> Void)?
72
74
 
73
75
  // Per-trip config captured from start().
@@ -76,18 +78,18 @@ final class NavigationManager: NSObject {
76
78
  private var simulating: Bool = false
77
79
  private var simulationSpeed: Double = 1.0
78
80
 
79
- // Dedup the maneuver emission to ~1m granularity (matches Android's
80
- // emitManeuverIfChanged so the "In X m" countdown updates ~1 Hz and stays in
81
- // lockstep with the trip distance). Key = "type|distM|tripM".
81
+ /// Dedup the maneuver emission to ~1m granularity (matches Android's
82
+ /// emitManeuverIfChanged so the "In X m" countdown updates ~1 Hz and stays in
83
+ /// lockstep with the trip distance). Key = "type|distM|tripM".
82
84
  private var lastEmittedManeuverKey: String?
83
85
 
84
- // Arrival is reported once; the SDK keeps ticking COMPLETE afterwards.
86
+ /// Arrival is reported once; the SDK keeps ticking COMPLETE afterwards.
85
87
  private var arrivedHandled = false
86
- // Off-route emitted once per episode (reset on reroute), same as Android.
88
+ /// Off-route emitted once per episode (reset on reroute), same as Android.
87
89
  private var offRouteEmitted = false
88
- // The navigationRoutes publisher fires once with the INITIAL route (already
89
- // emitted by requestAndStartRoute). We skip that first emission and treat
90
- // every subsequent one as a reroute redraw. false until the first fires.
90
+ /// The navigationRoutes publisher fires once with the INITIAL route (already
91
+ /// emitted by requestAndStartRoute). We skip that first emission and treat
92
+ /// every subsequent one as a reroute redraw. false until the first fires.
91
93
  private var didEmitInitialRoute = false
92
94
 
93
95
  // Last two forwarded coords — the Deviate dev walker derives its bearing
@@ -99,9 +101,9 @@ final class NavigationManager: NSObject {
99
101
 
100
102
  // MARK: - Access token
101
103
 
102
- // User-supplied Mapbox token (Settings > Keys), pushed from JS via
103
- // CrustModule.setMapboxAccessToken. Non-empty wins over the Info.plist
104
- // MBXAccessToken the SDK reads at boot; "" means "no override".
104
+ /// User-supplied Mapbox token (Settings > Keys), pushed from JS via
105
+ /// CrustModule.setMapboxAccessToken. Non-empty wins over the Info.plist
106
+ /// MBXAccessToken the SDK reads at boot; "" means "no override".
105
107
  private var accessTokenOverride: String = ""
106
108
 
107
109
  /// Set (or with "" clear) the runtime token override. Applied to the
@@ -115,7 +117,9 @@ final class NavigationManager: NSObject {
115
117
  /// The token the next provider build must use: the override when set, else
116
118
  /// the baked Info.plist token, else "" (nothing to authenticate with).
117
119
  private func resolvedAccessToken() -> String {
118
- if !accessTokenOverride.isEmpty { return accessTokenOverride }
120
+ if !accessTokenOverride.isEmpty {
121
+ return accessTokenOverride
122
+ }
119
123
  if let baked = Bundle.main.object(forInfoDictionaryKey: "MBXAccessToken") as? String, !baked.isEmpty {
120
124
  return baked
121
125
  }
@@ -127,7 +131,9 @@ final class NavigationManager: NSObject {
127
131
  /// components read it, so keep it in step.
128
132
  private func applyAccessToken() {
129
133
  let token = resolvedAccessToken()
130
- if !token.isEmpty { MapboxOptions.accessToken = token }
134
+ if !token.isEmpty {
135
+ MapboxOptions.accessToken = token
136
+ }
131
137
  }
132
138
 
133
139
  // MARK: - Permissions
@@ -162,73 +168,71 @@ final class NavigationManager: NSObject {
162
168
  return
163
169
  }
164
170
 
171
+ startCompletion = completion
172
+ let completion: StartCompletion = { [weak self] success, error in
173
+ self?.finishStart(success, error)
174
+ }
165
175
  self.onEvent = onEvent
166
176
  self.onLocation = onLocation
167
177
  self.onRoute = onRoute
168
- self.tripStops = stops
169
- self.travelMode = mode
170
- self.simulating = simulate
171
- self.simulationSpeed = max(0.5, min(speedMultiplier, 50))
172
- self.arrivedHandled = false
173
- self.offRouteEmitted = false
174
- self.didEmitInitialRoute = false
175
- self.lastEmittedManeuverKey = nil
178
+ tripStops = stops
179
+ travelMode = mode
180
+ simulating = simulate
181
+ simulationSpeed = max(0.5, min(speedMultiplier, 50))
182
+ arrivedHandled = false
183
+ offRouteEmitted = false
184
+ didEmitInitialRoute = false
185
+ lastEmittedManeuverKey = nil
176
186
  // (missedTurnRerouteMeters is intentionally unused on the Mapbox path —
177
187
  // Mapbox owns reroute natively, same decision as Android. Kept in the
178
188
  // signature for contract parity with CrustModule.)
179
189
  _ = missedTurnRerouteMeters
180
190
 
181
- DispatchQueue.main.async { [weak self] in
182
- guard let self else { return }
183
-
184
- // Token precedence must hold for THIS provider build: user override
185
- // when set, else the baked Info.plist token.
186
- self.applyAccessToken()
187
-
188
- // Build the provider with the right location source. Simulation is a
189
- // CoreConfig choice; `.simulation` drives a synthetic puck along the
190
- // active route once one is set.
191
- let provider = self.makeProvider(simulate: simulate)
192
- self.provider = provider
193
-
194
- // `mapboxNavigation` is non-optional on the provider.
195
- let nav = provider.mapboxNavigation
196
-
197
- // Subscribe to the publishers BEFORE starting the session so we don't
198
- // miss the first ticks.
199
- self.subscribe(nav)
200
-
201
- // Start a free-drive (passive) session so location starts flowing; the
202
- // first fix satisfies the gate below, then we request the route and
203
- // switch to active guidance.
204
- // VERIFY-IN-XCODE: free-drive start method name. v3 examples show
205
- // `nav.tripSession().startFreeDrive()`.
206
- nav.tripSession().startFreeDrive()
207
-
208
- // First-fix gate: request the route from the device's real origin.
209
- self.pendingRouteRequest = { [weak self] originLat, originLng in
210
- self?.requestAndStartRoute(
211
- nav: nav,
212
- originLat: originLat,
213
- originLng: originLng,
214
- completion: completion
215
- )
216
- }
191
+ // Already on the main actor: do not enqueue a second, overlapping start.
192
+ // Token precedence must hold for THIS provider build: user override
193
+ // when set, else the baked Info.plist token.
194
+ applyAccessToken()
217
195
 
218
- // First-fix TIMEOUT (live AND sim). The gate above waits for Mapbox's
219
- // locationMatching publisher to emit the first fix before requesting the
220
- // route. On a COLD APP LAUNCH that first fix can be slow or never arrive
221
- // until the CLLocationManager fully spins up — which is exactly the
222
- // "first nav after launch hangs at Starting…, works on the 2nd try" bug
223
- // (the 2nd try has a warm location manager). So we ALWAYS arm a timeout:
224
- // if no fix satisfies the gate in time, request the route from a one-shot
225
- // CoreLocation fix / last known location so we never hang.
226
- self.armFirstFixTimeout(nav: nav, completion: completion)
196
+ // Reconfigure the shared provider for live or simulated locations.
197
+ let provider = makeProvider(simulate: simulate)
198
+ self.provider = provider
199
+
200
+ // `mapboxNavigation` is non-optional on the provider.
201
+ let nav = provider.mapboxNavigation
202
+
203
+ // Subscribe to the publishers BEFORE starting the session so we don't
204
+ // miss the first ticks.
205
+ subscribe(nav)
206
+
207
+ // Start a free-drive (passive) session so location starts flowing; the
208
+ // first fix satisfies the gate below, then we request the route and
209
+ // switch to active guidance.
210
+ // VERIFY-IN-XCODE: free-drive start method name. v3 examples show
211
+ // `nav.tripSession().startFreeDrive()`.
212
+ nav.tripSession().startFreeDrive()
213
+
214
+ // First-fix gate: request the route from the device's real origin.
215
+ pendingRouteRequest = { [weak self] originLat, originLng in
216
+ self?.requestAndStartRoute(
217
+ nav: nav,
218
+ originLat: originLat,
219
+ originLng: originLng,
220
+ completion: completion
221
+ )
227
222
  }
223
+
224
+ // First-fix TIMEOUT (live AND sim). The gate above waits for Mapbox's
225
+ // locationMatching publisher to emit the first fix before requesting the
226
+ // route. On a COLD APP LAUNCH that first fix can be slow or never arrive
227
+ // until the CLLocationManager fully spins up — which is exactly the
228
+ // "first nav after launch hangs at Starting…, works on the 2nd try" bug
229
+ // (the 2nd try has a warm location manager). So we ALWAYS arm a timeout:
230
+ // if no fix satisfies the gate in time, request the route from a one-shot
231
+ // CoreLocation fix / last known location so we never hang.
232
+ armFirstFixTimeout(nav: nav, completion: completion)
228
233
  }
229
234
 
230
- /// Build a fresh MapboxNavigationProvider. `.simulation` vs `.live` is fixed
231
- /// at construction, which is why a sim toggle rebuilds the provider.
235
+ /// Reuse the provider even when a previous trip still has routing work in flight.
232
236
  private func makeProvider(simulate: Bool) -> MapboxNavigationProvider {
233
237
  // Explicit reroute config: native off-route detection + auto-reroute ON
234
238
  // (this is the SDK default — detectsReroute defaults to true — but we set it
@@ -254,6 +258,10 @@ final class NavigationManager: NSObject {
254
258
  routingConfig: routingConfig,
255
259
  locationSource: simulate ? .simulation(initialLocation: nil) : .live
256
260
  )
261
+ if let provider {
262
+ provider.apply(coreConfig: coreConfig)
263
+ return provider
264
+ }
257
265
  return MapboxNavigationProvider(coreConfig: coreConfig)
258
266
  }
259
267
 
@@ -364,10 +372,11 @@ final class NavigationManager: NSObject {
364
372
  completion: @escaping StartCompletion,
365
373
  attempt: Int = 0
366
374
  ) {
367
- let maxAttempts = 8 // ~8 × 0.75s ≈ 6s total before giving up
375
+ let maxAttempts = 8 // ~8 × 0.75s ≈ 6s total before giving up
368
376
  let interval: TimeInterval = 0.75
377
+ let generation = tripGeneration
369
378
  DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in
370
- guard let self else { return }
379
+ guard let self, self.tripGeneration == generation else { return }
371
380
  // Gate already fired (a real fix arrived) — nothing to do.
372
381
  guard let pending = self.pendingRouteRequest else { return }
373
382
 
@@ -416,24 +425,22 @@ final class NavigationManager: NSObject {
416
425
 
417
426
  // VERIFY-IN-XCODE: `routingProvider().calculateRoutes(options:)` returns a
418
427
  // Task whose `.result` is `Result<NavigationRoutes, Error>` (v3 example).
428
+ let generation = tripGeneration
419
429
  let task = nav.routingProvider().calculateRoutes(options: options)
420
430
  Task { [weak self] in
421
- guard let self else { return }
422
- switch await task.result {
423
- case .success(let navigationRoutes):
424
- await MainActor.run {
425
- // v3 SessionController: startActiveGuidance(with:startLegIndex:).
426
- // Off-route detection + auto-reroute only run in ACTIVE GUIDANCE — so
427
- // this call (not the earlier free-drive) is what arms rerouting.
428
- print("[NavMgr] startActiveGuidance — \(navigationRoutes.mainRoute.route.legs.count) legs, profile=\(self.travelMode)")
429
- nav.tripSession().startActiveGuidance(with: navigationRoutes, startLegIndex: 0)
430
- self.emitRoute(navigationRoutes)
431
- completion(true, nil)
432
- }
433
- case .failure(let error):
434
- await MainActor.run {
435
- completion(false, "route request failed: \(error.localizedDescription)")
436
- }
431
+ let result = await task.result
432
+ guard let self, self.tripGeneration == generation else { return }
433
+ switch result {
434
+ case let .success(navigationRoutes):
435
+ // v3 SessionController: startActiveGuidance(with:startLegIndex:).
436
+ // Off-route detection + auto-reroute only run in ACTIVE GUIDANCE — so
437
+ // this call (not the earlier free-drive) is what arms rerouting.
438
+ print("[NavMgr] startActiveGuidance — \(navigationRoutes.mainRoute.route.legs.count) legs, profile=\(self.travelMode)")
439
+ nav.tripSession().startActiveGuidance(with: navigationRoutes, startLegIndex: 0)
440
+ self.emitRoute(navigationRoutes)
441
+ completion(true, nil)
442
+ case let .failure(error):
443
+ completion(false, "route request failed: \(error.localizedDescription)")
437
444
  }
438
445
  }
439
446
  }
@@ -453,12 +460,18 @@ final class NavigationManager: NSObject {
453
460
  // MARK: - Stop
454
461
 
455
462
  func stop() {
456
- DispatchQueue.main.async { [weak self] in
457
- self?.stopInternal()
458
- }
463
+ stopInternal()
464
+ }
465
+
466
+ private func finishStart(_ success: Bool, _ error: String?) {
467
+ let completion = startCompletion
468
+ startCompletion = nil
469
+ completion?(success, error)
459
470
  }
460
471
 
461
472
  private func stopInternal() {
473
+ tripGeneration = UUID()
474
+ finishStart(false, "navigation stopped")
462
475
  deviateTimer?.invalidate()
463
476
  deviateTimer = nil
464
477
  cancellables.forEach { $0.cancel() }
@@ -466,7 +479,6 @@ final class NavigationManager: NSObject {
466
479
  // Trip-session stop: SessionController.setToIdle(). `mapboxNavigation` is
467
480
  // non-optional, so only `provider?` carries the optional chain.
468
481
  provider?.mapboxNavigation.tripSession().setToIdle()
469
- provider = nil
470
482
  onEvent = nil
471
483
  onLocation = nil
472
484
  onRoute = nil
@@ -522,7 +534,7 @@ final class NavigationManager: NSObject {
522
534
  if simulating {
523
535
  // In sim the puck is parked at the destination; nothing more to do
524
536
  // on iOS (no replay session to tear back to live). The next start()
525
- // rebuilds the provider cleanly.
537
+ // reconfigures the shared provider.
526
538
  }
527
539
  }
528
540
  return
@@ -592,7 +604,9 @@ final class NavigationManager: NSObject {
592
604
  let distBucket = distanceMeters >= 0 ? distanceMeters : -1
593
605
  let tripBucket = distanceToDestinationMeters >= 0 ? distanceToDestinationMeters : -1
594
606
  let key = "\(maneuverType)|\(distBucket)|\(tripBucket)"
595
- if key == lastEmittedManeuverKey { return }
607
+ if key == lastEmittedManeuverKey {
608
+ return
609
+ }
596
610
  lastEmittedManeuverKey = key
597
611
 
598
612
  var payload: [String: Any] = [
@@ -602,9 +616,15 @@ final class NavigationManager: NSObject {
602
616
  "distanceToDestinationMeters": distanceToDestinationMeters,
603
617
  "timeToDestinationSeconds": timeToDestinationSeconds,
604
618
  ]
605
- if let fromRoad { payload["fromRoad"] = fromRoad; payload["toRoad"] = fromRoad }
606
- if let nextStepRoad { payload["nextStepRoad"] = nextStepRoad }
607
- if let instruction { payload["instruction"] = instruction }
619
+ if let fromRoad {
620
+ payload["fromRoad"] = fromRoad; payload["toRoad"] = fromRoad
621
+ }
622
+ if let nextStepRoad {
623
+ payload["nextStepRoad"] = nextStepRoad
624
+ }
625
+ if let instruction {
626
+ payload["instruction"] = instruction
627
+ }
608
628
  onEvent?(payload)
609
629
  }
610
630
 
@@ -647,11 +667,15 @@ final class NavigationManager: NSObject {
647
667
  "maneuver": maneuverString(type: step.maneuverType, direction: step.maneuverDirection),
648
668
  "distanceMeters": Int(step.distance.rounded()),
649
669
  ]
650
- if let road = step.names?.first?.nonBlank { entry["road"] = road }
670
+ if let road = step.names?.first?.nonBlank {
671
+ entry["road"] = road
672
+ }
651
673
  steps.append(entry)
652
674
  }
653
675
  }
654
- if !steps.isEmpty { payload["steps"] = steps }
676
+ if !steps.isEmpty {
677
+ payload["steps"] = steps
678
+ }
655
679
 
656
680
  onRoute?(payload)
657
681
  }
@@ -663,7 +687,9 @@ final class NavigationManager: NSObject {
663
687
  let dx = (p["lat"] ?? 0) - lat
664
688
  let dy = (p["lng"] ?? 0) - lng
665
689
  let d = dx * dx + dy * dy
666
- if d < bestD { bestD = d; best = i }
690
+ if d < bestD {
691
+ bestD = d; best = i
692
+ }
667
693
  }
668
694
  return best
669
695
  }
@@ -685,7 +711,9 @@ final class NavigationManager: NSObject {
685
711
  let bearing: Double = {
686
712
  if let prev = self.prevReportedCoord, let last = self.lastReportedCoord {
687
713
  let d = self.haversineMeters(prev, last)
688
- if d > 0.5 { return self.bearingDegrees(from: prev, to: last) }
714
+ if d > 0.5 {
715
+ return self.bearingDegrees(from: prev, to: last)
716
+ }
689
717
  }
690
718
  return .nan
691
719
  }()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/crust",
3
- "version": "0.4.0-beta.1025",
3
+ "version": "0.4.0-beta.1052",
4
4
  "description": "Glassly Native Module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -35,7 +35,7 @@
35
35
  "license": "MIT",
36
36
  "homepage": "https://github.com/fossephate/crust#readme",
37
37
  "dependencies": {
38
- "@glassly/jspolyfill": "0.4.0-beta.1025"
38
+ "@glassly/jspolyfill": "0.4.0-beta.1052"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@expo/config-plugins": ">=8.0.0",