@craft-native/ios 0.0.238 → 0.0.242

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/dist/cli.js CHANGED
@@ -303,10 +303,25 @@ function hybridConfigProblems(config) {
303
303
  function installNativeBundle(source, output) {
304
304
  if (!existsSync(source))
305
305
  throw new Error(`Native bundle not found: ${source}`);
306
- const destination = join3(output, "dist", "native-screen.js");
307
- mkdirSync(dirname2(destination), { recursive: true });
308
- if (resolve(source) !== resolve(destination))
309
- cpSync(source, destination);
306
+ const single = join3(output, "dist", "native-screen.js");
307
+ const routes = join3(output, "dist", "native-screens");
308
+ mkdirSync(dirname2(single), { recursive: true });
309
+ if (statSync(source).isDirectory()) {
310
+ if (!readdirSync2(source).some((name) => name.endsWith(".js")))
311
+ throw new Error(`Native route bundles not found in ${source}`);
312
+ if (resolve(source) !== resolve(routes)) {
313
+ rmSync(routes, { recursive: true, force: true });
314
+ cpSync(source, routes, { recursive: true });
315
+ }
316
+ rmSync(single, { force: true });
317
+ return;
318
+ }
319
+ if (resolve(source) !== resolve(single))
320
+ cpSync(source, single);
321
+ rmSync(routes, { recursive: true, force: true });
322
+ }
323
+ function hasInstalledNativeBundle(output) {
324
+ return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
310
325
  }
311
326
  var DEFAULT_CONFIG = {
312
327
  renderer: "web",
@@ -1156,7 +1171,7 @@ async function build(options) {
1156
1171
  const bundle = nativeBundlePath ?? config.nativeBundle;
1157
1172
  if (bundle)
1158
1173
  installNativeBundle(bundle, output);
1159
- if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1174
+ if (!hasInstalledNativeBundle(output)) {
1160
1175
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1161
1176
  }
1162
1177
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -1181,8 +1196,8 @@ async function build(options) {
1181
1196
  const bundle = nativeBundlePath ?? config.nativeBundle;
1182
1197
  if (bundle) {
1183
1198
  installNativeBundle(bundle, output);
1184
- console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
1185
- } else if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1199
+ console.log(` Native screens: ${bundle} \u2192 ${statSync(bundle).isDirectory() ? "dist/native-screens/" : "dist/native-screen.js"}`);
1200
+ } else if (!hasInstalledNativeBundle(output)) {
1186
1201
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
1187
1202
  }
1188
1203
  }
package/dist/index.d.ts CHANGED
@@ -223,7 +223,12 @@ export interface CraftConfig {
223
223
  * opens in the in-app browser on the app's site.
224
224
  */
225
225
  nativeScreens?: Record<string, string>;
226
- /** The bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
226
+ /**
227
+ * The bundle `stx native compile --format bundle` wrote, copied to
228
+ * `dist/native-screen.js`; or a directory of route bundles (stx's
229
+ * `routesDir`: `<Screen>.js` and `__app.js`), copied to
230
+ * `dist/native-screens/`, so each screen evaluates only its own file.
231
+ */
227
232
  nativeBundle?: string;
228
233
  /**
229
234
  * The bundle route of a screen-less script that runs once for the app's
@@ -307,6 +312,12 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
307
312
  export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
308
313
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
309
314
  export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
315
+ /**
316
+ * The builder installs a directory of route bundles (stx's `routesDir`) as
317
+ * well as the one-file bundle, and the app runs each screen from its own
318
+ * route's file. Stacks asks for route bundles only when this is true.
319
+ */
320
+ export declare const nativeRouteBundles = true;
310
321
  export interface CraftBackgroundRefresh {
311
322
  enabled: boolean;
312
323
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
package/dist/index.js CHANGED
@@ -228,10 +228,18 @@ function serializeSimulatorSteps(steps) {
228
228
  if (!Number.isInteger(number(step.index)))
229
229
  throw new Error("field index must be an integer");
230
230
  arg = `${step.index} ${step.text}`;
231
+ } else if ("position" in step) {
232
+ if (number(step.position) > 1)
233
+ throw new Error("Slider position must be between 0 and 1");
234
+ arg = `${token(step.id)} ${step.position}`;
231
235
  } else if ("id" in step)
232
236
  arg = `${token(step.id)} ${step.text}`;
233
237
  else if ("text" in step) {
234
238
  arg = step.text;
239
+ if (step.command === "longpressid")
240
+ token(arg);
241
+ if (step.command === "expectapp" && !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(arg))
242
+ throw new Error("Expected an application bundle identifier");
235
243
  if ("seconds" in step && step.seconds !== undefined) {
236
244
  if (arg.includes("|"))
237
245
  throw new Error("A timed expectation cannot contain |");
@@ -476,13 +484,29 @@ function hybridConfigProblems(config) {
476
484
  }
477
485
  return problems;
478
486
  }
487
+ var nativeRouteBundles = true;
479
488
  function installNativeBundle(source, output) {
480
489
  if (!existsSync(source))
481
490
  throw new Error(`Native bundle not found: ${source}`);
482
- const destination = join3(output, "dist", "native-screen.js");
483
- mkdirSync2(dirname2(destination), { recursive: true });
484
- if (resolve(source) !== resolve(destination))
485
- cpSync(source, destination);
491
+ const single = join3(output, "dist", "native-screen.js");
492
+ const routes = join3(output, "dist", "native-screens");
493
+ mkdirSync2(dirname2(single), { recursive: true });
494
+ if (statSync(source).isDirectory()) {
495
+ if (!readdirSync2(source).some((name) => name.endsWith(".js")))
496
+ throw new Error(`Native route bundles not found in ${source}`);
497
+ if (resolve(source) !== resolve(routes)) {
498
+ rmSync2(routes, { recursive: true, force: true });
499
+ cpSync(source, routes, { recursive: true });
500
+ }
501
+ rmSync2(single, { force: true });
502
+ return;
503
+ }
504
+ if (resolve(source) !== resolve(single))
505
+ cpSync(source, single);
506
+ rmSync2(routes, { recursive: true, force: true });
507
+ }
508
+ function hasInstalledNativeBundle(output) {
509
+ return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
486
510
  }
487
511
  var DEFAULT_CONFIG = {
488
512
  renderer: "web",
@@ -1345,7 +1369,7 @@ async function build(options) {
1345
1369
  const bundle = nativeBundlePath ?? config.nativeBundle;
1346
1370
  if (bundle)
1347
1371
  installNativeBundle(bundle, output);
1348
- if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1372
+ if (!hasInstalledNativeBundle(output)) {
1349
1373
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1350
1374
  }
1351
1375
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -1370,8 +1394,8 @@ async function build(options) {
1370
1394
  const bundle = nativeBundlePath ?? config.nativeBundle;
1371
1395
  if (bundle) {
1372
1396
  installNativeBundle(bundle, output);
1373
- console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
1374
- } else if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1397
+ console.log(` Native screens: ${bundle} \u2192 ${statSync(bundle).isDirectory() ? "dist/native-screens/" : "dist/native-screen.js"}`);
1398
+ } else if (!hasInstalledNativeBundle(output)) {
1375
1399
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
1376
1400
  }
1377
1401
  }
@@ -1544,6 +1568,7 @@ export {
1544
1568
  open,
1545
1569
  normalizeNativePath,
1546
1570
  nativeStartPath,
1571
+ nativeRouteBundles,
1547
1572
  matchNativePath,
1548
1573
  isHybrid,
1549
1574
  installRuntime,
package/dist/testing.d.ts CHANGED
@@ -66,15 +66,27 @@ export type SimulatorStep = {
66
66
  index: number;
67
67
  text: string;
68
68
  } | {
69
- command: 'typeid' | 'labelid';
69
+ command: 'typeid' | 'labelid' | 'valueid';
70
70
  id: string;
71
71
  text: string;
72
+ }
73
+ /** Adjust an actual native UISlider by identifier, using its normalized range. */
74
+ | {
75
+ command: 'sliderid';
76
+ id: string;
77
+ position: number;
78
+ }
79
+ /** Assert a handoff reached the foreground application without launching it. */
80
+ | {
81
+ command: 'expectapp';
82
+ text: string;
83
+ seconds?: number;
72
84
  } | {
73
85
  command: 'expect' | 'expectexact' | 'expectid' | 'systemexpect';
74
86
  text: string;
75
87
  seconds?: number;
76
88
  } | {
77
- command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent';
89
+ command: 'tap' | 'tapif' | 'tapid' | 'longpressid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent';
78
90
  text: string;
79
91
  } | {
80
92
  command: 'shot' | 'tree' | 'audit' | 'exec' | 'systemtree';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.238",
3
+ "version": "0.0.242",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -251,6 +251,17 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
251
251
  CraftEventManager.shared.sendToWeb("craftSilentPush", data: ["id": id, "payload": CraftEventManager.pageData(userInfo)])
252
252
  }
253
253
 
254
+ /// iOS relaunched the app because `craft.media.keep`'s background
255
+ /// transfers finished (CraftNativeMediaTransfers): their session takes
256
+ /// the events, stores the files, then says it is done.
257
+ func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
258
+ guard identifier == CraftNativeMediaTransfers.identifier else {
259
+ completionHandler()
260
+ return
261
+ }
262
+ CraftNativeMediaTransfers.shared.backgroundCompletion = completionHandler
263
+ }
264
+
254
265
  func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
255
266
  let token = deviceToken.map { String(format: "%02x", $0) }.joined()
256
267
  NotificationCenter.default.post(name: .craftPushToken, object: token)
@@ -679,6 +690,9 @@ final class CraftChrome: ObservableObject {
679
690
  private var splashDeadline: DispatchWorkItem?
680
691
  private var tabSwipePrevious: String?
681
692
  private var tabSwipeTarget: String?
693
+ /// One generator for every tab change. A new one per tick started the
694
+ /// Taptic Engine cold each time, on the main thread, late.
695
+ private let selectionFeedback = UISelectionFeedbackGenerator()
682
696
  /// The keyboard is up and covers the tab bar, so the page need not leave
683
697
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
684
698
  private var keyboardUp = false
@@ -866,7 +880,7 @@ final class CraftChrome: ObservableObject {
866
880
  func swipe(to id: String) {
867
881
  guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
868
882
  withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
869
- UISelectionFeedbackGenerator().selectionChanged()
883
+ selectionFeedback.selectionChanged()
870
884
  guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
871
885
  emit("craftTabSelect", ["id": id])
872
886
  }
@@ -875,13 +889,19 @@ final class CraftChrome: ObservableObject {
875
889
  /// controller commits or restores this preview when the finger lifts.
876
890
  func beginTabSwipe(to id: String, from previous: String) {
877
891
  guard id != previous, tabs.contains(where: { $0.id == id }) else { return }
892
+ // The tick comes as the swipe commits; wake the Taptic Engine now, as
893
+ // UIFeedbackGenerator asks, so it is on time rather than late.
894
+ selectionFeedback.prepare()
878
895
  // A page's pending selection report may disagree with the native
879
896
  // stack. Cancellation belongs to the stack the finger left.
880
897
  tabSwipePrevious = previous
881
898
  tabSwipeTarget = id
899
+ // Only the indicator moves now. The highlight follows it across the
900
+ // midpoint (updateTabSwipe), as iOS's own tab bar lens does; turning
901
+ // it at once rebuilt every tab in the swipe's first frame, which is
902
+ // also when UIKit puts the incoming screen on screen.
882
903
  withAnimation(nil) {
883
904
  tabSwipePosition = tabs.firstIndex(where: { $0.id == previous }).map { CGFloat($0) }
884
- selected = id
885
905
  }
886
906
  }
887
907
 
@@ -893,11 +913,14 @@ final class CraftChrome: ObservableObject {
893
913
  let to = tabs.firstIndex(where: { $0.id == target }) else { return }
894
914
  let position = CGFloat(from) + CGFloat(to - from) * min(1, max(0, progress))
895
915
  if tabSwipePosition != position { withAnimation(nil) { tabSwipePosition = position } }
916
+ let lit = progress >= 0.5 ? target : previous
917
+ if selected != lit { withAnimation(nil) { selected = lit } }
896
918
  }
897
919
 
898
920
  func settleTabSwipe(completed: Bool, duration: TimeInterval) {
899
921
  guard let id = completed ? tabSwipeTarget : tabSwipePrevious,
900
922
  let index = tabs.firstIndex(where: { $0.id == id }) else { return }
923
+ if selected != id { withAnimation(nil) { selected = id } }
901
924
  withAnimation(Self.motion(.easeOut(duration: duration))) { tabSwipePosition = CGFloat(index) }
902
925
  }
903
926
 
@@ -917,7 +940,7 @@ final class CraftChrome: ObservableObject {
917
940
  withAnimation(nil) { tabSwipePosition = nil }
918
941
  guard tabs.contains(where: { $0.id == id }) else { return }
919
942
  withAnimation(nil) { selected = id }
920
- UISelectionFeedbackGenerator().selectionChanged()
943
+ selectionFeedback.selectionChanged()
921
944
  guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
922
945
  emit("craftTabSelect", ["id": id])
923
946
  }
@@ -1183,9 +1206,20 @@ struct CraftTabBarView: View {
1183
1206
 
1184
1207
  /// The filled variant for the current tab, when the symbol has one.
1185
1208
  private func symbol(_ name: String, selected: Bool) -> String {
1186
- guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
1209
+ guard selected, !name.hasSuffix(".fill"), Self.hasFilledSymbol(name) else { return name }
1187
1210
  return "\(name).fill"
1188
1211
  }
1212
+
1213
+ /// Whether `<name>.fill` exists, asked of the symbol catalog once per
1214
+ /// name: the bar's body runs again for every selection change and asked
1215
+ /// it for every tab each time.
1216
+ private static var filledSymbols: [String: Bool] = [:]
1217
+ private static func hasFilledSymbol(_ name: String) -> Bool {
1218
+ if let known = filledSymbols[name] { return known }
1219
+ let exists = UIImage(systemName: "\(name).fill") != nil
1220
+ filledSymbols[name] = exists
1221
+ return exists
1222
+ }
1189
1223
  }
1190
1224
 
1191
1225
  private struct CraftTabSelectionIndicator: View {
@@ -1267,13 +1301,14 @@ class DeepLinkManager {
1267
1301
  // Every link that arrived while no page could receive it, in order. This
1268
1302
  // was a single slot, so a second link before the bridge was ready replaced
1269
1303
  // the first; on a cold start, that could be the link that launched the app.
1270
- private var pendingURLs: [URL] = []
1304
+ private var pendingURLs: [(url: URL, initial: Bool)] = []
1305
+ private var replayingNativeLinks = false
1271
1306
  private weak var webView: WKWebView?
1272
1307
  private var isReady = false
1273
1308
  // Replay bookkeeping only. A native screen can become ready before UIKit
1274
1309
  // delivers the launch URL; listener readiness cannot classify that URL.
1275
1310
  private var hasBeenReady = false
1276
- private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
1311
+ private var nativeListeners: [UUID: (canReceive: () -> Bool, receive: (URL, Bool) -> Void)] = [:]
1277
1312
 
1278
1313
  private init() {
1279
1314
  for name in [UIApplication.didBecomeActiveNotification, UIApplication.didEnterBackgroundNotification] {
@@ -1297,12 +1332,11 @@ class DeepLinkManager {
1297
1332
 
1298
1333
  func setReady() {
1299
1334
  isReady = true
1300
- let firstPage = !hasBeenReady
1301
1335
  hasBeenReady = true
1302
1336
  let urls = pendingURLs
1303
1337
  pendingURLs.removeAll()
1304
- for url in urls {
1305
- dispatchDeepLink(url, initial: firstPage && url == initialURL)
1338
+ for link in urls {
1339
+ dispatchDeepLink(link.url, initial: link.initial)
1306
1340
  }
1307
1341
  }
1308
1342
 
@@ -1316,21 +1350,21 @@ class DeepLinkManager {
1316
1350
  if CraftHybrid.shared.handleDeepLink(url) {
1317
1351
  dispatchNative(url, initial: false)
1318
1352
  } else if !CraftHybrid.shared.hasWeb {
1319
- if nativeListeners.isEmpty { pendingURLs.append(url) } else { dispatchNative(url, initial: false) }
1353
+ enqueueNative(url)
1320
1354
  } else if isReady && webView != nil {
1321
1355
  dispatchDeepLink(url, initial: false)
1322
1356
  } else {
1323
- pendingURLs.append(url)
1357
+ pendingURLs.append((url, !hasBeenReady && url == initialURL))
1324
1358
  }
1325
1359
  return
1326
1360
  }
1327
1361
 
1328
1362
  if !nativeListeners.isEmpty {
1329
- dispatchNative(url, initial: false)
1363
+ enqueueNative(url)
1330
1364
  } else if isReady && webView != nil {
1331
1365
  dispatchDeepLink(url, initial: false)
1332
1366
  } else {
1333
- pendingURLs.append(url)
1367
+ pendingURLs.append((url, !hasBeenReady && url == initialURL))
1334
1368
  }
1335
1369
  }
1336
1370
 
@@ -1350,20 +1384,14 @@ class DeepLinkManager {
1350
1384
  }
1351
1385
 
1352
1386
  @discardableResult
1353
- func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
1387
+ func addNativeListener(canReceive: @escaping () -> Bool = { true }, _ listener: @escaping (URL, Bool) -> Void) -> UUID {
1354
1388
  let token = UUID()
1355
- nativeListeners[token] = listener
1389
+ nativeListeners[token] = (canReceive, listener)
1356
1390
  // In a hybrid app the page is the one waiting for the links that
1357
1391
  // arrived early; a native screen hears only native ones, as they come.
1358
1392
  // An all-native app has no page, so its first screen hears them.
1359
1393
  if CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb { return token }
1360
- let firstNativeScreen = !hasBeenReady
1361
- hasBeenReady = true
1362
- let urls = pendingURLs
1363
- pendingURLs.removeAll()
1364
- for url in urls {
1365
- listener(url, firstNativeScreen && url == initialURL)
1366
- }
1394
+ replayNativeLinks()
1367
1395
  return token
1368
1396
  }
1369
1397
 
@@ -1371,8 +1399,31 @@ class DeepLinkManager {
1371
1399
  nativeListeners.removeValue(forKey: token)
1372
1400
  }
1373
1401
 
1402
+ private func enqueueNative(_ url: URL) {
1403
+ pendingURLs.append((url, !hasBeenReady && url == initialURL))
1404
+ replayNativeLinks()
1405
+ }
1406
+
1407
+ /// Registration can happen while a controller is being prewarmed. Keep
1408
+ /// links until a screen is visible, including across a tab switch.
1409
+ func replayNativeLinks() {
1410
+ guard !(CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb), !replayingNativeLinks else { return }
1411
+ replayingNativeLinks = true
1412
+ defer { replayingNativeLinks = false }
1413
+ while let link = pendingURLs.first {
1414
+ let receivers = nativeListeners.values.filter { $0.canReceive() }
1415
+ guard !receivers.isEmpty else { return }
1416
+ pendingURLs.removeFirst()
1417
+ hasBeenReady = true
1418
+ for receiver in receivers { receiver.receive(link.url, link.initial) }
1419
+ }
1420
+ }
1421
+
1374
1422
  private func dispatchNative(_ url: URL, initial: Bool) {
1375
- for listener in nativeListeners.values { listener(url, initial) }
1423
+ // The hybrid shell already opened this route; notify visible screens
1424
+ // without queuing a second navigation for its future destination.
1425
+ let receivers = nativeListeners.values.filter { $0.canReceive() }
1426
+ for receiver in receivers { receiver.receive(url, initial) }
1376
1427
  }
1377
1428
 
1378
1429
  private func dispatchDeepLink(_ url: URL, initial: Bool) {
@@ -1991,6 +2042,9 @@ struct CraftWebView: UIViewRepresentable {
1991
2042
  private var locationManager: CLLocationManager?
1992
2043
  private var singleLocationCallbackId: String?
1993
2044
  private var singleLocationTimeoutWorkItem: DispatchWorkItem?
2045
+ private var singleLocationTimeoutMilliseconds: Double = 30_000
2046
+ private var singleLocationAcquisitionStarted = false
2047
+ private var singleLocationAuthorizationRequested = false
1994
2048
  private var locationPermissionCallbackId: String?
1995
2049
  private var locationPermissionRequiresAlways = false
1996
2050
  private var isWatchingLocation = false
@@ -2065,7 +2119,7 @@ struct CraftWebView: UIViewRepresentable {
2065
2119
  // SQLite database
2066
2120
  private var db: OpaquePointer?
2067
2121
 
2068
- init(config: CraftConfig, recordingDirectory: URL? = nil) {
2122
+ init(config: CraftConfig, locationManager suppliedLocationManager: CLLocationManager? = nil, recordingDirectory: URL? = nil) {
2069
2123
  self.recordingDirectory = recordingDirectory
2070
2124
  self.config = config
2071
2125
  super.init()
@@ -2073,7 +2127,7 @@ struct CraftWebView: UIViewRepresentable {
2073
2127
  speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
2074
2128
  }
2075
2129
  if config.enableGeolocation {
2076
- locationManager = CLLocationManager()
2130
+ locationManager = suppliedLocationManager ?? CLLocationManager()
2077
2131
  locationManager?.desiredAccuracy = kCLLocationAccuracyBest
2078
2132
  locationManager?.activityType = .fitness
2079
2133
  locationManager?.pausesLocationUpdatesAutomatically = false
@@ -6400,7 +6454,7 @@ struct CraftWebView: UIViewRepresentable {
6400
6454
  }
6401
6455
  singleLocationCallbackId = callbackId
6402
6456
  let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
6403
- if maximumAge > 0,
6457
+ if (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse), maximumAge > 0,
6404
6458
  let cachedLocation = manager.location,
6405
6459
  max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge {
6406
6460
  finishSingleLocationRequest()
@@ -6408,7 +6462,35 @@ struct CraftWebView: UIViewRepresentable {
6408
6462
  return
6409
6463
  }
6410
6464
 
6411
- let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
6465
+ singleLocationTimeoutMilliseconds = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
6466
+ continueSingleLocationRequest(manager)
6467
+ }
6468
+
6469
+ private func continueSingleLocationRequest(_ manager: CLLocationManager) {
6470
+ guard let callbackId = singleLocationCallbackId else { return }
6471
+ switch manager.authorizationStatus {
6472
+ case .notDetermined:
6473
+ if !singleLocationAuthorizationRequested {
6474
+ singleLocationAuthorizationRequested = true
6475
+ manager.requestWhenInUseAuthorization()
6476
+ }
6477
+ return
6478
+ case .denied, .restricted:
6479
+ finishSingleLocationRequest()
6480
+ rejectCallback(callbackId, error: "Location permission was denied.", code: "PERMISSION_DENIED")
6481
+ return
6482
+ case .authorizedAlways, .authorizedWhenInUse:
6483
+ break
6484
+ @unknown default:
6485
+ finishSingleLocationRequest()
6486
+ rejectCallback(callbackId, error: "Location authorization is unavailable.", code: "PERMISSION_DENIED")
6487
+ return
6488
+ }
6489
+ guard !singleLocationAcquisitionStarted else { return }
6490
+ singleLocationAcquisitionStarted = true
6491
+ // The acquisition timeout starts after the person decides. A
6492
+ // permission sheet is not a failed or timed-out GPS fix.
6493
+ let timeoutMs = singleLocationTimeoutMilliseconds
6412
6494
  let timeoutWorkItem = DispatchWorkItem { [weak self] in
6413
6495
  guard let self, self.singleLocationCallbackId == callbackId else { return }
6414
6496
  self.finishSingleLocationRequest()
@@ -6416,7 +6498,6 @@ struct CraftWebView: UIViewRepresentable {
6416
6498
  }
6417
6499
  singleLocationTimeoutWorkItem = timeoutWorkItem
6418
6500
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
6419
- requestLocationAuthorization()
6420
6501
  manager.requestLocation()
6421
6502
  }
6422
6503
 
@@ -6424,6 +6505,8 @@ struct CraftWebView: UIViewRepresentable {
6424
6505
  singleLocationTimeoutWorkItem?.cancel()
6425
6506
  singleLocationTimeoutWorkItem = nil
6426
6507
  singleLocationCallbackId = nil
6508
+ singleLocationAcquisitionStarted = false
6509
+ singleLocationAuthorizationRequested = false
6427
6510
  locationManager?.desiredAccuracy = kCLLocationAccuracyBest
6428
6511
  }
6429
6512
 
@@ -6753,6 +6836,10 @@ struct CraftWebView: UIViewRepresentable {
6753
6836
 
6754
6837
  func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
6755
6838
  let nativeError = error as NSError
6839
+ if nativeError.domain == kCLErrorDomain, nativeError.code == CLError.Code.denied.rawValue,
6840
+ manager.authorizationStatus == .notDetermined, singleLocationCallbackId != nil, !singleLocationAcquisitionStarted {
6841
+ return
6842
+ }
6756
6843
  // kCLErrorLocationUnknown is Core Location saying it has no fix
6757
6844
  // *yet*, not that it failed. requestLocation gives up on it, so a
6758
6845
  // one-shot asks again shortly instead of failing a caller whose
@@ -6761,7 +6848,7 @@ struct CraftWebView: UIViewRepresentable {
6761
6848
  if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue {
6762
6849
  if let waiting = singleLocationCallbackId {
6763
6850
  DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
6764
- guard let self, self.singleLocationCallbackId == waiting else { return }
6851
+ guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }
6765
6852
  self.locationManager?.requestLocation()
6766
6853
  }
6767
6854
  }
@@ -6777,6 +6864,7 @@ struct CraftWebView: UIViewRepresentable {
6777
6864
  }
6778
6865
 
6779
6866
  func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
6867
+ if singleLocationCallbackId != nil { continueSingleLocationRequest(manager) }
6780
6868
  guard let callbackId = locationPermissionCallbackId else { return }
6781
6869
  let status = manager.authorizationStatus
6782
6870
  if status == .notDetermined { return }