catalyst-core 1.0.0 → 1.0.1-canary.2

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.
Files changed (40) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +18 -21
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +60 -11
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/server/expressServer.js +1 -0
  31. package/mcp_v2/conversion-tasks.json +10 -9
  32. package/mcp_v2/knowledge-base.json +34 -4
  33. package/mcp_v2/test/config.test.ts +52 -0
  34. package/mcp_v2/tools/config.d.ts +18 -0
  35. package/mcp_v2/tools/config.js +40 -0
  36. package/mcp_v2/tools/conversion.js +2 -2
  37. package/package.json +7 -6
  38. package/test/hooks/aiProviders.test.ts +33 -0
  39. package/test/hooks/useSafeNavigate.test.ts +21 -0
  40. package/test/native/aiPluginComposition.test.js +161 -0
@@ -0,0 +1,215 @@
1
+ //
2
+ // FrameworkServerUtils+Requests.swift
3
+ // iosnativeWebView
4
+ //
5
+ // HTTP request assembly (headers + Content-Length body across reads) and routing for the framework server,
6
+ // split out of FrameworkServerUtils.swift to keep that type within the lint limits.
7
+ //
8
+
9
+ import Foundation
10
+ import Network
11
+ import os
12
+
13
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "FrameworkServer")
14
+
15
+ extension FrameworkServerUtils {
16
+ /// Completion body of receiveHTTPRequest. Internal so tests can drive the
17
+ /// error branch directly; a real receive error is timing-dependent.
18
+ ///
19
+ /// A POST's headers and body can arrive in separate reads, so bytes are
20
+ /// buffered per connection until the headers end (CRLFCRLF) and Content-Length
21
+ /// bytes of body are present. A GET has no body and dispatches immediately.
22
+ func handleReceivedData(_ data: Data?, isComplete: Bool, error: NWError?, on connection: NWConnection) {
23
+ if let error = error {
24
+ discardPendingRequest(for: connection)
25
+ handleReceiveError(error, on: connection)
26
+ return
27
+ }
28
+
29
+ guard let data = data else {
30
+ discardPendingRequest(for: connection)
31
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
32
+ return
33
+ }
34
+
35
+ let key = ObjectIdentifier(connection)
36
+ // Append to the connection's own buffer in place: `old + new` would copy every byte received so far on
37
+ // each 8 KB read.
38
+ requestStateLock.lock()
39
+ var buffer = pendingRequests.removeValue(forKey: key) ?? Data()
40
+ buffer.append(data)
41
+ requestStateLock.unlock()
42
+
43
+ let terminator = Data("\r\n\r\n".utf8)
44
+ guard let headerEnd = buffer.range(of: terminator) else {
45
+ if buffer.count > Self.maxHeaderBytes {
46
+ discardPendingRequest(for: connection)
47
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
48
+ } else if isComplete {
49
+ // Client closed mid-headers: fall through to the legacy single-read behaviour.
50
+ discardPendingRequest(for: connection)
51
+ dispatchRequest(headerData: buffer, body: Data(), isComplete: isComplete, on: connection)
52
+ } else {
53
+ requestStateLock.lock(); pendingRequests[key] = buffer; requestStateLock.unlock()
54
+ receiveHTTPRequest(on: connection)
55
+ }
56
+ return
57
+ }
58
+
59
+ let headerData = buffer[buffer.startIndex..<headerEnd.lowerBound]
60
+ let body = buffer[headerEnd.upperBound...]
61
+ guard let expected = Self.contentLength(inHeaders: headerData) else {
62
+ discardPendingRequest(for: connection)
63
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
64
+ return
65
+ }
66
+
67
+ if expected > Self.maxBodyBytes {
68
+ discardPendingRequest(for: connection)
69
+ sendHTTPResponse(on: connection, statusCode: 413, body: "Payload Too Large")
70
+ return
71
+ }
72
+ if body.count < expected {
73
+ if isComplete {
74
+ // The client closed the connection before sending the declared body: reject rather than
75
+ // handle a truncated request.
76
+ discardPendingRequest(for: connection)
77
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
78
+ return
79
+ }
80
+ requestStateLock.lock(); pendingRequests[key] = buffer; requestStateLock.unlock()
81
+ receiveHTTPRequest(on: connection)
82
+ return
83
+ }
84
+
85
+ discardPendingRequest(for: connection)
86
+ dispatchRequest(headerData: Data(headerData), body: Data(body.prefix(max(expected, 0))), isComplete: isComplete, on: connection)
87
+ }
88
+
89
+ func discardPendingRequest(for connection: NWConnection) {
90
+ requestStateLock.lock()
91
+ pendingRequests.removeValue(forKey: ObjectIdentifier(connection))
92
+ requestStateLock.unlock()
93
+ }
94
+
95
+ /// The declared body length: 0 when there is no Content-Length header, nil when it is malformed or negative
96
+ /// (the request is then rejected instead of being read as an empty body).
97
+ static func contentLength(inHeaders headerData: Data) -> Int? {
98
+ guard let text = String(data: headerData, encoding: .utf8) else { return nil }
99
+ for line in text.components(separatedBy: "\r\n") {
100
+ let parts = line.split(separator: ":", maxSplits: 1)
101
+ if parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces).lowercased() == "content-length" {
102
+ guard let length = Int(parts[1].trimmingCharacters(in: .whitespaces)), length >= 0 else { return nil }
103
+ return length
104
+ }
105
+ }
106
+ return 0
107
+ }
108
+
109
+ /// True when the peer is on this device. The listener is not bound to loopback only (that predates the AI
110
+ /// routes and also serves files), so the AI routes — which run the on-device model — check the peer themselves.
111
+ static func isLoopbackPeer(_ endpoint: NWEndpoint) -> Bool {
112
+ guard case let .hostPort(host, _) = endpoint else { return false }
113
+ switch host {
114
+ case .ipv4(let address):
115
+ return address.isLoopback
116
+ case .ipv6(let address):
117
+ return address.isLoopback || address.asIPv4?.isLoopback == true
118
+ case .name(let name, _):
119
+ return name == "localhost"
120
+ @unknown default:
121
+ return false
122
+ }
123
+ }
124
+
125
+ func dispatchRequest(headerData: Data, body: Data, isComplete: Bool, on connection: NWConnection) {
126
+ guard let requestString = String(data: headerData, encoding: .utf8) else {
127
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
128
+ return
129
+ }
130
+
131
+ let keepOpen = processHTTPRequest(requestString, body: body, on: connection)
132
+
133
+ // A client half-closing after sending its request must not kill an SSE stream we are still writing.
134
+ if isComplete && !keepOpen {
135
+ connection.cancel()
136
+ }
137
+ }
138
+
139
+ /// Returns true when the connection is now owned by a long-lived AI stream and must stay open.
140
+ @discardableResult
141
+ func processHTTPRequest(_ requestString: String, body: Data = Data(), on connection: NWConnection) -> Bool {
142
+ let lines = requestString.components(separatedBy: "\r\n")
143
+ guard let requestLine = lines.first else {
144
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
145
+ return false
146
+ }
147
+
148
+ let components = requestLine.components(separatedBy: " ")
149
+ guard components.count >= 2 else {
150
+ sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
151
+ return false
152
+ }
153
+
154
+ let method = components[0]
155
+ let aiPrefix = "/framework-\(self.sessionId)/ai/"
156
+ let isAIRoute = components[1] == aiPrefix + "stream" || components[1] == aiPrefix + "generate"
157
+
158
+ if isAIRoute {
159
+ guard Self.isLoopbackPeer(connection.endpoint) else {
160
+ logger.warning("Rejected AI request from a non-loopback peer")
161
+ sendHTTPResponse(on: connection, statusCode: 403, body: "Forbidden")
162
+ return false
163
+ }
164
+ switch method {
165
+ case "OPTIONS":
166
+ sendAIPreflight(on: connection)
167
+ return false
168
+ case "POST":
169
+ if components[1] == aiPrefix + "stream" {
170
+ startAIStream(NativeAIRequest(body: body), on: connection)
171
+ return true
172
+ }
173
+ runAIGenerate(NativeAIRequest(body: body), on: connection)
174
+ return true
175
+ default:
176
+ sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
177
+ return false
178
+ }
179
+ }
180
+
181
+ guard method == "GET" else {
182
+ sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
183
+ return false
184
+ }
185
+
186
+ let path = components[1]
187
+
188
+ // Handle status endpoint
189
+ if path == "/framework-\(self.sessionId)/status" {
190
+ fileQueue.sync {
191
+ let statusResponse = """
192
+ {
193
+ "status": "running",
194
+ "sessionId": "\(self.sessionId)",
195
+ "port": \(self.serverPort),
196
+ "servedFiles": \(self.servedFiles.count)
197
+ }
198
+ """
199
+ sendHTTPResponse(on: connection, statusCode: 200, body: statusResponse, contentType: "application/json")
200
+ }
201
+ return false
202
+ }
203
+
204
+ // Handle file requests
205
+ if path.hasPrefix("/framework-\(self.sessionId)/file-") {
206
+ let fileId = String(path.dropFirst("/framework-\(self.sessionId)/file-".count))
207
+ serveFile(fileId: fileId, on: connection)
208
+ return false
209
+ }
210
+
211
+ // Invalid route
212
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.fileNotFound, body: "Not Found")
213
+ return false
214
+ }
215
+ }
@@ -102,10 +102,10 @@ public class FrameworkServerUtils {
102
102
 
103
103
  // Server state
104
104
  private var listener: NWListener?
105
- private var serverPort: UInt16 = 0
106
- private var sessionId: String = ""
107
- private var isServerRunning: Bool = false
108
- private var isHTTPS: Bool = false
105
+ var serverPort: UInt16 = 0
106
+ var sessionId: String = ""
107
+ var isServerRunning: Bool = false
108
+ var isHTTPS: Bool = false
109
109
 
110
110
  // Connection management
111
111
  private var activeConnections: Set<ConnectionWrapper> = []
@@ -114,11 +114,11 @@ public class FrameworkServerUtils {
114
114
  private let connectionTimeoutSeconds: TimeInterval = CatalystConstants.NetworkServer.connectionTimeout
115
115
 
116
116
  // CORS configuration - store the base URL from WebView
117
- private var allowedOrigin: String = "*"
117
+ var allowedOrigin: String = "*"
118
118
 
119
119
  // File management
120
- private var servedFiles: [String: ServedFile] = [:]
121
- private let fileQueue = DispatchQueue(label: "framework.server.files", attributes: .concurrent)
120
+ var servedFiles: [String: ServedFile] = [:]
121
+ let fileQueue = DispatchQueue(label: "framework.server.files", attributes: .concurrent)
122
122
 
123
123
  // Cache directory
124
124
  private var cacheDirectory: URL?
@@ -126,6 +126,21 @@ public class FrameworkServerUtils {
126
126
  // Cleanup timer
127
127
  private var cleanupTimer: Timer?
128
128
 
129
+ // Native AI — supplier is set by the AI engine (catalyst-ai) before POST /ai/stream is called.
130
+ // Kotlin equivalent: nativeAiSupplier / nativeSystemPrompt in FrameworkServerUtils.kt.
131
+ var nativeAiSupplier: NativeAISupplier?
132
+ var nativeSystemPrompt: String = ""
133
+ let aiLock = NSLock()
134
+
135
+ // Requests whose headers/body arrive across several reads, keyed by connection.
136
+ var pendingRequests: [ObjectIdentifier: Data] = [:]
137
+ // In-flight AI stream tasks, cancelled when the client goes away.
138
+ var aiStreamTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
139
+ let requestStateLock = NSLock()
140
+
141
+ static let maxHeaderBytes = 16 * 1024
142
+ static let maxBodyBytes = 1024 * 1024
143
+
129
144
  private init() {}
130
145
 
131
146
  // MARK: - Public API
@@ -332,20 +347,35 @@ public class FrameworkServerUtils {
332
347
  return isServerRunning
333
348
  }
334
349
 
350
+ /**
351
+ * Number of client connections currently tracked by the server.
352
+ * Internal so tests can wait on connection lifecycle events instead of sleeping.
353
+ */
354
+ func activeConnectionCount() -> Int {
355
+ return connectionQueue.sync { activeConnections.count }
356
+ }
357
+
335
358
  /**
336
359
  * Get server port
337
360
  */
338
- func getServerPort() -> UInt16 {
361
+ public func getServerPort() -> UInt16 {
339
362
  return serverPort
340
363
  }
341
364
 
342
365
  /**
343
366
  * Get current session ID
344
367
  */
345
- func getSessionId() -> String {
368
+ public func getSessionId() -> String {
346
369
  return sessionId
347
370
  }
348
371
 
372
+ /// Stream and generate connections outlive the default per-connection timeout (slow on-device decoding).
373
+ func cancelConnectionTimeout(for connection: NWConnection) {
374
+ connectionQueue.sync {
375
+ activeConnections.first { $0.connection === connection }?.cancelTimeout()
376
+ }
377
+ }
378
+
349
379
  // MARK: - Private Implementation
350
380
 
351
381
  /**
@@ -529,28 +559,35 @@ public class FrameworkServerUtils {
529
559
  let port = NWEndpoint.Port(integerLiteral: serverPort)
530
560
  // parameters.requiredLocalEndpoint = .hostPort(host: host, port: port)
531
561
 
532
- listener = try NWListener(using: parameters, on: port)
562
+ let newListener = try NWListener(using: parameters, on: port)
563
+ listener = newListener
533
564
 
534
- listener?.newConnectionHandler = { [weak self] connection in
565
+ newListener.newConnectionHandler = { [weak self] connection in
535
566
  self?.handleNewConnection(connection)
536
567
  }
537
568
 
538
- listener?.stateUpdateHandler = { [weak self] state in
569
+ newListener.stateUpdateHandler = { [weak self, weak newListener] state in
570
+ // NWListener delivers state changes asynchronously, so a cancelled
571
+ // or failed listener can report after stopServer() -> startServer()
572
+ // has already installed a replacement. Only the current listener
573
+ // may flip isServerRunning; a stale callback would otherwise mark a
574
+ // healthy new server as stopped.
575
+ let isCurrent = { self?.listener === newListener }
539
576
  switch state {
540
577
  case .ready:
541
578
  logger.debug("NW server ready on port \(self?.serverPort ?? 0)")
542
579
  case .failed(let error):
543
580
  logger.error("NW server failed: \(error.localizedDescription)")
544
- self?.isServerRunning = false
581
+ if isCurrent() { self?.isServerRunning = false }
545
582
  case .cancelled:
546
583
  logger.debug("NW server cancelled")
547
- self?.isServerRunning = false
584
+ if isCurrent() { self?.isServerRunning = false }
548
585
  default:
549
586
  break
550
587
  }
551
588
  }
552
589
 
553
- listener?.start(queue: .global(qos: .utility))
590
+ newListener.start(queue: .global(qos: .utility))
554
591
  }
555
592
 
556
593
  private func handleNewConnection(_ connection: NWConnection) {
@@ -566,102 +603,73 @@ public class FrameworkServerUtils {
566
603
  let wrapper = ConnectionWrapper(connection: connection, timeout: self.connectionTimeoutSeconds)
567
604
  self.activeConnections.insert(wrapper)
568
605
 
569
- connection.stateUpdateHandler = { [weak self, weak wrapper] state in
570
- switch state {
571
- case .ready:
572
- self?.receiveHTTPRequest(on: connection)
573
- case .failed(let error):
574
- logger.debug("Connection failed: \(error.localizedDescription)")
575
- if let wrapper = wrapper {
576
- self?.removeConnection(wrapper)
577
- }
578
- connection.cancel()
579
- case .cancelled:
580
- logger.debug("Connection cancelled")
581
- if let wrapper = wrapper {
582
- self?.removeConnection(wrapper)
583
- }
584
- default:
585
- break
586
- }
606
+ connection.stateUpdateHandler = { [weak self] state in
607
+ self?.handleConnectionStateChange(state, on: connection)
587
608
  }
588
609
 
589
610
  connection.start(queue: .global(qos: .utility))
590
611
  }
591
612
  }
592
613
 
593
- private func removeConnection(_ wrapper: ConnectionWrapper) {
594
- connectionQueue.sync {
595
- self.activeConnections.remove(wrapper)
596
- wrapper.cancelTimeout()
614
+ /// Reacts to a client connection's state change. Internal (not private) so
615
+ /// tests can drive the failed/cancelled paths directly: which one Network.framework
616
+ /// reports for a dropped client is timing-dependent, so real sockets cannot
617
+ /// exercise them reliably.
618
+ func handleConnectionStateChange(_ state: NWConnection.State, on connection: NWConnection) {
619
+ switch state {
620
+ case .ready:
621
+ receiveHTTPRequest(on: connection)
622
+ case .failed(let error):
623
+ logger.debug("Connection failed: \(error.localizedDescription)")
624
+ cancelAIStream(for: connection)
625
+ discardPendingRequest(for: connection)
626
+ removeTrackedConnection(for: connection)
627
+ connection.cancel()
628
+ case .cancelled:
629
+ logger.debug("Connection cancelled")
630
+ cancelAIStream(for: connection)
631
+ discardPendingRequest(for: connection)
632
+ removeTrackedConnection(for: connection)
633
+ default:
634
+ break
597
635
  }
598
636
  }
599
637
 
600
- private func receiveHTTPRequest(on connection: NWConnection) {
601
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isComplete, error in
602
-
603
- if let error = error {
604
- logger.error("Error receiving request: \(error.localizedDescription)")
605
- connection.cancel()
606
- return
607
- }
608
-
609
- guard let data = data, let requestString = String(data: data, encoding: .utf8) else {
610
- self?.sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
611
- return
612
- }
613
-
614
- self?.processHTTPRequest(requestString, on: connection)
615
-
616
- if isComplete {
617
- connection.cancel()
618
- }
619
- }
638
+ /// Internal for the same reason as handleConnectionStateChange.
639
+ func handleReceiveError(_ error: NWError, on connection: NWConnection) {
640
+ logger.error("Error receiving request: \(error.localizedDescription)")
641
+ connection.cancel()
620
642
  }
621
643
 
622
- private func processHTTPRequest(_ requestString: String, on connection: NWConnection) {
623
- let lines = requestString.components(separatedBy: "\r\n")
624
- guard let requestLine = lines.first else {
625
- sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
626
- return
644
+ /// Internal for the same reason as handleConnectionStateChange.
645
+ func handleSendCompletion(_ error: NWError?, on connection: NWConnection) {
646
+ if let error = error {
647
+ logger.debug("Error sending response: \(error.localizedDescription)")
627
648
  }
649
+ connection.cancel()
650
+ }
628
651
 
629
- let components = requestLine.components(separatedBy: " ")
630
- guard components.count >= 2, components[0] == "GET" else {
631
- sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
632
- return
652
+ private func removeTrackedConnection(for connection: NWConnection) {
653
+ let wrapper = connectionQueue.sync { activeConnections.first { $0.connection === connection } }
654
+ if let wrapper = wrapper {
655
+ removeConnection(wrapper)
633
656
  }
657
+ }
634
658
 
635
- let path = components[1]
636
-
637
- // Handle status endpoint
638
- if path == "/framework-\(self.sessionId)/status" {
639
- fileQueue.sync {
640
- let statusResponse = """
641
- {
642
- "status": "running",
643
- "sessionId": "\(self.sessionId)",
644
- "port": \(self.serverPort),
645
- "servedFiles": \(self.servedFiles.count)
646
- }
647
- """
648
- sendHTTPResponse(on: connection, statusCode: 200, body: statusResponse, contentType: "application/json")
649
- }
650
- return
659
+ private func removeConnection(_ wrapper: ConnectionWrapper) {
660
+ connectionQueue.sync {
661
+ self.activeConnections.remove(wrapper)
662
+ wrapper.cancelTimeout()
651
663
  }
664
+ }
652
665
 
653
- // Handle file requests
654
- if path.hasPrefix("/framework-\(self.sessionId)/file-") {
655
- let fileId = String(path.dropFirst("/framework-\(self.sessionId)/file-".count))
656
- serveFile(fileId: fileId, on: connection)
657
- return
666
+ func receiveHTTPRequest(on connection: NWConnection) {
667
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isComplete, error in
668
+ self?.handleReceivedData(data, isComplete: isComplete, error: error, on: connection)
658
669
  }
659
-
660
- // Invalid route
661
- sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.fileNotFound, body: "Not Found")
662
670
  }
663
671
 
664
- private func serveFile(fileId: String, on connection: NWConnection) {
672
+ func serveFile(fileId: String, on connection: NWConnection) {
665
673
  fileQueue.sync {
666
674
  guard let servedFile = self.servedFiles[fileId] else {
667
675
  logger.warning("File not found for fileId: \(fileId)")
@@ -706,7 +714,7 @@ public class FrameworkServerUtils {
706
714
 
707
715
  // Add CORS headers
708
716
  response += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
709
- response += "Access-Control-Allow-Methods: GET, OPTIONS\r\n"
717
+ response += "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
710
718
  response += "Access-Control-Allow-Headers: *\r\n"
711
719
 
712
720
  // Add headers
@@ -771,18 +779,18 @@ public class FrameworkServerUtils {
771
779
  })
772
780
  }
773
781
 
774
- private func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: String, contentType: String = "text/plain") {
782
+ func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: String, contentType: String = "text/plain") {
775
783
  let bodyData = body.data(using: .utf8) ?? Data()
776
784
  sendHTTPResponse(on: connection, statusCode: statusCode, body: bodyData, headers: ["Content-Type": contentType])
777
785
  }
778
786
 
779
- private func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: Data, headers: [String: String] = [:]) {
787
+ func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: Data, headers: [String: String] = [:]) {
780
788
  let statusText = HTTPURLResponse.localizedString(forStatusCode: statusCode)
781
789
  var response = "HTTP/1.1 \(statusCode) \(statusText)\r\n"
782
790
 
783
791
  // Add CORS headers
784
792
  response += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
785
- response += "Access-Control-Allow-Methods: GET, OPTIONS\r\n"
793
+ response += "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
786
794
  response += "Access-Control-Allow-Headers: *\r\n"
787
795
 
788
796
  // Add default headers
@@ -803,11 +811,8 @@ public class FrameworkServerUtils {
803
811
  }
804
812
  let responseData = responseHeaderData + body
805
813
 
806
- connection.send(content: responseData, completion: .contentProcessed { error in
807
- if let error = error {
808
- logger.debug("Error sending response: \(error.localizedDescription)")
809
- }
810
- connection.cancel()
814
+ connection.send(content: responseData, completion: .contentProcessed { [weak self] error in
815
+ self?.handleSendCompletion(error, on: connection)
811
816
  })
812
817
  }
813
818
 
@@ -0,0 +1,76 @@
1
+ //
2
+ // NativeAI.swift
3
+ // iosnativeWebView
4
+ //
5
+ // Types shared between the framework server's native-AI routes and the AI
6
+ // engine that catalyst-ai plugs in. Mirrors the Kotlin contract in
7
+ // FrameworkServerUtils.kt: an engine hands the server a "supplier" that
8
+ // turns (prompt, genConfig, conversationId) into (conversationId, token stream).
9
+ // catalyst-core has no knowledge of any inference library.
10
+ //
11
+
12
+ import Foundation
13
+
14
+ /// Result of one native-AI request: the active conversation id plus the token stream.
15
+ /// Kotlin equivalent: Pair<String, Flow<String>>.
16
+ public typealias NativeAIStream = (conversationId: String, tokens: AsyncThrowingStream<String, Error>)
17
+
18
+ /// Kotlin equivalent: suspend (String, JSONObject, String?) -> Pair<String, Flow<String>>.
19
+ public typealias NativeAISupplier = (
20
+ _ prompt: String,
21
+ _ genConfig: [String: Any],
22
+ _ conversationId: String?
23
+ ) async throws -> NativeAIStream
24
+
25
+ /// Error codes come from packages/catalyst-core/src/errors/registry.js (AI-xxx) so the
26
+ /// JS side surfaces the same code on iOS and Android.
27
+ public enum NativeAIErrorCode {
28
+ public static let invalidRequestBody = "AI-003"
29
+ public static let streamNotReady = "AI-005"
30
+ public static let requestFailed = "AI-006"
31
+ }
32
+
33
+ /// An error an engine/server can raise with the registry code that should reach JS.
34
+ public struct NativeAIError: LocalizedError {
35
+ public let message: String
36
+ public let code: String
37
+
38
+ public init(message: String, code: String = NativeAIErrorCode.requestFailed) {
39
+ self.message = message
40
+ self.code = code
41
+ }
42
+
43
+ public var errorDescription: String? { message }
44
+ }
45
+
46
+ /// Server-Sent Events framing. Frame shapes match Android's /ai/stream exactly:
47
+ /// {"conversationId"}, {"token"}, {"done":true}, {"error"} (+ "code" on iOS).
48
+ enum NativeAISSE {
49
+ static func frame(_ payload: [String: Any]) -> Data {
50
+ // Payloads are built from Strings/Bools only, so serialization cannot fail;
51
+ // the fallback keeps the stream well-formed regardless.
52
+ let json = (try? JSONSerialization.data(withJSONObject: payload, options: [.withoutEscapingSlashes]))
53
+ ?? Data("{\"error\":\"serialization failed\"}".utf8)
54
+ return Data("data: ".utf8) + json + Data("\n\n".utf8)
55
+ }
56
+
57
+ static func conversationId(_ id: String) -> Data { frame(["conversationId": id]) }
58
+ static func token(_ text: String) -> Data { frame(["token": text]) }
59
+ static func done() -> Data { frame(["done": true]) }
60
+ static func error(_ message: String, code: String) -> Data { frame(["error": message, "code": code]) }
61
+ }
62
+
63
+ /// Parsed body of POST /ai/stream and /ai/generate: { prompt, genConfig, conversationId }.
64
+ struct NativeAIRequest {
65
+ let prompt: String
66
+ let genConfig: [String: Any]
67
+ let conversationId: String?
68
+
69
+ init(body: Data) {
70
+ let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] ?? [:]
71
+ prompt = object["prompt"] as? String ?? ""
72
+ genConfig = object["genConfig"] as? [String: Any] ?? [:]
73
+ let id = (object["conversationId"] as? String) ?? ""
74
+ conversationId = id.isEmpty ? nil : id
75
+ }
76
+ }