@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 +22 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +32 -7
- package/dist/testing.d.ts +14 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +117 -29
- package/templates/CraftHybrid.swift +193 -29
- package/templates/CraftNativeActions.swift +26 -27
- package/templates/CraftNativeFiles.swift +205 -1
- package/templates/CraftNativeScreen.swift +230 -33
- package/templates/testing/DriverTests.swift.template +37 -0
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
/**
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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 (!
|
|
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 (!
|
|
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
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"),
|
|
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
|
|
1305
|
-
dispatchDeepLink(url, initial:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|