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.
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +18 -21
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +60 -11
- package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
- package/dist/native/bridge/hooks/aiProviders.js +27 -0
- package/dist/native/bridge/hooks/useAI.js +9 -8
- package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
- package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
- package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
- package/dist/native/buildAppIos.js +8 -2
- package/dist/native/buildIos/plugins.js +3 -1
- package/dist/native/internalPluginUtils.js +78 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
- package/dist/native/pluginComposerIos.js +5 -1
- package/dist/server/expressServer.js +1 -0
- package/mcp_v2/conversion-tasks.json +10 -9
- package/mcp_v2/knowledge-base.json +34 -4
- package/mcp_v2/test/config.test.ts +52 -0
- package/mcp_v2/tools/config.d.ts +18 -0
- package/mcp_v2/tools/config.js +40 -0
- package/mcp_v2/tools/conversion.js +2 -2
- package/package.json +7 -6
- package/test/hooks/aiProviders.test.ts +33 -0
- package/test/hooks/useSafeNavigate.test.ts +21 -0
- 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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
+
var allowedOrigin: String = "*"
|
|
118
118
|
|
|
119
119
|
// File management
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
562
|
+
let newListener = try NWListener(using: parameters, on: port)
|
|
563
|
+
listener = newListener
|
|
533
564
|
|
|
534
|
-
|
|
565
|
+
newListener.newConnectionHandler = { [weak self] connection in
|
|
535
566
|
self?.handleNewConnection(connection)
|
|
536
567
|
}
|
|
537
568
|
|
|
538
|
-
|
|
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
|
-
|
|
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
|
|
570
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|