craft-native 0.0.105 → 0.0.107
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/android/src/index.d.ts +2 -0
- package/dist/android/src/index.js +33 -6
- package/dist/android/templates/MainActivityNative.kt.template +390 -0
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/window.d.ts +6 -11
- package/dist/cli.js +79 -1480
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +4 -0
- package/dist/ios/src/index.js +32 -3
- package/dist/ios/templates/CraftApp.swift +20 -92
- package/dist/ios/templates/CraftNativeActions.swift +93 -0
- package/dist/ios/templates/CraftNativeScreen.swift +381 -0
- package/package.json +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import JavaScriptCore
|
|
3
|
+
import SwiftUI
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
/// A WebView-free host for the first stx-native vertical slice. The bundled
|
|
7
|
+
/// JavaScript sends whole, compiled view trees to UIKit and receives control
|
|
8
|
+
/// events and Craft API replies through JavaScriptCore.
|
|
9
|
+
struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
10
|
+
let config: CraftConfig
|
|
11
|
+
|
|
12
|
+
func makeUIViewController(context: Context) -> UINavigationController {
|
|
13
|
+
UINavigationController(rootViewController: CraftNativeScreenController(config: config))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func updateUIViewController(_ controller: UINavigationController, context: Context) {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
final class CraftNativeScreenController: UIViewController {
|
|
20
|
+
private final class RenderedNode {
|
|
21
|
+
let identity: String
|
|
22
|
+
let type: String
|
|
23
|
+
let view: UIView
|
|
24
|
+
var children: [RenderedNode] = []
|
|
25
|
+
var widthConstraint: NSLayoutConstraint?
|
|
26
|
+
var heightConstraint: NSLayoutConstraint?
|
|
27
|
+
|
|
28
|
+
init(identity: String, type: String, view: UIView) {
|
|
29
|
+
self.identity = identity
|
|
30
|
+
self.type = type
|
|
31
|
+
self.view = view
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private let config: CraftConfig
|
|
36
|
+
private let routeName: String?
|
|
37
|
+
private let routeParams: [String: Any]
|
|
38
|
+
private let jsContext = JSContext()!
|
|
39
|
+
private let rootStack = UIStackView()
|
|
40
|
+
private var handlers: [ObjectIdentifier: String] = [:]
|
|
41
|
+
private var renderedRoot: RenderedNode?
|
|
42
|
+
|
|
43
|
+
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
44
|
+
self.config = config
|
|
45
|
+
self.routeName = routeName
|
|
46
|
+
self.routeParams = routeParams
|
|
47
|
+
super.init(nibName: nil, bundle: nil)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
required init?(coder: NSCoder) {
|
|
51
|
+
fatalError("init(coder:) has not been implemented")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override func viewDidLoad() {
|
|
55
|
+
super.viewDidLoad()
|
|
56
|
+
navigationItem.title = routeName ?? config.appName
|
|
57
|
+
view.backgroundColor = config.resolvedBackgroundColor
|
|
58
|
+
rootStack.axis = .vertical
|
|
59
|
+
rootStack.alignment = .fill
|
|
60
|
+
rootStack.distribution = .fill
|
|
61
|
+
rootStack.translatesAutoresizingMaskIntoConstraints = false
|
|
62
|
+
view.addSubview(rootStack)
|
|
63
|
+
NSLayoutConstraint.activate([
|
|
64
|
+
rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
65
|
+
rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
66
|
+
rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
67
|
+
rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
|
|
68
|
+
])
|
|
69
|
+
setupJavaScript()
|
|
70
|
+
loadBundle()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func setupJavaScript() {
|
|
74
|
+
if let routeName = routeName {
|
|
75
|
+
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
76
|
+
}
|
|
77
|
+
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
78
|
+
jsContext.exceptionHandler = { _, exception in
|
|
79
|
+
NSLog("[craft native] JavaScript exception: %@", exception?.toString() ?? "unknown")
|
|
80
|
+
}
|
|
81
|
+
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
82
|
+
self?.receive(json)
|
|
83
|
+
}
|
|
84
|
+
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
85
|
+
jsContext.evaluateScript("""
|
|
86
|
+
globalThis.__stxNativeCallback = null;
|
|
87
|
+
globalThis.__stxNativeBridge = {
|
|
88
|
+
postMessage: function(message) { craftNativePostMessage(message); },
|
|
89
|
+
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
90
|
+
};
|
|
91
|
+
globalThis.console = {
|
|
92
|
+
log: function() {}, warn: function() {}, error: function() {}
|
|
93
|
+
};
|
|
94
|
+
""")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private func loadBundle() {
|
|
98
|
+
guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
99
|
+
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
100
|
+
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
101
|
+
showError("Missing dist/native-screen.js. Compile a .stx screen with stx-native first.")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
jsContext.evaluateScript(script)
|
|
105
|
+
if routeName == nil, let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute")?.toString() {
|
|
106
|
+
navigationItem.title = selected
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func showError(_ message: String) {
|
|
111
|
+
let label = UILabel()
|
|
112
|
+
label.numberOfLines = 0
|
|
113
|
+
label.textColor = .systemRed
|
|
114
|
+
label.textAlignment = .center
|
|
115
|
+
label.text = message
|
|
116
|
+
rootStack.addArrangedSubview(label)
|
|
117
|
+
NSLog("[craft native] %@", message)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func receive(_ raw: String) {
|
|
121
|
+
guard let data = raw.data(using: .utf8),
|
|
122
|
+
let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
123
|
+
let type = message["type"] as? String,
|
|
124
|
+
let payload = message["payload"] as? [String: Any] else { return }
|
|
125
|
+
|
|
126
|
+
switch type {
|
|
127
|
+
case "RENDER":
|
|
128
|
+
guard let document = payload["document"] as? [String: Any] else { return }
|
|
129
|
+
render(document)
|
|
130
|
+
case "NAVIGATE", "NAVIGATE_REPLACE":
|
|
131
|
+
guard navigationController?.topViewController === self,
|
|
132
|
+
let screen = payload["screen"] as? String, !screen.isEmpty,
|
|
133
|
+
let navigation = navigationController else { return }
|
|
134
|
+
let params = payload["params"] as? [String: Any] ?? [:]
|
|
135
|
+
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
136
|
+
if type == "NAVIGATE" {
|
|
137
|
+
navigation.pushViewController(next, animated: true)
|
|
138
|
+
} else {
|
|
139
|
+
navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
|
|
140
|
+
}
|
|
141
|
+
case "NAVIGATE_BACK":
|
|
142
|
+
if navigationController?.topViewController === self {
|
|
143
|
+
navigationController?.popViewController(animated: true)
|
|
144
|
+
}
|
|
145
|
+
case "API_REQUEST":
|
|
146
|
+
guard let id = message["id"] as? String else { return }
|
|
147
|
+
let args = payload["args"] as? [Any] ?? []
|
|
148
|
+
let route: (action: String, body: [String: Any])?
|
|
149
|
+
switch (payload["module"] as? String, payload["method"] as? String) {
|
|
150
|
+
case ("Device", "getInfo"):
|
|
151
|
+
route = ("getDeviceInfo", [:])
|
|
152
|
+
case ("Haptics", "impact"):
|
|
153
|
+
route = ("haptic", ["style": args.first ?? NSNull()])
|
|
154
|
+
case ("Clipboard", "write"):
|
|
155
|
+
route = ("clipboardWrite", ["text": args.first ?? NSNull()])
|
|
156
|
+
case ("Clipboard", "read"):
|
|
157
|
+
route = ("clipboardRead", [:])
|
|
158
|
+
default:
|
|
159
|
+
route = nil
|
|
160
|
+
}
|
|
161
|
+
guard let route = route,
|
|
162
|
+
let answer = CraftNativeActions.perform(action: route.action, body: route.body, config: config) else {
|
|
163
|
+
send(type: "API_ERROR", payload: ["requestId": id, "code": "UNKNOWN_ACTION", "message": "Unsupported native API"], correlationId: id)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
switch answer {
|
|
167
|
+
case .success(let data):
|
|
168
|
+
send(type: "API_RESPONSE", payload: ["requestId": id, "data": data], correlationId: id)
|
|
169
|
+
case .failure(let error):
|
|
170
|
+
send(type: "API_ERROR", payload: ["requestId": id, "code": error.code, "message": error.message], correlationId: id)
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
178
|
+
func render(_ document: [String: Any]) {
|
|
179
|
+
let previous = renderedRoot
|
|
180
|
+
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
181
|
+
if previous?.view !== next.view {
|
|
182
|
+
if let old = previous { detach(old, from: rootStack) }
|
|
183
|
+
rootStack.addArrangedSubview(next.view)
|
|
184
|
+
}
|
|
185
|
+
renderedRoot = next
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
|
|
189
|
+
let type = node["type"] as? String ?? "View"
|
|
190
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
191
|
+
let style = node["style"] as? [String: Any] ?? [:]
|
|
192
|
+
let events = node["events"] as? [String: String] ?? [:]
|
|
193
|
+
let children = node["children"] as? [Any] ?? []
|
|
194
|
+
let current: RenderedNode
|
|
195
|
+
if let previous = previous, previous.identity == identity, previous.type == type {
|
|
196
|
+
current = previous
|
|
197
|
+
} else {
|
|
198
|
+
current = RenderedNode(identity: identity, type: type, view: makeView(type))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let result = current.view
|
|
202
|
+
result.accessibilityIdentifier = explicitKey(node, props: props) ?? path
|
|
203
|
+
if type != "View" && type != "SafeAreaView" {
|
|
204
|
+
result.setContentHuggingPriority(.required, for: .vertical)
|
|
205
|
+
}
|
|
206
|
+
result.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
207
|
+
result.layer.cornerRadius = number(style["borderRadius"]) ?? 0
|
|
208
|
+
updateDimension(number(style["width"]), constraint: ¤t.widthConstraint, anchor: result.widthAnchor)
|
|
209
|
+
updateDimension(number(style["height"]), constraint: ¤t.heightConstraint, anchor: result.heightAnchor)
|
|
210
|
+
|
|
211
|
+
switch type {
|
|
212
|
+
case "Text":
|
|
213
|
+
let label = result as! UILabel
|
|
214
|
+
label.text = children.compactMap { $0 as? String }.joined()
|
|
215
|
+
label.textColor = color(style["color"]) ?? .label
|
|
216
|
+
label.font = .systemFont(ofSize: number(style["fontSize"]) ?? UIFont.systemFontSize)
|
|
217
|
+
case "Button":
|
|
218
|
+
let button = result as! UIButton
|
|
219
|
+
button.setTitle(props["title"] as? String ?? children.compactMap { $0 as? String }.joined(), for: .normal)
|
|
220
|
+
updateHandler(events["onPress"] ?? events["onClick"], for: button)
|
|
221
|
+
case "TextInput":
|
|
222
|
+
let field = result as! UITextField
|
|
223
|
+
field.placeholder = props["placeholder"] as? String
|
|
224
|
+
updateField(field, value: props["value"] as? String)
|
|
225
|
+
updateHandler(events["onChange"] ?? events["onChangeText"], for: field)
|
|
226
|
+
default:
|
|
227
|
+
let stack = result as! UIStackView
|
|
228
|
+
stack.axis = style["flexDirection"] as? String == "row" ? .horizontal : .vertical
|
|
229
|
+
stack.spacing = number(style["gap"]) ?? 0
|
|
230
|
+
let padding = number(style["padding"]) ?? 0
|
|
231
|
+
stack.layoutMargins = UIEdgeInsets(
|
|
232
|
+
top: number(style["paddingTop"]) ?? padding,
|
|
233
|
+
left: number(style["paddingLeft"]) ?? padding,
|
|
234
|
+
bottom: number(style["paddingBottom"]) ?? padding,
|
|
235
|
+
right: number(style["paddingRight"]) ?? padding
|
|
236
|
+
)
|
|
237
|
+
reconcileChildren(children, in: stack, parent: current, path: path)
|
|
238
|
+
}
|
|
239
|
+
return current
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private func makeView(_ type: String) -> UIView {
|
|
243
|
+
switch type {
|
|
244
|
+
case "Text":
|
|
245
|
+
let label = UILabel()
|
|
246
|
+
label.numberOfLines = 0
|
|
247
|
+
return label
|
|
248
|
+
case "Button":
|
|
249
|
+
let button = UIButton(type: .system)
|
|
250
|
+
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
251
|
+
return button
|
|
252
|
+
case "TextInput":
|
|
253
|
+
let field = UITextField()
|
|
254
|
+
field.borderStyle = .roundedRect
|
|
255
|
+
field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
|
|
256
|
+
return field
|
|
257
|
+
default:
|
|
258
|
+
let stack = UIStackView()
|
|
259
|
+
stack.alignment = .fill
|
|
260
|
+
stack.distribution = .fill
|
|
261
|
+
stack.isLayoutMarginsRelativeArrangement = true
|
|
262
|
+
return stack
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
267
|
+
// The current stx-native compiler emits key in props; accept the IR
|
|
268
|
+
// field too so keyed children keep working when the compiler adopts it.
|
|
269
|
+
[node["key"], props["key"], props["testID"]]
|
|
270
|
+
.compactMap { $0 as? String }
|
|
271
|
+
.first { !$0.isEmpty }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func reconcileChildren(_ children: [Any], in stack: UIStackView, parent: RenderedNode, path: String) {
|
|
275
|
+
let nodes = children.compactMap { $0 as? [String: Any] }
|
|
276
|
+
let keys = nodes.compactMap { explicitKey($0, props: $0["props"] as? [String: Any] ?? [:]) }
|
|
277
|
+
let counts = Dictionary(keys.map { ($0, 1) }, uniquingKeysWith: +)
|
|
278
|
+
let old = Dictionary(uniqueKeysWithValues: parent.children.map { ($0.identity, $0) })
|
|
279
|
+
var next: [RenderedNode] = []
|
|
280
|
+
|
|
281
|
+
for (index, node) in nodes.enumerated() {
|
|
282
|
+
let key = explicitKey(node, props: node["props"] as? [String: Any] ?? [:])
|
|
283
|
+
// Keys are local to a parent. Ambiguous sibling keys fall back to
|
|
284
|
+
// position so a move never attaches the wrong live control.
|
|
285
|
+
let identity = key.flatMap { counts[$0] == 1 ? "key:\($0)" : nil } ?? "index:\(index)"
|
|
286
|
+
let child = reconcile(node, identity: identity, path: "\(path).\(index)", previous: old[identity])
|
|
287
|
+
next.append(child)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for child in parent.children where !next.contains(where: { $0 === child }) {
|
|
291
|
+
detach(child, from: stack)
|
|
292
|
+
}
|
|
293
|
+
for (index, child) in next.enumerated() {
|
|
294
|
+
if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view { continue }
|
|
295
|
+
if stack.arrangedSubviews.contains(where: { $0 === child.view }) {
|
|
296
|
+
stack.removeArrangedSubview(child.view)
|
|
297
|
+
}
|
|
298
|
+
stack.insertArrangedSubview(child.view, at: index)
|
|
299
|
+
}
|
|
300
|
+
parent.children = next
|
|
301
|
+
if path == "root" && stack.axis == .vertical {
|
|
302
|
+
// Keep the initial screen pinned to the safe-area top.
|
|
303
|
+
if stack.arrangedSubviews.count == next.count { stack.addArrangedSubview(UIView()) }
|
|
304
|
+
} else if stack.arrangedSubviews.count > next.count {
|
|
305
|
+
for filler in stack.arrangedSubviews.dropFirst(next.count) {
|
|
306
|
+
stack.removeArrangedSubview(filler)
|
|
307
|
+
filler.removeFromSuperview()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private func detach(_ node: RenderedNode, from stack: UIStackView) {
|
|
313
|
+
forgetHandlers(node)
|
|
314
|
+
stack.removeArrangedSubview(node.view)
|
|
315
|
+
node.view.removeFromSuperview()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private func forgetHandlers(_ node: RenderedNode) {
|
|
319
|
+
handlers.removeValue(forKey: ObjectIdentifier(node.view))
|
|
320
|
+
for child in node.children { forgetHandlers(child) }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private func updateHandler(_ handler: String?, for view: UIView) {
|
|
324
|
+
let id = ObjectIdentifier(view)
|
|
325
|
+
if let handler = handler { handlers[id] = handler }
|
|
326
|
+
else { handlers.removeValue(forKey: id) }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private func updateField(_ field: UITextField, value: String?) {
|
|
330
|
+
guard let value = value, field.text != value, field.markedTextRange == nil else { return }
|
|
331
|
+
let selection = field.selectedTextRange
|
|
332
|
+
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
333
|
+
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
334
|
+
field.text = value
|
|
335
|
+
if let start = start, let end = end,
|
|
336
|
+
let from = field.position(from: field.beginningOfDocument, offset: min(start, value.utf16.count)),
|
|
337
|
+
let to = field.position(from: field.beginningOfDocument, offset: min(end, value.utf16.count)) {
|
|
338
|
+
field.selectedTextRange = field.textRange(from: from, to: to)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func updateDimension(_ value: CGFloat?, constraint: inout NSLayoutConstraint?, anchor: NSLayoutDimension) {
|
|
343
|
+
if let value = value {
|
|
344
|
+
if let constraint = constraint { constraint.constant = value }
|
|
345
|
+
else {
|
|
346
|
+
constraint = anchor.constraint(equalToConstant: value)
|
|
347
|
+
constraint?.isActive = true
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
constraint?.isActive = false
|
|
351
|
+
constraint = nil
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@objc private func buttonPressed(_ sender: UIButton) {
|
|
356
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
357
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
@objc private func textChanged(_ sender: UITextField) {
|
|
361
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
362
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private func send(type: String, payload: [String: Any], correlationId: String? = nil) {
|
|
366
|
+
var message: [String: Any] = ["type": type, "payload": payload]
|
|
367
|
+
if let correlationId = correlationId { message["correlationId"] = correlationId }
|
|
368
|
+
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
|
369
|
+
let json = String(data: data, encoding: .utf8) else { return }
|
|
370
|
+
jsContext.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json])
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private func number(_ value: Any?) -> CGFloat? {
|
|
374
|
+
(value as? NSNumber).map { CGFloat(truncating: $0) }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private func color(_ value: Any?) -> UIColor? {
|
|
378
|
+
guard let hex = value as? String else { return nil }
|
|
379
|
+
return UIColor(hex: hex)
|
|
380
|
+
}
|
|
381
|
+
}
|