craft-native 0.0.171 → 0.0.173
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 +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +6 -0
- package/dist/ios/templates/CraftHybrid.swift +33 -18
- package/dist/ios/templates/CraftNativeScreen.swift +56 -6
- package/dist/macos/src/index.d.ts +16 -0
- package/dist/macos/src/index.js +75 -0
- package/dist/macos/templates/CraftMacApplication.swift +342 -0
- package/dist/macos/templates/CraftMacRenderer.swift +481 -0
- package/dist/macos/templates/CraftMacRuntime.swift +266 -0
- package/dist/macos/templates/CraftNativeMutation.swift +390 -0
- package/dist/macos/templates/native_layout.zig +1651 -0
- package/package.json +9 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import ZigJS
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// One zig-js context per STX window. No DOM and no browser view.
|
|
6
|
+
final class CraftMacRuntime {
|
|
7
|
+
let context = ZigJSContext()!
|
|
8
|
+
let route: String
|
|
9
|
+
let network: CraftMacNetwork
|
|
10
|
+
weak var scene: CraftMacScene?
|
|
11
|
+
private var timers: [Int: DispatchWorkItem] = [:]
|
|
12
|
+
private var tasks: [String: URLSessionTask] = [:]
|
|
13
|
+
private var disposed = false
|
|
14
|
+
var errors: [String] = []
|
|
15
|
+
let document = CraftNativeMutationDocument()
|
|
16
|
+
|
|
17
|
+
init(route: String, params: [String: Any], network: CraftMacNetwork) {
|
|
18
|
+
self.route = route
|
|
19
|
+
self.network = network
|
|
20
|
+
context.exceptionHandler = { [weak self] _, value in
|
|
21
|
+
let message = value?.objectForKeyedSubscript("message")?.toString() ?? "Native script failed"
|
|
22
|
+
let stack = value?.objectForKeyedSubscript("stack")?.toString() ?? ""
|
|
23
|
+
self?.errors.append(message)
|
|
24
|
+
fputs("[craft native] \(message)\n\(stack)\n", stderr)
|
|
25
|
+
}
|
|
26
|
+
let post: @convention(block) (String) -> Void = { [weak self] text in self?.receive(text) }
|
|
27
|
+
let schedule: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
|
|
28
|
+
guard let self, !self.disposed else { return }
|
|
29
|
+
let work = DispatchWorkItem { [weak self] in
|
|
30
|
+
guard let self, self.timers.removeValue(forKey: id) != nil, !self.disposed else { return }
|
|
31
|
+
self.context.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
|
|
32
|
+
ZJSContextRunMicrotasks(self.context)
|
|
33
|
+
}
|
|
34
|
+
self.timers[id] = work
|
|
35
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + max(0, min(delay.isFinite ? delay : 0, 2_147_483_647)) / 1000, execute: work)
|
|
36
|
+
}
|
|
37
|
+
let cancel: @convention(block) (Int) -> Void = { [weak self] id in self?.timers.removeValue(forKey: id)?.cancel() }
|
|
38
|
+
let log: @convention(block) (String, String) -> Void = { [weak self] level, text in
|
|
39
|
+
if level == "error" { self?.errors.append(text) }
|
|
40
|
+
fputs("[stx \(level)] \(text)\n", stderr)
|
|
41
|
+
}
|
|
42
|
+
context.setObject(post, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
43
|
+
context.setObject(schedule, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
|
|
44
|
+
context.setObject(cancel, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
|
|
45
|
+
context.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
46
|
+
context.setObject(route, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
47
|
+
context.setObject(params, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
48
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
49
|
+
let bridge: [String: Any] = [
|
|
50
|
+
"platform": "macos", "mutationProtocolVersion": 1, "capabilityProtocolVersion": 1,
|
|
51
|
+
"capabilityTimeoutMs": 30_000, "capabilities": ["desktop", "fetch", "eventStream", "storage", "clipboard", "dialog", "linking", "haptics", "notifications"],
|
|
52
|
+
"initialAppState": "active", "colorScheme": CraftMacColor.isDark ? "dark" : "light",
|
|
53
|
+
"appURL": network.base.absoluteString, "appVersion": info["CFBundleShortVersionString"] as? String ?? "", "appBuild": info["CFBundleVersion"] as? String ?? ""
|
|
54
|
+
]
|
|
55
|
+
let bridgeJSON = String(data: try! JSONSerialization.data(withJSONObject: bridge), encoding: .utf8)!
|
|
56
|
+
context.evaluateScript("""
|
|
57
|
+
globalThis.__stxNativeCallback = null;
|
|
58
|
+
globalThis.__stxNativeBridge = \(bridgeJSON);
|
|
59
|
+
__stxNativeBridge.postMessage = function(text) { craftNativePostMessage(text); };
|
|
60
|
+
__stxNativeBridge.onMessage = function(callback) { globalThis.__stxNativeCallback = callback; };
|
|
61
|
+
globalThis.console = {};
|
|
62
|
+
['log','info','warn','error','debug'].forEach(function(level) {
|
|
63
|
+
console[level] = function() { craftNativeLog(level, Array.from(arguments).map(String).join(' ')); };
|
|
64
|
+
});
|
|
65
|
+
(function() {
|
|
66
|
+
var timers = new Map(), sequence = 0;
|
|
67
|
+
globalThis.setTimeout = function(callback, delay) {
|
|
68
|
+
var args = Array.prototype.slice.call(arguments, 2), id = ++sequence;
|
|
69
|
+
timers.set(id, function() { if (typeof callback === 'function') callback.apply(undefined, args); });
|
|
70
|
+
craftNativeScheduleTimer(id, Number(delay) || 0); return id;
|
|
71
|
+
};
|
|
72
|
+
globalThis.clearTimeout = function(id) { if (timers.delete(id)) craftNativeCancelTimer(id); };
|
|
73
|
+
globalThis.__craftNativeFireTimer = function(id) {
|
|
74
|
+
var callback = timers.get(id); if (!callback) return; timers.delete(id); callback();
|
|
75
|
+
};
|
|
76
|
+
var intervals = new Map();
|
|
77
|
+
globalThis.setInterval = function(callback, delay) {
|
|
78
|
+
var id = ++sequence, args = Array.prototype.slice.call(arguments, 2);
|
|
79
|
+
function tick() { if (!intervals.has(id)) return; intervals.set(id, setTimeout(tick, Math.max(1, Number(delay) || 0))); callback.apply(undefined,args); }
|
|
80
|
+
intervals.set(id, setTimeout(tick, Math.max(1, Number(delay) || 0))); return id;
|
|
81
|
+
};
|
|
82
|
+
globalThis.clearInterval = function(id) { clearTimeout(intervals.get(id)); intervals.delete(id); };
|
|
83
|
+
})();
|
|
84
|
+
""")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func load(_ bundle: String) { context.evaluateScript(bundle); ZJSContextRunMicrotasks(context) }
|
|
88
|
+
|
|
89
|
+
func dispose() {
|
|
90
|
+
guard !disposed else { return }
|
|
91
|
+
context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
92
|
+
disposed = true
|
|
93
|
+
timers.values.forEach { $0.cancel() }; timers.removeAll()
|
|
94
|
+
tasks.values.forEach { $0.cancel() }; tasks.removeAll()
|
|
95
|
+
network.cancelStreams(owner: self)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func send(_ type: String, _ payload: [String: Any], correlation: String? = nil) {
|
|
99
|
+
guard !disposed else { return }
|
|
100
|
+
var message: [String: Any] = ["id": UUID().uuidString, "type": type, "timestamp": Date().timeIntervalSince1970 * 1000, "source": "native", "payload": payload]
|
|
101
|
+
if let correlation { message["correlationId"] = correlation }
|
|
102
|
+
guard let bytes = try? JSONSerialization.data(withJSONObject: message), let text = String(data: bytes, encoding: .utf8) else { return }
|
|
103
|
+
DispatchQueue.main.async { [weak self] in
|
|
104
|
+
guard let self, !self.disposed else { return }
|
|
105
|
+
self.context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [text])
|
|
106
|
+
ZJSContextRunMicrotasks(self.context)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func event(_ id: String?, payload: [String: Any] = [:]) {
|
|
111
|
+
guard let id, !id.isEmpty else { return }
|
|
112
|
+
send("EVENT", ["handlerId": id, "nativeEvent": payload])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func receive(_ text: String) {
|
|
116
|
+
guard let data = text.data(using: .utf8), let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
117
|
+
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
118
|
+
switch message["type"] as? String {
|
|
119
|
+
case "RENDER":
|
|
120
|
+
guard let tree = payload["document"] as? [String: Any] else { return }
|
|
121
|
+
document.replace(with: tree)
|
|
122
|
+
scene?.renderer.render(tree)
|
|
123
|
+
case "MUTATE":
|
|
124
|
+
do {
|
|
125
|
+
let result = try document.apply(payload)
|
|
126
|
+
if let tree = result.document { scene?.renderer.render(tree) }
|
|
127
|
+
send("MUTATION_ACK", ["version": 1, "batchId": result.batchId, "revision": result.revision])
|
|
128
|
+
} catch let error as CraftNativeMutationFailure {
|
|
129
|
+
send("MUTATION_ERROR", ["version": 1, "batchId": payload["batchId"] ?? "", "code": error.code, "message": error.message])
|
|
130
|
+
} catch { errors.append(error.localizedDescription) }
|
|
131
|
+
case "API_REQUEST":
|
|
132
|
+
guard let id = message["id"] as? String else { return }
|
|
133
|
+
guard payload["version"] as? Int == 1 else { fail(id, "UNSUPPORTED_VERSION", "Native capability version must be 1"); return }
|
|
134
|
+
perform(id, payload["module"] as? String ?? "", payload["method"] as? String ?? "", payload["args"] as? [Any] ?? [])
|
|
135
|
+
case "API_CANCEL":
|
|
136
|
+
if let id = payload["requestId"] as? String { tasks.removeValue(forKey: id)?.cancel() }
|
|
137
|
+
default: break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func answer(_ id: String, _ value: Any = NSNull()) {
|
|
142
|
+
tasks.removeValue(forKey: id)
|
|
143
|
+
send("API_RESPONSE", ["version": 1, "requestId": id, "data": value], correlation: id)
|
|
144
|
+
}
|
|
145
|
+
func fail(_ id: String, _ code: String, _ message: String) {
|
|
146
|
+
tasks.removeValue(forKey: id)
|
|
147
|
+
send("API_ERROR", ["version": 1, "requestId": id, "code": code, "message": message], correlation: id)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private func perform(_ id: String, _ module: String, _ method: String, _ args: [Any]) {
|
|
151
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
152
|
+
switch module {
|
|
153
|
+
case "Network":
|
|
154
|
+
if method == "cancelStream" { network.cancelStream(args.first as? String ?? "", owner: self); answer(id); return }
|
|
155
|
+
guard let request = network.request(options) else { fail(id, "INVALID_ARGUMENT", "Network.fetch requires an HTTP URL"); return }
|
|
156
|
+
if method == "stream" {
|
|
157
|
+
network.stream(request, id: options["id"] as? String ?? id, owner: self, requestId: id)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
guard method == "fetch" else { fail(id, "UNSUPPORTED_METHOD", method); return }
|
|
161
|
+
let task = network.session.dataTask(with: request) { [weak self] data, response, error in
|
|
162
|
+
DispatchQueue.main.async {
|
|
163
|
+
guard let self, !self.disposed else { return }
|
|
164
|
+
if let error { self.fail(id, "NETWORK_ERROR", error.localizedDescription); return }
|
|
165
|
+
guard let http = response as? HTTPURLResponse else { self.fail(id, "NETWORK_ERROR", "Invalid HTTP response"); return }
|
|
166
|
+
if (data?.count ?? 0) > 32 * 1024 * 1024 { self.fail(id, "RESPONSE_TOO_LARGE", "Response exceeds 32 MB"); return }
|
|
167
|
+
self.answer(id, CraftMacNetwork.response(http, original: request.url, body: String(decoding: data ?? Data(), as: UTF8.self)))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
tasks[id] = task; task.resume()
|
|
171
|
+
case "Storage":
|
|
172
|
+
let prefix = "craft.native."
|
|
173
|
+
let key = prefix + (args.first as? String ?? "")
|
|
174
|
+
switch method {
|
|
175
|
+
case "get": answer(id, UserDefaults.standard.object(forKey: key) ?? NSNull())
|
|
176
|
+
case "set": if args.count > 1 { UserDefaults.standard.set(args[1], forKey: key) }; answer(id)
|
|
177
|
+
case "remove": UserDefaults.standard.removeObject(forKey: key); answer(id)
|
|
178
|
+
case "keys": answer(id, UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
|
|
179
|
+
default: fail(id, "UNSUPPORTED_METHOD", method)
|
|
180
|
+
}
|
|
181
|
+
case "Clipboard":
|
|
182
|
+
if method == "read" { answer(id, NSPasteboard.general.string(forType: .string) ?? "") }
|
|
183
|
+
else { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args.first as? String ?? "", forType: .string); answer(id) }
|
|
184
|
+
case "Haptics": NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default); answer(id)
|
|
185
|
+
case "Dialog":
|
|
186
|
+
let alert = NSAlert(); alert.messageText = options["title"] as? String ?? ""; alert.informativeText = options["message"] as? String ?? options["body"] as? String ?? ""
|
|
187
|
+
alert.addButton(withTitle: options["confirmText"] as? String ?? "OK")
|
|
188
|
+
if method == "confirm" { alert.addButton(withTitle: options["cancelText"] as? String ?? "Cancel") }
|
|
189
|
+
let accepted = alert.runModal() == .alertFirstButtonReturn
|
|
190
|
+
answer(id, method == "confirm" ? accepted : NSNull())
|
|
191
|
+
case "Linking":
|
|
192
|
+
guard let text = args.first as? String, let url = URL(string: text), let scheme = url.scheme, ["https", "http", "imessage", "sms", "mailto", "x-apple.systempreferences"].contains(scheme) else { fail(id, "INVALID_ARGUMENT", "Unsupported URL scheme"); return }
|
|
193
|
+
answer(id, method == "canOpenURL" ? NSWorkspace.shared.urlForApplication(toOpen: url) != nil : NSWorkspace.shared.open(url))
|
|
194
|
+
case "Desktop": CraftMacApplication.shared.perform(method, args: args, scene: scene, reply: { [weak self] value in self?.answer(id, value) })
|
|
195
|
+
case "Notifications": CraftMacApplication.shared.notification(method, options: options, reply: { [weak self] value in self?.answer(id, value) })
|
|
196
|
+
default: fail(id, "CAPABILITY_DISABLED", "Unsupported native capability \(module).\(method)")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// A session shared by native windows, with an isolated, HttpOnly cookie store.
|
|
202
|
+
final class CraftMacNetwork: NSObject, URLSessionDataDelegate {
|
|
203
|
+
let base: URL
|
|
204
|
+
lazy var session: URLSession = {
|
|
205
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
206
|
+
configuration.timeoutIntervalForRequest = 300
|
|
207
|
+
configuration.timeoutIntervalForResource = 7 * 24 * 60 * 60
|
|
208
|
+
return URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
|
|
209
|
+
}()
|
|
210
|
+
private struct Stream { let id: String; weak var owner: CraftMacRuntime?; let requestId: String; var opened: Bool = false; var carry = Data() }
|
|
211
|
+
private var streams: [Int: Stream] = [:]
|
|
212
|
+
init(base: URL) { self.base = base }
|
|
213
|
+
func request(_ options: [String: Any]) -> URLRequest? {
|
|
214
|
+
guard let address = options["url"] as? String, let url = URL(string: address, relativeTo: base)?.absoluteURL,
|
|
215
|
+
["http", "https"].contains(url.scheme ?? ""), url.host != nil else { return nil }
|
|
216
|
+
var request = URLRequest(url: url)
|
|
217
|
+
request.httpMethod = (options["method"] as? String ?? "GET").uppercased()
|
|
218
|
+
for (key, value) in options["headers"] as? [String: String] ?? [:] { request.setValue(value, forHTTPHeaderField: key) }
|
|
219
|
+
if let body = options["body"] as? String { request.httpBody = Data(body.utf8) }
|
|
220
|
+
return request
|
|
221
|
+
}
|
|
222
|
+
static func response(_ response: HTTPURLResponse, original: URL?, body: String = "") -> [String: Any] {
|
|
223
|
+
["url": response.url?.absoluteString ?? "", "status": response.statusCode,
|
|
224
|
+
"statusText": HTTPURLResponse.localizedString(forStatusCode: response.statusCode), "body": body,
|
|
225
|
+
"redirected": response.url != original,
|
|
226
|
+
"headers": Dictionary(response.allHeaderFields.map { (String(describing: $0.key).lowercased(), String(describing: $0.value)) }, uniquingKeysWith: { _, value in value })]
|
|
227
|
+
}
|
|
228
|
+
func stream(_ request: URLRequest, id: String, owner: CraftMacRuntime, requestId: String) {
|
|
229
|
+
let task = session.dataTask(with: request)
|
|
230
|
+
streams[task.taskIdentifier] = Stream(id: id, owner: owner, requestId: requestId)
|
|
231
|
+
task.resume()
|
|
232
|
+
}
|
|
233
|
+
func cancelStream(_ id: String, owner: CraftMacRuntime) {
|
|
234
|
+
let identifiers = streams.filter { $0.value.id == id && $0.value.owner === owner }.map(\.key)
|
|
235
|
+
session.getAllTasks { tasks in DispatchQueue.main.async { for task in tasks where identifiers.contains(task.taskIdentifier) { self.streams.removeValue(forKey: task.taskIdentifier); task.cancel() } } }
|
|
236
|
+
}
|
|
237
|
+
func cancelStreams(owner: CraftMacRuntime) { for stream in streams.values where stream.owner === owner { cancelStream(stream.id, owner: owner) } }
|
|
238
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
|
239
|
+
guard var stream = streams[dataTask.taskIdentifier] else { completionHandler(.allow); return }
|
|
240
|
+
guard let http = response as? HTTPURLResponse else { completionHandler(.cancel); stream.owner?.fail(stream.requestId, "NETWORK_ERROR", "Invalid stream response"); return }
|
|
241
|
+
stream.opened = true; streams[dataTask.taskIdentifier] = stream
|
|
242
|
+
stream.owner?.answer(stream.requestId, Self.response(http, original: dataTask.originalRequest?.url))
|
|
243
|
+
completionHandler(.allow)
|
|
244
|
+
}
|
|
245
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
246
|
+
guard var stream = streams[dataTask.taskIdentifier] else { return }
|
|
247
|
+
let bytes = [UInt8](stream.carry + data)
|
|
248
|
+
var cut = bytes.count, back = 1
|
|
249
|
+
while back <= min(3, bytes.count) {
|
|
250
|
+
let byte = bytes[bytes.count - back]
|
|
251
|
+
if byte & 0xC0 == 0x80 { back += 1; continue }
|
|
252
|
+
let length = byte >= 0xF0 ? 4 : byte >= 0xE0 ? 3 : byte >= 0xC0 ? 2 : 1
|
|
253
|
+
if length > back { cut -= back }
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
stream.carry = Data(bytes[cut...]); streams[dataTask.taskIdentifier] = stream
|
|
257
|
+
let text = String(decoding: bytes[..<cut], as: UTF8.self)
|
|
258
|
+
if !text.isEmpty { stream.owner?.send("NETWORK_STREAM", ["id": stream.id, "chunk": text]) }
|
|
259
|
+
}
|
|
260
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
261
|
+
guard let stream = streams.removeValue(forKey: task.taskIdentifier) else { return }
|
|
262
|
+
if !stream.opened { stream.owner?.fail(stream.requestId, "NETWORK_ERROR", error?.localizedDescription ?? "Stream failed") }
|
|
263
|
+
else if let error { stream.owner?.send("NETWORK_STREAM", ["id": stream.id, "error": error.localizedDescription]) }
|
|
264
|
+
else { stream.owner?.send("NETWORK_STREAM", ["id": stream.id, "done": true]) }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import CoreFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
let craftNativeMutationProtocolVersion = 1
|
|
5
|
+
|
|
6
|
+
struct CraftNativeMutationFailure: Error, Equatable {
|
|
7
|
+
let code: String
|
|
8
|
+
let message: String
|
|
9
|
+
let operationIndex: Int?
|
|
10
|
+
|
|
11
|
+
init(_ code: String, _ message: String, operationIndex: Int? = nil) {
|
|
12
|
+
self.code = code
|
|
13
|
+
self.message = message
|
|
14
|
+
self.operationIndex = operationIndex
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
struct CraftNativeMutationResult {
|
|
19
|
+
let batchId: String
|
|
20
|
+
let revision: Int
|
|
21
|
+
let document: [String: Any]?
|
|
22
|
+
let updatedNodeIds: [String]
|
|
23
|
+
let affectedNodeIds: [String]
|
|
24
|
+
let requiresFullRender: Bool
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Validates and applies versioned native-tree mutations without touching UIKit.
|
|
28
|
+
/// A batch is committed only after every operation succeeds against a copy.
|
|
29
|
+
final class CraftNativeMutationDocument {
|
|
30
|
+
private struct Node {
|
|
31
|
+
let id: String
|
|
32
|
+
var type: String
|
|
33
|
+
var props: [String: Any]
|
|
34
|
+
var style: [String: Any]
|
|
35
|
+
var events: [String: Any]
|
|
36
|
+
var text: [String]
|
|
37
|
+
var children: [String]
|
|
38
|
+
var parent: String?
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private var nodes: [String: Node] = [:]
|
|
42
|
+
private var rootId: String?
|
|
43
|
+
private(set) var revision = 0
|
|
44
|
+
|
|
45
|
+
func replace(with document: [String: Any]) {
|
|
46
|
+
var replacement: [String: Node] = [:]
|
|
47
|
+
let root = importNode(document, preferredId: "root", parent: nil, nodes: &replacement)
|
|
48
|
+
nodes = replacement
|
|
49
|
+
rootId = root
|
|
50
|
+
revision = 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func apply(_ payload: [String: Any]) throws -> CraftNativeMutationResult {
|
|
54
|
+
let batchId = payload["batchId"] as? String ?? ""
|
|
55
|
+
guard !batchId.isEmpty else {
|
|
56
|
+
throw CraftNativeMutationFailure("INVALID_BATCH", "batchId must be a non-empty string")
|
|
57
|
+
}
|
|
58
|
+
guard integer(payload["version"]) == craftNativeMutationProtocolVersion else {
|
|
59
|
+
throw CraftNativeMutationFailure(
|
|
60
|
+
"UNSUPPORTED_VERSION",
|
|
61
|
+
"mutation protocol version must be \(craftNativeMutationProtocolVersion)"
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
guard let baseRevision = integer(payload["baseRevision"]), baseRevision == revision else {
|
|
65
|
+
throw CraftNativeMutationFailure(
|
|
66
|
+
"REVISION_MISMATCH",
|
|
67
|
+
"baseRevision must equal the current revision \(revision)"
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
guard let nextRevision = integer(payload["revision"]), nextRevision == revision + 1 else {
|
|
71
|
+
throw CraftNativeMutationFailure(
|
|
72
|
+
"INVALID_REVISION",
|
|
73
|
+
"revision must advance exactly once from \(revision)"
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
guard let operations = payload["operations"] as? [Any], !operations.isEmpty else {
|
|
77
|
+
throw CraftNativeMutationFailure("INVALID_BATCH", "operations must be a non-empty array")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var candidateNodes = nodes
|
|
81
|
+
var candidateRoot = rootId
|
|
82
|
+
var updatedNodeIds: [String] = []
|
|
83
|
+
var affectedNodeIds: [String] = []
|
|
84
|
+
var requiresFullRender = false
|
|
85
|
+
for (index, value) in operations.enumerated() {
|
|
86
|
+
guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
|
|
87
|
+
throw CraftNativeMutationFailure(
|
|
88
|
+
"INVALID_OPERATION",
|
|
89
|
+
"operation must be an object with an op string",
|
|
90
|
+
operationIndex: index
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
do {
|
|
94
|
+
switch name {
|
|
95
|
+
case "createNode":
|
|
96
|
+
if operation["root"] as? Bool == true { requiresFullRender = true }
|
|
97
|
+
try createNode(operation, nodes: &candidateNodes, rootId: &candidateRoot)
|
|
98
|
+
case "updateNode":
|
|
99
|
+
try updateNode(operation, nodes: &candidateNodes)
|
|
100
|
+
if let id = operation["id"] as? String {
|
|
101
|
+
appendUnique(id, to: &updatedNodeIds)
|
|
102
|
+
appendUnique(id, to: &affectedNodeIds)
|
|
103
|
+
}
|
|
104
|
+
case "insertChild":
|
|
105
|
+
try insertChild(operation, nodes: &candidateNodes)
|
|
106
|
+
if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
|
|
107
|
+
case "moveChild":
|
|
108
|
+
try moveChild(operation, nodes: &candidateNodes)
|
|
109
|
+
if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
|
|
110
|
+
case "removeNode":
|
|
111
|
+
if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
|
|
112
|
+
appendUnique(parent, to: &affectedNodeIds)
|
|
113
|
+
} else {
|
|
114
|
+
requiresFullRender = true
|
|
115
|
+
}
|
|
116
|
+
try removeNode(operation, nodes: &candidateNodes, rootId: &candidateRoot)
|
|
117
|
+
default:
|
|
118
|
+
throw CraftNativeMutationFailure("UNKNOWN_OPERATION", "unsupported operation \(name)")
|
|
119
|
+
}
|
|
120
|
+
} catch let failure as CraftNativeMutationFailure {
|
|
121
|
+
throw CraftNativeMutationFailure(failure.code, failure.message, operationIndex: index)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if candidateRoot == nil, !candidateNodes.isEmpty {
|
|
125
|
+
throw CraftNativeMutationFailure("MISSING_ROOT", "a non-empty document must have one root node")
|
|
126
|
+
}
|
|
127
|
+
if let candidateRoot {
|
|
128
|
+
guard let root = candidateNodes[candidateRoot], root.parent == nil else {
|
|
129
|
+
throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
|
|
130
|
+
}
|
|
131
|
+
guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
132
|
+
throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
nodes = candidateNodes
|
|
137
|
+
rootId = candidateRoot
|
|
138
|
+
revision = nextRevision
|
|
139
|
+
return CraftNativeMutationResult(
|
|
140
|
+
batchId: batchId,
|
|
141
|
+
revision: nextRevision,
|
|
142
|
+
document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
|
|
143
|
+
updatedNodeIds: updatedNodeIds,
|
|
144
|
+
affectedNodeIds: affectedNodeIds,
|
|
145
|
+
requiresFullRender: requiresFullRender
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func node(_ id: String) -> [String: Any]? {
|
|
150
|
+
materialize(id, nodes: nodes)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The committed parent of a node: nil for the root or a node it does not hold.
|
|
154
|
+
func parentId(of id: String) -> String? {
|
|
155
|
+
nodes[id]?.parent
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func createNode(
|
|
159
|
+
_ operation: [String: Any],
|
|
160
|
+
nodes: inout [String: Node],
|
|
161
|
+
rootId: inout String?
|
|
162
|
+
) throws {
|
|
163
|
+
guard let id = operation["id"] as? String, !id.isEmpty else {
|
|
164
|
+
throw CraftNativeMutationFailure("INVALID_NODE", "createNode.id must be a non-empty string")
|
|
165
|
+
}
|
|
166
|
+
guard nodes[id] == nil else {
|
|
167
|
+
throw CraftNativeMutationFailure("DUPLICATE_NODE", "node \(id) already exists")
|
|
168
|
+
}
|
|
169
|
+
guard let value = operation["node"] as? [String: Any],
|
|
170
|
+
let type = value["type"] as? String, !type.isEmpty else {
|
|
171
|
+
throw CraftNativeMutationFailure("INVALID_NODE", "createNode.node.type must be a non-empty string")
|
|
172
|
+
}
|
|
173
|
+
let text = try textChildren(value["children"] ?? value["text"])
|
|
174
|
+
let makeRoot = operation["root"] as? Bool == true
|
|
175
|
+
if makeRoot, rootId != nil {
|
|
176
|
+
throw CraftNativeMutationFailure("ROOT_EXISTS", "the document already has a root node")
|
|
177
|
+
}
|
|
178
|
+
nodes[id] = Node(
|
|
179
|
+
id: id,
|
|
180
|
+
type: type,
|
|
181
|
+
props: dictionary(value["props"]),
|
|
182
|
+
style: dictionary(value["style"]),
|
|
183
|
+
events: dictionary(value["events"]),
|
|
184
|
+
text: text,
|
|
185
|
+
children: [],
|
|
186
|
+
parent: nil
|
|
187
|
+
)
|
|
188
|
+
if makeRoot { rootId = id }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private func updateNode(_ operation: [String: Any], nodes: inout [String: Node]) throws {
|
|
192
|
+
let id = try requiredId(operation, name: "updateNode")
|
|
193
|
+
guard var node = nodes[id] else {
|
|
194
|
+
throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(id) does not exist")
|
|
195
|
+
}
|
|
196
|
+
guard let patch = operation["patch"] as? [String: Any] else {
|
|
197
|
+
throw CraftNativeMutationFailure("INVALID_PATCH", "updateNode.patch must be an object")
|
|
198
|
+
}
|
|
199
|
+
let allowed = Set(["props", "style", "events", "children", "text"])
|
|
200
|
+
if let unknown = patch.keys.sorted().first(where: { !allowed.contains($0) }) {
|
|
201
|
+
throw CraftNativeMutationFailure("INVALID_PATCH", "unsupported patch field \(unknown)")
|
|
202
|
+
}
|
|
203
|
+
if patch.keys.contains("props") { mergePatch(patch["props"], into: &node.props) }
|
|
204
|
+
if patch.keys.contains("style") { mergePatch(patch["style"], into: &node.style) }
|
|
205
|
+
if patch.keys.contains("events") { mergePatch(patch["events"], into: &node.events) }
|
|
206
|
+
if patch.keys.contains("children") || patch.keys.contains("text") {
|
|
207
|
+
node.text = try textChildren(patch["children"] ?? patch["text"])
|
|
208
|
+
}
|
|
209
|
+
nodes[id] = node
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func insertChild(_ operation: [String: Any], nodes: inout [String: Node]) throws {
|
|
213
|
+
let parentId = try requiredId(operation, field: "parentId", name: "insertChild")
|
|
214
|
+
let childId = try requiredId(operation, field: "childId", name: "insertChild")
|
|
215
|
+
guard var parent = nodes[parentId] else {
|
|
216
|
+
throw CraftNativeMutationFailure("UNKNOWN_PARENT", "parent node \(parentId) does not exist")
|
|
217
|
+
}
|
|
218
|
+
guard var child = nodes[childId] else {
|
|
219
|
+
throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
|
|
220
|
+
}
|
|
221
|
+
guard acceptsChildren(parent.type) else {
|
|
222
|
+
throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
|
|
223
|
+
}
|
|
224
|
+
guard child.parent == nil else {
|
|
225
|
+
throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
|
|
226
|
+
}
|
|
227
|
+
guard parentId != childId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
|
|
228
|
+
throw CraftNativeMutationFailure("CYCLE", "inserting node \(childId) would create a cycle")
|
|
229
|
+
}
|
|
230
|
+
let index = try insertionIndex(operation, count: parent.children.count)
|
|
231
|
+
parent.children.insert(childId, at: index)
|
|
232
|
+
child.parent = parentId
|
|
233
|
+
nodes[parentId] = parent
|
|
234
|
+
nodes[childId] = child
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private func moveChild(_ operation: [String: Any], nodes: inout [String: Node]) throws {
|
|
238
|
+
let parentId = try requiredId(operation, field: "parentId", name: "moveChild")
|
|
239
|
+
let childId = try requiredId(operation, field: "childId", name: "moveChild")
|
|
240
|
+
guard var parent = nodes[parentId] else {
|
|
241
|
+
throw CraftNativeMutationFailure("UNKNOWN_PARENT", "parent node \(parentId) does not exist")
|
|
242
|
+
}
|
|
243
|
+
guard nodes[childId]?.parent == parentId, let oldIndex = parent.children.firstIndex(of: childId) else {
|
|
244
|
+
throw CraftNativeMutationFailure("NOT_A_CHILD", "node \(childId) is not a child of \(parentId)")
|
|
245
|
+
}
|
|
246
|
+
parent.children.remove(at: oldIndex)
|
|
247
|
+
let index = try insertionIndex(operation, count: parent.children.count)
|
|
248
|
+
parent.children.insert(childId, at: index)
|
|
249
|
+
nodes[parentId] = parent
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private func removeNode(
|
|
253
|
+
_ operation: [String: Any],
|
|
254
|
+
nodes: inout [String: Node],
|
|
255
|
+
rootId: inout String?
|
|
256
|
+
) throws {
|
|
257
|
+
let id = try requiredId(operation, name: "removeNode")
|
|
258
|
+
guard let node = nodes[id] else {
|
|
259
|
+
throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(id) does not exist")
|
|
260
|
+
}
|
|
261
|
+
if let parentId = node.parent, var parent = nodes[parentId] {
|
|
262
|
+
parent.children.removeAll { $0 == id }
|
|
263
|
+
nodes[parentId] = parent
|
|
264
|
+
}
|
|
265
|
+
for descendant in descendants(of: id, nodes: nodes).union([id]) { nodes.removeValue(forKey: descendant) }
|
|
266
|
+
if rootId == id { rootId = nil }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func requiredId(
|
|
270
|
+
_ operation: [String: Any],
|
|
271
|
+
field: String = "id",
|
|
272
|
+
name: String
|
|
273
|
+
) throws -> String {
|
|
274
|
+
guard let id = operation[field] as? String, !id.isEmpty else {
|
|
275
|
+
throw CraftNativeMutationFailure("INVALID_NODE", "\(name).\(field) must be a non-empty string")
|
|
276
|
+
}
|
|
277
|
+
return id
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private func insertionIndex(_ operation: [String: Any], count: Int) throws -> Int {
|
|
281
|
+
guard let index = integer(operation["index"]), index >= 0, index <= count else {
|
|
282
|
+
throw CraftNativeMutationFailure("INVALID_INDEX", "index must be between 0 and \(count)")
|
|
283
|
+
}
|
|
284
|
+
return index
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private func textChildren(_ value: Any?) throws -> [String] {
|
|
288
|
+
if value == nil || value is NSNull { return [] }
|
|
289
|
+
if let text = value as? String { return [text] }
|
|
290
|
+
guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else {
|
|
291
|
+
throw CraftNativeMutationFailure(
|
|
292
|
+
"INVALID_NODE",
|
|
293
|
+
"node children may contain text only; use insertChild for node relationships"
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
return values.compactMap { $0 as? String }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// Every type the renderer draws children of (CraftNativeScreen's
|
|
300
|
+
/// reconcile), by the names it accepts (`canonicalType`, so `svg` is
|
|
301
|
+
/// `Svg`). Only View, SafeAreaView and ScrollView used to be here, so a
|
|
302
|
+
/// drawing revealed after the first paint (an `:if` around an `<svg>`)
|
|
303
|
+
/// had its shapes refused as INVALID_PARENT, and the whole screen was
|
|
304
|
+
/// drawn again; Android's host already took the same list.
|
|
305
|
+
static let containerTypes: Set<String> = [
|
|
306
|
+
"View", "SafeAreaView", "ScrollView", "KeyboardAvoidingView", "Modal", "Pressable",
|
|
307
|
+
"TouchableOpacity", "TouchableHighlight", "ImageBackground", "FlatList", "SectionList",
|
|
308
|
+
"Fragment", "Svg", "G",
|
|
309
|
+
]
|
|
310
|
+
private static let containerTypeKeys = Set(containerTypes.map { $0.lowercased() })
|
|
311
|
+
|
|
312
|
+
private func acceptsChildren(_ type: String) -> Bool {
|
|
313
|
+
Self.containerTypeKeys.contains(type.lowercased())
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private func descendants(of id: String, nodes: [String: Node]) -> Set<String> {
|
|
317
|
+
guard let node = nodes[id] else { return [] }
|
|
318
|
+
return node.children.reduce(into: Set<String>()) { result, child in
|
|
319
|
+
result.insert(child)
|
|
320
|
+
result.formUnion(descendants(of: child, nodes: nodes))
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func materialize(_ id: String, nodes: [String: Node]) -> [String: Any]? {
|
|
325
|
+
guard let node = nodes[id] else { return nil }
|
|
326
|
+
var value: [String: Any] = [
|
|
327
|
+
"id": node.id,
|
|
328
|
+
"type": node.type,
|
|
329
|
+
"props": node.props,
|
|
330
|
+
"style": node.style,
|
|
331
|
+
"events": node.events,
|
|
332
|
+
]
|
|
333
|
+
let text: [Any] = node.text
|
|
334
|
+
let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
|
|
335
|
+
value["children"] = text + children
|
|
336
|
+
return value
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private func importNode(
|
|
340
|
+
_ value: [String: Any],
|
|
341
|
+
preferredId: String,
|
|
342
|
+
parent: String?,
|
|
343
|
+
nodes: inout [String: Node]
|
|
344
|
+
) -> String {
|
|
345
|
+
let props = dictionary(value["props"])
|
|
346
|
+
let explicit = (value["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
347
|
+
let key = ([value["key"], props["key"], props["testID"]].compactMap { $0 as? String }.first)
|
|
348
|
+
var id = explicit ?? key.map { "\(parent ?? "root")/key:\($0)" } ?? preferredId
|
|
349
|
+
if nodes[id] != nil { id = "\(preferredId)#\(nodes.count)" }
|
|
350
|
+
let children = value["children"] as? [Any] ?? []
|
|
351
|
+
nodes[id] = Node(
|
|
352
|
+
id: id,
|
|
353
|
+
type: value["type"] as? String ?? "View",
|
|
354
|
+
props: props,
|
|
355
|
+
style: dictionary(value["style"]),
|
|
356
|
+
events: dictionary(value["events"]),
|
|
357
|
+
text: children.compactMap { $0 as? String },
|
|
358
|
+
children: [],
|
|
359
|
+
parent: parent
|
|
360
|
+
)
|
|
361
|
+
let childIds = children.enumerated().compactMap { index, child -> String? in
|
|
362
|
+
guard let child = child as? [String: Any] else { return nil }
|
|
363
|
+
return importNode(child, preferredId: "\(id)/index:\(index)", parent: id, nodes: &nodes)
|
|
364
|
+
}
|
|
365
|
+
nodes[id]?.children = childIds
|
|
366
|
+
return id
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private func dictionary(_ value: Any?) -> [String: Any] {
|
|
370
|
+
value as? [String: Any] ?? [:]
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private func mergePatch(_ value: Any?, into target: inout [String: Any]) {
|
|
374
|
+
for (key, next) in dictionary(value) {
|
|
375
|
+
if next is NSNull { target.removeValue(forKey: key) }
|
|
376
|
+
else { target[key] = next }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private func integer(_ value: Any?) -> Int? {
|
|
381
|
+
guard let number = value as? NSNumber else { return nil }
|
|
382
|
+
guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
|
383
|
+
let candidate = number.intValue
|
|
384
|
+
return number.doubleValue == Double(candidate) ? candidate : nil
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private func appendUnique(_ id: String, to values: inout [String]) {
|
|
388
|
+
if !values.contains(id) { values.append(id) }
|
|
389
|
+
}
|
|
390
|
+
}
|