craft-native 0.0.124 → 0.0.125
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.js +5 -3
- package/dist/android/templates/CraftNativeFlatList.kt.template +39 -2
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivityNative.kt.template +269 -26
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +43 -0
- package/dist/cli.js +3 -3
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +49 -1
- package/dist/ios/src/index.js +150 -4
- package/dist/ios/templates/CraftApp.swift +111 -13
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +213 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +150 -21
- package/dist/ios/templates/CraftNativeScreen.swift +1492 -307
- package/dist/mobile.js +66 -0
- package/package.json +1 -1
|
@@ -227,13 +227,14 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
227
227
|
/// `craft.background.complete(ok)`, or after 25 seconds, inside the 30 iOS
|
|
228
228
|
/// allows.
|
|
229
229
|
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
|
230
|
-
CraftBackgroundWork.shared.
|
|
230
|
+
let id = CraftBackgroundWork.shared.hold { ok in
|
|
231
231
|
switch ok {
|
|
232
232
|
case true?: completionHandler(.newData)
|
|
233
233
|
case false?: completionHandler(.failed)
|
|
234
234
|
case nil: completionHandler(.noData)
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
+
CraftEventManager.shared.sendToWeb("craftSilentPush", data: ["id": id, "payload": CraftEventManager.pageData(userInfo)])
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
@@ -377,15 +378,15 @@ final class CraftBackgroundWork {
|
|
|
377
378
|
|
|
378
379
|
private init() {}
|
|
379
380
|
|
|
380
|
-
///
|
|
381
|
-
/// the page, nil when the deadline passed
|
|
382
|
-
|
|
383
|
-
|
|
381
|
+
/// Hold `finish` until the page answers the event that carries the
|
|
382
|
+
/// returned id: true or false from the page, nil when the deadline passed
|
|
383
|
+
/// first. The caller sends that event, with the id in its detail, after
|
|
384
|
+
/// this returns. It names the event itself so every event the page can
|
|
385
|
+
/// hear is spelled at a `sendToWeb` call, where the conformance test reads
|
|
386
|
+
/// the vocabulary from.
|
|
387
|
+
func hold(_ finish: @escaping (Bool?) -> Void) -> String {
|
|
384
388
|
let id = UUID().uuidString
|
|
385
389
|
pending[id] = finish
|
|
386
|
-
var detail = detail
|
|
387
|
-
detail["id"] = id
|
|
388
|
-
CraftEventManager.shared.sendToWeb(event, data: detail)
|
|
389
390
|
DispatchQueue.main.asyncAfter(deadline: .now() + Self.deadline) { [weak self] in self?.end(id, ok: nil) }
|
|
390
391
|
return id
|
|
391
392
|
}
|
|
@@ -441,12 +442,13 @@ final class CraftBackgroundWork {
|
|
|
441
442
|
|
|
442
443
|
private func run(_ task: BGTask) {
|
|
443
444
|
scheduleRefresh()
|
|
444
|
-
let id =
|
|
445
|
+
let id = hold { ok in
|
|
445
446
|
task.setTaskCompleted(success: ok ?? false)
|
|
446
447
|
}
|
|
447
448
|
task.expirationHandler = {
|
|
448
449
|
DispatchQueue.main.async { CraftBackgroundWork.shared.end(id, ok: false) }
|
|
449
450
|
}
|
|
451
|
+
CraftEventManager.shared.sendToWeb("craftBackgroundRefresh", data: ["id": id])
|
|
450
452
|
}
|
|
451
453
|
}
|
|
452
454
|
|
|
@@ -461,6 +463,16 @@ struct CraftApp: App {
|
|
|
461
463
|
Group {
|
|
462
464
|
if appState.config.renderer == "native" {
|
|
463
465
|
CraftNativeScreen(config: appState.config)
|
|
466
|
+
} else if CraftHybrid.shared.isEnabled {
|
|
467
|
+
// Web plus native screens (CraftHybrid.swift): the native
|
|
468
|
+
// screen for the start path at once, the page warm behind it.
|
|
469
|
+
ZStack(alignment: .bottom) {
|
|
470
|
+
CraftColorSchemeObserver()
|
|
471
|
+
CraftHybridRoot(config: appState.config)
|
|
472
|
+
.ignoresSafeArea()
|
|
473
|
+
CraftTabBarView()
|
|
474
|
+
CraftSplashView(background: appState.config.resolvedBackgroundColor)
|
|
475
|
+
}
|
|
464
476
|
} else {
|
|
465
477
|
// The page's own chrome, drawn natively over it: the tab
|
|
466
478
|
// bar the page asks for, and the splash until it is ready.
|
|
@@ -521,6 +533,17 @@ final class CraftChrome: ObservableObject {
|
|
|
521
533
|
|
|
522
534
|
private init() {}
|
|
523
535
|
|
|
536
|
+
/// The tab bar from `tabs` in craft.config.json, there before the page
|
|
537
|
+
/// loads; the page's own `tabBar` message replaces it later. `splash`
|
|
538
|
+
/// false when the first screen is native and draws itself.
|
|
539
|
+
func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
|
|
540
|
+
splashVisible = splash
|
|
541
|
+
guard !configured.isEmpty else { return }
|
|
542
|
+
tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
|
|
543
|
+
self.selected = selected ?? configured.first?.id
|
|
544
|
+
tabBarVisible = true
|
|
545
|
+
}
|
|
546
|
+
|
|
524
547
|
/// However the page behaves, the splash is gone after `seconds`
|
|
525
548
|
/// (`splashMaxSeconds`, three by default). It used to be ten: a page that
|
|
526
549
|
/// never painted held a logo on screen for ten seconds, which reads as a
|
|
@@ -541,7 +564,10 @@ final class CraftChrome: ObservableObject {
|
|
|
541
564
|
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
542
565
|
return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge)
|
|
543
566
|
}
|
|
544
|
-
if let selected = body["selected"] as? String {
|
|
567
|
+
if let selected = body["selected"] as? String {
|
|
568
|
+
self.selected = selected
|
|
569
|
+
CraftHybrid.shared.pageSelectedTab(selected)
|
|
570
|
+
}
|
|
545
571
|
if let hex = body["tint"] as? String {
|
|
546
572
|
let light = UIColor(hex: hex)
|
|
547
573
|
let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
|
|
@@ -553,6 +579,7 @@ final class CraftChrome: ObservableObject {
|
|
|
553
579
|
if let id = body["id"] as? String, id != selected {
|
|
554
580
|
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
555
581
|
}
|
|
582
|
+
if let id = body["id"] as? String { CraftHybrid.shared.pageSelectedTab(id) }
|
|
556
583
|
case "hideTabBar":
|
|
557
584
|
// Later, and only if no screen asks for the bar meanwhile: moving
|
|
558
585
|
// between two screens that both have it unmounts one bar and
|
|
@@ -582,9 +609,13 @@ final class CraftChrome: ObservableObject {
|
|
|
582
609
|
/// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
|
|
583
610
|
/// one of the small things that gives a web shell away.
|
|
584
611
|
func tap(_ tab: Tab) {
|
|
585
|
-
|
|
612
|
+
let reselect = tab.id == selected
|
|
613
|
+
if !reselect {
|
|
586
614
|
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
|
|
587
615
|
}
|
|
616
|
+
// A hybrid app shows the tab's own stack first; the page hears the
|
|
617
|
+
// tap unless the shell handled all of it (a native tab's reselect).
|
|
618
|
+
guard CraftHybrid.shared.tabTapped(tab.id, reselect: reselect) else { return }
|
|
588
619
|
emit("craftTabSelect", ["id": tab.id])
|
|
589
620
|
}
|
|
590
621
|
|
|
@@ -605,6 +636,9 @@ final class CraftChrome: ObservableObject {
|
|
|
605
636
|
splashDeadline?.cancel()
|
|
606
637
|
splashDeadline = nil
|
|
607
638
|
guard splashVisible else { return }
|
|
639
|
+
#if DEBUG
|
|
640
|
+
NSLog("[craft] splash hidden")
|
|
641
|
+
#endif
|
|
608
642
|
withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
|
|
609
643
|
}
|
|
610
644
|
|
|
@@ -647,6 +681,7 @@ final class CraftChrome: ObservableObject {
|
|
|
647
681
|
/// for it, and as an event for anything that measures.
|
|
648
682
|
func publishLayout() {
|
|
649
683
|
let height = tabBarVisible ? Int(occupied) : 0
|
|
684
|
+
CraftHybrid.shared.tabBarCovers(CGFloat(height))
|
|
650
685
|
let script = "document.documentElement.style.setProperty('--craft-tab-bar-height','\(height)px');window.dispatchEvent(new CustomEvent('craftTabBarLayout',{detail:{height:\(height)}}));"
|
|
651
686
|
DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
|
|
652
687
|
}
|
|
@@ -857,6 +892,19 @@ class DeepLinkManager {
|
|
|
857
892
|
initialURL = url
|
|
858
893
|
}
|
|
859
894
|
|
|
895
|
+
// A hybrid app opens a native path's screen itself, and tells the
|
|
896
|
+
// native screens that listen; every other link is the page's.
|
|
897
|
+
if CraftHybrid.shared.isEnabled {
|
|
898
|
+
if CraftHybrid.shared.handleDeepLink(url) {
|
|
899
|
+
dispatchNative(url, initial: false)
|
|
900
|
+
} else if isReady && webView != nil {
|
|
901
|
+
dispatchDeepLink(url, initial: false)
|
|
902
|
+
} else {
|
|
903
|
+
pendingURLs.append(url)
|
|
904
|
+
}
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
|
|
860
908
|
if !nativeListeners.isEmpty {
|
|
861
909
|
dispatchNative(url, initial: false)
|
|
862
910
|
} else if isReady && webView != nil {
|
|
@@ -874,6 +922,9 @@ class DeepLinkManager {
|
|
|
874
922
|
func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
|
|
875
923
|
let token = UUID()
|
|
876
924
|
nativeListeners[token] = listener
|
|
925
|
+
// In a hybrid app the page is the one waiting for the links that
|
|
926
|
+
// arrived early; a native screen hears only native ones, as they come.
|
|
927
|
+
if CraftHybrid.shared.isEnabled { return token }
|
|
877
928
|
let firstNativeScreen = !hasBeenReady
|
|
878
929
|
hasBeenReady = true
|
|
879
930
|
let urls = pendingURLs
|
|
@@ -941,6 +992,9 @@ class AppState: ObservableObject {
|
|
|
941
992
|
} else {
|
|
942
993
|
self.config = CraftConfig()
|
|
943
994
|
}
|
|
995
|
+
// Before the first frame: the hybrid start screen, and the tab bar
|
|
996
|
+
// from the config, are decided here.
|
|
997
|
+
CraftHybrid.shared.configure(config)
|
|
944
998
|
}
|
|
945
999
|
}
|
|
946
1000
|
|
|
@@ -1048,6 +1102,20 @@ struct CraftConfig: Codable {
|
|
|
1048
1102
|
var disableZoom: Bool? = nil
|
|
1049
1103
|
/// iOS's scheduled app refresh, which fires `craftBackgroundRefresh`.
|
|
1050
1104
|
var backgroundRefresh: BackgroundRefresh? = nil
|
|
1105
|
+
/// Hybrid: app paths drawn natively, to the screen in `dist/native-screen.js`
|
|
1106
|
+
/// that draws each (`{"/m": "today"}`). Everything else is the web page.
|
|
1107
|
+
var nativeScreens: [String: String]? = nil
|
|
1108
|
+
/// The tab bar to show before the page describes its own.
|
|
1109
|
+
var tabs: [Tab]? = nil
|
|
1110
|
+
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1111
|
+
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1112
|
+
var shareStorage: [String: String]? = nil
|
|
1113
|
+
|
|
1114
|
+
struct Tab: Codable {
|
|
1115
|
+
var id: String
|
|
1116
|
+
var title: String
|
|
1117
|
+
var symbol: String? = nil
|
|
1118
|
+
}
|
|
1051
1119
|
|
|
1052
1120
|
struct BackgroundRefresh: Codable {
|
|
1053
1121
|
var enabled: Bool = false
|
|
@@ -1290,6 +1358,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1290
1358
|
// they are the screen's, not a device API.
|
|
1291
1359
|
let coordinator = context.coordinator
|
|
1292
1360
|
contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
|
|
1361
|
+
// Hybrid apps: navigation to native screens, snapshots and shared
|
|
1362
|
+
// storage, outside the bridge's action dispatch like the chrome.
|
|
1363
|
+
if CraftHybrid.shared.isEnabled {
|
|
1364
|
+
contentController.add(CraftHybridRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftHybrid")
|
|
1365
|
+
}
|
|
1293
1366
|
#if DEBUG
|
|
1294
1367
|
// The page's errors and console, in the device log, so a debug build
|
|
1295
1368
|
// can be diagnosed from `log stream` or `simctl spawn … log show`
|
|
@@ -1341,6 +1414,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1341
1414
|
DeepLinkManager.shared.setWebView(webView)
|
|
1342
1415
|
CraftEventManager.shared.setWebView(webView)
|
|
1343
1416
|
CraftChrome.shared.webView = webView
|
|
1417
|
+
CraftHybrid.shared.webView = webView
|
|
1344
1418
|
CraftChrome.shared.holdSplash(atMost: config.splashMaxSeconds)
|
|
1345
1419
|
|
|
1346
1420
|
// Parse background color
|
|
@@ -2604,7 +2678,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2604
2678
|
recoverOnForeground = false
|
|
2605
2679
|
switch recoveryBudget.onCrash(at: ProcessInfo.processInfo.systemUptime) {
|
|
2606
2680
|
case .reload:
|
|
2607
|
-
|
|
2681
|
+
// Not over a native screen in a hybrid app: the page rebuilds
|
|
2682
|
+
// out of sight there.
|
|
2683
|
+
if CraftHybrid.shared.webIsVisible {
|
|
2684
|
+
CraftChrome.shared.showSplash(atMost: config.splashMaxSeconds)
|
|
2685
|
+
}
|
|
2608
2686
|
installUserScripts(into: webView.configuration.userContentController)
|
|
2609
2687
|
if webView.url == nil, let lastURL = lastPageURL {
|
|
2610
2688
|
webView.load(config.request(for: lastURL))
|
|
@@ -2615,7 +2693,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2615
2693
|
}
|
|
2616
2694
|
case .giveUp:
|
|
2617
2695
|
print("Craft: the page's content process died \(recoveryBudget.attempts) times in a minute; showing the offline page")
|
|
2618
|
-
CraftChrome.shared.hideSplash()
|
|
2696
|
+
if CraftHybrid.shared.webIsVisible { CraftChrome.shared.hideSplash() }
|
|
2619
2697
|
showOfflinePage(in: webView)
|
|
2620
2698
|
}
|
|
2621
2699
|
}
|
|
@@ -2705,6 +2783,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2705
2783
|
|
|
2706
2784
|
/// True when a saved place was handed to the web view to load.
|
|
2707
2785
|
func restoreInteractionState(into webView: WKWebView) -> Bool {
|
|
2786
|
+
// A hybrid app that opens on a native screen loads the page at
|
|
2787
|
+
// that screen's path, never where the page was left: the native
|
|
2788
|
+
// screen is the start.
|
|
2789
|
+
guard !CraftHybrid.shared.startsNative else { return false }
|
|
2708
2790
|
guard #available(iOS 15.0, *), let file = Self.interactionStateFile,
|
|
2709
2791
|
UserDefaults.standard.string(forKey: Self.interactionStateBuildKey) == Self.currentBuild,
|
|
2710
2792
|
let state = try? Data(contentsOf: file) else { return false }
|
|
@@ -2792,6 +2874,19 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2792
2874
|
webView.load(config.request(for: remote))
|
|
2793
2875
|
}
|
|
2794
2876
|
|
|
2877
|
+
/// Load `path` on the app's own origin afresh: for a hybrid app whose
|
|
2878
|
+
/// page cannot navigate itself (still loading, or the offline page).
|
|
2879
|
+
func loadAppPath(_ path: String) {
|
|
2880
|
+
guard let webView else { return }
|
|
2881
|
+
guard let remote = config.devServerURL.flatMap(URL.init(string:)),
|
|
2882
|
+
let url = URL(string: path, relativeTo: remote)?.absoluteURL else {
|
|
2883
|
+
if let bundled = URL(string: "craft://app\(path)") { webView.load(URLRequest(url: bundled)) }
|
|
2884
|
+
return
|
|
2885
|
+
}
|
|
2886
|
+
loadedBundledFallback = false
|
|
2887
|
+
webView.load(config.request(for: url))
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2795
2890
|
fileprivate func isTrustedURL(_ url: URL?) -> Bool {
|
|
2796
2891
|
guard let url = url else { return false }
|
|
2797
2892
|
return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
|
|
@@ -2882,6 +2977,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2882
2977
|
controller.addUserScript(WKUserScript(source: CraftConfig.viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2883
2978
|
}
|
|
2884
2979
|
controller.addUserScript(WKUserScript(source: bridgeScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2980
|
+
if CraftHybrid.shared.isEnabled {
|
|
2981
|
+
controller.addUserScript(WKUserScript(source: CraftHybrid.shared.pageScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2982
|
+
}
|
|
2885
2983
|
}
|
|
2886
2984
|
|
|
2887
2985
|
private func appearanceSnapshot() -> [String: Any] {
|