craft-native 0.0.105 → 0.0.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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: &current.widthConstraint, anchor: result.widthAnchor)
209
+ updateDimension(number(style["height"]), constraint: &current.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
+ }
package/dist/types.d.ts CHANGED
@@ -1062,6 +1062,44 @@ export interface CraftWindowControls {
1062
1062
  */
1063
1063
  replicas: 'none' | null;
1064
1064
  }
1065
+ /** One row of a native context menu. */
1066
+ export interface CraftContextMenuItem {
1067
+ id: string;
1068
+ title: string;
1069
+ /** `'separator'` draws a divider; `'submenu'` nests `submenu` one level. */
1070
+ type?: 'standard' | 'separator' | 'submenu';
1071
+ /** SF Symbol name, e.g. `'archivebox'`. */
1072
+ icon?: string;
1073
+ /**
1074
+ * `'cmd+e'`, `'cmd+delete'`, `'cmd+shift+up'`: modifiers, then a character
1075
+ * or a key name (delete, forwarddelete, return, escape, tab, space, up,
1076
+ * down, left, right, home, end, pageup, pagedown, f1-f12, plus, minus).
1077
+ */
1078
+ shortcut?: string;
1079
+ enabled?: boolean;
1080
+ submenu?: CraftContextMenuItem[];
1081
+ }
1082
+ /** How a context menu closed. `id` is null when it was dismissed. */
1083
+ export interface CraftContextMenuResult {
1084
+ id: string | null;
1085
+ targetId: string;
1086
+ targetType: string;
1087
+ }
1088
+ export interface CraftNativeUIAPI {
1089
+ /**
1090
+ * Open a native context menu at the page point `x`/`y` (an event's
1091
+ * `clientX`/`clientY`). Resolves when the menu closes.
1092
+ */
1093
+ showContextMenu: (options: {
1094
+ items: CraftContextMenuItem[];
1095
+ x: number;
1096
+ y: number;
1097
+ targetId?: string;
1098
+ targetType?: string;
1099
+ }) => Promise<CraftContextMenuResult>;
1100
+ /** Every context menu's outcome. Returns an unsubscribe function. */
1101
+ onContextMenuAction: (callback: (result: CraftContextMenuResult) => void) => () => void;
1102
+ }
1065
1103
  export interface CraftBridgeAPI {
1066
1104
  /**
1067
1105
  * Where the platform drew this window's close/minimise/zoom buttons.
@@ -1111,6 +1149,11 @@ export interface CraftBridgeAPI {
1111
1149
  * Native sidebar APIs (macOS)
1112
1150
  */
1113
1151
  sidebar?: CraftSidebarAPI;
1152
+ /**
1153
+ * Native AppKit controls the page can open (macOS). Also reachable as
1154
+ * `craft.components`.
1155
+ */
1156
+ nativeUI?: CraftNativeUIAPI;
1114
1157
  /**
1115
1158
  * Crypto APIs
1116
1159
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.105",
3
+ "version": "0.0.109",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",