craft-native 0.0.124 → 0.0.126

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.
@@ -0,0 +1,1199 @@
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 unless it asked for the bar; a pushed native screen gets the
1081
+ // bar and its Back.
1082
+ // A root screen that asked for its own bar (setOptions) keeps it.
1083
+ let asksForBar = (viewController as? CraftNativeScreenController)?.wantsNavigationBar ?? false
1084
+ let bare = viewController is CraftHybridWebSlot || (navigationController.viewControllers.first === viewController && !asksForBar)
1085
+ navigationController.setNavigationBarHidden(bare, animated: animated)
1086
+ }
1087
+
1088
+ func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
1089
+ let key = ObjectIdentifier(navigationController)
1090
+ let before = shownStacks[key] ?? []
1091
+ let now = navigationController.viewControllers
1092
+ shownStacks[key] = now
1093
+ let removed = before.filter { old in !now.contains(where: { $0 === old }) }
1094
+ for gone in removed { nativePaths.removeValue(forKey: ObjectIdentifier(gone)) }
1095
+ // The page goes back to where it was before the first web entry the
1096
+ // pop removed.
1097
+ if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
1098
+ hybrid.pageBack(to: depth - 1)
1099
+ }
1100
+ guard navigationController === visibleStack else { return }
1101
+ if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
1102
+ }
1103
+
1104
+ // MARK: UIGestureRecognizerDelegate
1105
+
1106
+ /// The edge swipe back, and on iOS 26 the swipe back from anywhere on the
1107
+ /// screen, which is the one that answers a swipe that starts a little in
1108
+ /// from the edge. Both refuse on their own while the bar is hidden, which
1109
+ /// it is over a web entry; the shell decides for them instead.
1110
+ private func popRecognizers(of nav: UINavigationController) -> [UIGestureRecognizer] {
1111
+ var recognizers = [nav.interactivePopGestureRecognizer].compactMap { $0 }
1112
+ #if compiler(>=6.2)
1113
+ if #available(iOS 26.0, *), let content = nav.interactiveContentPopGestureRecognizer {
1114
+ recognizers.append(content)
1115
+ }
1116
+ #endif
1117
+ return recognizers
1118
+ }
1119
+
1120
+ private func isPopRecognizer(_ recognizer: UIGestureRecognizer) -> Bool {
1121
+ guard let nav = visibleStack else { return false }
1122
+ return popRecognizers(of: nav).contains { $0 === recognizer }
1123
+ }
1124
+
1125
+ /// The edge swipe goes ahead of the web view's own gestures, which
1126
+ /// otherwise hold every touch over the page until the page has answered it.
1127
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
1128
+ gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
1129
+ }
1130
+
1131
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
1132
+ false
1133
+ }
1134
+
1135
+ func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1136
+ guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
1137
+ nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
1138
+ guard let slot = nav.topViewController as? CraftHybridWebSlot else { return true }
1139
+ // Over the page, only from its left edge: a swipe across the page is
1140
+ // the page's (a carousel, a slider).
1141
+ if gestureRecognizer !== nav.interactivePopGestureRecognizer {
1142
+ // Where the finger came down: where it is, less how far it moved.
1143
+ let moved = (gestureRecognizer as? UIPanGestureRecognizer)?.translation(in: nav.view).x ?? 0
1144
+ if gestureRecognizer.location(in: nav.view).x - moved > 32 { return false }
1145
+ }
1146
+ // And only while the page is at that entry: deeper, the swipe is the
1147
+ // page's own Back.
1148
+ if let depth = slot.depth { return hybrid.pageDepth <= depth }
1149
+ return true
1150
+ }
1151
+
1152
+ // MARK: CraftHybridScreenEvents
1153
+
1154
+ func nativeScreenDidRender(_ screen: CraftNativeScreenController) {
1155
+ guard !firstFrameLogged else { return }
1156
+ firstFrameLogged = true
1157
+ let path = nativePaths[ObjectIdentifier(screen)] ?? "?"
1158
+ // Logged when the frame is committed: a cold launch is timed from the
1159
+ // process's first log line to this one, as the web's "splash hidden".
1160
+ CATransaction.setCompletionBlock { [weak self] in
1161
+ NSLog("[craft hybrid] native first frame %@", path)
1162
+ self?.attachWeb()
1163
+ }
1164
+ CraftChrome.shared.hideSplash()
1165
+ }
1166
+
1167
+ func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String) {
1168
+ let path = nativePaths[ObjectIdentifier(screen)] ?? hybrid.startPath
1169
+ NSLog("[craft hybrid] native screen for %@ failed (%@); opening it in the web view", path, message)
1170
+ DispatchQueue.main.async { [weak self, weak screen] in
1171
+ guard let self, let screen, let nav = screen.navigationController else { return }
1172
+ self.attachWeb()
1173
+ let isRoot = nav.viewControllers.first === screen
1174
+ let slot = CraftHybridWebSlot(path: path, depth: isRoot ? nil : self.hybrid.pageDepth, isTabRoot: isRoot)
1175
+ self.nativePaths.removeValue(forKey: ObjectIdentifier(screen))
1176
+ if !self.firstFrameLogged, !self.hybrid.pageReady {
1177
+ CraftChrome.shared.showSplash(atMost: self.config.splashMaxSeconds)
1178
+ }
1179
+ // The page loads the start path already; anywhere else it is sent.
1180
+ let pageAt = self.hybrid.pagePath ?? (self.hybrid.pageReady ? nil : self.hybrid.startPath)
1181
+ if pageAt.flatMap(CraftHybridRoutes.normalize) != CraftHybridRoutes.normalize(path) {
1182
+ self.hybrid.navigatePage(path) { _ in }
1183
+ }
1184
+ nav.setViewControllers(nav.viewControllers.map { $0 === screen ? slot : $0 }, animated: false)
1185
+ self.shownStacks[ObjectIdentifier(nav)] = nav.viewControllers
1186
+ if nav === self.visibleStack, nav.topViewController === slot { self.adoptWeb(into: slot) }
1187
+ }
1188
+ }
1189
+
1190
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
1191
+ open(path)
1192
+ }
1193
+
1194
+ private func log(_ message: String) {
1195
+ #if DEBUG
1196
+ NSLog("[craft hybrid] %@", message)
1197
+ #endif
1198
+ }
1199
+ }