craft-native 0.0.123 → 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/README.md +3 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +22 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +151 -7
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +1085 -84
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +250 -2
- package/dist/cli.js +3 -3
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +125 -3
- package/dist/ios/src/index.js +284 -47
- package/dist/ios/templates/CraftApp.swift +1186 -69
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +310 -10
- package/dist/ios/templates/CraftNativeFlatList.swift +226 -17
- package/dist/ios/templates/CraftNativeScreen.swift +2217 -220
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +2 -1
- package/dist/mobile.js +204 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import UIKit
|
|
4
|
+
import WebKit
|
|
5
|
+
|
|
6
|
+
// MARK: - Hybrid: web plus native screens
|
|
7
|
+
//
|
|
8
|
+
// `renderer: "web"` with `nativeScreens` set: the app is its web page, except
|
|
9
|
+
// for the paths the config names, which are stx native screens drawn with
|
|
10
|
+
// UIKit from `dist/native-screen.js`. Each tab keeps one UINavigationController
|
|
11
|
+
// whose stack mixes native screens and web entries, and the one WKWebView
|
|
12
|
+
// moves into whichever web entry is on screen. It is created at launch and
|
|
13
|
+
// loads the app's page behind the first screen, so it is warm by the time
|
|
14
|
+
// anything opens it.
|
|
15
|
+
//
|
|
16
|
+
// The page takes part through a document-start script (`pageScript`): it
|
|
17
|
+
// hands navigations to native paths to the shell instead of drawing them,
|
|
18
|
+
// reports where it went, and leaves the edge swipe to UIKit while it is at the
|
|
19
|
+
// entry a native screen opened. Nothing in the page has to change for that;
|
|
20
|
+
// craft's typescript mobile API adds `snapshots` and `hybrid` on top.
|
|
21
|
+
|
|
22
|
+
/// The `nativeScreens` table: which paths are native, and with what params.
|
|
23
|
+
///
|
|
24
|
+
/// Foundation only, so `hybrid.test.ts` compiles and runs it on its own. The
|
|
25
|
+
/// page script and the typescript API (`matchNativePath`) implement the same
|
|
26
|
+
/// rules, and the tests hold all three to one set of cases.
|
|
27
|
+
struct CraftHybridRoutes {
|
|
28
|
+
struct Match: Equatable {
|
|
29
|
+
let path: String
|
|
30
|
+
let screen: String
|
|
31
|
+
let params: [String: String]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private struct Route {
|
|
35
|
+
let pattern: String
|
|
36
|
+
let segments: [String]
|
|
37
|
+
let screen: String
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private let routes: [Route]
|
|
41
|
+
|
|
42
|
+
init(_ table: [String: String]) {
|
|
43
|
+
routes = table.compactMap { pattern, screen -> Route? in
|
|
44
|
+
guard let normalized = CraftHybridRoutes.normalize(pattern), !screen.isEmpty else { return nil }
|
|
45
|
+
return Route(pattern: normalized, segments: CraftHybridRoutes.segments(normalized), screen: screen)
|
|
46
|
+
}.sorted(by: CraftHybridRoutes.precedes)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var isEmpty: Bool { routes.isEmpty }
|
|
50
|
+
|
|
51
|
+
/// Static segments before parameters, parameters before a wildcard, so
|
|
52
|
+
/// `/m/workout/new` wins over `/m/workout/:id`, which wins over `/m/*`.
|
|
53
|
+
private static func precedes(_ left: Route, _ right: Route) -> Bool {
|
|
54
|
+
let wild = { (route: Route) in route.segments.last == "*" ? 1 : 0 }
|
|
55
|
+
let fixed = { (route: Route) in route.segments.filter { $0 != "*" && !$0.hasPrefix(":") }.count }
|
|
56
|
+
if wild(left) != wild(right) { return wild(left) < wild(right) }
|
|
57
|
+
if fixed(left) != fixed(right) { return fixed(left) > fixed(right) }
|
|
58
|
+
if left.segments.count != right.segments.count { return left.segments.count > right.segments.count }
|
|
59
|
+
return left.pattern < right.pattern
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// `/a//b/?q#h` → `/a/b`. Nil for anything that is not an absolute path.
|
|
63
|
+
static func normalize(_ raw: String) -> String? {
|
|
64
|
+
var path = raw
|
|
65
|
+
if let cut = path.firstIndex(where: { $0 == "?" || $0 == "#" }) { path = String(path[..<cut]) }
|
|
66
|
+
guard path.hasPrefix("/") else { return nil }
|
|
67
|
+
while path.contains("//") { path = path.replacingOccurrences(of: "//", with: "/") }
|
|
68
|
+
if path.count > 1 && path.hasSuffix("/") { path.removeLast() }
|
|
69
|
+
return path
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static func segments(_ path: String) -> [String] {
|
|
73
|
+
path == "/" ? [] : path.dropFirst().split(separator: "/", omittingEmptySubsequences: false).map(String.init)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// The screen for `raw` (a path, with or without a query), or nil when the
|
|
77
|
+
/// path belongs to the web. Query items become params, under the path's own.
|
|
78
|
+
func match(_ raw: String) -> Match? {
|
|
79
|
+
guard let path = Self.normalize(raw) else { return nil }
|
|
80
|
+
let parts = Self.segments(path)
|
|
81
|
+
for route in routes {
|
|
82
|
+
guard var params = Self.bind(route.segments, to: parts) else { continue }
|
|
83
|
+
if let query = raw.firstIndex(of: "?") {
|
|
84
|
+
let rest = raw[raw.index(after: query)...].split(separator: "#", maxSplits: 1).first.map(String.init) ?? ""
|
|
85
|
+
for item in rest.split(separator: "&") {
|
|
86
|
+
let pair = item.split(separator: "=", maxSplits: 1).map(String.init)
|
|
87
|
+
let key = (pair.first ?? "").removingPercentEncoding ?? ""
|
|
88
|
+
guard !key.isEmpty, params[key] == nil else { continue }
|
|
89
|
+
let value = pair.count > 1 ? pair[1].replacingOccurrences(of: "+", with: " ") : ""
|
|
90
|
+
params[key] = value.removingPercentEncoding ?? value
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return Match(path: path, screen: route.screen, params: params)
|
|
94
|
+
}
|
|
95
|
+
return nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private static func bind(_ pattern: [String], to parts: [String]) -> [String: String]? {
|
|
99
|
+
var params: [String: String] = [:]
|
|
100
|
+
for (index, segment) in pattern.enumerated() {
|
|
101
|
+
if segment == "*" {
|
|
102
|
+
params["*"] = parts[min(index, parts.count)...].joined(separator: "/")
|
|
103
|
+
return params
|
|
104
|
+
}
|
|
105
|
+
guard index < parts.count else { return nil }
|
|
106
|
+
if segment.hasPrefix(":") {
|
|
107
|
+
guard !parts[index].isEmpty else { return nil }
|
|
108
|
+
params[String(segment.dropFirst())] = parts[index].removingPercentEncoding ?? parts[index]
|
|
109
|
+
} else if segment != parts[index] {
|
|
110
|
+
return nil
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return pattern.count == parts.count ? params : nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// The app path a link stands for: a web link's path, or for a custom
|
|
117
|
+
/// scheme the host as the first segment (`hq://m/calendar` → `/m/calendar`).
|
|
118
|
+
/// The query is kept.
|
|
119
|
+
static func appPath(of url: URL) -> String {
|
|
120
|
+
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "/" }
|
|
121
|
+
let scheme = (components.scheme ?? "").lowercased()
|
|
122
|
+
var path = components.percentEncodedPath
|
|
123
|
+
if scheme != "http" && scheme != "https", let host = components.host, !host.isEmpty {
|
|
124
|
+
path = "/" + host + (path.isEmpty || path.hasPrefix("/") ? path : "/" + path)
|
|
125
|
+
}
|
|
126
|
+
if path.isEmpty { path = "/" }
|
|
127
|
+
if let query = components.percentEncodedQuery, !query.isEmpty { path += "?" + query }
|
|
128
|
+
return path
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// The tab a path belongs to: the tab whose id is the path, or else the
|
|
132
|
+
/// longest tab id that is a whole-segment prefix of it.
|
|
133
|
+
static func tab(owning raw: String, among tabs: [String]) -> String? {
|
|
134
|
+
guard let path = normalize(raw) else { return nil }
|
|
135
|
+
var best: String?
|
|
136
|
+
for tab in tabs {
|
|
137
|
+
guard let id = normalize(tab) else { continue }
|
|
138
|
+
let owns = path == id || id == "/" || path.hasPrefix(id + "/")
|
|
139
|
+
if owns && (best.flatMap(normalize)?.count ?? -1) < id.count { best = tab }
|
|
140
|
+
}
|
|
141
|
+
return best
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// What the page's `craftHybrid` channel says. Foundation only, for the tests.
|
|
146
|
+
enum CraftHybridMessage: Equatable {
|
|
147
|
+
/// The router was asked for a native path; the page drew nothing.
|
|
148
|
+
case navigateNative(path: String, replace: Bool)
|
|
149
|
+
/// The page is somewhere new. `byHost` when the shell asked it to go there.
|
|
150
|
+
case navigated(path: String, direction: String, depth: Int, byHost: Bool)
|
|
151
|
+
/// Back, from the entry a native screen opened: the shell's to do.
|
|
152
|
+
case back
|
|
153
|
+
/// `craft.hybrid.open(path)` from the page.
|
|
154
|
+
case open(path: String)
|
|
155
|
+
case snapshotSet(name: String, json: String)
|
|
156
|
+
case snapshotRemove(name: String)
|
|
157
|
+
case snapshotClear
|
|
158
|
+
/// A web-storage key the config shares with native screens changed.
|
|
159
|
+
case shareStorage(key: String, value: String?)
|
|
160
|
+
|
|
161
|
+
static func parse(_ body: [String: Any]) -> CraftHybridMessage? {
|
|
162
|
+
func text(_ key: String) -> String? { (body[key] as? String).flatMap { $0.isEmpty ? nil : $0 } }
|
|
163
|
+
switch body["type"] as? String {
|
|
164
|
+
case "navigateNative":
|
|
165
|
+
guard let path = text("path"), path.hasPrefix("/") else { return nil }
|
|
166
|
+
return .navigateNative(path: path, replace: body["replace"] as? Bool ?? false)
|
|
167
|
+
case "navigated":
|
|
168
|
+
guard let path = text("path"), path.hasPrefix("/") else { return nil }
|
|
169
|
+
let depth = (body["depth"] as? NSNumber)?.intValue ?? 0
|
|
170
|
+
return .navigated(path: path, direction: text("direction") ?? "push", depth: max(0, depth), byHost: body["byHost"] as? Bool ?? false)
|
|
171
|
+
case "back":
|
|
172
|
+
return .back
|
|
173
|
+
case "open":
|
|
174
|
+
guard let path = text("path"), path.hasPrefix("/") else { return nil }
|
|
175
|
+
return .open(path: path)
|
|
176
|
+
case "snapshotSet":
|
|
177
|
+
guard let name = text("name"), let json = body["json"] as? String else { return nil }
|
|
178
|
+
return .snapshotSet(name: name, json: json)
|
|
179
|
+
case "snapshotRemove":
|
|
180
|
+
guard let name = text("name") else { return nil }
|
|
181
|
+
return .snapshotRemove(name: name)
|
|
182
|
+
case "snapshotClear":
|
|
183
|
+
return .snapshotClear
|
|
184
|
+
case "shareStorage":
|
|
185
|
+
guard let key = text("key") else { return nil }
|
|
186
|
+
return .shareStorage(key: key, value: body["value"] as? String)
|
|
187
|
+
default:
|
|
188
|
+
return nil
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// A key the page shares with native screens, in the Keychain twice: as the
|
|
194
|
+
/// web's `secureStorage` writes it (generic password, account = key, no
|
|
195
|
+
/// service), which `craft.secureStorage.getSync` reads, and under the native
|
|
196
|
+
/// screens' own service, which their async `craft.secureStorage.get` reads.
|
|
197
|
+
enum CraftHybridKeychain {
|
|
198
|
+
static let nativeService = "dev.craft.native.secure"
|
|
199
|
+
|
|
200
|
+
static func set(_ key: String, value: String?) {
|
|
201
|
+
// Every item for the account, whatever its service.
|
|
202
|
+
SecItemDelete([kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key] as CFDictionary)
|
|
203
|
+
guard let value else { return }
|
|
204
|
+
for service in [nil, nativeService] {
|
|
205
|
+
var add: [String: Any] = [
|
|
206
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
207
|
+
kSecAttrAccount as String: key,
|
|
208
|
+
kSecValueData as String: Data(value.utf8),
|
|
209
|
+
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
210
|
+
]
|
|
211
|
+
if let service { add[kSecAttrService as String] = service }
|
|
212
|
+
let status = SecItemAdd(add as CFDictionary, nil)
|
|
213
|
+
if status != errSecSuccess { NSLog("[craft hybrid] could not share %@ with native screens (%d)", key, status) }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// What a native screen tells the hybrid shell. `CraftNativeScreenController`
|
|
219
|
+
/// calls these when the shell set itself as its `hybridEvents`.
|
|
220
|
+
protocol CraftHybridScreenEvents: AnyObject {
|
|
221
|
+
/// The screen's first frame is laid out.
|
|
222
|
+
func nativeScreenDidRender(_ screen: CraftNativeScreenController)
|
|
223
|
+
/// The screen could not draw its first frame: no bundle, or its script threw.
|
|
224
|
+
func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
|
|
225
|
+
/// `craft.navigation.open(path)`.
|
|
226
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// MARK: - The shell
|
|
230
|
+
|
|
231
|
+
final class CraftHybrid: NSObject {
|
|
232
|
+
static let shared = CraftHybrid()
|
|
233
|
+
|
|
234
|
+
private(set) var config = CraftConfig()
|
|
235
|
+
private(set) var routes = CraftHybridRoutes([:])
|
|
236
|
+
/// Whether this app mixes native screens into its web page.
|
|
237
|
+
private(set) var isEnabled = false
|
|
238
|
+
/// The path the app opens on: its page's path.
|
|
239
|
+
private(set) var startPath = "/"
|
|
240
|
+
/// Whether that path is a native screen, drawn before the page loads.
|
|
241
|
+
private(set) var startsNative = false
|
|
242
|
+
weak var controller: CraftHybridController?
|
|
243
|
+
weak var webView: WKWebView?
|
|
244
|
+
|
|
245
|
+
/// Where the page is, as it last said.
|
|
246
|
+
fileprivate(set) var pagePath: String?
|
|
247
|
+
fileprivate(set) var pageDepth = 0
|
|
248
|
+
fileprivate(set) var pageReady = false
|
|
249
|
+
|
|
250
|
+
private override init() {}
|
|
251
|
+
|
|
252
|
+
static var bundleURL: URL? {
|
|
253
|
+
Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
254
|
+
?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// From the bundled config, before the first frame: whether the app is
|
|
258
|
+
/// hybrid, where it starts, and the tab bar it starts with.
|
|
259
|
+
func configure(_ config: CraftConfig) {
|
|
260
|
+
self.config = config
|
|
261
|
+
routes = CraftHybridRoutes(config.nativeScreens ?? [:])
|
|
262
|
+
startPath = config.devServerURL.flatMap(URL.init(string:)).map(CraftHybridRoutes.appPath(of:)) ?? "/"
|
|
263
|
+
if startPath.isEmpty { startPath = "/" }
|
|
264
|
+
let wanted = config.renderer == "web" && !routes.isEmpty
|
|
265
|
+
if wanted && Self.bundleURL == nil {
|
|
266
|
+
NSLog("[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; every path opens in the web view")
|
|
267
|
+
}
|
|
268
|
+
isEnabled = wanted && Self.bundleURL != nil
|
|
269
|
+
startsNative = isEnabled && routes.match(startPath) != nil
|
|
270
|
+
CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: !startsNative)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func tabIds(from tabs: [CraftConfig.Tab]) -> [String] { tabs.map(\.id) }
|
|
274
|
+
|
|
275
|
+
/// The tab ids the bar shows now: the page's once it has described them.
|
|
276
|
+
var tabIds: [String] {
|
|
277
|
+
let shown = CraftChrome.shared.tabs.map(\.id)
|
|
278
|
+
return shown.isEmpty ? (config.tabs ?? []).map(\.id) : shown
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/// Whether the web view is what the person sees: always, outside hybrid.
|
|
282
|
+
var webIsVisible: Bool {
|
|
283
|
+
guard isEnabled else { return true }
|
|
284
|
+
return controller?.webIsVisible ?? !startsNative
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MARK: Page script
|
|
288
|
+
|
|
289
|
+
/// The document-start script the hybrid page runs, with the route table
|
|
290
|
+
/// and the shared storage keys filled in.
|
|
291
|
+
func pageScript() -> String {
|
|
292
|
+
let routeList = (config.nativeScreens ?? [:]).map { ["pattern": $0.key, "screen": $0.value] }
|
|
293
|
+
let share = Array((config.shareStorage ?? [:]).keys).sorted()
|
|
294
|
+
func json(_ value: Any) -> String {
|
|
295
|
+
(try? JSONSerialization.data(withJSONObject: value)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
296
|
+
}
|
|
297
|
+
return Self.pageScriptTemplate
|
|
298
|
+
.replacingOccurrences(of: "__CRAFT_HYBRID_ROUTES__", with: json(routeList))
|
|
299
|
+
.replacingOccurrences(of: "__CRAFT_HYBRID_SHARE__", with: json(share))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// CRAFT_HYBRID_PAGE_SCRIPT_START
|
|
303
|
+
static let pageScriptTemplate = #"""
|
|
304
|
+
(function () {
|
|
305
|
+
if (window.__craftHybrid) return;
|
|
306
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craftHybrid;
|
|
307
|
+
if (!handler) return;
|
|
308
|
+
var ROUTES = __CRAFT_HYBRID_ROUTES__;
|
|
309
|
+
var SHARE = __CRAFT_HYBRID_SHARE__;
|
|
310
|
+
function post(message) { try { handler.postMessage(message); } catch (e) {} }
|
|
311
|
+
var web = location.protocol === 'http:' || location.protocol === 'https:';
|
|
312
|
+
|
|
313
|
+
function normalize(path) {
|
|
314
|
+
if (typeof path !== 'string') return null;
|
|
315
|
+
var cut = path.search(/[?#]/);
|
|
316
|
+
if (cut >= 0) path = path.slice(0, cut);
|
|
317
|
+
if (path.charAt(0) !== '/') return null;
|
|
318
|
+
path = path.replace(/\/{2,}/g, '/');
|
|
319
|
+
if (path.length > 1 && path.charAt(path.length - 1) === '/') path = path.slice(0, -1);
|
|
320
|
+
return path;
|
|
321
|
+
}
|
|
322
|
+
function segments(path) { return path === '/' ? [] : path.slice(1).split('/'); }
|
|
323
|
+
function decode(value) { try { return decodeURIComponent(value); } catch (e) { return value; } }
|
|
324
|
+
var table = [];
|
|
325
|
+
for (var i = 0; i < ROUTES.length; i++) {
|
|
326
|
+
var pattern = normalize(ROUTES[i].pattern);
|
|
327
|
+
if (pattern && ROUTES[i].screen) table.push({ pattern: pattern, segments: segments(pattern), screen: ROUTES[i].screen });
|
|
328
|
+
}
|
|
329
|
+
function wild(r) { return r.segments[r.segments.length - 1] === '*' ? 1 : 0; }
|
|
330
|
+
function fixed(r) { return r.segments.filter(function (s) { return s !== '*' && s.charAt(0) !== ':'; }).length; }
|
|
331
|
+
table.sort(function (a, b) {
|
|
332
|
+
return (wild(a) - wild(b)) || (fixed(b) - fixed(a)) || (b.segments.length - a.segments.length) || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0);
|
|
333
|
+
});
|
|
334
|
+
function bind(pattern, parts) {
|
|
335
|
+
var params = {};
|
|
336
|
+
for (var i = 0; i < pattern.length; i++) {
|
|
337
|
+
var s = pattern[i];
|
|
338
|
+
if (s === '*') { params['*'] = parts.slice(i).join('/'); return params; }
|
|
339
|
+
if (i >= parts.length) return null;
|
|
340
|
+
if (s.charAt(0) === ':') { if (!parts[i]) return null; params[s.slice(1)] = decode(parts[i]); }
|
|
341
|
+
else if (s !== parts[i]) return null;
|
|
342
|
+
}
|
|
343
|
+
return pattern.length === parts.length ? params : null;
|
|
344
|
+
}
|
|
345
|
+
function match(raw) {
|
|
346
|
+
var path = normalize(raw);
|
|
347
|
+
if (path === null) return null;
|
|
348
|
+
var parts = segments(path);
|
|
349
|
+
for (var i = 0; i < table.length; i++) {
|
|
350
|
+
var params = bind(table[i].segments, parts);
|
|
351
|
+
if (!params) continue;
|
|
352
|
+
var query = raw.indexOf('?');
|
|
353
|
+
if (query >= 0) {
|
|
354
|
+
raw.slice(query + 1).split('#')[0].split('&').forEach(function (item) {
|
|
355
|
+
if (!item) return;
|
|
356
|
+
var at = item.indexOf('=');
|
|
357
|
+
var key = decode(at < 0 ? item : item.slice(0, at));
|
|
358
|
+
if (!key || Object.prototype.hasOwnProperty.call(params, key)) return;
|
|
359
|
+
params[key] = at < 0 ? '' : decode(item.slice(at + 1).replace(/\+/g, ' '));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return { path: path, screen: table[i].screen, params: params };
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
// The same origin's path and query, or null for anywhere else.
|
|
367
|
+
function local(url) {
|
|
368
|
+
try {
|
|
369
|
+
var u = new URL(String(url), location.href);
|
|
370
|
+
if (u.origin !== location.origin) return null;
|
|
371
|
+
return u.pathname + u.search;
|
|
372
|
+
} catch (e) { return null; }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Where the page is in its router's stack, and the entry a native
|
|
376
|
+
// screen opened (null when the page's own tab root is underneath).
|
|
377
|
+
function depth() {
|
|
378
|
+
var router = window.stxRouter;
|
|
379
|
+
if (router && typeof router.screens === 'function') {
|
|
380
|
+
try {
|
|
381
|
+
var list = router.screens();
|
|
382
|
+
for (var i = 0; i < list.length; i++) if (list[i].active) return list[i].depth | 0;
|
|
383
|
+
} catch (e) {}
|
|
384
|
+
}
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
var base = null;
|
|
388
|
+
function atBase() { return base !== null && depth() <= base; }
|
|
389
|
+
var hostNav = null;
|
|
390
|
+
var hostPop = false;
|
|
391
|
+
// The shell's navigation in flight, answered once the page has drawn it.
|
|
392
|
+
var arriving = null;
|
|
393
|
+
function frame(callback) { (window.requestAnimationFrame || setTimeout)(callback); }
|
|
394
|
+
|
|
395
|
+
var navigate = null;
|
|
396
|
+
var historyBack = history.back.bind(history);
|
|
397
|
+
var historyGo = history.go.bind(history);
|
|
398
|
+
function adopt(router) {
|
|
399
|
+
if (!router || typeof router !== 'object' || router.__craftHybrid) return router;
|
|
400
|
+
var own = router.navigate;
|
|
401
|
+
if (typeof own !== 'function') return router;
|
|
402
|
+
navigate = function () { return own.apply(router, arguments); };
|
|
403
|
+
// A forward navigation to a native path is the shell's to show; the
|
|
404
|
+
// page draws nothing. Back (pushState false), tab switches and the
|
|
405
|
+
// shell's own navigations go through untouched.
|
|
406
|
+
router.navigate = router.navigateTo = function (url, pushState, force) {
|
|
407
|
+
var path = local(url);
|
|
408
|
+
if (path !== null && pushState !== false && pushState !== 'tab' && hostNav === null && match(path)) {
|
|
409
|
+
var replace = pushState === 'replace' || !!(pushState && typeof pushState === 'object' && pushState.replace);
|
|
410
|
+
post({ type: 'navigateNative', path: path, replace: replace });
|
|
411
|
+
return Promise.resolve(false);
|
|
412
|
+
}
|
|
413
|
+
return own.apply(router, arguments);
|
|
414
|
+
};
|
|
415
|
+
var ownBack = router.back;
|
|
416
|
+
router.back = function () {
|
|
417
|
+
if (atBase()) { post({ type: 'back' }); return; }
|
|
418
|
+
return typeof ownBack === 'function' ? ownBack.apply(router, arguments) : historyBack();
|
|
419
|
+
};
|
|
420
|
+
router.__craftHybrid = true;
|
|
421
|
+
return router;
|
|
422
|
+
}
|
|
423
|
+
var current = adopt(window.stxRouter);
|
|
424
|
+
try {
|
|
425
|
+
Object.defineProperty(window, 'stxRouter', {
|
|
426
|
+
configurable: true,
|
|
427
|
+
enumerable: true,
|
|
428
|
+
get: function () { return current; },
|
|
429
|
+
set: function (value) { current = adopt(value); }
|
|
430
|
+
});
|
|
431
|
+
} catch (e) {}
|
|
432
|
+
|
|
433
|
+
// Back at the entry a native screen opened goes to that screen.
|
|
434
|
+
history.back = function () {
|
|
435
|
+
if (atBase()) { post({ type: 'back' }); return; }
|
|
436
|
+
return historyBack();
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// A link to a native path, before the router sees the click.
|
|
440
|
+
window.addEventListener('click', function (event) {
|
|
441
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
442
|
+
var link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
443
|
+
if (!link) return;
|
|
444
|
+
if ((link.target && link.target !== '_self') || link.hasAttribute('download') || link.hasAttribute('data-native-tab')
|
|
445
|
+
|| link.getAttribute('data-stx-nav') === 'tab' || link.hasAttribute('data-craft-web')) return;
|
|
446
|
+
var path = local(link.href);
|
|
447
|
+
if (path === null || !match(path)) return;
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
event.stopPropagation();
|
|
450
|
+
post({ type: 'navigateNative', path: path, replace: false });
|
|
451
|
+
}, true);
|
|
452
|
+
|
|
453
|
+
// At that entry the edge swipe is UIKit's, back to the native screen;
|
|
454
|
+
// the router's own swipe would show the page's previous screen instead.
|
|
455
|
+
window.addEventListener('touchstart', function (event) {
|
|
456
|
+
var touch = event.touches && event.touches[0];
|
|
457
|
+
if (touch && touch.clientX <= 30 && atBase()) event.stopPropagation();
|
|
458
|
+
}, { capture: true, passive: true });
|
|
459
|
+
|
|
460
|
+
function report(direction) {
|
|
461
|
+
if (!web) return;
|
|
462
|
+
var message = { type: 'navigated', path: location.pathname + location.search, direction: direction, depth: depth() };
|
|
463
|
+
if (direction === 'pop' && hostPop) { message.byHost = true; hostPop = false; }
|
|
464
|
+
else if (hostNav !== null && local(hostNav) === message.path) { message.byHost = true; hostNav = null; }
|
|
465
|
+
post(message);
|
|
466
|
+
if (arriving && arriving.path === message.path) {
|
|
467
|
+
var finish = arriving.finish;
|
|
468
|
+
arriving = null;
|
|
469
|
+
// Two frames on: the new screen is on screen, not just in the DOM.
|
|
470
|
+
frame(function () { frame(finish); });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
window.addEventListener('stx:navigate', function (event) {
|
|
474
|
+
var detail = (event && event.detail) || {};
|
|
475
|
+
report(detail.direction || 'push');
|
|
476
|
+
});
|
|
477
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { report('load'); });
|
|
478
|
+
else report('load');
|
|
479
|
+
|
|
480
|
+
// Web storage the config shares with native screens (`shareStorage`).
|
|
481
|
+
var shared = {};
|
|
482
|
+
function share(key) {
|
|
483
|
+
var value = null;
|
|
484
|
+
try { value = window.sessionStorage.getItem(key); } catch (e) {}
|
|
485
|
+
try { if (value === null) value = window.localStorage.getItem(key); } catch (e) {}
|
|
486
|
+
if (shared[key] === value) return;
|
|
487
|
+
shared[key] = value;
|
|
488
|
+
post({ type: 'shareStorage', key: key, value: value });
|
|
489
|
+
}
|
|
490
|
+
if (SHARE.length && window.Storage) {
|
|
491
|
+
var proto = window.Storage.prototype;
|
|
492
|
+
var setItem = proto.setItem, removeItem = proto.removeItem, clear = proto.clear;
|
|
493
|
+
proto.setItem = function (key) { var out = setItem.apply(this, arguments); if (SHARE.indexOf(String(key)) >= 0) share(String(key)); return out; };
|
|
494
|
+
proto.removeItem = function (key) { var out = removeItem.apply(this, arguments); if (SHARE.indexOf(String(key)) >= 0) share(String(key)); return out; };
|
|
495
|
+
proto.clear = function () { var out = clear.apply(this, arguments); SHARE.forEach(share); return out; };
|
|
496
|
+
SHARE.forEach(share);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
window.__craftHybrid = {
|
|
500
|
+
match: function (url) { var path = local(url); return path === null ? null : match(path); },
|
|
501
|
+
// The shell's own navigation: never handed back to it, and without
|
|
502
|
+
// the page's slide, since the shell animates the push itself.
|
|
503
|
+
// Answers once the page has drawn `path` (its router settles before
|
|
504
|
+
// the new screen is up), or at once when it went nowhere.
|
|
505
|
+
navigate: function (path) {
|
|
506
|
+
hostNav = path;
|
|
507
|
+
return new Promise(function (resolve) {
|
|
508
|
+
var done = false;
|
|
509
|
+
var finish = function () {
|
|
510
|
+
if (done) return;
|
|
511
|
+
done = true;
|
|
512
|
+
// A navigation that never reported must not leave the next one unhandled.
|
|
513
|
+
setTimeout(function () { if (hostNav === path) hostNav = null; }, 0);
|
|
514
|
+
resolve({ path: location.pathname + location.search, depth: depth() });
|
|
515
|
+
};
|
|
516
|
+
if (!(navigate && current && current.__craftHybrid)) {
|
|
517
|
+
location.assign(path);
|
|
518
|
+
resolve({ path: path, depth: 0 });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
arriving = { path: local(path), finish: finish };
|
|
522
|
+
Promise.resolve(navigate(path, { instant: true })).then(function (went) {
|
|
523
|
+
if (went === false && arriving && arriving.finish === finish) { arriving = null; finish(); }
|
|
524
|
+
}, function () { arriving = null; finish(); });
|
|
525
|
+
});
|
|
526
|
+
},
|
|
527
|
+
setBase: function (value) { base = typeof value === 'number' ? value : null; },
|
|
528
|
+
// Back to `target` in the page's stack, after the shell popped the
|
|
529
|
+
// entries above it.
|
|
530
|
+
backTo: function (target) {
|
|
531
|
+
var steps = depth() - (target | 0);
|
|
532
|
+
if (steps <= 0) return 0;
|
|
533
|
+
hostPop = true;
|
|
534
|
+
historyGo(-steps);
|
|
535
|
+
return steps;
|
|
536
|
+
},
|
|
537
|
+
depth: depth
|
|
538
|
+
};
|
|
539
|
+
})();
|
|
540
|
+
"""#
|
|
541
|
+
// CRAFT_HYBRID_PAGE_SCRIPT_END
|
|
542
|
+
|
|
543
|
+
// MARK: Messages from the page
|
|
544
|
+
|
|
545
|
+
func receive(_ body: [String: Any]) {
|
|
546
|
+
guard let message = CraftHybridMessage.parse(body) else { return }
|
|
547
|
+
#if DEBUG
|
|
548
|
+
if case .shareStorage(let key, let value) = message {
|
|
549
|
+
NSLog("[craft hybrid] page: shareStorage %@ (%@)", key, value == nil ? "cleared" : "set")
|
|
550
|
+
} else if case .snapshotSet(let name, _) = message {
|
|
551
|
+
NSLog("[craft hybrid] page: snapshot %@", name)
|
|
552
|
+
} else {
|
|
553
|
+
NSLog("[craft hybrid] page: %@", String(describing: message))
|
|
554
|
+
}
|
|
555
|
+
#endif
|
|
556
|
+
switch message {
|
|
557
|
+
// The same files, through the same writer, as a native screen's
|
|
558
|
+
// `craft.snapshots` (CraftSnapshots, CraftNativeActions.swift).
|
|
559
|
+
case .snapshotSet(let name, let json):
|
|
560
|
+
CraftSnapshots.write(name, json: json) { ok in
|
|
561
|
+
if ok { NotificationCenter.default.post(name: Self.snapshotChanged, object: nil, userInfo: ["name": name]) }
|
|
562
|
+
else { NSLog("[craft hybrid] snapshot %@ was not written: not a snapshot name, or not JSON", name) }
|
|
563
|
+
}
|
|
564
|
+
case .snapshotRemove(let name):
|
|
565
|
+
CraftSnapshots.write(name, json: nil) { _ in
|
|
566
|
+
NotificationCenter.default.post(name: Self.snapshotChanged, object: nil, userInfo: ["name": name])
|
|
567
|
+
}
|
|
568
|
+
case .snapshotClear:
|
|
569
|
+
let names = CraftSnapshots.directory
|
|
570
|
+
.flatMap { try? FileManager.default.contentsOfDirectory(atPath: $0.path) }?
|
|
571
|
+
.filter { $0.hasSuffix(".json") }
|
|
572
|
+
.map { String($0.dropLast(5)) } ?? []
|
|
573
|
+
for name in names { CraftSnapshots.write(name, json: nil) }
|
|
574
|
+
case .shareStorage(let key, let value):
|
|
575
|
+
guard let nativeKey = config.shareStorage?[key] else { return }
|
|
576
|
+
CraftHybridKeychain.set(nativeKey, value: value)
|
|
577
|
+
case .navigated(let path, let direction, let depth, let byHost):
|
|
578
|
+
pagePath = path
|
|
579
|
+
pageDepth = depth
|
|
580
|
+
if direction == "load" { pageReady = true }
|
|
581
|
+
controller?.pageNavigated(path: path, direction: direction, depth: depth, byHost: byHost)
|
|
582
|
+
case .navigateNative(let path, let replace):
|
|
583
|
+
controller?.showNative(path: path, replace: replace)
|
|
584
|
+
case .open(let path):
|
|
585
|
+
controller?.open(path)
|
|
586
|
+
case .back:
|
|
587
|
+
controller?.popTop()
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/// Posted when the page wrote or removed a snapshot (`userInfo["name"]`).
|
|
592
|
+
static let snapshotChanged = Notification.Name("craftSnapshotChanged")
|
|
593
|
+
|
|
594
|
+
// MARK: Tabs and links
|
|
595
|
+
|
|
596
|
+
/// A tap on the native bar. Answers whether the page should also hear
|
|
597
|
+
/// it (`craftTabSelect`), which it does for every switch so the page's own
|
|
598
|
+
/// tab follows the screen.
|
|
599
|
+
func tabTapped(_ id: String, reselect: Bool) -> Bool {
|
|
600
|
+
guard isEnabled, let controller else { return true }
|
|
601
|
+
return controller.tabTapped(id, reselect: reselect)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/// The page chose a tab itself (its `selectTab`, or a tab link).
|
|
605
|
+
func pageSelectedTab(_ id: String) {
|
|
606
|
+
guard isEnabled else { return }
|
|
607
|
+
controller?.pageSelectedTab(id)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/// A deep link to a native path opens the native screen; true when it did.
|
|
611
|
+
func handleDeepLink(_ url: URL) -> Bool {
|
|
612
|
+
guard isEnabled, let controller else { return false }
|
|
613
|
+
let path = CraftHybridRoutes.appPath(of: url)
|
|
614
|
+
guard routes.match(path) != nil else { return false }
|
|
615
|
+
controller.showNative(path: path, replace: false, fromLink: true)
|
|
616
|
+
return true
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/// The bar covers this much of the bottom of the screen.
|
|
620
|
+
func tabBarCovers(_ height: CGFloat) {
|
|
621
|
+
controller?.tabBarCovers(height)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// MARK: Driving the page
|
|
625
|
+
|
|
626
|
+
/// Navigate the page to `path` and answer with its depth there, or nil
|
|
627
|
+
/// when the page could only be loaded afresh or is still on its way.
|
|
628
|
+
/// Answers within `within` seconds: a push waits that long at most for
|
|
629
|
+
/// the page, then slides in and shows the page when it has arrived.
|
|
630
|
+
func navigatePage(_ path: String, within: TimeInterval = 0.35, completion: @escaping (Int?) -> Void) {
|
|
631
|
+
var answered = false
|
|
632
|
+
let answer: (Int?) -> Void = { depth in
|
|
633
|
+
guard !answered else { return }
|
|
634
|
+
answered = true
|
|
635
|
+
completion(depth)
|
|
636
|
+
}
|
|
637
|
+
guard let webView, pageReady, let scheme = webView.url?.scheme, scheme == "http" || scheme == "https" else {
|
|
638
|
+
CraftSwiftShim.coordinator?.loadAppPath(path)
|
|
639
|
+
answer(nil)
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + within) { answer(nil) }
|
|
643
|
+
webView.callAsyncJavaScript(
|
|
644
|
+
"if (!window.__craftHybrid) return null; var r = await window.__craftHybrid.navigate(path); return r ? r.depth : null;",
|
|
645
|
+
arguments: ["path": path],
|
|
646
|
+
in: nil,
|
|
647
|
+
in: .page
|
|
648
|
+
) { result in
|
|
649
|
+
if case .success(let value) = result, let depth = value as? NSNumber { answer(depth.intValue) } else { answer(nil) }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
func setPageBase(_ depth: Int?) {
|
|
654
|
+
let value = depth.map(String.init) ?? "null"
|
|
655
|
+
webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.setBase(\(value))", completionHandler: nil)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
func pageBack(to depth: Int) {
|
|
659
|
+
webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.backTo(\(max(0, depth)))", completionHandler: nil)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/// The page's `craftHybrid` channel, only from a trusted origin's main frame.
|
|
664
|
+
final class CraftHybridRelay: NSObject, WKScriptMessageHandler {
|
|
665
|
+
private let trusts: (WKSecurityOrigin) -> Bool
|
|
666
|
+
|
|
667
|
+
init(trusts: @escaping (WKSecurityOrigin) -> Bool) {
|
|
668
|
+
self.trusts = trusts
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
672
|
+
guard message.frameInfo.isMainFrame, trusts(message.frameInfo.securityOrigin),
|
|
673
|
+
let body = message.body as? [String: Any] else { return }
|
|
674
|
+
DispatchQueue.main.async { CraftHybrid.shared.receive(body) }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// MARK: - Screens
|
|
679
|
+
|
|
680
|
+
struct CraftHybridRoot: UIViewControllerRepresentable {
|
|
681
|
+
let config: CraftConfig
|
|
682
|
+
|
|
683
|
+
func makeUIViewController(context: Context) -> CraftHybridController {
|
|
684
|
+
CraftHybridController(config: config)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
func updateUIViewController(_ controller: CraftHybridController, context: Context) {}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// A web entry in a tab's stack. The one web view moves into whichever entry
|
|
691
|
+
/// is on screen; the others are empty until they are shown again.
|
|
692
|
+
final class CraftHybridWebSlot: UIViewController {
|
|
693
|
+
/// The path the entry opened at.
|
|
694
|
+
var path: String
|
|
695
|
+
/// The page's depth at this entry; nil for a tab's own web root, where the
|
|
696
|
+
/// page's stack is all the page's.
|
|
697
|
+
var depth: Int?
|
|
698
|
+
let isTabRoot: Bool
|
|
699
|
+
/// The page has not reached `path` yet: the entry shows its background,
|
|
700
|
+
/// not the page it is leaving, until it has.
|
|
701
|
+
var awaitingPage = false
|
|
702
|
+
|
|
703
|
+
init(path: String, depth: Int?, isTabRoot: Bool) {
|
|
704
|
+
self.path = path
|
|
705
|
+
self.depth = depth
|
|
706
|
+
self.isTabRoot = isTabRoot
|
|
707
|
+
super.init(nibName: nil, bundle: nil)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
711
|
+
|
|
712
|
+
override func viewDidLoad() {
|
|
713
|
+
super.viewDidLoad()
|
|
714
|
+
view.backgroundColor = CraftHybrid.shared.config.resolvedBackgroundColor
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
override func viewWillAppear(_ animated: Bool) {
|
|
718
|
+
super.viewWillAppear(animated)
|
|
719
|
+
(parent?.parent as? CraftHybridController)?.adoptWeb(into: self)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
|
|
724
|
+
private let config: CraftConfig
|
|
725
|
+
private let hybrid = CraftHybrid.shared
|
|
726
|
+
private let webHost: UIHostingController<AnyView>
|
|
727
|
+
/// Where the web view waits, behind the screens, while none of them is web.
|
|
728
|
+
private let parking = UIView()
|
|
729
|
+
private var stacks: [String: UINavigationController] = [:]
|
|
730
|
+
private var currentTab = ""
|
|
731
|
+
/// The path each native entry opened at.
|
|
732
|
+
private var nativePaths: [ObjectIdentifier: String] = [:]
|
|
733
|
+
/// Each stack as it was after its last transition, to see what a pop removed.
|
|
734
|
+
private var shownStacks: [ObjectIdentifier: [UIViewController]] = [:]
|
|
735
|
+
/// A tab whose page has not caught up yet; shown when it has, or soon.
|
|
736
|
+
private var pendingTab: (id: String, deadline: DispatchWorkItem)?
|
|
737
|
+
private var openingWeb = false
|
|
738
|
+
private var firstFrameLogged = false
|
|
739
|
+
private var tabBarHeight: CGFloat = 0
|
|
740
|
+
|
|
741
|
+
init(config: CraftConfig) {
|
|
742
|
+
self.config = config
|
|
743
|
+
webHost = UIHostingController(rootView: AnyView(CraftWebView(config: config).ignoresSafeArea()))
|
|
744
|
+
super.init(nibName: nil, bundle: nil)
|
|
745
|
+
hybrid.controller = self
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
749
|
+
|
|
750
|
+
override func viewDidLoad() {
|
|
751
|
+
super.viewDidLoad()
|
|
752
|
+
view.backgroundColor = config.resolvedBackgroundColor
|
|
753
|
+
parking.frame = view.bounds
|
|
754
|
+
parking.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
755
|
+
view.addSubview(parking)
|
|
756
|
+
|
|
757
|
+
// A native start screen draws first; the web view, whose creation
|
|
758
|
+
// costs a frame or more, starts loading straight after it.
|
|
759
|
+
if !hybrid.startsNative { attachWeb() }
|
|
760
|
+
let tabs = hybrid.tabIds
|
|
761
|
+
let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
|
|
762
|
+
show(tab: start, rootPath: hybrid.startPath)
|
|
763
|
+
if hybrid.startsNative {
|
|
764
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private var webAttached = false
|
|
769
|
+
|
|
770
|
+
/// The web view joins the screen, behind the native screens, and loads.
|
|
771
|
+
private func attachWeb() {
|
|
772
|
+
guard !webAttached else { return }
|
|
773
|
+
webAttached = true
|
|
774
|
+
webHost.view.backgroundColor = config.resolvedBackgroundColor
|
|
775
|
+
if #available(iOS 16.4, *) { webHost.safeAreaRegions = [] }
|
|
776
|
+
if let slot = visibleStack?.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
|
|
777
|
+
moveWeb(to: slot, in: slot.view)
|
|
778
|
+
} else {
|
|
779
|
+
moveWeb(to: self, in: parking)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/// The web view's controller is the child of whichever controller shows
|
|
784
|
+
/// it: UIKit refuses a child's view in another controller's hierarchy.
|
|
785
|
+
private func moveWeb(to owner: UIViewController, in container: UIView) {
|
|
786
|
+
if webHost.parent !== owner {
|
|
787
|
+
if webHost.parent != nil {
|
|
788
|
+
webHost.willMove(toParent: nil)
|
|
789
|
+
webHost.view.removeFromSuperview()
|
|
790
|
+
webHost.removeFromParent()
|
|
791
|
+
}
|
|
792
|
+
owner.addChild(webHost)
|
|
793
|
+
place(webHost.view, in: container)
|
|
794
|
+
webHost.didMove(toParent: owner)
|
|
795
|
+
} else if webHost.view.superview !== container {
|
|
796
|
+
place(webHost.view, in: container)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private func place(_ child: UIView, in container: UIView) {
|
|
801
|
+
child.removeFromSuperview()
|
|
802
|
+
child.translatesAutoresizingMaskIntoConstraints = false
|
|
803
|
+
container.addSubview(child)
|
|
804
|
+
NSLayoutConstraint.activate([
|
|
805
|
+
child.topAnchor.constraint(equalTo: container.topAnchor),
|
|
806
|
+
child.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
807
|
+
child.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
808
|
+
child.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
809
|
+
])
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
813
|
+
|
|
814
|
+
var webIsVisible: Bool {
|
|
815
|
+
guard let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
|
|
816
|
+
return webHost.view.superview === slot.view
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// MARK: Stacks
|
|
820
|
+
|
|
821
|
+
private func makeStack(tab: String, rootPath: String) -> UINavigationController {
|
|
822
|
+
let nav = UINavigationController(rootViewController: entry(for: rootPath, isTabRoot: true, depth: nil))
|
|
823
|
+
nav.delegate = self
|
|
824
|
+
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
825
|
+
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
826
|
+
nav.setNavigationBarHidden(true, animated: false)
|
|
827
|
+
addChild(nav)
|
|
828
|
+
nav.view.frame = view.bounds
|
|
829
|
+
nav.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
830
|
+
view.addSubview(nav.view)
|
|
831
|
+
nav.didMove(toParent: self)
|
|
832
|
+
// Only once the stack's view has loaded: the recognizer is made then,
|
|
833
|
+
// and before it is nil, so the delegate never took.
|
|
834
|
+
for recognizer in popRecognizers(of: nav) { recognizer.delegate = self }
|
|
835
|
+
stacks[tab] = nav
|
|
836
|
+
shownStacks[ObjectIdentifier(nav)] = nav.viewControllers
|
|
837
|
+
return nav
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/// The entry for a path: its native screen, or a web entry.
|
|
841
|
+
private func entry(for path: String, isTabRoot: Bool, depth: Int?, params extra: [String: Any] = [:]) -> UIViewController {
|
|
842
|
+
if let match = hybrid.routes.match(path) {
|
|
843
|
+
var params: [String: Any] = match.params
|
|
844
|
+
params["path"] = path
|
|
845
|
+
extra.forEach { params[$0.key] = $0.value }
|
|
846
|
+
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
|
|
847
|
+
screen.hybridEvents = self
|
|
848
|
+
nativePaths[ObjectIdentifier(screen)] = match.path
|
|
849
|
+
return screen
|
|
850
|
+
}
|
|
851
|
+
return CraftHybridWebSlot(path: path, depth: depth, isTabRoot: isTabRoot)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private func show(tab id: String, rootPath: String? = nil) {
|
|
855
|
+
cancelPendingTab()
|
|
856
|
+
let nav = stacks[id] ?? makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id))
|
|
857
|
+
currentTab = id
|
|
858
|
+
for (key, other) in stacks { other.view.isHidden = key != id }
|
|
859
|
+
view.bringSubviewToFront(nav.view)
|
|
860
|
+
if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
|
|
861
|
+
adoptWeb(into: slot)
|
|
862
|
+
} else if !(nav.topViewController is CraftHybridWebSlot) {
|
|
863
|
+
parkWeb()
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private func cancelPendingTab() {
|
|
868
|
+
pendingTab?.deadline.cancel()
|
|
869
|
+
pendingTab = nil
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
func adoptWeb(into slot: CraftHybridWebSlot) {
|
|
873
|
+
guard slot.navigationController === visibleStack || visibleStack == nil else { return }
|
|
874
|
+
attachWeb()
|
|
875
|
+
moveWeb(to: slot, in: slot.view)
|
|
876
|
+
webHost.view.alpha = slot.awaitingPage ? 0 : 1
|
|
877
|
+
hybrid.setPageBase(slot.isTabRoot ? nil : slot.depth)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private func parkWeb() {
|
|
881
|
+
guard webAttached else { return }
|
|
882
|
+
webHost.view.alpha = 1
|
|
883
|
+
moveWeb(to: self, in: parking)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// MARK: Opening paths
|
|
887
|
+
|
|
888
|
+
/// `craft.navigation.open(path)` or `craft.hybrid.open(path)`: a tab, a
|
|
889
|
+
/// native screen, or the web view pushed over the screen that asked.
|
|
890
|
+
func open(_ raw: String) {
|
|
891
|
+
guard let path = CraftHybridRoutes.normalize(raw) else { return }
|
|
892
|
+
if let tab = hybrid.tabIds.first(where: { CraftHybridRoutes.normalize($0) == path }) {
|
|
893
|
+
selectTabAsIfTapped(tab)
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
if hybrid.routes.match(raw) != nil {
|
|
897
|
+
showNative(path: raw, replace: false)
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
pushWeb(raw)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private func selectTabAsIfTapped(_ id: String) {
|
|
904
|
+
if let tab = CraftChrome.shared.tabs.first(where: { $0.id == id }) {
|
|
905
|
+
CraftChrome.shared.tap(tab)
|
|
906
|
+
} else {
|
|
907
|
+
_ = tabTapped(id, reselect: id == currentTab)
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/// The native screen for `raw`: the one already in the stack (popping to
|
|
912
|
+
/// it), a tab's root, or a new one pushed.
|
|
913
|
+
func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
|
|
914
|
+
guard let match = hybrid.routes.match(raw) else { return }
|
|
915
|
+
let tabs = hybrid.tabIds
|
|
916
|
+
if let tab = tabs.first(where: { CraftHybridRoutes.normalize($0) == match.path }) {
|
|
917
|
+
if tab != currentTab {
|
|
918
|
+
selectTabAsIfTapped(tab)
|
|
919
|
+
}
|
|
920
|
+
if let nav = stacks[tab], nav.viewControllers.count > 1 {
|
|
921
|
+
nav.popToRootViewController(animated: !fromLink)
|
|
922
|
+
}
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
|
|
926
|
+
selectTabAsIfTapped(owner)
|
|
927
|
+
}
|
|
928
|
+
guard let nav = visibleStack ?? stacks.values.first else { return }
|
|
929
|
+
if let existing = nav.viewControllers.last(where: { nativePaths[ObjectIdentifier($0)] == match.path }) {
|
|
930
|
+
if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
934
|
+
if replace, nav.viewControllers.count > 1 {
|
|
935
|
+
nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
|
|
936
|
+
} else {
|
|
937
|
+
nav.pushViewController(screen, animated: true)
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/// The web view, pushed: the page goes to `path` first, without its own
|
|
942
|
+
/// slide, then the entry slides in over the screen that opened it.
|
|
943
|
+
private func pushWeb(_ path: String) {
|
|
944
|
+
guard !openingWeb, let nav = visibleStack else { return }
|
|
945
|
+
openingWeb = true
|
|
946
|
+
hybrid.navigatePage(path) { [weak self, weak nav] depth in
|
|
947
|
+
guard let self else { return }
|
|
948
|
+
self.openingWeb = false
|
|
949
|
+
guard let nav else { return }
|
|
950
|
+
let slot = CraftHybridWebSlot(path: path, depth: depth, isTabRoot: false)
|
|
951
|
+
// Slow to answer: slide in the entry, and the page once it is there.
|
|
952
|
+
if depth == nil {
|
|
953
|
+
slot.awaitingPage = true
|
|
954
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self, weak slot] in
|
|
955
|
+
guard let slot, slot.awaitingPage else { return }
|
|
956
|
+
self?.reveal(slot)
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
nav.pushViewController(slot, animated: true)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private func reveal(_ slot: CraftHybridWebSlot) {
|
|
964
|
+
slot.awaitingPage = false
|
|
965
|
+
guard webHost.view.superview === slot.view else { return }
|
|
966
|
+
UIView.animate(withDuration: 0.15) { self.webHost.view.alpha = 1 }
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/// Back from the web entry on top, as the page's Back at that entry asks.
|
|
970
|
+
func popTop() {
|
|
971
|
+
guard let nav = visibleStack, nav.viewControllers.count > 1, nav.topViewController is CraftHybridWebSlot else { return }
|
|
972
|
+
nav.popViewController(animated: true)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// MARK: The page moved
|
|
976
|
+
|
|
977
|
+
func pageNavigated(path: String, direction: String, depth: Int, byHost: Bool) {
|
|
978
|
+
let owner = CraftHybridRoutes.tab(owning: path, among: hybrid.tabIds)
|
|
979
|
+
if let pending = pendingTab, owner == pending.id || direction == "tab" {
|
|
980
|
+
show(tab: pending.id)
|
|
981
|
+
}
|
|
982
|
+
if let slot = visibleStack?.topViewController as? CraftHybridWebSlot {
|
|
983
|
+
if !slot.isTabRoot, slot.depth == nil { slot.depth = depth }
|
|
984
|
+
if slot.awaitingPage, CraftHybridRoutes.normalize(path) == CraftHybridRoutes.normalize(slot.path) { reveal(slot) }
|
|
985
|
+
if webIsVisible { hybrid.setPageBase(slot.isTabRoot ? nil : slot.depth) }
|
|
986
|
+
}
|
|
987
|
+
guard !byHost, direction != "tab", !openingWeb, let nav = visibleStack else { return }
|
|
988
|
+
let top = nav.topViewController
|
|
989
|
+
if let match = hybrid.routes.match(path) {
|
|
990
|
+
// The page went to a native path the shell did not catch first:
|
|
991
|
+
// a full load, or Back past the entry a native screen opened.
|
|
992
|
+
guard top is CraftHybridWebSlot else { return }
|
|
993
|
+
if let existing = nav.viewControllers.last(where: { nativePaths[ObjectIdentifier($0)] == match.path }) {
|
|
994
|
+
nav.popToViewController(existing, animated: direction != "pop")
|
|
995
|
+
} else if !((top as? CraftHybridWebSlot)?.isTabRoot ?? false) {
|
|
996
|
+
nav.pushViewController(entry(for: path, isTabRoot: false, depth: nil), animated: true)
|
|
997
|
+
}
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
// The page went somewhere of its own accord (a sign-in redirect, a
|
|
1001
|
+
// notification, a link it handled) while a native screen shows.
|
|
1002
|
+
if top is CraftNativeScreenController {
|
|
1003
|
+
log("the page went to \(path) by itself; showing it")
|
|
1004
|
+
nav.pushViewController(CraftHybridWebSlot(path: path, depth: depth, isTabRoot: false), animated: direction != "load" || firstFrameLogged)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// MARK: Tabs
|
|
1009
|
+
|
|
1010
|
+
func tabTapped(_ id: String, reselect: Bool) -> Bool {
|
|
1011
|
+
if reselect || id == currentTab {
|
|
1012
|
+
guard let nav = stacks[id] else { return true }
|
|
1013
|
+
if nav.viewControllers.count > 1 {
|
|
1014
|
+
nav.popToRootViewController(animated: true)
|
|
1015
|
+
return false
|
|
1016
|
+
}
|
|
1017
|
+
// The root again: a web root is the page's to handle (to its top,
|
|
1018
|
+
// then its own first screen).
|
|
1019
|
+
return nav.topViewController is CraftHybridWebSlot
|
|
1020
|
+
}
|
|
1021
|
+
let target = stacks[id]
|
|
1022
|
+
let rootIsWeb = target.map { $0.topViewController is CraftHybridWebSlot } ?? (hybrid.routes.match(id) == nil)
|
|
1023
|
+
let pageThere = hybrid.pagePath.flatMap { CraftHybridRoutes.tab(owning: $0, among: hybrid.tabIds) } == id
|
|
1024
|
+
if rootIsWeb && !pageThere {
|
|
1025
|
+
// Keep this screen up until the page has switched, rather than
|
|
1026
|
+
// showing it half way.
|
|
1027
|
+
cancelPendingTab()
|
|
1028
|
+
if stacks[id] == nil { _ = makeStack(tab: id, rootPath: id); stacks[id]?.view.isHidden = true; view.bringSubviewToFront(visibleStack?.view ?? view) }
|
|
1029
|
+
let deadline = DispatchWorkItem { [weak self] in self?.show(tab: id) }
|
|
1030
|
+
pendingTab = (id, deadline)
|
|
1031
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45, execute: deadline)
|
|
1032
|
+
} else {
|
|
1033
|
+
show(tab: id)
|
|
1034
|
+
}
|
|
1035
|
+
return true
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/// The page's own idea of its tab counts only while the page is a
|
|
1039
|
+
/// tab's root: over a native screen, or on an entry the shell pushed
|
|
1040
|
+
/// there, the page cannot know which tab it is in (a workout page
|
|
1041
|
+
/// loaded on its own lights Calendar, though Today opened it). The bar
|
|
1042
|
+
/// then keeps the tab that is on screen.
|
|
1043
|
+
func pageSelectedTab(_ id: String) {
|
|
1044
|
+
guard id != currentTab, pendingTab?.id != id else { return }
|
|
1045
|
+
guard let top = visibleStack?.topViewController as? CraftHybridWebSlot, top.isTabRoot else {
|
|
1046
|
+
let shown = currentTab
|
|
1047
|
+
DispatchQueue.main.async {
|
|
1048
|
+
guard CraftChrome.shared.selected != shown else { return }
|
|
1049
|
+
CraftChrome.shared.selected = shown
|
|
1050
|
+
}
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
show(tab: id)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// MARK: Layout
|
|
1057
|
+
|
|
1058
|
+
func tabBarCovers(_ height: CGFloat) {
|
|
1059
|
+
tabBarHeight = height
|
|
1060
|
+
let inset = safeAreaForTabBar()
|
|
1061
|
+
for nav in stacks.values where nav.additionalSafeAreaInsets.bottom != inset {
|
|
1062
|
+
nav.additionalSafeAreaInsets.bottom = inset
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private func safeAreaForTabBar() -> CGFloat {
|
|
1067
|
+
let bottom = view.window?.safeAreaInsets.bottom ?? view.safeAreaInsets.bottom
|
|
1068
|
+
return max(0, tabBarHeight - bottom)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
override func viewSafeAreaInsetsDidChange() {
|
|
1072
|
+
super.viewSafeAreaInsetsDidChange()
|
|
1073
|
+
tabBarCovers(tabBarHeight)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// MARK: UINavigationControllerDelegate
|
|
1077
|
+
|
|
1078
|
+
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
|
1079
|
+
// The page draws its own header, and a tab's root screen its own
|
|
1080
|
+
// title; a pushed native screen gets the bar and its Back.
|
|
1081
|
+
let bare = viewController is CraftHybridWebSlot || navigationController.viewControllers.first === viewController
|
|
1082
|
+
navigationController.setNavigationBarHidden(bare, animated: animated)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
|
1086
|
+
let key = ObjectIdentifier(navigationController)
|
|
1087
|
+
let before = shownStacks[key] ?? []
|
|
1088
|
+
let now = navigationController.viewControllers
|
|
1089
|
+
shownStacks[key] = now
|
|
1090
|
+
let removed = before.filter { old in !now.contains(where: { $0 === old }) }
|
|
1091
|
+
for gone in removed { nativePaths.removeValue(forKey: ObjectIdentifier(gone)) }
|
|
1092
|
+
// The page goes back to where it was before the first web entry the
|
|
1093
|
+
// pop removed.
|
|
1094
|
+
if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
|
|
1095
|
+
hybrid.pageBack(to: depth - 1)
|
|
1096
|
+
}
|
|
1097
|
+
guard navigationController === visibleStack else { return }
|
|
1098
|
+
if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// MARK: UIGestureRecognizerDelegate
|
|
1102
|
+
|
|
1103
|
+
/// The edge swipe back, and on iOS 26 the swipe back from anywhere on the
|
|
1104
|
+
/// screen, which is the one that answers a swipe that starts a little in
|
|
1105
|
+
/// from the edge. Both refuse on their own while the bar is hidden, which
|
|
1106
|
+
/// it is over a web entry; the shell decides for them instead.
|
|
1107
|
+
private func popRecognizers(of nav: UINavigationController) -> [UIGestureRecognizer] {
|
|
1108
|
+
var recognizers = [nav.interactivePopGestureRecognizer].compactMap { $0 }
|
|
1109
|
+
#if compiler(>=6.2)
|
|
1110
|
+
if #available(iOS 26.0, *), let content = nav.interactiveContentPopGestureRecognizer {
|
|
1111
|
+
recognizers.append(content)
|
|
1112
|
+
}
|
|
1113
|
+
#endif
|
|
1114
|
+
return recognizers
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
private func isPopRecognizer(_ recognizer: UIGestureRecognizer) -> Bool {
|
|
1118
|
+
guard let nav = visibleStack else { return false }
|
|
1119
|
+
return popRecognizers(of: nav).contains { $0 === recognizer }
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/// The edge swipe goes ahead of the web view's own gestures, which
|
|
1123
|
+
/// otherwise hold every touch over the page until the page has answered it.
|
|
1124
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1125
|
+
gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1129
|
+
false
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1133
|
+
guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
|
|
1134
|
+
nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
|
|
1135
|
+
guard let slot = nav.topViewController as? CraftHybridWebSlot else { return true }
|
|
1136
|
+
// Over the page, only from its left edge: a swipe across the page is
|
|
1137
|
+
// the page's (a carousel, a slider).
|
|
1138
|
+
if gestureRecognizer !== nav.interactivePopGestureRecognizer {
|
|
1139
|
+
// Where the finger came down: where it is, less how far it moved.
|
|
1140
|
+
let moved = (gestureRecognizer as? UIPanGestureRecognizer)?.translation(in: nav.view).x ?? 0
|
|
1141
|
+
if gestureRecognizer.location(in: nav.view).x - moved > 32 { return false }
|
|
1142
|
+
}
|
|
1143
|
+
// And only while the page is at that entry: deeper, the swipe is the
|
|
1144
|
+
// page's own Back.
|
|
1145
|
+
if let depth = slot.depth { return hybrid.pageDepth <= depth }
|
|
1146
|
+
return true
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// MARK: CraftHybridScreenEvents
|
|
1150
|
+
|
|
1151
|
+
func nativeScreenDidRender(_ screen: CraftNativeScreenController) {
|
|
1152
|
+
guard !firstFrameLogged else { return }
|
|
1153
|
+
firstFrameLogged = true
|
|
1154
|
+
let path = nativePaths[ObjectIdentifier(screen)] ?? "?"
|
|
1155
|
+
// Logged when the frame is committed: a cold launch is timed from the
|
|
1156
|
+
// process's first log line to this one, as the web's "splash hidden".
|
|
1157
|
+
CATransaction.setCompletionBlock { [weak self] in
|
|
1158
|
+
NSLog("[craft hybrid] native first frame %@", path)
|
|
1159
|
+
self?.attachWeb()
|
|
1160
|
+
}
|
|
1161
|
+
CraftChrome.shared.hideSplash()
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String) {
|
|
1165
|
+
let path = nativePaths[ObjectIdentifier(screen)] ?? hybrid.startPath
|
|
1166
|
+
NSLog("[craft hybrid] native screen for %@ failed (%@); opening it in the web view", path, message)
|
|
1167
|
+
DispatchQueue.main.async { [weak self, weak screen] in
|
|
1168
|
+
guard let self, let screen, let nav = screen.navigationController else { return }
|
|
1169
|
+
self.attachWeb()
|
|
1170
|
+
let isRoot = nav.viewControllers.first === screen
|
|
1171
|
+
let slot = CraftHybridWebSlot(path: path, depth: isRoot ? nil : self.hybrid.pageDepth, isTabRoot: isRoot)
|
|
1172
|
+
self.nativePaths.removeValue(forKey: ObjectIdentifier(screen))
|
|
1173
|
+
if !self.firstFrameLogged, !self.hybrid.pageReady {
|
|
1174
|
+
CraftChrome.shared.showSplash(atMost: self.config.splashMaxSeconds)
|
|
1175
|
+
}
|
|
1176
|
+
// The page loads the start path already; anywhere else it is sent.
|
|
1177
|
+
let pageAt = self.hybrid.pagePath ?? (self.hybrid.pageReady ? nil : self.hybrid.startPath)
|
|
1178
|
+
if pageAt.flatMap(CraftHybridRoutes.normalize) != CraftHybridRoutes.normalize(path) {
|
|
1179
|
+
self.hybrid.navigatePage(path) { _ in }
|
|
1180
|
+
}
|
|
1181
|
+
nav.setViewControllers(nav.viewControllers.map { $0 === screen ? slot : $0 }, animated: false)
|
|
1182
|
+
self.shownStacks[ObjectIdentifier(nav)] = nav.viewControllers
|
|
1183
|
+
if nav === self.visibleStack, nav.topViewController === slot { self.adoptWeb(into: slot) }
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
1188
|
+
open(path)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private func log(_ message: String) {
|
|
1192
|
+
#if DEBUG
|
|
1193
|
+
NSLog("[craft hybrid] %@", message)
|
|
1194
|
+
#endif
|
|
1195
|
+
}
|
|
1196
|
+
}
|