@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
|
@@ -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,6 +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 ?? "")
|
|
4977
|
+
let sessionTokenKey = inputs.sessionTokenKey
|
|
4858
4978
|
jsContext.evaluateScript("""
|
|
4859
4979
|
// JavaScriptCore has no DOM window, but browser-safe SDK bridges
|
|
4860
4980
|
// address the host through `window.craft`. Keep native screens
|
|
@@ -4867,12 +4987,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4867
4987
|
capabilityProtocolVersion: \(craftNativeCapabilityProtocolVersion),
|
|
4868
4988
|
capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
|
|
4869
4989
|
capabilities: \(capabilityJSON),
|
|
4870
|
-
initialAppState: "\(
|
|
4871
|
-
colorScheme: "\(colorScheme)",
|
|
4872
|
-
appearance: \(appearanceJSON
|
|
4990
|
+
initialAppState: "\(inputs.appState)",
|
|
4991
|
+
colorScheme: "\(inputs.colorScheme)",
|
|
4992
|
+
appearance: \(inputs.appearanceJSON),
|
|
4873
4993
|
appURL: \(appURL),
|
|
4874
4994
|
appVersion: \(appVersion),
|
|
4875
4995
|
appBuild: \(appBuild),
|
|
4996
|
+
sessionTokenKey: \(sessionTokenKey),
|
|
4876
4997
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
4877
4998
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
4878
4999
|
};
|
|
@@ -4966,6 +5087,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4966
5087
|
/// else the one a route bundle selected for itself.
|
|
4967
5088
|
private func screenName(in context: JSContext? = nil) -> String {
|
|
4968
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" }
|
|
4969
5092
|
if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
|
|
4970
5093
|
return selected.toString()
|
|
4971
5094
|
}
|
|
@@ -5015,13 +5138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5015
5138
|
}
|
|
5016
5139
|
let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
|
|
5017
5140
|
CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
|
|
5018
|
-
done.call(withArguments: [ok])
|
|
5141
|
+
CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
|
|
5019
5142
|
}
|
|
5020
5143
|
}
|
|
5021
5144
|
let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
|
|
5022
5145
|
let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
|
|
5023
5146
|
CraftSnapshots.clear(keeping: Set(names)) { ok in
|
|
5024
|
-
done.call(withArguments: [ok])
|
|
5147
|
+
CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
|
|
5025
5148
|
}
|
|
5026
5149
|
}
|
|
5027
5150
|
let config = self.config
|
|
@@ -5031,7 +5154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5031
5154
|
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
5032
5155
|
guard let data = json.data(using: .utf8),
|
|
5033
5156
|
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
5034
|
-
self?.applyNavigationOptions(options)
|
|
5157
|
+
CraftNativeScriptThread.toMain { self?.applyNavigationOptions(options) }
|
|
5035
5158
|
}
|
|
5036
5159
|
jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
5037
5160
|
jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
|
|
@@ -5274,6 +5397,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5274
5397
|
return value === undefined ? null : value;
|
|
5275
5398
|
}
|
|
5276
5399
|
});
|
|
5400
|
+
// The signed-in account (CraftSession): the token under
|
|
5401
|
+
// `sessionTokenKey`, and each change of it, which the host
|
|
5402
|
+
// reports through `__craftSessionChanged` after the Keychain
|
|
5403
|
+
// holds it, beside the `SESSION_CHANGED` bridge message.
|
|
5404
|
+
var sessionListeners = [];
|
|
5405
|
+
var sessionKey = globalThis.__stxNativeBridge && __stxNativeBridge.sessionTokenKey || 'auth.token';
|
|
5406
|
+
function sessionToken() {
|
|
5407
|
+
var value = craftNativeSecureGet(String(sessionKey));
|
|
5408
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
5409
|
+
}
|
|
5410
|
+
globalThis.__craftSessionChanged = function(token, previous) {
|
|
5411
|
+
sessionListeners.slice().forEach(function(callback) {
|
|
5412
|
+
try { callback(token, previous); } catch (error) { console.error('[session] ' + (error && error.stack || error)); }
|
|
5413
|
+
});
|
|
5414
|
+
};
|
|
5415
|
+
keep('session', {
|
|
5416
|
+
key: sessionKey,
|
|
5417
|
+
token: sessionToken,
|
|
5418
|
+
onChange: function(callback) {
|
|
5419
|
+
if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
|
|
5420
|
+
sessionListeners.push(callback);
|
|
5421
|
+
return function() {
|
|
5422
|
+
var index = sessionListeners.indexOf(callback);
|
|
5423
|
+
if (index >= 0) sessionListeners.splice(index, 1);
|
|
5424
|
+
};
|
|
5425
|
+
}
|
|
5426
|
+
});
|
|
5277
5427
|
keep('navigation', {
|
|
5278
5428
|
setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
|
|
5279
5429
|
});
|
|
@@ -5281,19 +5431,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5281
5431
|
""")
|
|
5282
5432
|
}
|
|
5283
5433
|
|
|
5434
|
+
/// Runs where the script runs (CraftNativeScriptThread).
|
|
5284
5435
|
private func loadBundle() {
|
|
5285
5436
|
guard let bundle = CraftNativeBundle.source(for: routeName) else {
|
|
5286
|
-
|
|
5437
|
+
CraftNativeScriptThread.toMain { [weak self] in
|
|
5438
|
+
self?.showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
5439
|
+
}
|
|
5287
5440
|
return
|
|
5288
5441
|
}
|
|
5442
|
+
if let runtime = CraftNativeBundle.runtime(for: bundle.url) {
|
|
5443
|
+
jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
|
|
5444
|
+
}
|
|
5289
5445
|
jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
|
|
5290
5446
|
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
5291
5447
|
// its title is the app's, not the word "undefined". A title the
|
|
5292
5448
|
// script set with `navigation.setOptions` wins over both.
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
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
|
|
5297
5455
|
}
|
|
5298
5456
|
}
|
|
5299
5457
|
|
|
@@ -5739,6 +5897,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5739
5897
|
guard let name = notification.userInfo?["name"] as? String else { return }
|
|
5740
5898
|
self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
|
|
5741
5899
|
})
|
|
5900
|
+
if config.enableSecureStorage {
|
|
5901
|
+
// A sign-in, sign-out or another account, made on any screen or
|
|
5902
|
+
// the page (CraftSession): `SESSION_CHANGED { key, token,
|
|
5903
|
+
// previous }` for the runtime, then craft.session's listeners.
|
|
5904
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
5905
|
+
forName: CraftSession.changed,
|
|
5906
|
+
object: nil,
|
|
5907
|
+
queue: .main
|
|
5908
|
+
) { [weak self] notification in
|
|
5909
|
+
guard let self, let info = notification.userInfo, let key = info["key"] as? String else { return }
|
|
5910
|
+
let token = info["token"] as? String
|
|
5911
|
+
let previous = info["previous"] as? String
|
|
5912
|
+
self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
|
|
5913
|
+
let context = self.jsContext
|
|
5914
|
+
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
|
|
5915
|
+
})
|
|
5916
|
+
}
|
|
5742
5917
|
if config.enablePushNotifications {
|
|
5743
5918
|
// A new device token (iOS hands one out after a reinstall or a
|
|
5744
5919
|
// restore): every screen's `craft.push.onToken` hears it.
|
|
@@ -5905,10 +6080,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5905
6080
|
hybridEvents?.nativeScreen(self, didFail: message)
|
|
5906
6081
|
}
|
|
5907
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.
|
|
5908
6085
|
private func receive(_ raw: String) {
|
|
5909
6086
|
guard let data = raw.data(using: .utf8),
|
|
5910
6087
|
let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
5911
|
-
|
|
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 }
|
|
5912
6094
|
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
5913
6095
|
|
|
5914
6096
|
switch type {
|
|
@@ -5955,11 +6137,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5955
6137
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
5956
6138
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
5957
6139
|
next.hybridEvents = hybridEvents
|
|
5958
|
-
|
|
5959
|
-
navigation
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
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
|
+
}
|
|
5963
6148
|
}
|
|
5964
6149
|
case "NAVIGATION_SET_OPTIONS":
|
|
5965
6150
|
applyNavigationOptions(payload)
|
|
@@ -6110,7 +6295,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6110
6295
|
/// simulator-hosted XCTest of the host APIs.
|
|
6111
6296
|
@discardableResult
|
|
6112
6297
|
func evaluateScript(_ script: String) -> JSValue? {
|
|
6113
|
-
|
|
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) }
|
|
6114
6301
|
}
|
|
6115
6302
|
|
|
6116
6303
|
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
@@ -6121,7 +6308,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6121
6308
|
hybridRendered = true
|
|
6122
6309
|
// The app script's drawing is never on screen: not a first frame.
|
|
6123
6310
|
if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
|
|
6311
|
+
let waiting = renderWaiters
|
|
6312
|
+
renderWaiters.removeAll()
|
|
6313
|
+
waiting.forEach { $0() }
|
|
6314
|
+
}
|
|
6315
|
+
}
|
|
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()
|
|
6124
6333
|
}
|
|
6334
|
+
renderWaiters.append(once)
|
|
6335
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: once)
|
|
6125
6336
|
}
|
|
6126
6337
|
|
|
6127
6338
|
@discardableResult
|
|
@@ -6267,15 +6478,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6267
6478
|
_ = focused.becomeFirstResponder()
|
|
6268
6479
|
}
|
|
6269
6480
|
|
|
6270
|
-
|
|
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 {
|
|
6271
6484
|
let type = canonicalType(node["type"] as? String ?? "View")
|
|
6272
6485
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
6273
6486
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
6274
6487
|
let events = node["events"] as? [String: String] ?? [:]
|
|
6275
6488
|
let children = node["children"] as? [Any] ?? []
|
|
6276
6489
|
let current: RenderedNode
|
|
6277
|
-
if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
|
|
6490
|
+
if let previous = previous, previous.identity == identity || adopting, canReuseView(node, previous: previous) {
|
|
6278
6491
|
current = previous
|
|
6492
|
+
current.identity = identity
|
|
6279
6493
|
} else {
|
|
6280
6494
|
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
6281
6495
|
}
|
|
@@ -7051,16 +7265,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7051
7265
|
columns: Int(number(props["numColumns"]) ?? 1),
|
|
7052
7266
|
inverted: props["inverted"] as? Bool == true,
|
|
7053
7267
|
endReachedThreshold: threshold,
|
|
7054
|
-
renderItem: { [weak self] node, identity,
|
|
7268
|
+
renderItem: { [weak self] node, identity, reusable in
|
|
7055
7269
|
guard let self = self else { return UIView() }
|
|
7056
|
-
|
|
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
|
+
}
|
|
7057
7281
|
let next = self.reconcile(
|
|
7058
7282
|
node,
|
|
7059
7283
|
identity: "list:\(identity)",
|
|
7060
7284
|
path: "\(path).\(identity)",
|
|
7061
|
-
previous: previous
|
|
7285
|
+
previous: previous,
|
|
7286
|
+
adopting: adopting
|
|
7062
7287
|
)
|
|
7063
|
-
|
|
7288
|
+
// A recycled row's handlers went when it was recycled.
|
|
7289
|
+
if let previous, previous.view !== next.view, !adopting {
|
|
7064
7290
|
self.forgetHandlers(previous)
|
|
7065
7291
|
}
|
|
7066
7292
|
self.flatListRows[listKey, default: [:]][identity] = next
|
|
@@ -7070,6 +7296,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7070
7296
|
guard let self = self,
|
|
7071
7297
|
let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
|
|
7072
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
|
|
7073
7304
|
},
|
|
7074
7305
|
endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
|
|
7075
7306
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
@@ -7119,6 +7350,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7119
7350
|
list.setRefreshHandler(nil, refreshing: false)
|
|
7120
7351
|
list.discardAll()
|
|
7121
7352
|
flatListRows.removeValue(forKey: listKey)
|
|
7353
|
+
flatListRecycled.removeValue(forKey: listKey)
|
|
7122
7354
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
7123
7355
|
}
|
|
7124
7356
|
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
@@ -7143,6 +7375,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7143
7375
|
endEditingHandlers.removeValue(forKey: id)
|
|
7144
7376
|
submitHandlers.removeValue(forKey: id)
|
|
7145
7377
|
inputIdentities.removeValue(forKey: id)
|
|
7378
|
+
unechoedText.removeValue(forKey: id)
|
|
7146
7379
|
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
7147
7380
|
longPressHandlers.removeValue(forKey: id)
|
|
7148
7381
|
textMaxLengths.removeValue(forKey: id)
|
|
@@ -7354,8 +7587,39 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7354
7587
|
}
|
|
7355
7588
|
}
|
|
7356
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
|
+
|
|
7357
7620
|
private func updateField(_ field: UITextField, value: String?) {
|
|
7358
|
-
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 }
|
|
7359
7623
|
let selection = field.selectedTextRange
|
|
7360
7624
|
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
7361
7625
|
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
@@ -7368,7 +7632,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7368
7632
|
}
|
|
7369
7633
|
|
|
7370
7634
|
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
7371
|
-
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 }
|
|
7372
7637
|
let selection = textView.selectedTextRange
|
|
7373
7638
|
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
7374
7639
|
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
@@ -7852,6 +8117,64 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7852
8117
|
return cache
|
|
7853
8118
|
}()
|
|
7854
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
|
+
|
|
7855
8178
|
/// An image the screen downloads: https anywhere, and http only from this
|
|
7856
8179
|
/// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
|
|
7857
8180
|
/// server the app's ATS exception already allows. Internal for XCTest.
|
|
@@ -7903,8 +8226,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7903
8226
|
return
|
|
7904
8227
|
}
|
|
7905
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
|
|
7906
8233
|
// Shown before: on screen in this same frame, already decoded.
|
|
7907
|
-
if let decoded = Self.decodedImages.object(forKey:
|
|
8234
|
+
if let decoded = Self.decodedImages.object(forKey: cacheKey as NSString) {
|
|
7908
8235
|
view.image = imageForDisplay(decoded, view: view)
|
|
7909
8236
|
imageDidChange(view)
|
|
7910
8237
|
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
@@ -7917,10 +8244,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7917
8244
|
// Decoded here, off the main thread. A UIImage made from data
|
|
7918
8245
|
// is only decoded when it first draws, on the main thread, in
|
|
7919
8246
|
// the frame it scrolls into view: Apple's "prepare" hitch.
|
|
7920
|
-
let decoded = data.flatMap
|
|
8247
|
+
let decoded = data.flatMap { Self.decodeImage($0, fitting: pixels, fill: fill) }
|
|
7921
8248
|
if let decoded {
|
|
7922
|
-
let
|
|
7923
|
-
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))
|
|
7924
8251
|
}
|
|
7925
8252
|
guard let self, let view else { return }
|
|
7926
8253
|
DispatchQueue.main.async {
|
|
@@ -8170,6 +8497,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8170
8497
|
inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
|
|
8171
8498
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
8172
8499
|
guard let handler = handlers[id] else { return }
|
|
8500
|
+
noteSentText(textView.text ?? "", for: textView)
|
|
8173
8501
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
8174
8502
|
}
|
|
8175
8503
|
|
|
@@ -8197,6 +8525,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8197
8525
|
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
8198
8526
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
8199
8527
|
guard let handler = handlers[id] else { return }
|
|
8528
|
+
noteSentText(sender.text ?? "", for: sender)
|
|
8200
8529
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
8201
8530
|
restoreFocus(focused)
|
|
8202
8531
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
@@ -8478,7 +8807,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8478
8807
|
if let correlationId = correlationId { message["correlationId"] = correlationId }
|
|
8479
8808
|
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
|
8480
8809
|
let json = String(data: data, encoding: .utf8) else { return }
|
|
8481
|
-
jsContext
|
|
8810
|
+
let context = jsContext
|
|
8811
|
+
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
|
|
8482
8812
|
}
|
|
8483
8813
|
|
|
8484
8814
|
private func number(_ value: Any?) -> CGFloat? {
|