craft-native 0.0.241 → 0.0.243
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/android/src/index.d.ts +6 -0
- package/dist/android/src/index.js +19 -3
- package/dist/android/templates/CraftNativeBackground.kt.template +28 -2
- package/dist/android/templates/MainActivityNative.kt.template +7 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +12 -1
- package/dist/ios/src/index.js +24 -7
- package/dist/ios/templates/CraftApp.swift +67 -22
- package/dist/ios/templates/CraftHybrid.swift +200 -33
- package/dist/ios/templates/CraftNativeActions.swift +26 -27
- package/dist/ios/templates/CraftNativeFiles.swift +205 -1
- package/dist/ios/templates/CraftNativeScreen.swift +184 -27
- package/package.json +1 -1
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type AndroidMapProvider } from './native-map.js';
|
|
7
7
|
export type { AndroidMapProvider } from './native-map.js';
|
|
8
|
+
/**
|
|
9
|
+
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
10
|
+
* well as the one-file bundle, and the app runs each screen from its own
|
|
11
|
+
* route's file. Stacks asks for route bundles only when this is true.
|
|
12
|
+
*/
|
|
13
|
+
export declare const nativeRouteBundles = true;
|
|
8
14
|
/** Providers this published builder can generate as real Android map views. */
|
|
9
15
|
export declare const nativeMapProviders: readonly ['google', 'maplibre'];
|
|
10
16
|
export { runKotlinFixture } from './testing.js';
|
|
@@ -357,6 +357,7 @@ async function createAndroidEmulator(options) {
|
|
|
357
357
|
}
|
|
358
358
|
|
|
359
359
|
// ../android/src/index.ts
|
|
360
|
+
var nativeRouteBundles = true;
|
|
360
361
|
var nativeMapProviders = ["google", "maplibre"];
|
|
361
362
|
function syncNativeFonts(fonts, output) {
|
|
362
363
|
const destination = join3(output, "app/src/main/assets/NativeFonts");
|
|
@@ -1243,15 +1244,29 @@ async function build(options) {
|
|
|
1243
1244
|
if (htmlPath || devServer)
|
|
1244
1245
|
throw new Error("A native Android screen cannot use --html-path or --dev-server.");
|
|
1245
1246
|
const destination = join3(output, "app/src/main/assets/native-screen.js");
|
|
1246
|
-
|
|
1247
|
-
|
|
1247
|
+
const routeDestination = join3(output, "app/src/main/assets/native-screens");
|
|
1248
|
+
if (nativeBundlePath) {
|
|
1249
|
+
if (!existsSync(nativeBundlePath))
|
|
1250
|
+
throw new Error(`Native bundle not found: ${nativeBundlePath}`);
|
|
1251
|
+
if (statSync(nativeBundlePath).isDirectory()) {
|
|
1252
|
+
if (resolve2(nativeBundlePath) !== resolve2(routeDestination)) {
|
|
1253
|
+
rmSync2(routeDestination, { recursive: true, force: true });
|
|
1254
|
+
cpSync(nativeBundlePath, routeDestination, { recursive: true });
|
|
1255
|
+
}
|
|
1256
|
+
rmSync2(destination, { force: true });
|
|
1257
|
+
} else {
|
|
1258
|
+
if (resolve2(nativeBundlePath) !== resolve2(destination))
|
|
1259
|
+
cpSync(nativeBundlePath, destination);
|
|
1260
|
+
rmSync2(routeDestination, { recursive: true, force: true });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1248
1263
|
const icons = join3(output, "app/src/main/assets/craft-icons.json");
|
|
1249
1264
|
if (options.iconsPath) {
|
|
1250
1265
|
requireRegularFile(options.iconsPath, "Icon table");
|
|
1251
1266
|
if (resolve2(options.iconsPath) !== resolve2(icons))
|
|
1252
1267
|
cpSync(options.iconsPath, icons);
|
|
1253
1268
|
}
|
|
1254
|
-
if (!existsSync(destination)) {
|
|
1269
|
+
if (!existsSync(destination) && !existsSync(routeDestination)) {
|
|
1255
1270
|
throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
1256
1271
|
}
|
|
1257
1272
|
} else if (nativeBundlePath) {
|
|
@@ -1362,6 +1377,7 @@ export {
|
|
|
1362
1377
|
renderAndroidPermissions,
|
|
1363
1378
|
renderAndroidDeepLinks,
|
|
1364
1379
|
open,
|
|
1380
|
+
nativeRouteBundles,
|
|
1365
1381
|
nativeMapProviders,
|
|
1366
1382
|
installRuntime,
|
|
1367
1383
|
init,
|
|
@@ -56,6 +56,33 @@ internal object CraftNativeSandbox {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The native screens' JavaScript: a route's own file when stx wrote one
|
|
61
|
+
* (`native-screens/<route>.js`, its `routesDir`), else the one-file
|
|
62
|
+
* `native-screen.js`. An isolate evaluates all it is given, so a route file
|
|
63
|
+
* spares each screen parsing (and sending the sandbox) every other screen.
|
|
64
|
+
* Read once and kept while memory allows.
|
|
65
|
+
*/
|
|
66
|
+
internal object CraftNativeBundle {
|
|
67
|
+
private val sources = object : android.util.LruCache<String, String>(24 shl 20) {
|
|
68
|
+
override fun sizeOf(key: String, value: String) = value.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun read(context: Context, asset: String): String? = sources.get(asset)
|
|
72
|
+
?: runCatching { context.assets.open(asset).bufferedReader().use { it.readText() } }.getOrNull()?.also { sources.put(asset, it) }
|
|
73
|
+
|
|
74
|
+
fun source(context: Context, route: String?): String? {
|
|
75
|
+
if (!route.isNullOrBlank() && !route.contains('/') && !route.startsWith('.')) {
|
|
76
|
+
read(context, "native-screens/$route.js")?.let { return it }
|
|
77
|
+
}
|
|
78
|
+
return read(context, "native-screen.js")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun isAvailable(context: Context): Boolean =
|
|
82
|
+
runCatching { context.assets.open("native-screen.js").close(); true }.getOrDefault(false)
|
|
83
|
+
|| (context.assets.list("native-screens")?.isNotEmpty() == true)
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
/**
|
|
60
87
|
* Background refresh, as iOS's BGAppRefreshTask gives it: WorkManager wakes
|
|
61
88
|
* the app every `backgroundRefresh.minimumIntervalMinutes` (15 at the
|
|
@@ -153,8 +180,7 @@ internal class CraftNativeHeadlessApp(
|
|
|
153
180
|
fun start() {
|
|
154
181
|
val route = config.optString("nativeApp")
|
|
155
182
|
if (route.isBlank() || !JavaScriptSandbox.isSupported()) return finish(true)
|
|
156
|
-
val bundle =
|
|
157
|
-
?: return finish(true)
|
|
183
|
+
val bundle = CraftNativeBundle.source(context, route) ?: return finish(true)
|
|
158
184
|
CraftNativeSandbox.connect(context) { engine, error ->
|
|
159
185
|
if (closed) return@connect
|
|
160
186
|
if (engine == null || !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
|
|
@@ -3683,7 +3683,6 @@ class MainActivity : AppCompatActivity() {
|
|
|
3683
3683
|
private val backgroundWaiters = mutableMapOf<String, (Boolean) -> Unit>()
|
|
3684
3684
|
/** A tapped notification that opened the app, for the app script once it runs. */
|
|
3685
3685
|
private var pendingNotificationTap: JSONObject? = null
|
|
3686
|
-
private var bundle = ""
|
|
3687
3686
|
private lateinit var config: JSONObject
|
|
3688
3687
|
private lateinit var capabilities: CraftNativeCapabilities
|
|
3689
3688
|
private val capabilityHandler = Handler(Looper.getMainLooper())
|
|
@@ -3794,9 +3793,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
3794
3793
|
for (index in 0 until schemes.length()) urlSchemes.add(schemes.optString(index).lowercase())
|
|
3795
3794
|
if (!routes.isEmpty) installChrome()
|
|
3796
3795
|
refreshThemeDefaults()
|
|
3797
|
-
|
|
3798
|
-
assets.open("native-screen.js").bufferedReader().use { it.readText() }
|
|
3799
|
-
}.getOrElse {
|
|
3796
|
+
if (!CraftNativeBundle.isAvailable(this)) {
|
|
3800
3797
|
showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
|
|
3801
3798
|
return
|
|
3802
3799
|
}
|
|
@@ -4362,7 +4359,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
4362
4359
|
if (!activeScreen.active) return@addListener
|
|
4363
4360
|
try {
|
|
4364
4361
|
setup.get()
|
|
4365
|
-
val
|
|
4362
|
+
val source = CraftNativeBundle.source(this, route)
|
|
4363
|
+
if (source == null) {
|
|
4364
|
+
if (activeScreen.active) showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
|
|
4365
|
+
return@addListener
|
|
4366
|
+
}
|
|
4367
|
+
val load = isolate.evaluateJavaScriptAsync(source)
|
|
4366
4368
|
load.addListener({
|
|
4367
4369
|
try {
|
|
4368
4370
|
load.get()
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.243";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/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/ios/src/index.js
CHANGED
|
@@ -484,13 +484,29 @@ function hybridConfigProblems(config) {
|
|
|
484
484
|
}
|
|
485
485
|
return problems;
|
|
486
486
|
}
|
|
487
|
+
var nativeRouteBundles = true;
|
|
487
488
|
function installNativeBundle(source, output) {
|
|
488
489
|
if (!existsSync(source))
|
|
489
490
|
throw new Error(`Native bundle not found: ${source}`);
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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"));
|
|
494
510
|
}
|
|
495
511
|
var DEFAULT_CONFIG = {
|
|
496
512
|
renderer: "web",
|
|
@@ -1353,7 +1369,7 @@ async function build(options) {
|
|
|
1353
1369
|
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
1354
1370
|
if (bundle)
|
|
1355
1371
|
installNativeBundle(bundle, output);
|
|
1356
|
-
if (!
|
|
1372
|
+
if (!hasInstalledNativeBundle(output)) {
|
|
1357
1373
|
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
1358
1374
|
}
|
|
1359
1375
|
} else if (nativeBundlePath && !isHybrid(config)) {
|
|
@@ -1378,8 +1394,8 @@ async function build(options) {
|
|
|
1378
1394
|
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
1379
1395
|
if (bundle) {
|
|
1380
1396
|
installNativeBundle(bundle, output);
|
|
1381
|
-
console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
|
|
1382
|
-
} else if (!
|
|
1397
|
+
console.log(` Native screens: ${bundle} \u2192 ${statSync(bundle).isDirectory() ? "dist/native-screens/" : "dist/native-screen.js"}`);
|
|
1398
|
+
} else if (!hasInstalledNativeBundle(output)) {
|
|
1383
1399
|
console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
|
|
1384
1400
|
}
|
|
1385
1401
|
}
|
|
@@ -1552,6 +1568,7 @@ export {
|
|
|
1552
1568
|
open,
|
|
1553
1569
|
normalizeNativePath,
|
|
1554
1570
|
nativeStartPath,
|
|
1571
|
+
nativeRouteBundles,
|
|
1555
1572
|
matchNativePath,
|
|
1556
1573
|
isHybrid,
|
|
1557
1574
|
installRuntime,
|
|
@@ -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,6 +889,9 @@ 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
|
|
@@ -917,7 +934,7 @@ final class CraftChrome: ObservableObject {
|
|
|
917
934
|
withAnimation(nil) { tabSwipePosition = nil }
|
|
918
935
|
guard tabs.contains(where: { $0.id == id }) else { return }
|
|
919
936
|
withAnimation(nil) { selected = id }
|
|
920
|
-
|
|
937
|
+
selectionFeedback.selectionChanged()
|
|
921
938
|
guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
|
|
922
939
|
emit("craftTabSelect", ["id": id])
|
|
923
940
|
}
|
|
@@ -1183,9 +1200,20 @@ struct CraftTabBarView: View {
|
|
|
1183
1200
|
|
|
1184
1201
|
/// The filled variant for the current tab, when the symbol has one.
|
|
1185
1202
|
private func symbol(_ name: String, selected: Bool) -> String {
|
|
1186
|
-
guard selected, !name.hasSuffix(".fill"),
|
|
1203
|
+
guard selected, !name.hasSuffix(".fill"), Self.hasFilledSymbol(name) else { return name }
|
|
1187
1204
|
return "\(name).fill"
|
|
1188
1205
|
}
|
|
1206
|
+
|
|
1207
|
+
/// Whether `<name>.fill` exists, asked of the symbol catalog once per
|
|
1208
|
+
/// name: the bar's body runs again for every selection change and asked
|
|
1209
|
+
/// it for every tab each time.
|
|
1210
|
+
private static var filledSymbols: [String: Bool] = [:]
|
|
1211
|
+
private static func hasFilledSymbol(_ name: String) -> Bool {
|
|
1212
|
+
if let known = filledSymbols[name] { return known }
|
|
1213
|
+
let exists = UIImage(systemName: "\(name).fill") != nil
|
|
1214
|
+
filledSymbols[name] = exists
|
|
1215
|
+
return exists
|
|
1216
|
+
}
|
|
1189
1217
|
}
|
|
1190
1218
|
|
|
1191
1219
|
private struct CraftTabSelectionIndicator: View {
|
|
@@ -1267,13 +1295,14 @@ class DeepLinkManager {
|
|
|
1267
1295
|
// Every link that arrived while no page could receive it, in order. This
|
|
1268
1296
|
// was a single slot, so a second link before the bridge was ready replaced
|
|
1269
1297
|
// the first; on a cold start, that could be the link that launched the app.
|
|
1270
|
-
private var pendingURLs: [URL] = []
|
|
1298
|
+
private var pendingURLs: [(url: URL, initial: Bool)] = []
|
|
1299
|
+
private var replayingNativeLinks = false
|
|
1271
1300
|
private weak var webView: WKWebView?
|
|
1272
1301
|
private var isReady = false
|
|
1273
1302
|
// Replay bookkeeping only. A native screen can become ready before UIKit
|
|
1274
1303
|
// delivers the launch URL; listener readiness cannot classify that URL.
|
|
1275
1304
|
private var hasBeenReady = false
|
|
1276
|
-
private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
|
|
1305
|
+
private var nativeListeners: [UUID: (canReceive: () -> Bool, receive: (URL, Bool) -> Void)] = [:]
|
|
1277
1306
|
|
|
1278
1307
|
private init() {
|
|
1279
1308
|
for name in [UIApplication.didBecomeActiveNotification, UIApplication.didEnterBackgroundNotification] {
|
|
@@ -1297,12 +1326,11 @@ class DeepLinkManager {
|
|
|
1297
1326
|
|
|
1298
1327
|
func setReady() {
|
|
1299
1328
|
isReady = true
|
|
1300
|
-
let firstPage = !hasBeenReady
|
|
1301
1329
|
hasBeenReady = true
|
|
1302
1330
|
let urls = pendingURLs
|
|
1303
1331
|
pendingURLs.removeAll()
|
|
1304
|
-
for
|
|
1305
|
-
dispatchDeepLink(url, initial:
|
|
1332
|
+
for link in urls {
|
|
1333
|
+
dispatchDeepLink(link.url, initial: link.initial)
|
|
1306
1334
|
}
|
|
1307
1335
|
}
|
|
1308
1336
|
|
|
@@ -1316,21 +1344,21 @@ class DeepLinkManager {
|
|
|
1316
1344
|
if CraftHybrid.shared.handleDeepLink(url) {
|
|
1317
1345
|
dispatchNative(url, initial: false)
|
|
1318
1346
|
} else if !CraftHybrid.shared.hasWeb {
|
|
1319
|
-
|
|
1347
|
+
enqueueNative(url)
|
|
1320
1348
|
} else if isReady && webView != nil {
|
|
1321
1349
|
dispatchDeepLink(url, initial: false)
|
|
1322
1350
|
} else {
|
|
1323
|
-
pendingURLs.append(url)
|
|
1351
|
+
pendingURLs.append((url, !hasBeenReady && url == initialURL))
|
|
1324
1352
|
}
|
|
1325
1353
|
return
|
|
1326
1354
|
}
|
|
1327
1355
|
|
|
1328
1356
|
if !nativeListeners.isEmpty {
|
|
1329
|
-
|
|
1357
|
+
enqueueNative(url)
|
|
1330
1358
|
} else if isReady && webView != nil {
|
|
1331
1359
|
dispatchDeepLink(url, initial: false)
|
|
1332
1360
|
} else {
|
|
1333
|
-
pendingURLs.append(url)
|
|
1361
|
+
pendingURLs.append((url, !hasBeenReady && url == initialURL))
|
|
1334
1362
|
}
|
|
1335
1363
|
}
|
|
1336
1364
|
|
|
@@ -1350,20 +1378,14 @@ class DeepLinkManager {
|
|
|
1350
1378
|
}
|
|
1351
1379
|
|
|
1352
1380
|
@discardableResult
|
|
1353
|
-
func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
|
|
1381
|
+
func addNativeListener(canReceive: @escaping () -> Bool = { true }, _ listener: @escaping (URL, Bool) -> Void) -> UUID {
|
|
1354
1382
|
let token = UUID()
|
|
1355
|
-
nativeListeners[token] = listener
|
|
1383
|
+
nativeListeners[token] = (canReceive, listener)
|
|
1356
1384
|
// In a hybrid app the page is the one waiting for the links that
|
|
1357
1385
|
// arrived early; a native screen hears only native ones, as they come.
|
|
1358
1386
|
// An all-native app has no page, so its first screen hears them.
|
|
1359
1387
|
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
|
-
}
|
|
1388
|
+
replayNativeLinks()
|
|
1367
1389
|
return token
|
|
1368
1390
|
}
|
|
1369
1391
|
|
|
@@ -1371,8 +1393,31 @@ class DeepLinkManager {
|
|
|
1371
1393
|
nativeListeners.removeValue(forKey: token)
|
|
1372
1394
|
}
|
|
1373
1395
|
|
|
1396
|
+
private func enqueueNative(_ url: URL) {
|
|
1397
|
+
pendingURLs.append((url, !hasBeenReady && url == initialURL))
|
|
1398
|
+
replayNativeLinks()
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/// Registration can happen while a controller is being prewarmed. Keep
|
|
1402
|
+
/// links until a screen is visible, including across a tab switch.
|
|
1403
|
+
func replayNativeLinks() {
|
|
1404
|
+
guard !(CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb), !replayingNativeLinks else { return }
|
|
1405
|
+
replayingNativeLinks = true
|
|
1406
|
+
defer { replayingNativeLinks = false }
|
|
1407
|
+
while let link = pendingURLs.first {
|
|
1408
|
+
let receivers = nativeListeners.values.filter { $0.canReceive() }
|
|
1409
|
+
guard !receivers.isEmpty else { return }
|
|
1410
|
+
pendingURLs.removeFirst()
|
|
1411
|
+
hasBeenReady = true
|
|
1412
|
+
for receiver in receivers { receiver.receive(link.url, link.initial) }
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1374
1416
|
private func dispatchNative(_ url: URL, initial: Bool) {
|
|
1375
|
-
|
|
1417
|
+
// The hybrid shell already opened this route; notify visible screens
|
|
1418
|
+
// without queuing a second navigation for its future destination.
|
|
1419
|
+
let receivers = nativeListeners.values.filter { $0.canReceive() }
|
|
1420
|
+
for receiver in receivers { receiver.receive(url, initial) }
|
|
1376
1421
|
}
|
|
1377
1422
|
|
|
1378
1423
|
private func dispatchDeepLink(_ url: URL, initial: Bool) {
|