@craft-native/ios 0.0.244 → 0.0.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -0
- package/dist/index.d.ts +32 -1
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +8 -0
- package/templates/CraftHybrid.swift +192 -16
- package/templates/CraftNativeActions.swift +63 -5
- package/templates/CraftNativeFlatList.swift +13 -6
- package/templates/CraftNativeScreen.swift +396 -66
- package/templates/CraftWebAudio.swift +21 -13
package/dist/cli.js
CHANGED
|
@@ -298,6 +298,8 @@ function hybridConfigProblems(config) {
|
|
|
298
298
|
else if (!usesNativeBundle(config))
|
|
299
299
|
problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
|
|
300
300
|
}
|
|
301
|
+
if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
|
|
302
|
+
problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
|
|
301
303
|
return problems;
|
|
302
304
|
}
|
|
303
305
|
function installNativeBundle(source, output) {
|
package/dist/index.d.ts
CHANGED
|
@@ -240,6 +240,15 @@ export interface CraftConfig {
|
|
|
240
240
|
* `nativeScreens` or `renderer: "native"`, and the native bundle.
|
|
241
241
|
*/
|
|
242
242
|
nativeApp?: string;
|
|
243
|
+
/**
|
|
244
|
+
* Where native screens run their JavaScript. `"main"` (the default) runs it
|
|
245
|
+
* on the main thread; `"background"` gives screens a serial thread of their
|
|
246
|
+
* own, so a screen's script, its timers and its renders never hold the
|
|
247
|
+
* main thread while it scrolls or animates. UIKit still runs on main: the
|
|
248
|
+
* shell hands each render to it, and a pushed screen waits for its first
|
|
249
|
+
* render (at most 0.3 s) so it never slides in empty.
|
|
250
|
+
*/
|
|
251
|
+
nativeScriptThread?: 'main' | 'background';
|
|
243
252
|
/**
|
|
244
253
|
* The native tab bar, shown from the first frame, before the page loads.
|
|
245
254
|
* The page's own `tabBar.set` replaces it later. Each `id` is the tab's
|
|
@@ -253,6 +262,14 @@ export interface CraftConfig {
|
|
|
253
262
|
* `craft.secureStorage.getSync('auth.token')`. Turns on secure storage.
|
|
254
263
|
*/
|
|
255
264
|
shareStorage?: Record<string, string>;
|
|
265
|
+
/**
|
|
266
|
+
* The secure-storage key the signed-in account's token lives under
|
|
267
|
+
* (default `auth.token`). `craft.session` follows it: a change of it, made
|
|
268
|
+
* by any native screen or by the page through a `shareStorage` key, is
|
|
269
|
+
* `SESSION_CHANGED { key, token, previous }` on every native screen and a
|
|
270
|
+
* `craftsession` event on the page.
|
|
271
|
+
*/
|
|
272
|
+
sessionTokenKey?: string;
|
|
256
273
|
/**
|
|
257
274
|
* Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
|
|
258
275
|
* and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
|
|
@@ -311,13 +328,27 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
|
|
|
311
328
|
*/
|
|
312
329
|
export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
|
|
313
330
|
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
314
|
-
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
|
|
331
|
+
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread'>): string[];
|
|
315
332
|
/**
|
|
316
333
|
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
317
334
|
* well as the one-file bundle, and the app runs each screen from its own
|
|
318
335
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
319
336
|
*/
|
|
320
337
|
export declare const nativeRouteBundles = true;
|
|
338
|
+
/**
|
|
339
|
+
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
340
|
+
* `sharedRuntime`) in each screen's context before the route's own file.
|
|
341
|
+
* Stacks asks stx for it only when this is true: a route file built so
|
|
342
|
+
* refuses to run without it.
|
|
343
|
+
*/
|
|
344
|
+
export declare const nativeRouteRuntime = true;
|
|
345
|
+
/**
|
|
346
|
+
* The app gives a screen its messages a language at a time (stx's
|
|
347
|
+
* `messages`, `native-screens/__messages/`): read when the screen first
|
|
348
|
+
* looks inside them (`__stxNativeReadMessages`). Stacks leaves the languages
|
|
349
|
+
* out of the screens' code only when this is true.
|
|
350
|
+
*/
|
|
351
|
+
export declare const nativeMessages = true;
|
|
321
352
|
export interface CraftBackgroundRefresh {
|
|
322
353
|
enabled: boolean;
|
|
323
354
|
/** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
|
package/dist/index.js
CHANGED
|
@@ -482,9 +482,13 @@ function hybridConfigProblems(config) {
|
|
|
482
482
|
else if (!usesNativeBundle(config))
|
|
483
483
|
problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
|
|
484
484
|
}
|
|
485
|
+
if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
|
|
486
|
+
problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
|
|
485
487
|
return problems;
|
|
486
488
|
}
|
|
487
489
|
var nativeRouteBundles = true;
|
|
490
|
+
var nativeRouteRuntime = true;
|
|
491
|
+
var nativeMessages = true;
|
|
488
492
|
function installNativeBundle(source, output) {
|
|
489
493
|
if (!existsSync(source))
|
|
490
494
|
throw new Error(`Native bundle not found: ${source}`);
|
|
@@ -1568,7 +1572,9 @@ export {
|
|
|
1568
1572
|
open,
|
|
1569
1573
|
normalizeNativePath,
|
|
1570
1574
|
nativeStartPath,
|
|
1575
|
+
nativeRouteRuntime,
|
|
1571
1576
|
nativeRouteBundles,
|
|
1577
|
+
nativeMessages,
|
|
1572
1578
|
matchNativePath,
|
|
1573
1579
|
isHybrid,
|
|
1574
1580
|
installRuntime,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -1619,9 +1619,17 @@ struct CraftConfig: Codable {
|
|
|
1619
1619
|
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1620
1620
|
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1621
1621
|
var shareStorage: [String: String]? = nil
|
|
1622
|
+
/// The secure-storage key the signed-in account's token lives under
|
|
1623
|
+
/// (`auth.token` unless set): `craft.session` follows it, and a change of
|
|
1624
|
+
/// it is `SESSION_CHANGED` on every screen (CraftSession).
|
|
1625
|
+
var sessionTokenKey: String? = nil
|
|
1622
1626
|
/// The bundle route of the app script: a screen-less script that runs for
|
|
1623
1627
|
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
1624
1628
|
var nativeApp: String? = nil
|
|
1629
|
+
/// Where native screens run their JavaScript: `"main"` (the default) or
|
|
1630
|
+
/// `"background"`, a thread of their own, so a screen's script and its
|
|
1631
|
+
/// renders never hold the main thread (CraftNativeScriptThread).
|
|
1632
|
+
var nativeScriptThread: String? = nil
|
|
1625
1633
|
/// Crash and error reporting to bughq (CraftCrashReporter.swift). Off
|
|
1626
1634
|
/// unless the config names a project key.
|
|
1627
1635
|
var crashReporting: CrashReporting? = nil
|
|
@@ -354,6 +354,7 @@ final class CraftHybrid: NSObject {
|
|
|
354
354
|
guard !isConfigured else { return }
|
|
355
355
|
isConfigured = true
|
|
356
356
|
self.config = config
|
|
357
|
+
CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
|
|
357
358
|
hasWeb = config.renderer != "native"
|
|
358
359
|
siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
|
|
359
360
|
["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
|
|
@@ -488,12 +489,16 @@ final class CraftHybrid: NSObject {
|
|
|
488
489
|
func pageScript() -> String {
|
|
489
490
|
let routeList = (config.nativeScreens ?? [:]).map { ["pattern": $0.key, "screen": $0.value] }
|
|
490
491
|
let share = Array((config.shareStorage ?? [:]).keys).sorted()
|
|
492
|
+
// The page's key for the sign-in token, when it shares one.
|
|
493
|
+
let tokenKey = CraftSession.tokenKey(config)
|
|
494
|
+
let session = (config.shareStorage ?? [:]).filter { $0.value == tokenKey }.map(\.key).sorted().first
|
|
491
495
|
func json(_ value: Any) -> String {
|
|
492
496
|
(try? JSONSerialization.data(withJSONObject: value)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
493
497
|
}
|
|
494
498
|
return Self.pageScriptTemplate
|
|
495
499
|
.replacingOccurrences(of: "__CRAFT_HYBRID_ROUTES__", with: json(routeList))
|
|
496
500
|
.replacingOccurrences(of: "__CRAFT_HYBRID_SHARE__", with: json(share))
|
|
501
|
+
.replacingOccurrences(of: "__CRAFT_HYBRID_SESSION__", with: session.map { json([$0]) } ?? "[]")
|
|
497
502
|
}
|
|
498
503
|
|
|
499
504
|
// CRAFT_HYBRID_PAGE_SCRIPT_START
|
|
@@ -691,13 +696,46 @@ final class CraftHybrid: NSObject {
|
|
|
691
696
|
|
|
692
697
|
// Web storage the config shares with native screens (`shareStorage`).
|
|
693
698
|
var shared = {};
|
|
694
|
-
|
|
699
|
+
// The page's key for the sign-in token (`sessionTokenKey`), if shared.
|
|
700
|
+
var SESSION = (__CRAFT_HYBRID_SESSION__)[0] || null;
|
|
701
|
+
var sessionListeners = [];
|
|
702
|
+
function stored(key) {
|
|
695
703
|
var value = null;
|
|
696
704
|
try { value = window.sessionStorage.getItem(key); } catch (e) {}
|
|
697
705
|
try { if (value === null) value = window.localStorage.getItem(key); } catch (e) {}
|
|
706
|
+
return value;
|
|
707
|
+
}
|
|
708
|
+
function sessionToken(value) { return typeof value === 'string' && value !== '' ? value : null; }
|
|
709
|
+
function share(key) {
|
|
710
|
+
var value = stored(key);
|
|
698
711
|
if (shared[key] === value) return;
|
|
712
|
+
var known = Object.prototype.hasOwnProperty.call(shared, key);
|
|
713
|
+
var before = shared[key];
|
|
699
714
|
shared[key] = value;
|
|
700
715
|
post({ type: 'shareStorage', key: key, value: value });
|
|
716
|
+
// A sign-in, sign-out or another account, made here or on a native
|
|
717
|
+
// screen: `craftsession { key, token, previous }` and craft.session.
|
|
718
|
+
if (key === SESSION && known && sessionToken(before) !== sessionToken(value)) {
|
|
719
|
+
var change = { key: key, token: sessionToken(value), previous: sessionToken(before) };
|
|
720
|
+
sessionListeners.slice().forEach(function (callback) {
|
|
721
|
+
try { callback(change.token, change.previous); } catch (e) { console.error(e); }
|
|
722
|
+
});
|
|
723
|
+
try { window.dispatchEvent(new CustomEvent('craftsession', { detail: change })); } catch (e) {}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (SESSION && window.craft && !window.craft.session) {
|
|
727
|
+
window.craft.session = {
|
|
728
|
+
key: SESSION,
|
|
729
|
+
token: function () { return sessionToken(stored(SESSION)); },
|
|
730
|
+
onChange: function (callback) {
|
|
731
|
+
if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
|
|
732
|
+
sessionListeners.push(callback);
|
|
733
|
+
return function () {
|
|
734
|
+
var index = sessionListeners.indexOf(callback);
|
|
735
|
+
if (index >= 0) sessionListeners.splice(index, 1);
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
};
|
|
701
739
|
}
|
|
702
740
|
if (SHARE.length && window.Storage) {
|
|
703
741
|
var proto = window.Storage.prototype;
|
|
@@ -808,7 +846,9 @@ final class CraftHybrid: NSObject {
|
|
|
808
846
|
guard let nativeKey = config.shareStorage?[key] else { return }
|
|
809
847
|
// The page's own write, or its echo of one a native screen made.
|
|
810
848
|
pendingShared.removeValue(forKey: key)
|
|
811
|
-
|
|
849
|
+
// The page's sign-in or sign-out moves the account for every
|
|
850
|
+
// screen; an echo writes what is already there and moves nothing.
|
|
851
|
+
CraftSession.observe(key: nativeKey, config: config) { CraftHybridKeychain.set(nativeKey, value: value) }
|
|
812
852
|
case .navigated(let path, let direction, let depth, let byHost):
|
|
813
853
|
pagePath = path
|
|
814
854
|
pageDepth = depth
|
|
@@ -1065,6 +1105,21 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1065
1105
|
/// this separate from the live frame: deriving it after the drag begins
|
|
1066
1106
|
/// makes the target jump when the translation crosses the midpoint.
|
|
1067
1107
|
private var tabSwipeFrom: CGFloat = 0
|
|
1108
|
+
/// A native screen waiting for its first content before it is pushed.
|
|
1109
|
+
private var pendingPush: String?
|
|
1110
|
+
/// Where the current screen already was when this gesture took over: a
|
|
1111
|
+
/// swipe that caught the last one while it settled starts from there.
|
|
1112
|
+
private var tabSwipeBase: CGFloat = 0
|
|
1113
|
+
/// A screen still partly on screen when this gesture caught a settling
|
|
1114
|
+
/// swipe and went the other way: it keeps its distance from the current
|
|
1115
|
+
/// screen (`tabSwipeTrailingGap`) until it has left.
|
|
1116
|
+
private var tabSwipeTrailing: UINavigationController?
|
|
1117
|
+
private var tabSwipeTrailingGap: CGFloat = 0
|
|
1118
|
+
/// The end of the settle under way, run at once when a swipe catches it.
|
|
1119
|
+
private var settleNow: (() -> Void)?
|
|
1120
|
+
private var settleCompleting = false
|
|
1121
|
+
/// A caught settle's screens, where they were, for the gesture that caught it.
|
|
1122
|
+
private var caughtSettle: (other: String, otherX: CGFloat, currentX: CGFloat)?
|
|
1068
1123
|
|
|
1069
1124
|
init(config: CraftConfig) {
|
|
1070
1125
|
self.config = config
|
|
@@ -1121,6 +1176,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1121
1176
|
}
|
|
1122
1177
|
|
|
1123
1178
|
@objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
1179
|
+
// A finger that lands while the last swipe settles takes the screens
|
|
1180
|
+
// from where they are, as a paging scroll view does. This swipe used
|
|
1181
|
+
// to be ignored until the settle ended.
|
|
1182
|
+
if recognizer.state == .began, tabSwipeSettling { catchSettlingSwipe() }
|
|
1124
1183
|
guard !tabSwipeSettling else { return }
|
|
1125
1184
|
let tabs = hybrid.tabIds
|
|
1126
1185
|
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
@@ -1138,6 +1197,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1138
1197
|
updateInteractiveTabSwipe(translation: translation)
|
|
1139
1198
|
case .ended, .cancelled, .failed:
|
|
1140
1199
|
guard let target = activeTabSwipeTarget else {
|
|
1200
|
+
// Caught and let go without a direction (or toward the edge):
|
|
1201
|
+
// the caught screens finish where they were going.
|
|
1202
|
+
if caughtSettle != nil { settleCaughtScreens(); return }
|
|
1141
1203
|
guard recognizer.state == .ended else { return }
|
|
1142
1204
|
let delta = abs(translation) >= 32 ? translation : velocity
|
|
1143
1205
|
guard abs(delta) >= 32 else { return }
|
|
@@ -1152,7 +1214,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1152
1214
|
// jumping straight to their destination.
|
|
1153
1215
|
tabSwipeSettling = true
|
|
1154
1216
|
let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
|
|
1155
|
-
let progress = translation * direction
|
|
1217
|
+
let progress = (translation + tabSwipeBase) * direction
|
|
1156
1218
|
let speed = velocity * direction
|
|
1157
1219
|
let complete = recognizer.state == .ended && speed > -700
|
|
1158
1220
|
&& (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
|
|
@@ -1213,7 +1275,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1213
1275
|
target.view.isHidden = false
|
|
1214
1276
|
let width = view.bounds.width
|
|
1215
1277
|
tabSwipeFrom = -direction * width
|
|
1216
|
-
|
|
1278
|
+
// Caught from a settle: the current screen is still where the last
|
|
1279
|
+
// swipe left it, and the other screen it showed either is this
|
|
1280
|
+
// target (the finger went back to it) or trails behind.
|
|
1281
|
+
tabSwipeBase = 0
|
|
1282
|
+
tabSwipeTrailing = nil
|
|
1283
|
+
if let caught = caughtSettle {
|
|
1284
|
+
caughtSettle = nil
|
|
1285
|
+
tabSwipeBase = caught.currentX
|
|
1286
|
+
if caught.other != id, let other = stacks[caught.other] {
|
|
1287
|
+
tabSwipeTrailing = other
|
|
1288
|
+
tabSwipeTrailingGap = caught.otherX - caught.currentX
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom + tabSwipeBase, dy: 0)
|
|
1217
1292
|
view.bringSubviewToFront(target.view)
|
|
1218
1293
|
CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
|
|
1219
1294
|
lastNavigation = CACurrentMediaTime()
|
|
@@ -1226,13 +1301,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1226
1301
|
let target = stacks[targetId] else { return }
|
|
1227
1302
|
let width = max(1, view.bounds.width)
|
|
1228
1303
|
// Reversing past the start stays at zero progress, rather than
|
|
1229
|
-
// pulling both screens away and exposing the background
|
|
1304
|
+
// pulling both screens away and exposing the background; past where
|
|
1305
|
+
// a caught swipe left the screens, when it left them further out.
|
|
1306
|
+
let raw = translation + tabSwipeBase
|
|
1230
1307
|
let offset = tabSwipeFrom > 0
|
|
1231
|
-
? min(0, max(-width,
|
|
1232
|
-
: max(0, min(width,
|
|
1308
|
+
? min(max(0, tabSwipeBase), max(-width, raw))
|
|
1309
|
+
: max(min(0, tabSwipeBase), min(width, raw))
|
|
1233
1310
|
target.view.frame.origin.x = tabSwipeFrom + offset
|
|
1234
1311
|
current.view.frame.origin.x = offset
|
|
1235
|
-
|
|
1312
|
+
tabSwipeTrailing?.view.frame.origin.x = offset + tabSwipeTrailingGap
|
|
1313
|
+
CraftChrome.shared.updateTabSwipe(progress: max(0, tabSwipeFrom > 0 ? -offset : offset) / width)
|
|
1236
1314
|
}
|
|
1237
1315
|
|
|
1238
1316
|
private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
|
|
@@ -1247,13 +1325,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1247
1325
|
CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
|
|
1248
1326
|
let remaining = end - target.view.frame.minX
|
|
1249
1327
|
let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
|
|
1328
|
+
let trailing = tabSwipeTrailing
|
|
1329
|
+
let gap = tabSwipeTrailingGap
|
|
1250
1330
|
let move = {
|
|
1251
1331
|
target.view.frame.origin.x = end
|
|
1252
1332
|
current.view.frame.origin.x = completed ? -direction * width : 0
|
|
1333
|
+
trailing?.view.frame.origin.x = current.view.frame.origin.x + gap
|
|
1253
1334
|
}
|
|
1254
|
-
let
|
|
1255
|
-
|
|
1256
|
-
|
|
1335
|
+
let finish = {
|
|
1336
|
+
if let trailing {
|
|
1337
|
+
trailing.view.isHidden = true
|
|
1338
|
+
trailing.view.frame = self.view.bounds
|
|
1339
|
+
}
|
|
1340
|
+
self.tabSwipeTrailing = nil
|
|
1341
|
+
self.tabSwipeBase = 0
|
|
1257
1342
|
if completed {
|
|
1258
1343
|
current.view.isHidden = true
|
|
1259
1344
|
current.view.frame = self.view.bounds
|
|
@@ -1270,15 +1355,81 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1270
1355
|
}
|
|
1271
1356
|
completion()
|
|
1272
1357
|
}
|
|
1358
|
+
settleNow = finish
|
|
1359
|
+
settleCompleting = completed
|
|
1360
|
+
let settled: (Bool) -> Void = { _ in
|
|
1361
|
+
guard self.tabSwipeGeneration == generation,
|
|
1362
|
+
self.activeTabSwipeTarget == targetId else { return }
|
|
1363
|
+
self.settleNow = nil
|
|
1364
|
+
finish()
|
|
1365
|
+
}
|
|
1273
1366
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
|
|
1274
1367
|
initialSpringVelocity: initialVelocity,
|
|
1275
1368
|
options: [.beginFromCurrentState, .allowUserInteraction], animations: move, completion: settled)
|
|
1276
1369
|
}
|
|
1277
1370
|
|
|
1371
|
+
/// A finger landed while the last swipe settled. Its end happens now,
|
|
1372
|
+
/// commit or cancel, chrome and events included, and the two screens go
|
|
1373
|
+
/// back exactly where they were on screen (no frame is drawn between),
|
|
1374
|
+
/// for this gesture to carry on from.
|
|
1375
|
+
private func catchSettlingSwipe() {
|
|
1376
|
+
guard let finish = settleNow, let targetId = activeTabSwipeTarget,
|
|
1377
|
+
let current = visibleStack, let target = stacks[targetId] else { return }
|
|
1378
|
+
let outgoing = currentTab
|
|
1379
|
+
let currentX = current.view.layer.presentation()?.frame.minX ?? current.view.frame.minX
|
|
1380
|
+
let targetX = target.view.layer.presentation()?.frame.minX ?? target.view.frame.minX
|
|
1381
|
+
tabSwipeGeneration &+= 1
|
|
1382
|
+
for nav in [current, target] + (tabSwipeTrailing.map { [$0] } ?? []) { nav.view.layer.removeAllAnimations() }
|
|
1383
|
+
settleNow = nil
|
|
1384
|
+
let completing = settleCompleting
|
|
1385
|
+
CraftFramePacing.shared.end("tab swipe")
|
|
1386
|
+
finish()
|
|
1387
|
+
let (stay, stayX, other, otherId, otherX) = completing
|
|
1388
|
+
? (target, targetX, current, outgoing, currentX)
|
|
1389
|
+
: (current, currentX, target, targetId, targetX)
|
|
1390
|
+
guard abs(stayX) > 0.5 else { return }
|
|
1391
|
+
stay.view.frame = view.bounds.offsetBy(dx: stayX, dy: 0)
|
|
1392
|
+
other.view.isHidden = false
|
|
1393
|
+
other.view.frame = view.bounds.offsetBy(dx: otherX, dy: 0)
|
|
1394
|
+
caughtSettle = (otherId, otherX, stayX)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/// A caught settle that no new swipe took on: its screens finish where
|
|
1398
|
+
/// they were going.
|
|
1399
|
+
private func settleCaughtScreens(animated: Bool = true) {
|
|
1400
|
+
guard let caught = caughtSettle else { return }
|
|
1401
|
+
caughtSettle = nil
|
|
1402
|
+
guard let current = visibleStack else { return }
|
|
1403
|
+
let other = stacks[caught.other]
|
|
1404
|
+
let gap = caught.otherX - caught.currentX
|
|
1405
|
+
let generation = tabSwipeGeneration
|
|
1406
|
+
let done = {
|
|
1407
|
+
guard self.tabSwipeGeneration == generation else { return }
|
|
1408
|
+
current.view.frame = self.view.bounds
|
|
1409
|
+
other?.view.frame = self.view.bounds
|
|
1410
|
+
if other !== current { other?.view.isHidden = true }
|
|
1411
|
+
}
|
|
1412
|
+
guard animated, CraftChrome.motion(.easeOut(duration: 0.18)) != nil else { done(); return }
|
|
1413
|
+
UIView.animate(withDuration: 0.18, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0,
|
|
1414
|
+
options: [.beginFromCurrentState, .allowUserInteraction], animations: {
|
|
1415
|
+
current.view.frame.origin.x = 0
|
|
1416
|
+
other?.view.frame.origin.x = gap
|
|
1417
|
+
}, completion: { _ in done() })
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1278
1420
|
/// A newer navigation action wins immediately, including a tap back on
|
|
1279
1421
|
/// the outgoing tab. Invalidate the old animation before removing it:
|
|
1280
1422
|
/// UIKit may deliver its completion while the replacement is starting.
|
|
1281
1423
|
private func interruptTabSwipe(restoringSelection: Bool) {
|
|
1424
|
+
if caughtSettle != nil { settleCaughtScreens(animated: false) }
|
|
1425
|
+
if let trailing = tabSwipeTrailing {
|
|
1426
|
+
trailing.view.layer.removeAllAnimations()
|
|
1427
|
+
trailing.view.frame = view.bounds
|
|
1428
|
+
trailing.view.isHidden = trailing !== visibleStack
|
|
1429
|
+
tabSwipeTrailing = nil
|
|
1430
|
+
}
|
|
1431
|
+
tabSwipeBase = 0
|
|
1432
|
+
settleNow = nil
|
|
1282
1433
|
guard let targetId = activeTabSwipeTarget else { return }
|
|
1283
1434
|
tabSwipeGeneration &+= 1
|
|
1284
1435
|
activeTabSwipeTarget = nil
|
|
@@ -1529,6 +1680,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1529
1680
|
}
|
|
1530
1681
|
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1531
1682
|
(self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1683
|
+
// A script on its own thread draws a moment later: its first
|
|
1684
|
+
// content is drawn ahead too, once it is there.
|
|
1685
|
+
if CraftNativeScriptThread.isBackground, let screen = nav.topViewController as? CraftNativeScreenController {
|
|
1686
|
+
screen.whenFirstRendered(within: 1) { [weak nav] in
|
|
1687
|
+
guard let nav, nav.view.isHidden else { return }
|
|
1688
|
+
nav.view.layoutIfNeeded()
|
|
1689
|
+
Self.drawContents(of: nav.view.layer)
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1532
1692
|
self.prepareAdjacentNativeTabs()
|
|
1533
1693
|
}
|
|
1534
1694
|
preparation = timer
|
|
@@ -1640,10 +1800,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1640
1800
|
return
|
|
1641
1801
|
}
|
|
1642
1802
|
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1643
|
-
|
|
1644
|
-
nav
|
|
1645
|
-
|
|
1646
|
-
|
|
1803
|
+
let show = { [weak nav] in
|
|
1804
|
+
guard let nav else { return }
|
|
1805
|
+
if replace, !nav.viewControllers.isEmpty {
|
|
1806
|
+
nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
|
|
1807
|
+
} else {
|
|
1808
|
+
nav.pushViewController(screen, animated: true)
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
guard let native = screen as? CraftNativeScreenController, CraftNativeScriptThread.isBackground else { show(); return }
|
|
1812
|
+
// Its script runs on its own thread: it slides in with its content,
|
|
1813
|
+
// and a second tap meanwhile does not push it twice.
|
|
1814
|
+
guard pendingPush != match.path else { return }
|
|
1815
|
+
pendingPush = match.path
|
|
1816
|
+
native.whenFirstRendered(within: 0.3) { [weak self, weak nav] in
|
|
1817
|
+
guard let self, self.pendingPush == match.path else { return }
|
|
1818
|
+
self.pendingPush = nil
|
|
1819
|
+
// Not into a tab the person has left meanwhile.
|
|
1820
|
+
guard let nav, nav === self.visibleStack else { return }
|
|
1821
|
+
show()
|
|
1647
1822
|
}
|
|
1648
1823
|
}
|
|
1649
1824
|
|
|
@@ -1906,7 +2081,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1906
2081
|
|
|
1907
2082
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1908
2083
|
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1909
|
-
|
|
2084
|
+
// A swipe may begin while the last one settles: it catches it.
|
|
2085
|
+
guard activeTabSwipeTarget == nil || tabSwipeSettling,
|
|
1910
2086
|
hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
|
|
1911
2087
|
guard CraftChrome.shared.tabSwipeEnabled else { return false }
|
|
1912
2088
|
// Only between the tabs' own first screens, as the bar shows them:
|
|
@@ -205,11 +205,21 @@ enum CraftNativeActions {
|
|
|
205
205
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
|
|
206
206
|
return
|
|
207
207
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
let write = { () -> Result<Any, CraftNativeActionError> in
|
|
209
|
+
let result = secureStorage(method: method, args: args)
|
|
210
|
+
// A key the web page shares (`shareStorage`): the page gets it too,
|
|
211
|
+
// so a sign-in on a native screen signs the page in.
|
|
212
|
+
if case .success = result, ["set", "remove", "delete"].contains(method), let key = args.first as? String {
|
|
213
|
+
CraftHybrid.shared.nativeWroteShared(nativeKey: key, value: method == "set" ? args.count > 1 ? args[1] as? String : nil : nil)
|
|
214
|
+
}
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
// A write that moves the sign-in token is the account's change:
|
|
218
|
+
// every screen hears it (CraftSession). Reads skip the check.
|
|
219
|
+
if ["set", "remove", "delete", "clear"].contains(method) {
|
|
220
|
+
sync = CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write)
|
|
221
|
+
} else {
|
|
222
|
+
sync = write()
|
|
213
223
|
}
|
|
214
224
|
case ("Biometrics", "isAvailable"):
|
|
215
225
|
sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
|
|
@@ -1423,6 +1433,54 @@ extension CraftNativeActions {
|
|
|
1423
1433
|
}
|
|
1424
1434
|
}
|
|
1425
1435
|
|
|
1436
|
+
/// The signed-in account: the token kept in secure storage under
|
|
1437
|
+
/// `sessionTokenKey` (default `auth.token`). Whoever moves it, a native
|
|
1438
|
+
/// screen's `craft.secureStorage`, the page's own sign-in through a
|
|
1439
|
+
/// `shareStorage` key, or a sign-out's clear, posts `changed` once with
|
|
1440
|
+
/// `key`, `token` and `previous` (NSNull when signed out), after the Keychain
|
|
1441
|
+
/// holds the new value. Each native screen hands it to its script as
|
|
1442
|
+
/// `SESSION_CHANGED` and to `craft.session.onChange` (CraftNativeScreen).
|
|
1443
|
+
enum CraftSession {
|
|
1444
|
+
static let changed = Notification.Name("craftSessionChanged")
|
|
1445
|
+
static let defaultKey = "auth.token"
|
|
1446
|
+
/// One write and its before and after at a time, so two quick writes
|
|
1447
|
+
/// from different queues are two changes, never one reported twice.
|
|
1448
|
+
private static let lock = NSLock()
|
|
1449
|
+
|
|
1450
|
+
static func tokenKey(_ config: CraftConfig) -> String {
|
|
1451
|
+
guard let key = config.sessionTokenKey, !key.isEmpty else { return defaultKey }
|
|
1452
|
+
return key
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/// The token now, as `craft.secureStorage.getSync` reads it; nil when
|
|
1456
|
+
/// signed out (an empty token is no account either).
|
|
1457
|
+
static func token(_ config: CraftConfig) -> String? {
|
|
1458
|
+
guard config.enableSecureStorage,
|
|
1459
|
+
let value = CraftNativeActions.webSecureValue(forKey: tokenKey(config)), !value.isEmpty else { return nil }
|
|
1460
|
+
return value
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/// Run `write`, and post `changed` when it moved the token. `key` is the
|
|
1464
|
+
/// key written; nil for a clear, which can remove every key.
|
|
1465
|
+
static func observe<T>(key: String?, config: CraftConfig, _ write: () -> T) -> T {
|
|
1466
|
+
let name = tokenKey(config)
|
|
1467
|
+
guard key == nil || key == name else { return write() }
|
|
1468
|
+
lock.lock()
|
|
1469
|
+
let before = token(config)
|
|
1470
|
+
let result = write()
|
|
1471
|
+
let after = token(config)
|
|
1472
|
+
lock.unlock()
|
|
1473
|
+
if before != after { post(key: name, token: after, previous: before) }
|
|
1474
|
+
return result
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
static func post(key: String, token: String?, previous: String?) {
|
|
1478
|
+
let info: [String: Any] = ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()]
|
|
1479
|
+
let send = { NotificationCenter.default.post(name: changed, object: nil, userInfo: info) }
|
|
1480
|
+
if Thread.isMainThread { send() } else { DispatchQueue.main.async(execute: send) }
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1426
1484
|
/// Last-known data for a screen's first frame: one JSON file per name in
|
|
1427
1485
|
/// `Application Support/craft-snapshots/<name>.json`. A web page writes them
|
|
1428
1486
|
/// (`snapshots.set` in the mobile API) and a native screen reads them
|
|
@@ -52,11 +52,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
52
52
|
var onRecycle: ((String) -> Void)?
|
|
53
53
|
override func prepareForReuse() {
|
|
54
54
|
super.prepareForReuse()
|
|
55
|
-
|
|
55
|
+
recycle()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The row this cell showed is gone; its views stay in the cell, and
|
|
59
|
+
/// the next row it shows reuses them when it has the same shape.
|
|
60
|
+
func recycle() {
|
|
61
|
+
if let identity = representedIdentity { onRecycle?(identity) }
|
|
62
|
+
representedIdentity = nil
|
|
63
|
+
onRecycle = nil
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
func host(_ view: UIView, identity: String) {
|
|
59
|
-
if representedIdentity != identity {
|
|
67
|
+
if let shown = representedIdentity, shown != identity { recycle() }
|
|
60
68
|
representedIdentity = identity
|
|
61
69
|
guard hostedView !== view else { return }
|
|
62
70
|
hostedView?.removeFromSuperview()
|
|
@@ -72,11 +80,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
func releaseHostedView() {
|
|
75
|
-
|
|
83
|
+
recycle()
|
|
76
84
|
hostedView?.removeFromSuperview()
|
|
77
85
|
hostedView = nil
|
|
78
|
-
representedIdentity = nil
|
|
79
|
-
onRecycle = nil
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
override func preferredLayoutAttributesFitting(
|
|
@@ -354,7 +360,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
354
360
|
|
|
355
361
|
@objc private func refreshTriggered() {
|
|
356
362
|
refreshHandler?()
|
|
357
|
-
|
|
363
|
+
// Once the script has answered the pull, on its own thread or not.
|
|
364
|
+
CraftNativeScriptThread.afterScript { [weak self] in
|
|
358
365
|
guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
|
|
359
366
|
self.nativeRefreshControl.endRefreshing()
|
|
360
367
|
}
|