@craft-native/ios 0.0.245 → 0.0.247
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 +24 -1
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +4 -0
- package/templates/CraftHybrid.swift +151 -14
- package/templates/CraftNativeFlatList.swift +13 -6
- package/templates/CraftNativeScreen.swift +353 -68
- 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
|
|
@@ -319,13 +328,27 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
|
|
|
319
328
|
*/
|
|
320
329
|
export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
|
|
321
330
|
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
322
|
-
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[];
|
|
323
332
|
/**
|
|
324
333
|
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
325
334
|
* well as the one-file bundle, and the app runs each screen from its own
|
|
326
335
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
327
336
|
*/
|
|
328
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;
|
|
329
352
|
export interface CraftBackgroundRefresh {
|
|
330
353
|
enabled: boolean;
|
|
331
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
|
@@ -1626,6 +1626,10 @@ struct CraftConfig: Codable {
|
|
|
1626
1626
|
/// The bundle route of the app script: a screen-less script that runs for
|
|
1627
1627
|
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
1628
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
|
|
1629
1633
|
/// Crash and error reporting to bughq (CraftCrashReporter.swift). Off
|
|
1630
1634
|
/// unless the config names a project key.
|
|
1631
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
|
|
@@ -1104,6 +1105,21 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1104
1105
|
/// this separate from the live frame: deriving it after the drag begins
|
|
1105
1106
|
/// makes the target jump when the translation crosses the midpoint.
|
|
1106
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)?
|
|
1107
1123
|
|
|
1108
1124
|
init(config: CraftConfig) {
|
|
1109
1125
|
self.config = config
|
|
@@ -1160,6 +1176,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1160
1176
|
}
|
|
1161
1177
|
|
|
1162
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() }
|
|
1163
1183
|
guard !tabSwipeSettling else { return }
|
|
1164
1184
|
let tabs = hybrid.tabIds
|
|
1165
1185
|
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
@@ -1177,6 +1197,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1177
1197
|
updateInteractiveTabSwipe(translation: translation)
|
|
1178
1198
|
case .ended, .cancelled, .failed:
|
|
1179
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 }
|
|
1180
1203
|
guard recognizer.state == .ended else { return }
|
|
1181
1204
|
let delta = abs(translation) >= 32 ? translation : velocity
|
|
1182
1205
|
guard abs(delta) >= 32 else { return }
|
|
@@ -1191,7 +1214,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1191
1214
|
// jumping straight to their destination.
|
|
1192
1215
|
tabSwipeSettling = true
|
|
1193
1216
|
let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
|
|
1194
|
-
let progress = translation * direction
|
|
1217
|
+
let progress = (translation + tabSwipeBase) * direction
|
|
1195
1218
|
let speed = velocity * direction
|
|
1196
1219
|
let complete = recognizer.state == .ended && speed > -700
|
|
1197
1220
|
&& (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
|
|
@@ -1252,7 +1275,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1252
1275
|
target.view.isHidden = false
|
|
1253
1276
|
let width = view.bounds.width
|
|
1254
1277
|
tabSwipeFrom = -direction * width
|
|
1255
|
-
|
|
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)
|
|
1256
1292
|
view.bringSubviewToFront(target.view)
|
|
1257
1293
|
CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
|
|
1258
1294
|
lastNavigation = CACurrentMediaTime()
|
|
@@ -1265,13 +1301,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1265
1301
|
let target = stacks[targetId] else { return }
|
|
1266
1302
|
let width = max(1, view.bounds.width)
|
|
1267
1303
|
// Reversing past the start stays at zero progress, rather than
|
|
1268
|
-
// 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
|
|
1269
1307
|
let offset = tabSwipeFrom > 0
|
|
1270
|
-
? min(0, max(-width,
|
|
1271
|
-
: max(0, min(width,
|
|
1308
|
+
? min(max(0, tabSwipeBase), max(-width, raw))
|
|
1309
|
+
: max(min(0, tabSwipeBase), min(width, raw))
|
|
1272
1310
|
target.view.frame.origin.x = tabSwipeFrom + offset
|
|
1273
1311
|
current.view.frame.origin.x = offset
|
|
1274
|
-
|
|
1312
|
+
tabSwipeTrailing?.view.frame.origin.x = offset + tabSwipeTrailingGap
|
|
1313
|
+
CraftChrome.shared.updateTabSwipe(progress: max(0, tabSwipeFrom > 0 ? -offset : offset) / width)
|
|
1275
1314
|
}
|
|
1276
1315
|
|
|
1277
1316
|
private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
|
|
@@ -1286,13 +1325,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1286
1325
|
CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
|
|
1287
1326
|
let remaining = end - target.view.frame.minX
|
|
1288
1327
|
let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
|
|
1328
|
+
let trailing = tabSwipeTrailing
|
|
1329
|
+
let gap = tabSwipeTrailingGap
|
|
1289
1330
|
let move = {
|
|
1290
1331
|
target.view.frame.origin.x = end
|
|
1291
1332
|
current.view.frame.origin.x = completed ? -direction * width : 0
|
|
1333
|
+
trailing?.view.frame.origin.x = current.view.frame.origin.x + gap
|
|
1292
1334
|
}
|
|
1293
|
-
let
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
|
1296
1342
|
if completed {
|
|
1297
1343
|
current.view.isHidden = true
|
|
1298
1344
|
current.view.frame = self.view.bounds
|
|
@@ -1309,15 +1355,81 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1309
1355
|
}
|
|
1310
1356
|
completion()
|
|
1311
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
|
+
}
|
|
1312
1366
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
|
|
1313
1367
|
initialSpringVelocity: initialVelocity,
|
|
1314
1368
|
options: [.beginFromCurrentState, .allowUserInteraction], animations: move, completion: settled)
|
|
1315
1369
|
}
|
|
1316
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
|
+
|
|
1317
1420
|
/// A newer navigation action wins immediately, including a tap back on
|
|
1318
1421
|
/// the outgoing tab. Invalidate the old animation before removing it:
|
|
1319
1422
|
/// UIKit may deliver its completion while the replacement is starting.
|
|
1320
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
|
|
1321
1433
|
guard let targetId = activeTabSwipeTarget else { return }
|
|
1322
1434
|
tabSwipeGeneration &+= 1
|
|
1323
1435
|
activeTabSwipeTarget = nil
|
|
@@ -1568,6 +1680,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1568
1680
|
}
|
|
1569
1681
|
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1570
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
|
+
}
|
|
1571
1692
|
self.prepareAdjacentNativeTabs()
|
|
1572
1693
|
}
|
|
1573
1694
|
preparation = timer
|
|
@@ -1679,10 +1800,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1679
1800
|
return
|
|
1680
1801
|
}
|
|
1681
1802
|
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1682
|
-
|
|
1683
|
-
nav
|
|
1684
|
-
|
|
1685
|
-
|
|
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()
|
|
1686
1822
|
}
|
|
1687
1823
|
}
|
|
1688
1824
|
|
|
@@ -1945,7 +2081,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1945
2081
|
|
|
1946
2082
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1947
2083
|
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1948
|
-
|
|
2084
|
+
// A swipe may begin while the last one settles: it catches it.
|
|
2085
|
+
guard activeTabSwipeTarget == nil || tabSwipeSettling,
|
|
1949
2086
|
hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
|
|
1950
2087
|
guard CraftChrome.shared.tabSwipeEnabled else { return false }
|
|
1951
2088
|
// Only between the tabs' own first screens, as the bar shows 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
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import CoreFoundation
|
|
3
|
+
import ImageIO
|
|
3
4
|
import AVFoundation
|
|
4
5
|
import AVKit
|
|
5
6
|
import CryptoKit
|
|
@@ -3847,8 +3848,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
3847
3848
|
|
|
3848
3849
|
@objc private func refreshPulled() {
|
|
3849
3850
|
refreshHandler?()
|
|
3850
|
-
//
|
|
3851
|
-
|
|
3851
|
+
// Once the script has answered the pull (and its render has landed):
|
|
3852
|
+
// a script on its own thread answers a turn or two later.
|
|
3853
|
+
CraftNativeScriptThread.afterScript { [weak self] in
|
|
3852
3854
|
guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
|
|
3853
3855
|
self.pullToRefresh.endRefreshing()
|
|
3854
3856
|
}
|
|
@@ -4375,6 +4377,45 @@ private final class CraftNativeTextView: UITextView {
|
|
|
4375
4377
|
}
|
|
4376
4378
|
}
|
|
4377
4379
|
|
|
4380
|
+
/// Where native screens run their JavaScript.
|
|
4381
|
+
///
|
|
4382
|
+
/// On the main thread by default. With `nativeScriptThread: "background"` in
|
|
4383
|
+
/// craft.config.json, every screen's script runs on one serial thread of its
|
|
4384
|
+
/// own (the shared virtual machine's only thread): evaluating its bundle,
|
|
4385
|
+
/// handling events and timers, and working out each render all leave the main
|
|
4386
|
+
/// thread, which only applies the results to UIKit. Messages between the two
|
|
4387
|
+
/// were already asynchronous JSON, so the order of everything is kept.
|
|
4388
|
+
enum CraftNativeScriptThread {
|
|
4389
|
+
static var isBackground = false
|
|
4390
|
+
static let queue = DispatchQueue(label: "craft.native.script", qos: .userInteractive)
|
|
4391
|
+
|
|
4392
|
+
/// `work` where the screens' JavaScript runs: now on the main thread, or
|
|
4393
|
+
/// queued in order on the script thread.
|
|
4394
|
+
static func run(_ work: @escaping () -> Void) {
|
|
4395
|
+
if isBackground { queue.async(execute: work) } else { work() }
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4398
|
+
/// From the script thread, `work` on the main thread, not waited for.
|
|
4399
|
+
static func toMain(_ work: @escaping () -> Void) {
|
|
4400
|
+
if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
/// From the script thread, `work` on the main thread, waited for: for the
|
|
4404
|
+
/// few host functions a script calls synchronously that read main-thread
|
|
4405
|
+
/// state (the Web Audio graph). The main thread never waits for the
|
|
4406
|
+
/// script thread, so this cannot deadlock.
|
|
4407
|
+
static func onMain<T>(_ work: () -> T) -> T {
|
|
4408
|
+
Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
/// `work` on the main thread once a script has handled what was sent to
|
|
4412
|
+
/// it before now, and what it sent back has been applied.
|
|
4413
|
+
static func afterScript(_ work: @escaping () -> Void) {
|
|
4414
|
+
if isBackground { queue.async { DispatchQueue.main.async(execute: work) } }
|
|
4415
|
+
else { DispatchQueue.main.async(execute: work) }
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
|
|
4378
4419
|
/// The native screens' JavaScript. A screen is a JavaScriptCore context of
|
|
4379
4420
|
/// its own and evaluates the whole file it is given, so each route has a file
|
|
4380
4421
|
/// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
|
|
@@ -4382,7 +4423,8 @@ private final class CraftNativeTextView: UITextView {
|
|
|
4382
4423
|
/// screen's. The one-file `dist/native-screen.js` remains the fallback.
|
|
4383
4424
|
enum CraftNativeBundle {
|
|
4384
4425
|
/// Every screen's context lives in this one virtual machine. They all run
|
|
4385
|
-
/// on
|
|
4426
|
+
/// on one thread (main, or the script thread: CraftNativeScriptThread),
|
|
4427
|
+
/// the machine's only one, and sharing it shares its
|
|
4386
4428
|
/// code cache: a screen opened again (a second workout, a tab rebuilt)
|
|
4387
4429
|
/// is not parsed again. Each screen keeps a global object of its own.
|
|
4388
4430
|
static let virtualMachine = JSVirtualMachine()!
|
|
@@ -4409,10 +4451,39 @@ enum CraftNativeBundle {
|
|
|
4409
4451
|
url(for: nil) != nil || Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") != nil
|
|
4410
4452
|
}
|
|
4411
4453
|
|
|
4454
|
+
/// The runtime a route file runs on (stx's `__runtime.js`): evaluated
|
|
4455
|
+
/// first in each screen's context, as a source of its own, so the shared
|
|
4456
|
+
/// virtual machine parses it once for every screen. Nil for a one-file
|
|
4457
|
+
/// bundle, which carries its runtime itself, and for route files from an
|
|
4458
|
+
/// stx that wrote none.
|
|
4459
|
+
static func runtime(for route: URL) -> (source: String, url: URL)? {
|
|
4460
|
+
guard route.deletingLastPathComponent().lastPathComponent == "native-screens",
|
|
4461
|
+
let url = Bundle.main.url(forResource: "__runtime", withExtension: "js", subdirectory: "dist/native-screens") else { return nil }
|
|
4462
|
+
return read(url)
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
/// A language's messages for an area, as JSON text: stx's
|
|
4466
|
+
/// `__messages/<language>/<area>.json` beside the route files. Nil for a
|
|
4467
|
+
/// language or area the build has none for, and for a name that is not
|
|
4468
|
+
/// one (no path outside the messages).
|
|
4469
|
+
static func messages(language: String, area: String) -> String? {
|
|
4470
|
+
guard language.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
|
|
4471
|
+
area.range(of: #"^[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*$"#, options: .regularExpression) != nil,
|
|
4472
|
+
let routes = Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") else { return nil }
|
|
4473
|
+
let url = routes.appendingPathComponent("__messages", isDirectory: true)
|
|
4474
|
+
.appendingPathComponent(language, isDirectory: true)
|
|
4475
|
+
.appendingPathComponent("\(area).json")
|
|
4476
|
+
return read(url)?.source
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4412
4479
|
/// The route's source, read from disk once and kept while memory allows:
|
|
4413
4480
|
/// a push read and decoded the file on the main thread every time.
|
|
4414
4481
|
static func source(for route: String?) -> (source: String, url: URL)? {
|
|
4415
4482
|
guard let url = url(for: route) else { return nil }
|
|
4483
|
+
return read(url)
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
private static func read(_ url: URL) -> (source: String, url: URL)? {
|
|
4416
4487
|
let key = url.path as NSString
|
|
4417
4488
|
if let cached = sources.object(forKey: key) { return (cached as String, url) }
|
|
4418
4489
|
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
|
|
@@ -4423,7 +4494,8 @@ enum CraftNativeBundle {
|
|
|
4423
4494
|
|
|
4424
4495
|
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
|
|
4425
4496
|
private final class RenderedNode {
|
|
4426
|
-
|
|
4497
|
+
/// A recycled list row's root takes the identity of the row it shows next.
|
|
4498
|
+
var identity: String
|
|
4427
4499
|
let type: String
|
|
4428
4500
|
let view: UIView
|
|
4429
4501
|
var protocolId: String?
|
|
@@ -4509,6 +4581,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4509
4581
|
}
|
|
4510
4582
|
}
|
|
4511
4583
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
4584
|
+
/// Rows their cells left for reuse, by list and by the row's root view.
|
|
4585
|
+
private var flatListRecycled: [ObjectIdentifier: [ObjectIdentifier: RenderedNode]] = [:]
|
|
4512
4586
|
private var flatListOwners: [String: String] = [:]
|
|
4513
4587
|
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
4514
4588
|
private var svgOwners: [String: String] = [:]
|
|
@@ -4528,7 +4602,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4528
4602
|
private let capabilityScope = UUID().uuidString
|
|
4529
4603
|
private var pendingCapabilityRequests = Set<String>()
|
|
4530
4604
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
4531
|
-
|
|
4605
|
+
/// The script's timers. Touched only where the script runs.
|
|
4606
|
+
private final class Timers {
|
|
4607
|
+
var pending: [Int: DispatchWorkItem] = [:]
|
|
4608
|
+
}
|
|
4609
|
+
private let timers = Timers()
|
|
4532
4610
|
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
4533
4611
|
private var deepLinkListener: UUID?
|
|
4534
4612
|
private var keyboardSafeAreaInset: CGFloat = 0
|
|
@@ -4562,7 +4640,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4562
4640
|
disposeRuntime()
|
|
4563
4641
|
imageTasks.values.forEach { $0.cancel() }
|
|
4564
4642
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
4565
|
-
|
|
4643
|
+
let timers = self.timers
|
|
4644
|
+
CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
|
|
4566
4645
|
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
4567
4646
|
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
4568
4647
|
if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
|
|
@@ -4591,8 +4670,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4591
4670
|
rootTrailingToSafeArea!
|
|
4592
4671
|
])
|
|
4593
4672
|
let pacing = CraftFramePacing.shared
|
|
4594
|
-
|
|
4595
|
-
|
|
4673
|
+
let inputs = scriptInputs()
|
|
4674
|
+
let name = routeName ?? "screen"
|
|
4675
|
+
CraftNativeScriptThread.run { [self] in
|
|
4676
|
+
pacing.measure("mount \(name) setup") { setupJavaScript(inputs) }
|
|
4677
|
+
pacing.measure("mount \(name) bundle") { loadBundle() }
|
|
4678
|
+
}
|
|
4596
4679
|
observeNativeEvents()
|
|
4597
4680
|
if isAppScript {
|
|
4598
4681
|
// Its bundle has run, so its listeners are in place: the app's
|
|
@@ -4727,14 +4810,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4727
4810
|
func receiveRouteParams(_ params: [String: Any]) {
|
|
4728
4811
|
routeParams = params
|
|
4729
4812
|
guard isViewLoaded, !runtimeDisposed else { return }
|
|
4730
|
-
|
|
4813
|
+
let context = jsContext
|
|
4814
|
+
CraftNativeScriptThread.run { context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString) }
|
|
4731
4815
|
send(type: "ROUTE_PARAMS", payload: ["params": params])
|
|
4732
4816
|
}
|
|
4733
4817
|
|
|
4734
4818
|
private func disposeRuntime() {
|
|
4735
4819
|
guard !runtimeDisposed else { return }
|
|
4736
4820
|
runtimeDisposed = true
|
|
4737
|
-
jsContext
|
|
4821
|
+
let context = jsContext
|
|
4822
|
+
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
|
|
4738
4823
|
audio.close()
|
|
4739
4824
|
sounds.close()
|
|
4740
4825
|
streams.close()
|
|
@@ -4796,7 +4881,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4796
4881
|
return value.toString()
|
|
4797
4882
|
}
|
|
4798
4883
|
|
|
4799
|
-
|
|
4884
|
+
/// What a screen's script starts with that only the main thread may read:
|
|
4885
|
+
/// the app's state, the appearance, what the device can do.
|
|
4886
|
+
private struct ScriptInputs {
|
|
4887
|
+
let capabilityJSON: String
|
|
4888
|
+
let appState: String
|
|
4889
|
+
let colorScheme: String
|
|
4890
|
+
let appearanceJSON: String
|
|
4891
|
+
let sessionTokenKey: String
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4894
|
+
private func scriptInputs() -> ScriptInputs {
|
|
4895
|
+
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
4896
|
+
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
4897
|
+
return ScriptInputs(
|
|
4898
|
+
capabilityJSON: capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]",
|
|
4899
|
+
appState: CraftNativeActions.currentAppState(),
|
|
4900
|
+
colorScheme: colorScheme,
|
|
4901
|
+
appearanceJSON: appearanceJSON(),
|
|
4902
|
+
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config))
|
|
4903
|
+
)
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
private func setupJavaScript(_ inputs: ScriptInputs) {
|
|
4800
4907
|
if let routeName = routeName {
|
|
4801
4908
|
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
4802
4909
|
}
|
|
@@ -4811,43 +4918,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4811
4918
|
let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
|
|
4812
4919
|
let screen = self?.screenName(in: context) ?? "screen"
|
|
4813
4920
|
CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4921
|
+
let name = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name"))
|
|
4922
|
+
CraftNativeScriptThread.toMain { [weak self] in
|
|
4923
|
+
// To the app's crash reporter too (bughq, when configured):
|
|
4924
|
+
// an uncaught error in a shipped screen is otherwise only in
|
|
4925
|
+
// a log nobody reads on someone else's phone.
|
|
4926
|
+
CraftCrashReporter.shared.captureJavaScript(
|
|
4927
|
+
name: name,
|
|
4928
|
+
message: message,
|
|
4929
|
+
stack: stack,
|
|
4930
|
+
route: screen,
|
|
4931
|
+
isAppScript: self?.isAppScript ?? false
|
|
4932
|
+
)
|
|
4933
|
+
self?.hybridFailed(message)
|
|
4934
|
+
}
|
|
4825
4935
|
}
|
|
4826
4936
|
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
4827
4937
|
self?.receive(json)
|
|
4828
4938
|
}
|
|
4829
4939
|
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
4940
|
+
// A language's messages for an area, as JSON text, the first time the
|
|
4941
|
+
// screen looks inside them (stx's `messages`): a screen parses only
|
|
4942
|
+
// the language it is shown in, and only the areas it uses. Called
|
|
4943
|
+
// from the screen's script, on its thread, and answered there.
|
|
4944
|
+
let readMessages: @convention(block) (String, String) -> Any = { language, area in
|
|
4945
|
+
CraftNativeBundle.messages(language: language, area: area) ?? NSNull()
|
|
4946
|
+
}
|
|
4947
|
+
jsContext.setObject(readMessages, forKeyedSubscript: "__stxNativeReadMessages" as NSString)
|
|
4830
4948
|
// JavaScriptCore has no timers of its own. The callbacks stay in
|
|
4831
4949
|
// JavaScript; native only owns the main-queue deadline, so route
|
|
4832
4950
|
// teardown cancels every pending timer with the controller.
|
|
4833
|
-
let
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4951
|
+
let timers = self.timers
|
|
4952
|
+
let context = jsContext
|
|
4953
|
+
let scheduleTimer: @convention(block) (Int, Double) -> Void = { id, delay in
|
|
4954
|
+
let work = DispatchWorkItem { [weak context] in
|
|
4955
|
+
guard timers.pending.removeValue(forKey: id) != nil else { return }
|
|
4956
|
+
context?.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
|
|
4957
|
+
}
|
|
4958
|
+
timers.pending[id] = work
|
|
4840
4959
|
let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
|
|
4841
|
-
|
|
4960
|
+
// Fired where the script runs.
|
|
4961
|
+
let queue = CraftNativeScriptThread.isBackground ? CraftNativeScriptThread.queue : .main
|
|
4962
|
+
queue.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
|
|
4842
4963
|
}
|
|
4843
|
-
let cancelTimer: @convention(block) (Int) -> Void = {
|
|
4844
|
-
|
|
4964
|
+
let cancelTimer: @convention(block) (Int) -> Void = { id in
|
|
4965
|
+
timers.pending.removeValue(forKey: id)?.cancel()
|
|
4845
4966
|
}
|
|
4846
4967
|
jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
|
|
4847
4968
|
jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
|
|
4848
|
-
let
|
|
4849
|
-
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
4850
|
-
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
4969
|
+
let capabilityJSON = inputs.capabilityJSON
|
|
4851
4970
|
// The app's site and build, as JSON strings: the site an all-native
|
|
4852
4971
|
// app's paths and links belong to (`devServerURL`), and the version a
|
|
4853
4972
|
// request or a crash report names.
|
|
@@ -4855,7 +4974,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4855
4974
|
let appURL = Self.jsonString(config.devServerURL ?? "")
|
|
4856
4975
|
let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
|
|
4857
4976
|
let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
|
|
4858
|
-
let sessionTokenKey =
|
|
4977
|
+
let sessionTokenKey = inputs.sessionTokenKey
|
|
4859
4978
|
jsContext.evaluateScript("""
|
|
4860
4979
|
// JavaScriptCore has no DOM window, but browser-safe SDK bridges
|
|
4861
4980
|
// address the host through `window.craft`. Keep native screens
|
|
@@ -4868,9 +4987,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4868
4987
|
capabilityProtocolVersion: \(craftNativeCapabilityProtocolVersion),
|
|
4869
4988
|
capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
|
|
4870
4989
|
capabilities: \(capabilityJSON),
|
|
4871
|
-
initialAppState: "\(
|
|
4872
|
-
colorScheme: "\(colorScheme)",
|
|
4873
|
-
appearance: \(appearanceJSON
|
|
4990
|
+
initialAppState: "\(inputs.appState)",
|
|
4991
|
+
colorScheme: "\(inputs.colorScheme)",
|
|
4992
|
+
appearance: \(inputs.appearanceJSON),
|
|
4874
4993
|
appURL: \(appURL),
|
|
4875
4994
|
appVersion: \(appVersion),
|
|
4876
4995
|
appBuild: \(appBuild),
|
|
@@ -4968,6 +5087,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4968
5087
|
/// else the one a route bundle selected for itself.
|
|
4969
5088
|
private func screenName(in context: JSContext? = nil) -> String {
|
|
4970
5089
|
if let routeName, !routeName.isEmpty { return routeName }
|
|
5090
|
+
// The script's own context is read only where the script runs.
|
|
5091
|
+
if context == nil, CraftNativeScriptThread.isBackground { return navigationItem.title ?? "screen" }
|
|
4971
5092
|
if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
|
|
4972
5093
|
return selected.toString()
|
|
4973
5094
|
}
|
|
@@ -5017,13 +5138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5017
5138
|
}
|
|
5018
5139
|
let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
|
|
5019
5140
|
CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
|
|
5020
|
-
done.call(withArguments: [ok])
|
|
5141
|
+
CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
|
|
5021
5142
|
}
|
|
5022
5143
|
}
|
|
5023
5144
|
let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
|
|
5024
5145
|
let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
|
|
5025
5146
|
CraftSnapshots.clear(keeping: Set(names)) { ok in
|
|
5026
|
-
done.call(withArguments: [ok])
|
|
5147
|
+
CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
|
|
5027
5148
|
}
|
|
5028
5149
|
}
|
|
5029
5150
|
let config = self.config
|
|
@@ -5033,7 +5154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5033
5154
|
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
5034
5155
|
guard let data = json.data(using: .utf8),
|
|
5035
5156
|
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
5036
|
-
self?.applyNavigationOptions(options)
|
|
5157
|
+
CraftNativeScriptThread.toMain { self?.applyNavigationOptions(options) }
|
|
5037
5158
|
}
|
|
5038
5159
|
jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
5039
5160
|
jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
|
|
@@ -5310,19 +5431,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5310
5431
|
""")
|
|
5311
5432
|
}
|
|
5312
5433
|
|
|
5434
|
+
/// Runs where the script runs (CraftNativeScriptThread).
|
|
5313
5435
|
private func loadBundle() {
|
|
5314
5436
|
guard let bundle = CraftNativeBundle.source(for: routeName) else {
|
|
5315
|
-
|
|
5437
|
+
CraftNativeScriptThread.toMain { [weak self] in
|
|
5438
|
+
self?.showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
5439
|
+
}
|
|
5316
5440
|
return
|
|
5317
5441
|
}
|
|
5442
|
+
if let runtime = CraftNativeBundle.runtime(for: bundle.url) {
|
|
5443
|
+
jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
|
|
5444
|
+
}
|
|
5318
5445
|
jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
|
|
5319
5446
|
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
5320
5447
|
// its title is the app's, not the word "undefined". A title the
|
|
5321
5448
|
// script set with `navigation.setOptions` wins over both.
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5449
|
+
guard routeName == nil,
|
|
5450
|
+
let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
|
|
5451
|
+
let name = selected.toString(), !name.isEmpty else { return }
|
|
5452
|
+
CraftNativeScriptThread.toMain { [weak self] in
|
|
5453
|
+
guard let self, !self.titleFromOptions else { return }
|
|
5454
|
+
self.navigationItem.title = name
|
|
5326
5455
|
}
|
|
5327
5456
|
}
|
|
5328
5457
|
|
|
@@ -5781,7 +5910,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5781
5910
|
let token = info["token"] as? String
|
|
5782
5911
|
let previous = info["previous"] as? String
|
|
5783
5912
|
self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
|
|
5784
|
-
self.jsContext
|
|
5913
|
+
let context = self.jsContext
|
|
5914
|
+
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
|
|
5785
5915
|
})
|
|
5786
5916
|
}
|
|
5787
5917
|
if config.enablePushNotifications {
|
|
@@ -5950,10 +6080,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5950
6080
|
hybridEvents?.nativeScreen(self, didFail: message)
|
|
5951
6081
|
}
|
|
5952
6082
|
|
|
6083
|
+
/// A message from the screen's script, where the script runs: parsed
|
|
6084
|
+
/// there, then applied on the main thread in the order it was sent.
|
|
5953
6085
|
private func receive(_ raw: String) {
|
|
5954
6086
|
guard let data = raw.data(using: .utf8),
|
|
5955
6087
|
let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
5956
|
-
|
|
6088
|
+
message["type"] is String else { return }
|
|
6089
|
+
CraftNativeScriptThread.toMain { [weak self] in self?.handle(message) }
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6092
|
+
private func handle(_ message: [String: Any]) {
|
|
6093
|
+
guard let type = message["type"] as? String else { return }
|
|
5957
6094
|
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
5958
6095
|
|
|
5959
6096
|
switch type {
|
|
@@ -6000,11 +6137,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6000
6137
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
6001
6138
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
6002
6139
|
next.hybridEvents = hybridEvents
|
|
6003
|
-
|
|
6004
|
-
navigation
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6140
|
+
next.whenFirstRendered(within: 0.3) { [weak self, weak navigation] in
|
|
6141
|
+
guard let self, let navigation, navigation.topViewController === self else { return }
|
|
6142
|
+
if type == "NAVIGATE" {
|
|
6143
|
+
navigation.pushViewController(next, animated: true)
|
|
6144
|
+
} else {
|
|
6145
|
+
self.disposeRuntime()
|
|
6146
|
+
navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
|
|
6147
|
+
}
|
|
6008
6148
|
}
|
|
6009
6149
|
case "NAVIGATION_SET_OPTIONS":
|
|
6010
6150
|
applyNavigationOptions(payload)
|
|
@@ -6155,7 +6295,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6155
6295
|
/// simulator-hosted XCTest of the host APIs.
|
|
6156
6296
|
@discardableResult
|
|
6157
6297
|
func evaluateScript(_ script: String) -> JSValue? {
|
|
6158
|
-
|
|
6298
|
+
// From the main thread (tests); waits for the script thread's turn.
|
|
6299
|
+
guard CraftNativeScriptThread.isBackground else { return jsContext.evaluateScript(script) }
|
|
6300
|
+
return CraftNativeScriptThread.queue.sync { jsContext.evaluateScript(script) }
|
|
6159
6301
|
}
|
|
6160
6302
|
|
|
6161
6303
|
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
@@ -6166,9 +6308,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6166
6308
|
hybridRendered = true
|
|
6167
6309
|
// The app script's drawing is never on screen: not a first frame.
|
|
6168
6310
|
if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
|
|
6311
|
+
let waiting = renderWaiters
|
|
6312
|
+
renderWaiters.removeAll()
|
|
6313
|
+
waiting.forEach { $0() }
|
|
6169
6314
|
}
|
|
6170
6315
|
}
|
|
6171
6316
|
|
|
6317
|
+
private var renderWaiters: [() -> Void] = []
|
|
6318
|
+
|
|
6319
|
+
/// `work` once the screen has its first content, or after `seconds`,
|
|
6320
|
+
/// whichever comes first. A screen whose script runs on its own thread
|
|
6321
|
+
/// (CraftNativeScriptThread) draws a moment after it is made: a push
|
|
6322
|
+
/// waits for that, so the screen never slides in empty. Now, with the
|
|
6323
|
+
/// script on the main thread, which drew it as it was made.
|
|
6324
|
+
func whenFirstRendered(within seconds: TimeInterval, _ work: @escaping () -> Void) {
|
|
6325
|
+
guard CraftNativeScriptThread.isBackground, !isAppScript else { work(); return }
|
|
6326
|
+
loadViewIfNeeded()
|
|
6327
|
+
guard !hybridRendered else { work(); return }
|
|
6328
|
+
var done = false
|
|
6329
|
+
let once = {
|
|
6330
|
+
guard !done else { return }
|
|
6331
|
+
done = true
|
|
6332
|
+
work()
|
|
6333
|
+
}
|
|
6334
|
+
renderWaiters.append(once)
|
|
6335
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: once)
|
|
6336
|
+
}
|
|
6337
|
+
|
|
6172
6338
|
@discardableResult
|
|
6173
6339
|
func applyMutation(_ payload: [String: Any]) throws -> CraftNativeMutationResult {
|
|
6174
6340
|
let result = try mutationDocument.apply(payload)
|
|
@@ -6312,15 +6478,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6312
6478
|
_ = focused.becomeFirstResponder()
|
|
6313
6479
|
}
|
|
6314
6480
|
|
|
6315
|
-
|
|
6481
|
+
/// `adopting`: `previous` is a recycled list row's root, whose views may
|
|
6482
|
+
/// show this row instead (`identity` is the new row's).
|
|
6483
|
+
private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?, adopting: Bool = false) -> RenderedNode {
|
|
6316
6484
|
let type = canonicalType(node["type"] as? String ?? "View")
|
|
6317
6485
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
6318
6486
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
6319
6487
|
let events = node["events"] as? [String: String] ?? [:]
|
|
6320
6488
|
let children = node["children"] as? [Any] ?? []
|
|
6321
6489
|
let current: RenderedNode
|
|
6322
|
-
if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
|
|
6490
|
+
if let previous = previous, previous.identity == identity || adopting, canReuseView(node, previous: previous) {
|
|
6323
6491
|
current = previous
|
|
6492
|
+
current.identity = identity
|
|
6324
6493
|
} else {
|
|
6325
6494
|
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
6326
6495
|
}
|
|
@@ -7096,16 +7265,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7096
7265
|
columns: Int(number(props["numColumns"]) ?? 1),
|
|
7097
7266
|
inverted: props["inverted"] as? Bool == true,
|
|
7098
7267
|
endReachedThreshold: threshold,
|
|
7099
|
-
renderItem: { [weak self] node, identity,
|
|
7268
|
+
renderItem: { [weak self] node, identity, reusable in
|
|
7100
7269
|
guard let self = self else { return UIView() }
|
|
7101
|
-
|
|
7270
|
+
var previous = self.flatListRows[listKey]?[identity]
|
|
7271
|
+
// A cell scrolled back into use brings the views of the row it
|
|
7272
|
+
// showed: the new row reuses them when it has the same shape,
|
|
7273
|
+
// rather than building every view of it again in the frame it
|
|
7274
|
+
// scrolls in (Apple's view-churn hitch).
|
|
7275
|
+
var adopting = false
|
|
7276
|
+
if previous == nil, let reusable,
|
|
7277
|
+
let recycled = self.flatListRecycled[listKey]?.removeValue(forKey: ObjectIdentifier(reusable)) {
|
|
7278
|
+
previous = recycled
|
|
7279
|
+
adopting = true
|
|
7280
|
+
}
|
|
7102
7281
|
let next = self.reconcile(
|
|
7103
7282
|
node,
|
|
7104
7283
|
identity: "list:\(identity)",
|
|
7105
7284
|
path: "\(path).\(identity)",
|
|
7106
|
-
previous: previous
|
|
7285
|
+
previous: previous,
|
|
7286
|
+
adopting: adopting
|
|
7107
7287
|
)
|
|
7108
|
-
|
|
7288
|
+
// A recycled row's handlers went when it was recycled.
|
|
7289
|
+
if let previous, previous.view !== next.view, !adopting {
|
|
7109
7290
|
self.forgetHandlers(previous)
|
|
7110
7291
|
}
|
|
7111
7292
|
self.flatListRows[listKey, default: [:]][identity] = next
|
|
@@ -7115,6 +7296,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7115
7296
|
guard let self = self,
|
|
7116
7297
|
let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
|
|
7117
7298
|
self.forgetHandlers(row, preservingInputDrafts: true)
|
|
7299
|
+
// Kept, without its handlers, for the next row its cell shows.
|
|
7300
|
+
var recycled = self.flatListRecycled[listKey, default: [:]]
|
|
7301
|
+
if recycled.count >= 64, let oldest = recycled.keys.first { recycled.removeValue(forKey: oldest) }
|
|
7302
|
+
recycled[ObjectIdentifier(row.view)] = row
|
|
7303
|
+
self.flatListRecycled[listKey] = recycled
|
|
7118
7304
|
},
|
|
7119
7305
|
endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
|
|
7120
7306
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
@@ -7164,6 +7350,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7164
7350
|
list.setRefreshHandler(nil, refreshing: false)
|
|
7165
7351
|
list.discardAll()
|
|
7166
7352
|
flatListRows.removeValue(forKey: listKey)
|
|
7353
|
+
flatListRecycled.removeValue(forKey: listKey)
|
|
7167
7354
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
7168
7355
|
}
|
|
7169
7356
|
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
@@ -7188,6 +7375,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7188
7375
|
endEditingHandlers.removeValue(forKey: id)
|
|
7189
7376
|
submitHandlers.removeValue(forKey: id)
|
|
7190
7377
|
inputIdentities.removeValue(forKey: id)
|
|
7378
|
+
unechoedText.removeValue(forKey: id)
|
|
7191
7379
|
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
7192
7380
|
longPressHandlers.removeValue(forKey: id)
|
|
7193
7381
|
textMaxLengths.removeValue(forKey: id)
|
|
@@ -7399,8 +7587,39 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7399
7587
|
}
|
|
7400
7588
|
}
|
|
7401
7589
|
|
|
7590
|
+
/// Text a field sent its script that the script has not echoed back yet,
|
|
7591
|
+
/// while the script runs on its own thread: a value that matches an older
|
|
7592
|
+
/// one is the script catching up, not a new value, and applying it would
|
|
7593
|
+
/// undo what was typed since.
|
|
7594
|
+
private var unechoedText: [ObjectIdentifier: [String]] = [:]
|
|
7595
|
+
|
|
7596
|
+
private func noteSentText(_ text: String, for view: UIView) {
|
|
7597
|
+
guard CraftNativeScriptThread.isBackground else { return }
|
|
7598
|
+
let id = ObjectIdentifier(view)
|
|
7599
|
+
var sent = unechoedText[id] ?? []
|
|
7600
|
+
sent.append(text)
|
|
7601
|
+
if sent.count > 32 { sent.removeFirst(sent.count - 32) }
|
|
7602
|
+
unechoedText[id] = sent
|
|
7603
|
+
}
|
|
7604
|
+
|
|
7605
|
+
/// Whether `value` is a stale echo of text the view has moved past: it
|
|
7606
|
+
/// matches text sent before the latest. Text the field never sent is the
|
|
7607
|
+
/// script's own value (a field cleared after sending) and applies.
|
|
7608
|
+
private func isStaleEcho(_ value: String, for view: UIView) -> Bool {
|
|
7609
|
+
let id = ObjectIdentifier(view)
|
|
7610
|
+
guard var sent = unechoedText[id], let index = sent.lastIndex(of: value) else {
|
|
7611
|
+
unechoedText[id] = nil
|
|
7612
|
+
return false
|
|
7613
|
+
}
|
|
7614
|
+
let later = sent.count - index - 1
|
|
7615
|
+
sent.removeFirst(index + 1)
|
|
7616
|
+
unechoedText[id] = sent.isEmpty ? nil : sent
|
|
7617
|
+
return later > 0
|
|
7618
|
+
}
|
|
7619
|
+
|
|
7402
7620
|
private func updateField(_ field: UITextField, value: String?) {
|
|
7403
|
-
guard let value = value, field.
|
|
7621
|
+
guard let value = value, field.markedTextRange == nil else { return }
|
|
7622
|
+
if isStaleEcho(value, for: field) || field.text == value { return }
|
|
7404
7623
|
let selection = field.selectedTextRange
|
|
7405
7624
|
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
7406
7625
|
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
@@ -7413,7 +7632,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7413
7632
|
}
|
|
7414
7633
|
|
|
7415
7634
|
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
7416
|
-
guard let value = value, textView.
|
|
7635
|
+
guard let value = value, textView.markedTextRange == nil else { return }
|
|
7636
|
+
if isStaleEcho(value, for: textView) || textView.text == value { return }
|
|
7417
7637
|
let selection = textView.selectedTextRange
|
|
7418
7638
|
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
7419
7639
|
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
@@ -7897,6 +8117,64 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7897
8117
|
return cache
|
|
7898
8118
|
}()
|
|
7899
8119
|
|
|
8120
|
+
/// The pixels a picture is drawn at, when its style fixes its size (a
|
|
8121
|
+
/// 40-point avatar, a card's photo): nil otherwise, for a picture whose
|
|
8122
|
+
/// own size lays it out, which must keep its full size.
|
|
8123
|
+
static func displayPixels(of view: UIImageView) -> (size: CGSize, scale: CGFloat)? {
|
|
8124
|
+
let style = view.craftLayoutStyle
|
|
8125
|
+
let points: CGSize
|
|
8126
|
+
switch (style.width, style.height, style.aspectRatio) {
|
|
8127
|
+
case (.points(let width)?, .points(let height)?, _):
|
|
8128
|
+
points = CGSize(width: width, height: height)
|
|
8129
|
+
case (.points(let width)?, nil, let ratio?):
|
|
8130
|
+
points = CGSize(width: width, height: width / ratio)
|
|
8131
|
+
case (nil, .points(let height)?, let ratio?):
|
|
8132
|
+
points = CGSize(width: height * ratio, height: height)
|
|
8133
|
+
default:
|
|
8134
|
+
// Sized by its parent (a percentage, a stretch): its laid-out size.
|
|
8135
|
+
guard style.width != nil || style.height != nil || style.aspectRatio != nil,
|
|
8136
|
+
view.bounds.width >= 1, view.bounds.height >= 1 else { return nil }
|
|
8137
|
+
points = view.bounds.size
|
|
8138
|
+
}
|
|
8139
|
+
guard points.width >= 1, points.height >= 1 else { return nil }
|
|
8140
|
+
let scale = max(1, view.window?.screen.scale ?? view.traitCollection.displayScale)
|
|
8141
|
+
return (CGSize(width: ceil(points.width * scale), height: ceil(points.height * scale)), scale)
|
|
8142
|
+
}
|
|
8143
|
+
|
|
8144
|
+
/// Off the main thread: an image decoded at no more pixels than it is
|
|
8145
|
+
/// drawn at, by ImageIO's thumbnailer (Apple's downsampling recipe), so a
|
|
8146
|
+
/// 4000-pixel photo in a 40-point circle is never decoded whole; else
|
|
8147
|
+
/// decoded whole, ahead of its first draw.
|
|
8148
|
+
static func decodeImage(_ data: Data, fitting target: (size: CGSize, scale: CGFloat)?, fill: Bool) -> UIImage? {
|
|
8149
|
+
if let target,
|
|
8150
|
+
let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
|
|
8151
|
+
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
|
8152
|
+
let rawWidth = (properties[kCGImagePropertyPixelWidth] as? NSNumber).map({ CGFloat(truncating: $0) }),
|
|
8153
|
+
let rawHeight = (properties[kCGImagePropertyPixelHeight] as? NSNumber).map({ CGFloat(truncating: $0) }),
|
|
8154
|
+
rawWidth > 0, rawHeight > 0 {
|
|
8155
|
+
// EXIF orientations 5–8 turn the picture a quarter.
|
|
8156
|
+
let turned = ((properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1) >= 5
|
|
8157
|
+
let width = turned ? rawHeight : rawWidth
|
|
8158
|
+
let height = turned ? rawWidth : rawHeight
|
|
8159
|
+
let pixels = target.size
|
|
8160
|
+
let factor = fill ? max(pixels.width / width, pixels.height / height) : min(pixels.width / width, pixels.height / height)
|
|
8161
|
+
if factor < 1 {
|
|
8162
|
+
let options = [
|
|
8163
|
+
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
8164
|
+
kCGImageSourceShouldCacheImmediately: true,
|
|
8165
|
+
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
8166
|
+
kCGImageSourceThumbnailMaxPixelSize: ceil(max(width, height) * factor),
|
|
8167
|
+
] as CFDictionary
|
|
8168
|
+
if let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) {
|
|
8169
|
+
// At the screen's scale, so its size in points is the
|
|
8170
|
+
// size it is drawn at.
|
|
8171
|
+
return UIImage(cgImage: image, scale: target.scale, orientation: .up)
|
|
8172
|
+
}
|
|
8173
|
+
}
|
|
8174
|
+
}
|
|
8175
|
+
return UIImage(data: data).map { $0.preparingForDisplay() ?? $0 }
|
|
8176
|
+
}
|
|
8177
|
+
|
|
7900
8178
|
/// An image the screen downloads: https anywhere, and http only from this
|
|
7901
8179
|
/// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
|
|
7902
8180
|
/// server the app's ATS exception already allows. Internal for XCTest.
|
|
@@ -7948,8 +8226,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7948
8226
|
return
|
|
7949
8227
|
}
|
|
7950
8228
|
if let url = URL(string: uri), Self.loadsImage(from: url) {
|
|
8229
|
+
// Decoded at the size it is drawn, when the style fixes that size.
|
|
8230
|
+
let pixels = Self.displayPixels(of: view)
|
|
8231
|
+
let fill = view.contentMode == .scaleAspectFill
|
|
8232
|
+
let cacheKey = pixels.map { "\(requestKey)@\(Int($0.size.width))x\(Int($0.size.height))\(fill ? "f" : "")" } ?? requestKey
|
|
7951
8233
|
// Shown before: on screen in this same frame, already decoded.
|
|
7952
|
-
if let decoded = Self.decodedImages.object(forKey:
|
|
8234
|
+
if let decoded = Self.decodedImages.object(forKey: cacheKey as NSString) {
|
|
7953
8235
|
view.image = imageForDisplay(decoded, view: view)
|
|
7954
8236
|
imageDidChange(view)
|
|
7955
8237
|
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
@@ -7962,10 +8244,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7962
8244
|
// Decoded here, off the main thread. A UIImage made from data
|
|
7963
8245
|
// is only decoded when it first draws, on the main thread, in
|
|
7964
8246
|
// the frame it scrolls into view: Apple's "prepare" hitch.
|
|
7965
|
-
let decoded = data.flatMap
|
|
8247
|
+
let decoded = data.flatMap { Self.decodeImage($0, fitting: pixels, fill: fill) }
|
|
7966
8248
|
if let decoded {
|
|
7967
|
-
let
|
|
7968
|
-
Self.decodedImages.setObject(decoded, forKey:
|
|
8249
|
+
let bytes = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale * 4
|
|
8250
|
+
Self.decodedImages.setObject(decoded, forKey: cacheKey as NSString, cost: Int(bytes))
|
|
7969
8251
|
}
|
|
7970
8252
|
guard let self, let view else { return }
|
|
7971
8253
|
DispatchQueue.main.async {
|
|
@@ -8215,6 +8497,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8215
8497
|
inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
|
|
8216
8498
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
8217
8499
|
guard let handler = handlers[id] else { return }
|
|
8500
|
+
noteSentText(textView.text ?? "", for: textView)
|
|
8218
8501
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
8219
8502
|
}
|
|
8220
8503
|
|
|
@@ -8242,6 +8525,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8242
8525
|
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
8243
8526
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
8244
8527
|
guard let handler = handlers[id] else { return }
|
|
8528
|
+
noteSentText(sender.text ?? "", for: sender)
|
|
8245
8529
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
8246
8530
|
restoreFocus(focused)
|
|
8247
8531
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
@@ -8523,7 +8807,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8523
8807
|
if let correlationId = correlationId { message["correlationId"] = correlationId }
|
|
8524
8808
|
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
|
8525
8809
|
let json = String(data: data, encoding: .utf8) else { return }
|
|
8526
|
-
jsContext
|
|
8810
|
+
let context = jsContext
|
|
8811
|
+
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
|
|
8527
8812
|
}
|
|
8528
8813
|
|
|
8529
8814
|
private func number(_ value: Any?) -> CGFloat? {
|
|
@@ -1376,23 +1376,32 @@ final class CraftWebAudio {
|
|
|
1376
1376
|
/// JavaScript, before its bundle runs.
|
|
1377
1377
|
func install(in context: JSContext) {
|
|
1378
1378
|
script = context
|
|
1379
|
+
// The graph lives on the main thread. A script on its own thread
|
|
1380
|
+
// (CraftNativeScriptThread) reaches it there, waiting for the answer
|
|
1381
|
+
// its synchronous calls return; arguments are read off the JSValues
|
|
1382
|
+
// where the script runs.
|
|
1379
1383
|
let open: @convention(block) (Int, Double) -> NSDictionary? = { [weak self] id, rate in
|
|
1380
|
-
self?.open(id, requestedRate: rate) as NSDictionary?
|
|
1384
|
+
CraftNativeScriptThread.onMain { self?.open(id, requestedRate: rate) as NSDictionary? }
|
|
1381
1385
|
}
|
|
1382
1386
|
let send: @convention(block) (JSValue) -> Void = { [weak self] batch in
|
|
1383
|
-
|
|
1387
|
+
let commands = batch.toArray() ?? []
|
|
1388
|
+
CraftNativeScriptThread.onMain { self?.receive(commands) }
|
|
1384
1389
|
}
|
|
1385
1390
|
let time: @convention(block) (Int) -> Double = { [weak self] id in
|
|
1386
|
-
self?.contexts[id]?.graph.currentTime ?? 0
|
|
1391
|
+
CraftNativeScriptThread.onMain { self?.contexts[id]?.graph.currentTime ?? 0 }
|
|
1387
1392
|
}
|
|
1388
1393
|
let read: @convention(block) (Int, Int, String) -> Double = { [weak self] contextId, nodeId, key in
|
|
1389
|
-
self?.read(contextId, nodeId, key) ?? .nan
|
|
1394
|
+
CraftNativeScriptThread.onMain { self?.read(contextId, nodeId, key) ?? .nan }
|
|
1390
1395
|
}
|
|
1391
1396
|
let state: @convention(block) (Int, String) -> Bool = { [weak self] id, state in
|
|
1392
|
-
self?.setState(id, state) ?? false
|
|
1397
|
+
CraftNativeScriptThread.onMain { self?.setState(id, state) ?? false }
|
|
1393
1398
|
}
|
|
1394
1399
|
let buffer: @convention(block) (Int, Double, Int, JSValue) -> Bool = { [weak self] id, rate, length, channels in
|
|
1395
|
-
|
|
1400
|
+
// The samples are copied out of the script's typed arrays where
|
|
1401
|
+
// the script runs, then kept beside the graph.
|
|
1402
|
+
guard let data = Self.copyBuffer(rate, length, channels) else { return false }
|
|
1403
|
+
CraftNativeScriptThread.onMain { self?.buffers[id] = data }
|
|
1404
|
+
return true
|
|
1396
1405
|
}
|
|
1397
1406
|
context.setObject(open, forKeyedSubscript: "craftNativeAudioOpen" as NSString)
|
|
1398
1407
|
context.setObject(send, forKeyedSubscript: "craftNativeAudioSend" as NSString)
|
|
@@ -1637,10 +1646,10 @@ final class CraftWebAudio {
|
|
|
1637
1646
|
}
|
|
1638
1647
|
|
|
1639
1648
|
/// An AudioBuffer's channels, copied straight out of its Float32Arrays.
|
|
1640
|
-
private func
|
|
1641
|
-
guard length > 0, rate > 0, let context = JSContext.current() else { return
|
|
1649
|
+
private static func copyBuffer(_ rate: Double, _ length: Int, _ channels: JSValue) -> CraftAudioBufferData? {
|
|
1650
|
+
guard length > 0, rate > 0, let context = JSContext.current() else { return nil }
|
|
1642
1651
|
let count = Int(channels.objectForKeyedSubscript("length")?.toInt32() ?? 0)
|
|
1643
|
-
guard count > 0 else { return
|
|
1652
|
+
guard count > 0 else { return nil }
|
|
1644
1653
|
let data = CraftAudioBufferData(sampleRate: rate, length: length, channelCount: count)
|
|
1645
1654
|
let script = context.jsGlobalContextRef
|
|
1646
1655
|
for index in 0..<count {
|
|
@@ -1651,8 +1660,7 @@ final class CraftWebAudio {
|
|
|
1651
1660
|
let available = JSObjectGetTypedArrayLength(script, object, nil)
|
|
1652
1661
|
data.channels[index].update(from: bytes.assumingMemoryBound(to: Float.self), count: min(length, available))
|
|
1653
1662
|
}
|
|
1654
|
-
|
|
1655
|
-
return true
|
|
1663
|
+
return data
|
|
1656
1664
|
}
|
|
1657
1665
|
|
|
1658
1666
|
// MARK: Notices
|
|
@@ -1675,8 +1683,8 @@ final class CraftWebAudio {
|
|
|
1675
1683
|
}
|
|
1676
1684
|
}
|
|
1677
1685
|
}
|
|
1678
|
-
guard !ended.isEmpty else { return }
|
|
1679
|
-
script
|
|
1686
|
+
guard !ended.isEmpty, let script else { return }
|
|
1687
|
+
CraftNativeScriptThread.run { script.objectForKeyedSubscript("__craftNativeAudioEvents")?.call(withArguments: [ended]) }
|
|
1680
1688
|
}
|
|
1681
1689
|
|
|
1682
1690
|
// MARK: Tests
|